Skip to content

Commit 118c722

Browse files
committed
Centre the legend in the summer gap and fix mobile page overflow
The legend was pinned 85 px in from the container's right edge. The plot's right edge is 40 px in from that same edge and the August re-exam band ends there, so the legend's right edge always landed 45 px from the domain end - 5 px clear of the band at every width (measured at 1200-3440 px). It never quite overlapped, but it sat welded to the August markers with the whole summer gap empty beside it. legendLeftIn() now derives the x from the time scale and centres the box between the June and August re-exam periods. The gap is a fixed 18.3 % of the plot, so this holds at any width. The SVG export uses the same helper, so an exported chart matches the screen. Separately, on phones the header controls were a nowrap flex row whose items cannot shrink below their content, so the row - not the chart - set the page's scroll width: 864 px on a 390 px viewport, scrolling the whole page sideways and putting the menu button off-screen. The chart's own horizontal scroll from #2 was working correctly. The row now wraps, with smaller type and padding on small screens, and the chart scroller no longer chains its overscroll into the browser's back gesture.
1 parent ba77382 commit 118c722

3 files changed

Lines changed: 117 additions & 20 deletions

File tree

src/app/HomeClient.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -479,14 +479,21 @@ export default function HomeClient() {
479479

480480
return (
481481
<div className="min-h-screen bg-gray-50">
482-
<main className="container mx-auto px-4 py-8">
483-
<div className="flex justify-between items-center mb-8">
484-
<h1 className="text-3xl font-bold" style={{ color: kthColors.KthHeaven?.HEX }}>{ui[language].title}</h1>
482+
<main className="container mx-auto px-3 sm:px-4 py-4 sm:py-8">
483+
{/* `flex-wrap` is load-bearing on phones, not cosmetic. Without it this
484+
row is a nowrap flex line whose items cannot shrink below their
485+
content (flex items default to `min-width: auto`), so it set the
486+
page's scroll width — measured 864 px on a 390 px viewport, which
487+
scrolled the WHOLE page sideways and put the menu button off-screen.
488+
The chart's own horizontal scroll (issue #2) was working correctly;
489+
this row was the thing overflowing past it. */}
490+
<div className="flex flex-wrap justify-between items-center gap-x-6 gap-y-3 mb-6 sm:mb-8">
491+
<h1 className="text-2xl sm:text-3xl font-bold" style={{ color: kthColors.KthHeaven?.HEX }}>{ui[language].title}</h1>
485492
{/* Column so the provenance line can sit directly under the selectors
486493
that produced it, rather than becoming a third flex item beside
487494
them. */}
488-
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
489-
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
495+
<div className="items-start sm:items-end" style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
496+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
490497
<label style={{ color: kthColors.KthBlue?.HEX, fontWeight: 600 }}>{ui[language].programLabel}</label>
491498
<select
492499
value={selectedProgram.code}
@@ -711,7 +718,9 @@ export default function HomeClient() {
711718
)}
712719
</div>
713720
</div>
714-
<div className="bg-white rounded-lg shadow-lg p-6 min-h-[600px]">
721+
{/* Tighter padding on phones: `p-6` spent 48 of a 390 px viewport on
722+
whitespace either side of a chart that is already scrolled. */}
723+
<div className="bg-white rounded-lg shadow-lg p-3 sm:p-6 min-h-[600px]">
715724
{selectedProgram.specializations && selectedProgram.specializations.length > 0 && (
716725
<SpecializationFilter
717726
language={language}

src/components/Legend.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,25 @@ interface LegendProps {
4141
cosmetics: ProgramCosmetics | null | undefined;
4242
toggleLayer: (key: ToggleableLayerKey) => void;
4343
toggleGroup: (groupName: string) => void;
44+
/**
45+
* x offset within the positioned chart wrapper, in CSS px. Supplied by
46+
* `legendLeftIn()` so the box sits centred in the summer gap between the June
47+
* and August re-exam periods at any chart width. Optional so the component
48+
* still renders standalone; without it the box falls back to the old
49+
* right-edge anchor. (The SVG export builds its own legend rather than
50+
* reusing this component, but places it with the same helper.)
51+
*/
52+
left?: number;
4453
}
4554

46-
export default function Legend({ language, layers, cosmetics, toggleLayer, toggleGroup }: LegendProps) {
55+
export default function Legend({ language, layers, cosmetics, toggleLayer, toggleGroup, left }: LegendProps) {
56+
// `left` and `right` must not both be set: with a fixed width that
57+
// over-constrains the box and the browser drops one of them.
58+
const position = left === undefined
59+
? { right: STYLE.legend.offsetX }
60+
: { left };
4761
return (
48-
<div style={{ position: 'absolute', right: STYLE.legend.offsetX, bottom: STYLE.legend.offsetY, width: STYLE.legend.width, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-start', padding: '8px 12px', background: STYLE.legend.background, border: `1px solid ${STYLE.legend.borderColor}`, borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.12)', zIndex: 1000 }}>
62+
<div style={{ position: 'absolute', ...position, bottom: STYLE.legend.offsetY, width: STYLE.legend.width, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-start', padding: '8px 12px', background: STYLE.legend.background, border: `1px solid ${STYLE.legend.borderColor}`, borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.12)', zIndex: 1000 }}>
4963
{/* Exams */}
5064
<div role="button" tabIndex={0} aria-pressed={layers.exams} onClick={() => toggleLayer('exams')} onKeyDown={(e) => activate(e, () => toggleLayer('exams'))} style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', opacity: layers.exams ? 1 : 0.4 }} title={tr[language].legendToggleHint}>
5165
<svg width={16} height={16} viewBox="0 0 16 16">

src/components/TimelineVisualization.tsx

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,53 @@ import { getEmbeddedFontFaces } from '@/lib/fonts';
4141

4242
type CourseOrOptionGroup = Course | OptionGroup;
4343

44+
// Plot margins. Shared by the D3 render and by `legendLeftIn()` below, which
45+
// has to reproduce the same time→x mapping outside the render to place the
46+
// legend. Two copies of these numbers would drift silently: nothing in the
47+
// types connects a legend offset to a plot margin.
48+
const CHART_MARGIN = { top: 100, right: 40, bottom: 40, left: 100 } as const;
49+
50+
/**
51+
* x, in container pixels, where the legend should sit for a given container
52+
* width: horizontally centred in the summer gap between the P3 re-exams
53+
* (early June) and the P4 re-exams (mid August).
54+
*
55+
* The legend used to be pinned at `right: STYLE.legend.offsetX`, i.e. 85 px in
56+
* from the container's right edge. That looks width-independent but is not
57+
* *gap*-independent: the plot's right edge is `CHART_MARGIN.right` (40 px) in
58+
* from the same edge and the August re-exam band ends exactly there, so the
59+
* legend's right edge always landed 45 px from the domain end — which is 5 px
60+
* clear of the band's left edge at EVERY width, the band being ~3 % of the
61+
* plot. Measured at 1200/1440/1680/1920/2200/2560/3000/3440 px: the clearance
62+
* was 5 px at each one (13 px below 1500, where the page's max-width has not
63+
* yet kicked in). So the box never technically overlapped, but it was welded
64+
* to the August markers with the whole gap empty to its left.
65+
*
66+
* Centring in the gap is what "in the gap" actually means, and it holds at any
67+
* width because the gap is a fixed fraction of the plot: Jun 5 → Aug 10 is
68+
* 66/361 of the domain, i.e. 18.3 %, which is 194 px at the narrowest layout
69+
* and 241 px once the page's max-width caps the chart — both wider than the
70+
* 170 px legend.
71+
*/
72+
function legendLeftIn(containerWidth: number): number {
73+
const inner = containerWidth - CHART_MARGIN.left - CHART_MARGIN.right;
74+
const domainStart = +academicPeriods[0].start;
75+
const span = +academicPeriods[3].reExamEnd - domainStart;
76+
// Same linear mapping d3's scaleTime applies over [domainStart, domainEnd].
77+
const xOf = (d: Date) => CHART_MARGIN.left + ((+d - domainStart) / span) * inner;
78+
79+
const gapStart = xOf(academicPeriods[2].reExamEnd); // June re-exams end
80+
const gapEnd = xOf(academicPeriods[3].reExamStart); // August re-exams begin
81+
const centred = gapStart + (gapEnd - gapStart - STYLE.legend.width) / 2;
82+
83+
// Clamp to the plot area so a legend wider than the gap (a future translation
84+
// or an extra cosmetics family) degrades to "inside the chart" rather than
85+
// hanging off the edge.
86+
const min = CHART_MARGIN.left;
87+
const max = containerWidth - CHART_MARGIN.right - STYLE.legend.width;
88+
return Math.max(min, Math.min(max, centred));
89+
}
90+
4491
// Top-level layer keys that can be hidden via the legend / URL `hide=` param.
4592
type TopLayerKey = ToggleableLayerKey;
4693

@@ -188,17 +235,36 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
188235
// Below this, the outer wrapper scrolls horizontally rather than letting
189236
// D3's clientWidth-based layout cramp the bars and labels.
190237
//
191-
// 1200 px keeps the legend inside the summer gap between the P4 ordinary
192-
// exam period (ending ~Jun 1) and the August re-exams (starting ~Aug 10):
193-
// the legend's bottom-right slot is fixed at [W−255, W−85] (offsetX 85,
194-
// width 170), which at 1200 px lands at x≈945–1115, while the Jun 1 and
195-
// Aug 10 marks fall at x≈922 and x≈1127 respectively. Stay roughly within
196-
// [1100, 1600] to keep the legend in that gap; outside it the legend
197-
// starts overlapping P4 course bars or the August re-exam markers.
238+
// This used to carry a second job: 1200 px was also the width at which the
239+
// legend's fixed bottom-right slot happened to fall inside the summer gap,
240+
// with a note to stay within [1100, 1600]. `legendLeftIn()` now derives the
241+
// legend's x from the time scale, so that coupling is gone and this number
242+
// answers only "how narrow can the bars get before they stop being readable".
198243
const chartMinWidth = 1200;
199244

200245
const containerRef = useRef<HTMLDivElement>(null);
201246
const svgRef = useRef<SVGSVGElement>(null);
247+
// The positioned wrapper the legend is absolutely placed inside. Its width is
248+
// the SVG's width, and the legend's x is derived from it.
249+
const canvasRef = useRef<HTMLDivElement>(null);
250+
// Container width in CSS px, tracked so the legend can be re-centred in the
251+
// summer gap on resize. Starts at chartMinWidth so the first paint is already
252+
// in roughly the right place rather than jumping after the observer fires.
253+
const [canvasWidth, setCanvasWidth] = useState<number>(chartMinWidth);
254+
255+
// Keep `canvasWidth` in step with the wrapper. A window resize listener would
256+
// miss the cases that matter here — the page's max-width container changing
257+
// the chart's width without the window changing, and the info panel opening
258+
// below it — so observe the element itself.
259+
useEffect(() => {
260+
const el = canvasRef.current;
261+
if (!el) return;
262+
const apply = () => setCanvasWidth(w => (w === el.clientWidth ? w : el.clientWidth));
263+
apply();
264+
const ro = new ResizeObserver(apply);
265+
ro.observe(el);
266+
return () => ro.disconnect();
267+
}, []);
202268
// Preserve the initial chart height to keep a stable px-per-ECTS baseline across re-renders/toggles
203269
const initialChartHeightRef = useRef<number | null>(null);
204270
// Single delegated tooltip element, persisted across renders.
@@ -363,7 +429,9 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
363429
const legendHeight = legendPadding*2 + items.length * (itemHeight + itemGap) - itemGap;
364430
const svgW = exportWidth;
365431
const svgH = exportHeight;
366-
const legendX = svgW - legendWidth - STYLE.legend.offsetX;
432+
// Same gap-centred placement as the on-screen legend, so an exported
433+
// chart matches what the user was looking at when they exported it.
434+
const legendX = legendLeftIn(svgW);
367435
const legendY = svgH - legendHeight - STYLE.legend.offsetY;
368436
legendG.setAttribute('transform', `translate(${legendX},${legendY})`);
369437

@@ -767,7 +835,7 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
767835
const svg = select(svgRef.current);
768836
// Apply global font family to all SVG text
769837
svg.style('font-family', STYLE.fontFamily);
770-
const margin = { top: 100, right: 40, bottom: 40, left: 100 }; // Increased top margin for title and period labels
838+
const margin = CHART_MARGIN;
771839
const width = svgRef.current.clientWidth - margin.left - margin.right;
772840
// The chart height must be a pure function of the data plus a fixed baseline.
773841
// It used to be seeded from `svgRef.current.clientHeight` — i.e. from the
@@ -3042,10 +3110,15 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
30423110
<div ref={containerRef}>
30433111
{/* Horizontal-scroll container: when the viewport is narrower than
30443112
`chartMinWidth`, the inner wrapper (and the SVG it pins) overflow
3045-
here and produce a scrollbar instead of cramping the layout. */}
3046-
<div style={{ overflowX: 'auto' }}>
3113+
here and produce a scrollbar instead of cramping the layout.
3114+
3115+
`overscrollBehaviorX: contain` matters on touch devices: swiping the
3116+
chart past its left edge otherwise chains the scroll to the page and
3117+
triggers the browser's back-navigation gesture, so a student panning
3118+
back to year 1 can leave the page instead. */}
3119+
<div style={{ overflowX: 'auto', overscrollBehaviorX: 'contain' }}>
30473120
{/* Visualization canvas wrapper so legend anchors to the SVG area only */}
3048-
<div style={{ position: 'relative', minWidth: chartMinWidth }}>
3121+
<div ref={canvasRef} style={{ position: 'relative', minWidth: chartMinWidth }}>
30493122
<svg
30503123
ref={svgRef}
30513124
className="w-full h-full"
@@ -3060,6 +3133,7 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
30603133
cosmetics={cosmetics}
30613134
toggleLayer={toggleLayer}
30623135
toggleGroup={toggleGroup}
3136+
left={legendLeftIn(canvasWidth)}
30633137
/>
30643138
</div>
30653139
</div>

0 commit comments

Comments
 (0)