@@ -41,6 +41,53 @@ import { getEmbeddedFontFaces } from '@/lib/fonts';
4141
4242type 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.
4592type 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