@@ -131,20 +131,22 @@ interface HashTarget {
131131 element : Selection < SVGGElement , GraphNode , SVGGElement , unknown > ;
132132}
133133
134- function initGlyphs ( rootElement : Element ) : void {
134+ function initGlyphs ( rootElement : Element , options ?: { reduced ?: boolean } ) : void {
135135 const glyphContainer = rootElement . querySelector ( '.graph-glyphs' ) ;
136136 if ( ! glyphContainer ) {
137137 return ;
138138 }
139139
140+ const reduced = Boolean ( options ?. reduced ) ;
140141 const glyphSets = [
141142 '∆ ƒ π λ ξ 0 1 2 3 5 8 A C E G H K' ,
142143 'β γ ζ ψ Ω 7 9 B D F J L N Q Z' ,
143144 '▮ ▯ ▰ ϟ ψ Ω ≡ ≣ ≠ ✶ ✷ ✸ ✹ ✺' ,
144145 '0 1 0 1 0 1 0 1 0 1 0 1 0 1' ,
145146 ] ;
146147
147- const totalClusters = 16 ;
148+ const totalClusters = reduced ? 6 : 16 ;
149+ const baseTimeout = reduced ? 2600 : 1500 ;
148150 const fragment = document . createDocumentFragment ( ) ;
149151 const clusters : HTMLElement [ ] = [ ] ;
150152
@@ -161,9 +163,9 @@ function initGlyphs(rootElement: Element): void {
161163 function randomizeCluster ( cluster : HTMLElement ) : void {
162164 const left = Math . random ( ) * 100 ;
163165 const top = Math . random ( ) * 100 ;
164- const scale = 0.6 + Math . random ( ) * 1.4 ;
165- const flicker = `${ 3.5 + Math . random ( ) * 3 } s` ;
166- const hue = `${ 6 + Math . random ( ) * 5 } s` ;
166+ const scale = reduced ? 0.8 + Math . random ( ) * 0.8 : 0.6 + Math . random ( ) * 1.4 ;
167+ const flicker = reduced ? ` ${ 4.5 + Math . random ( ) * 2.5 } s` : `${ 3.5 + Math . random ( ) * 3 } s` ;
168+ const hue = reduced ? ` ${ 8 + Math . random ( ) * 4 } s` : `${ 6 + Math . random ( ) * 5 } s` ;
167169 const text = glyphSets [ Math . floor ( Math . random ( ) * glyphSets . length ) ] ;
168170
169171 cluster . style . setProperty ( 'left' , `${ left } %` ) ;
@@ -192,7 +194,7 @@ function initGlyphs(rootElement: Element): void {
192194 cluster . style . transition = 'opacity 0.35s ease' ;
193195 cluster . style . opacity = '0' ;
194196
195- const timeout = 1500 + Math . random ( ) * 2500 ;
197+ const timeout = baseTimeout + Math . random ( ) * ( reduced ? 2200 : 2500 ) ;
196198
197199 window . setTimeout ( ( ) => {
198200 randomizeCluster ( cluster ) ;
@@ -201,7 +203,7 @@ function initGlyphs(rootElement: Element): void {
201203 }
202204
203205 for ( let i = 0 ; i < clusters . length ; i += 1 ) {
204- const delay = ( i / clusters . length ) * 2000 ;
206+ const delay = reduced ? 0 : ( i / clusters . length ) * 2000 ;
205207 window . setTimeout ( ( ) => loopCluster ( i ) , delay ) ;
206208 }
207209}
@@ -212,7 +214,14 @@ export async function initGraph(containerSelector: string, dataUrl: string): Pro
212214 return ;
213215 }
214216
215- initGlyphs ( rootElement ) ;
217+ const prefersReducedMotion = window . matchMedia ( '(prefers-reduced-motion: reduce)' ) . matches ;
218+ const isSmallScreen = window . matchMedia ( '(max-width: 768px)' ) . matches ;
219+ const extendedNavigator = navigator as Navigator & { connection ?: { saveData ?: boolean } } ;
220+ const hasSaveDataPreference = Boolean ( extendedNavigator . connection ?. saveData ) ;
221+ const isLowConcurrency = typeof navigator . hardwareConcurrency === 'number' && navigator . hardwareConcurrency > 0 && navigator . hardwareConcurrency <= 4 ;
222+ const shouldReduceMotion = prefersReducedMotion || hasSaveDataPreference || isSmallScreen || isLowConcurrency ;
223+
224+ initGlyphs ( rootElement , { reduced : shouldReduceMotion } ) ;
216225
217226 const response = await fetch ( dataUrl ) ;
218227 if ( ! response . ok ) {
@@ -263,32 +272,35 @@ export async function initGraph(containerSelector: string, dataUrl: string): Pro
263272 default :
264273 customColor = NODE_COLORS [ node . type ] ;
265274 }
266- nodes . push ( {
275+ const nodeEntry : GraphNode = {
267276 ...node ,
268277 radius,
269278 chargeStrength,
270279 color : customColor ,
271- pulse : {
280+ spinAngle : 0 ,
281+ } ;
282+ if ( ! shouldReduceMotion ) {
283+ nodeEntry . pulse = {
272284 baseRadius : radius ,
273285 amplitude : radius * ( ( PULSE_SCALE [ node . type ] ?? 1.7 ) - 1 ) * ( 0.6 + Math . random ( ) * 1.2 ) ,
274286 speed : 0.003 + Math . random ( ) * 0.005 ,
275287 phase : Math . random ( ) * Math . PI * 2 ,
276288 baseCharge : chargeStrength ,
277- } ,
278- drift : {
289+ } ;
290+ nodeEntry . drift = {
279291 amplitudeX : 18 + Math . random ( ) * 26 ,
280292 amplitudeY : 12 + Math . random ( ) * 18 ,
281293 speedX : 0.00008 + Math . random ( ) * 0.00012 ,
282294 speedY : 0.00008 + Math . random ( ) * 0.00012 ,
283295 phaseX : Math . random ( ) * Math . PI * 2 ,
284296 phaseY : Math . random ( ) * Math . PI * 2 ,
285- } ,
286- spin : {
297+ } ;
298+ nodeEntry . spin = {
287299 speed : 0.00012 + Math . random ( ) * 0.00025 ,
288300 phase : Math . random ( ) * Math . PI * 2 ,
289- } ,
290- spinAngle : 0 ,
291- } ) ;
301+ } ;
302+ }
303+ nodes . push ( nodeEntry ) ;
292304 }
293305
294306 for ( let i = 0 ; i < data . links . length ; i += 1 ) {
@@ -321,18 +333,20 @@ export async function initGraph(containerSelector: string, dataUrl: string): Pro
321333 linkGradient . append ( 'stop' ) . attr ( 'offset' , '55%' ) . attr ( 'stop-color' , 'rgba(113, 213, 255, 0.52)' ) ;
322334 linkGradient . append ( 'stop' ) . attr ( 'offset' , '100%' ) . attr ( 'stop-color' , 'rgba(140, 91, 250, 0.55)' ) ;
323335
324- const glowFilter = defs
325- . append ( 'filter' )
326- . attr ( 'id' , 'node-glow' )
327- . attr ( 'x' , '-50%' )
328- . attr ( 'y' , '-50%' )
329- . attr ( 'width' , '200%' )
330- . attr ( 'height' , '200%' ) ;
331-
332- glowFilter . append ( 'feGaussianBlur' ) . attr ( 'stdDeviation' , 18 ) . attr ( 'result' , 'coloredBlur' ) ;
333- const feMerge = glowFilter . append ( 'feMerge' ) ;
334- feMerge . append ( 'feMergeNode' ) . attr ( 'in' , 'coloredBlur' ) ;
335- feMerge . append ( 'feMergeNode' ) . attr ( 'in' , 'SourceGraphic' ) ;
336+ if ( ! shouldReduceMotion ) {
337+ const glowFilter = defs
338+ . append ( 'filter' )
339+ . attr ( 'id' , 'node-glow' )
340+ . attr ( 'x' , '-50%' )
341+ . attr ( 'y' , '-50%' )
342+ . attr ( 'width' , '200%' )
343+ . attr ( 'height' , '200%' ) ;
344+
345+ glowFilter . append ( 'feGaussianBlur' ) . attr ( 'stdDeviation' , 18 ) . attr ( 'result' , 'coloredBlur' ) ;
346+ const feMerge = glowFilter . append ( 'feMerge' ) ;
347+ feMerge . append ( 'feMergeNode' ) . attr ( 'in' , 'coloredBlur' ) ;
348+ feMerge . append ( 'feMergeNode' ) . attr ( 'in' , 'SourceGraphic' ) ;
349+ }
336350
337351 const zoomBehavior = zoom < SVGSVGElement , unknown > ( )
338352 . scaleExtent ( [ 0.5 , 2 ] )
@@ -356,9 +370,12 @@ export async function initGraph(containerSelector: string, dataUrl: string): Pro
356370 . attr ( 'stroke-opacity' , 0.72 )
357371 . attr ( 'stroke' , 'url(#link-gradient)' ) ;
358372
359- const nodeSelection = g
360- . append ( 'g' )
361- . attr ( 'filter' , 'url(#node-glow)' )
373+ const nodesGroup = g . append ( 'g' ) ;
374+ if ( ! shouldReduceMotion ) {
375+ nodesGroup . attr ( 'filter' , 'url(#node-glow)' ) ;
376+ }
377+
378+ const nodeSelection = nodesGroup
362379 . selectAll < SVGGElement , GraphNode > ( 'g' )
363380 . data ( nodes )
364381 . join ( 'g' )
@@ -400,14 +417,16 @@ export async function initGraph(containerSelector: string, dataUrl: string): Pro
400417 . attr ( 'opacity' , 0.92 )
401418 . attr ( 'data-node-id' , ( d ) => d . id ) ;
402419
403- nodeSelection
404- . append ( 'circle' )
405- . attr ( 'r' , ( d ) => ( d . radius ?? NODE_RADIUS [ d . type ] ) * 1.25 )
406- . attr ( 'fill' , 'none' )
407- . attr ( 'stroke' , ( d ) => ( d as GraphNode & { color ?: string } ) . color ?? NODE_COLORS [ d . type ] )
408- . attr ( 'stroke-width' , 3.5 )
409- . attr ( 'opacity' , 0.36 )
410- . attr ( 'class' , 'graph-node__glow' ) ;
420+ if ( ! shouldReduceMotion ) {
421+ nodeSelection
422+ . append ( 'circle' )
423+ . attr ( 'r' , ( d ) => ( d . radius ?? NODE_RADIUS [ d . type ] ) * 1.25 )
424+ . attr ( 'fill' , 'none' )
425+ . attr ( 'stroke' , ( d ) => ( d as GraphNode & { color ?: string } ) . color ?? NODE_COLORS [ d . type ] )
426+ . attr ( 'stroke-width' , 3.5 )
427+ . attr ( 'opacity' , 0.36 )
428+ . attr ( 'class' , 'graph-node__glow' ) ;
429+ }
411430
412431 nodeSelection
413432 . append ( 'text' )
@@ -445,10 +464,10 @@ export async function initGraph(containerSelector: string, dataUrl: string): Pro
445464 } ) ,
446465 )
447466 . force ( 'center' , forceCenter ( width / 2 , height / 2 ) )
448- . force ( 'collision' , forceCollide ( ) . radius ( ( d ) => ( d . radius ?? 10 ) + 28 ) . strength ( 1.5 ) )
467+ . force ( 'collision' , forceCollide ( ) . radius ( ( d ) => ( d . radius ?? 10 ) + ( shouldReduceMotion ? 18 : 28 ) ) . strength ( shouldReduceMotion ? 1 : 1.5 ) )
449468 . force ( 'x' , forceX ( width / 2 ) . strength ( 0.008 ) )
450469 . force ( 'y' , forceY ( height / 2 ) . strength ( 0.008 ) )
451- . alphaDecay ( 0.04 ) ;
470+ . alphaDecay ( shouldReduceMotion ? 0.08 : 0.04 ) ;
452471
453472 simulation
454473 . force ( 'type-position' , forceY < GraphNode > ( ) . strength ( 0.035 ) . y ( ( d ) => TYPE_FORCE_TARGETS [ d . type ] ?? height / 2 ) ) ;
@@ -634,7 +653,7 @@ export async function initGraph(containerSelector: string, dataUrl: string): Pro
634653 return null ;
635654 } ) ;
636655
637- simulation . alpha ( 0.2 ) . restart ( ) ;
656+ simulation . alpha ( shouldReduceMotion ? 0.1 : 0.2 ) . restart ( ) ;
638657 }
639658
640659 nodeSelection
0 commit comments