@@ -47,11 +47,16 @@ let scene = {
4747}
4848
4949import ( './scene/index.js' ) . then ( ( { createScene } ) => {
50- scene = createScene ( { canvas, cool : COLOURS . resting , warm : COLOURS . lit , cord : token ( '--cord' ) , ink : token ( '--ink' ) , dim : token ( '--ink-4' ) , label : token ( '--ink-3' ) , ground : token ( '--bg' ) , onFit : placeCaption } )
50+ scene = createScene ( { canvas, cool : COLOURS . resting , warm : COLOURS . lit , cord : token ( '--cord' ) , ink : token ( '--ink' ) , dim : token ( '--ink-4' ) , label : token ( '--ink-3' ) , ground : token ( '--bg' ) , onFit : placeCaption , light : matchMedia ( '(hover: none) and (pointer: coarse)' ) . matches } )
5151 // Replay the viewport and the section the reader is already on; a scene that
52- // starts at frame 0 with a square aspect would visibly snap into place.
52+ // starts at frame 0 with a square aspect would visibly snap into place. On a
53+ // phone that is the block the canvas already stands in.
5354 sizeScene ( )
54- scene . setFrame ( active )
55+ if ( phone . matches && host ) {
56+ const fig = host
57+ host = null
58+ hostFig ( fig )
59+ } else scene . setFrame ( active )
5560 scene . setView ( shownViz )
5661 measureFun ( )
5762} )
@@ -113,64 +118,98 @@ function trackSection () {
113118 if ( i === active ) link . setAttribute ( 'aria-current' , 'true' )
114119 else link . removeAttribute ( 'aria-current' )
115120 } )
116- scene . setFrame ( active )
121+ if ( ! phone . matches ) scene . setFrame ( active )
117122 syncCaption ( )
118123}
119124
120125addEventListener ( 'scroll' , trackSection , { passive : true } )
121126
122- /* --- the model's presence on a phone ------------------------------------- */
127+ /* --- the model's place on a phone -- ------------------------------------- */
123128
124129/**
125- * Held upright on a narrow screen, the model has the lower part of the first
126- * screen to itself and the stylesheet shows it at whatever --gl-presence says:
127- * full at the top of the page, and settled to a quiet trace by the time the
128- * reader has scrolled a third of a screen and content is arriving over it. On
129- * a wide screen the stylesheet never reads the property, so this is inert.
130- *
131- * Rounded to hundredths so a long scroll writes the property eighty times at
132- * most rather than once per event.
130+ * Below the layout change there is no side for the model to stand on, so each
131+ * section with a view has a block of its own (.fig) and the one canvas moves
132+ * into whichever block is coming on screen. Moving the element keeps its GL
133+ * context, and inside a block it scrolls with the page natively. The framing
134+ * follows the block rather than the section the reader is in, and nothing is
135+ * drawn while no block is on screen: on a phone that is most of the page.
133136 */
134- const PRESENCE_FLOOR = 0.2
135- const PRESENCE_SPAN = 0.35 // of a viewport height
136- let presence = - 1
137- let viewportHeight = innerHeight
138-
139- function updatePresence ( ) {
140- const t = Math . min ( 1 , Math . max ( 0 , scrollY / ( viewportHeight * PRESENCE_SPAN ) ) )
141- const next = Math . round ( ( 1 - t * ( 1 - PRESENCE_FLOOR ) ) * 100 ) / 100
142- if ( next === presence ) return
143- presence = next
144- document . documentElement . style . setProperty ( '--gl-presence' , String ( next ) )
145- }
137+ const phone = matchMedia ( '(max-width: 1080px)' )
138+ const figs = [ ...document . querySelectorAll ( '.fig' ) ]
139+ const canvasHome = { parent : canvas . parentNode , next : canvas . nextSibling }
140+ const nearness = new Map ( )
141+ const visible = new Set ( )
142+ let host = null
146143
147- addEventListener ( 'scroll' , updatePresence , { passive : true } )
148- updatePresence ( )
144+ /** Mockup round: which of the three layouts this page is showing. */
145+ const variant = ( document . documentElement . className . match ( / \b m - ( [ a b c ] ) \b / ) || [ ] ) [ 1 ] || 'a'
149146
150147/**
151- * Where the band begins: just under the last line of the lede, measured at the
152- * top of the page. On a very short screen that would leave the model too little
153- * room to read as a brain, so the band keeps at least MIN_BAND of height and
154- * accepts running under the lede's last line, where the stylesheet's fade at
155- * the top of the band keeps the two apart. A layout read, but only on resize
156- * and once the fonts land, never in the frame loop.
148+ * How each layout wants the view placed in its block, as a vertical shift of
149+ * the image in normalised coordinates: an opener keeps its heading's room
150+ * clear at the foot of the block.
157151 */
158- const MIN_BAND = 240
159- const ledeText = document . querySelector ( '#lede .col' )
160-
161- function placeBand ( ) {
162- if ( ! ledeText ) return
163- const textBottom = ledeText . getBoundingClientRect ( ) . bottom + scrollY
164- const top = Math . round ( Math . max ( 0 , Math . min ( textBottom + 8 , innerHeight - MIN_BAND ) ) )
165- const rootStyle = document . documentElement . style
166- rootStyle . setProperty ( '--band-top' , `${ top } px` )
167- rootStyle . setProperty ( '--band-h' , `${ Math . max ( MIN_BAND , innerHeight - top ) } px` )
152+ const LENS = { a : 0 , b : 0.3 , c : 0 }
153+
154+ function hostFig ( fig ) {
155+ if ( fig === host ) return
156+ host = fig
157+ fig . prepend ( canvas )
158+ hostNote = fig . querySelector ( '.fig-note' )
159+ scene . setLens ?. ( { shift : Number ( fig . dataset . frame ) === FUN ? 0 : LENS [ variant ] , phone : true } )
160+ scene . setFrame ( Number ( fig . dataset . frame ) )
161+ measureFun ( )
162+ }
163+
164+ function pickFig ( ) {
165+ if ( ! phone . matches ) return
166+ let best = null
167+ let bestRatio = 0
168+ for ( const fig of figs ) {
169+ const ratio = nearness . get ( fig ) || 0
170+ if ( ratio > bestRatio ) { best = fig ; bestRatio = ratio }
171+ }
172+ if ( best ) hostFig ( best )
168173}
169174
170- placeBand ( )
171- // The display face is taller than its fallback, so the lede's height is only
172- // final once it has loaded.
173- document . fonts ?. ready . then ( ( ) => { placeBand ( ) ; measureFun ( ) } )
175+ // Chosen a little ahead, over a margin below the screen, so the framing has
176+ // settled by the time the block scrolls into view…
177+ const ahead = new IntersectionObserver ( ( entries ) => {
178+ for ( const entry of entries ) nearness . set ( entry . target , entry . isIntersecting ? entry . intersectionRatio || 0.001 : 0 )
179+ pickFig ( )
180+ } , { rootMargin : '0px 0px 60% 0px' , threshold : [ 0 , 0.1 , 0.2 , 0.3 , 0.4 , 0.5 , 0.6 , 0.7 , 0.8 , 0.9 , 1 ] } )
181+ // …and drawn only while it is actually on screen.
182+ const seen = new IntersectionObserver ( ( entries ) => {
183+ for ( const entry of entries ) {
184+ if ( entry . isIntersecting ) visible . add ( entry . target )
185+ else visible . delete ( entry . target )
186+ }
187+ } )
188+ for ( const fig of figs ) { ahead . observe ( fig ) ; seen . observe ( fig ) }
189+
190+ phone . addEventListener ( 'change' , ( ) => {
191+ if ( phone . matches ) {
192+ host = null
193+ pickFig ( )
194+ return
195+ }
196+ canvasHome . parent . insertBefore ( canvas , canvasHome . next )
197+ host = null
198+ scene . setLens ?. ( { shift : 0 } )
199+ scene . setFrame ( active )
200+ measureFun ( )
201+ } )
202+
203+ let hostNote = null
204+
205+ // Mockup round only: the chooser between the three phone layouts.
206+ {
207+ const chooser = document . createElement ( 'nav' )
208+ chooser . className = 'mock-switch'
209+ chooser . setAttribute ( 'aria-label' , 'Mockup layouts' )
210+ chooser . innerHTML = [ 'a' , 'b' , 'c' ] . map ( ( m ) => `<a href="?m=${ m } "${ m === variant ? ' class="on"' : '' } >${ m } </a>` ) . join ( '' )
211+ document . body . append ( chooser )
212+ }
174213
175214addEventListener ( 'pointermove' , ( event ) => {
176215 scene . setPointer ( ( event . clientX / innerWidth - 0.5 ) * 2 , ( event . clientY / innerHeight - 0.5 ) * 2 )
@@ -200,11 +239,8 @@ const footer = document.querySelector('footer')
200239
201240function resize ( ) {
202241 if ( footer ) document . documentElement . style . setProperty ( '--footer-h' , `${ Math . ceil ( footer . getBoundingClientRect ( ) . height ) } px` )
203- // iOS changes the viewport height as its toolbars come and go, and that
204- // moves where "a third of a screen" falls.
205- viewportHeight = innerHeight
206- updatePresence ( )
207- placeBand ( )
242+ const rail = document . querySelector ( '.rail' )
243+ if ( rail ) document . documentElement . style . setProperty ( '--bar-h' , `${ Math . ceil ( rail . getBoundingClientRect ( ) . height ) } px` )
208244 measureFun ( )
209245 rules . resize ( )
210246 trackSection ( )
@@ -276,22 +312,41 @@ function syncCaption () {
276312 document . documentElement . classList . toggle ( 'caption-on' , Boolean ( pointedRow ) && active === FUN )
277313}
278314
315+ // Pointing is for a mouse; a finger's taps fire the same enter and leave, and
316+ // would bring a view up only to drop it again as the finger lifts.
279317for ( const row of projectRows ) {
280- row . addEventListener ( 'pointerenter' , ( ) => point ( row ) )
281- row . addEventListener ( 'pointerleave' , ( ) => point ( null ) )
282- row . addEventListener ( 'focusin' , ( ) => point ( row ) )
283- row . addEventListener ( 'focusout' , ( ) => point ( null ) )
318+ row . addEventListener ( 'pointerenter' , ( event ) => { if ( event . pointerType !== 'touch' ) point ( row ) } )
319+ row . addEventListener ( 'pointerleave' , ( event ) => { if ( event . pointerType !== 'touch' ) point ( null ) } )
320+ row . addEventListener ( 'focusin' , ( ) => { if ( ! phone . matches ) point ( row ) } )
321+ row . addEventListener ( 'focusout' , ( ) => { if ( ! phone . matches ) point ( null ) } )
284322}
285323showViz ( null )
286324
287- if ( vizRows . length && matchMedia ( '(hover: none)' ) . matches ) {
288- const turns = [ null , ...vizRows . map ( ( row ) => row . dataset . viz ) ]
289- let turn = 0
290- setInterval ( ( ) => {
291- if ( active !== FUN || document . hidden ) return
292- turn = ( turn + 1 ) % turns . length
293- showViz ( turns [ turn ] )
294- } , 8000 )
325+ /**
326+ * On a phone there is no pointing, so a project with data behind it carries a
327+ * button that brings its view up in the section's block, and its title stays
328+ * the link it always was. Tapping it again, or another, puts the view back.
329+ */
330+ let picked = null
331+ const peeks = vizRows . map ( ( row ) => {
332+ const button = document . createElement ( 'button' )
333+ button . type = 'button'
334+ button . className = 'peek'
335+ button . setAttribute ( 'aria-pressed' , 'false' )
336+ button . textContent = 'Show the data'
337+ button . addEventListener ( 'click' , ( ) => pick ( picked === row ? null : row ) )
338+ row . querySelector ( 'h3' ) . after ( button )
339+ return button
340+ } )
341+
342+ function pick ( row ) {
343+ picked = row
344+ showViz ( row ?. dataset . viz ?? null )
345+ shownLive = null
346+ vizRows . forEach ( ( r , i ) => {
347+ peeks [ i ] . setAttribute ( 'aria-pressed' , String ( r === row ) )
348+ peeks [ i ] . textContent = r === row ? 'Showing, above' : 'Show the data'
349+ } )
295350}
296351
297352/**
@@ -302,13 +357,16 @@ if (vizRows.length && matchMedia('(hover: none)').matches) {
302357 * and once the fonts have landed.
303358 */
304359const funSection = document . getElementById ( 'fun' )
360+ // The section's last row only finds its height once the display face is in.
361+ document . fonts ?. ready . then ( ( ) => measureFun ( ) )
305362
306363function measureFun ( ) {
307364 if ( ! funSection || ! projectRows . length ) return
308- // Below the layout change the model sits behind the text rather than beside
309- // it, and the views keep a plain upper-middle band of the canvas.
310- if ( innerWidth <= 1080 ) {
311- scene . setFitBox ?. ( { top : 0.55 , bottom : - 0.35 } )
365+ // On a phone the views fill their own block, inside a margin, and leave
366+ // the foot of an opener to its heading.
367+ if ( phone . matches ) {
368+ const foot = variant === 'b' ? - 0.05 : - 0.74
369+ scene . setFitBox ?. ( { top : 0.7 , bottom : foot , left : - 0.84 , right : 0.84 } )
312370 return
313371 }
314372 const origin = funSection . getBoundingClientRect ( ) . top
@@ -332,6 +390,9 @@ const CONTACT = sections.findIndex((section) => section.id === 'contact')
332390let dragging = null
333391
334392function overModel ( event ) {
393+ // On a phone the model has its own block, so a finger can turn it there:
394+ // the block takes sideways moves and leaves upright ones to the page.
395+ if ( phone . matches ) return Number ( host ?. dataset . frame ) === CONTACT && Boolean ( host ?. contains ( event . target ) )
335396 if ( active !== CONTACT || event . pointerType === 'touch' ) return false
336397 if ( event . target . closest ?. ( 'a, button, input, textarea, label, .col, footer' ) ) return false
337398 const box = canvas . getBoundingClientRect ( )
@@ -422,16 +483,24 @@ function tick (now) {
422483 frameState . lit = stim . held
423484
424485 scene . update ( dt , frameState )
425- scene . render ( )
426- if ( pointedRow ?. dataset . viz && active === FUN ) {
486+ // On a phone the canvas is only ever inside a block, so with its block off
487+ // screen there is nothing to draw.
488+ if ( ! phone . matches || visible . has ( host ) ) scene . render ( )
489+ // What the project's view shows right now: in the caption beside it, or on
490+ // a phone at the head of its block.
491+ const onPhone = phone . matches
492+ const liveLine = onPhone ? ( Number ( host ?. dataset . frame ) === FUN ? hostNote : null ) : captionLive
493+ const describing = onPhone ? Boolean ( picked ) : Boolean ( pointedRow ?. dataset . viz ) && active === FUN
494+ if ( describing && liveLine ) {
427495 const live = scene . live ?? ''
428496 if ( live !== shownLive ) {
429497 shownLive = live
430- captionLive . textContent = live
498+ liveLine . textContent = live
431499 }
432500 } else if ( shownLive !== '' ) {
433501 shownLive = ''
434502 if ( captionLive ) captionLive . textContent = ''
503+ if ( hostNote ) hostNote . textContent = ''
435504 }
436505 rules . draw ( signal , COLOURS [ stim . state ] , RULE )
437506}
0 commit comments