@@ -11,6 +11,30 @@ const REPEL_RADIUS = 10;
1111const REPEL_FORCE = 4 ;
1212const PARTICLE_SIZE = 1.5 ;
1313
14+ /**
15+ * Pre-compute subsampled particle coordinates from articles and video windows.
16+ * Call this once after data loads, then pass the result to ParticleSystem.initWithPoints().
17+ *
18+ * @param {Array<{x: number, y: number}> } articles - Article coordinates
19+ * @param {Array<{x: number, y: number}> } videoPoints - Video window coordinates
20+ * @returns {Array<{x: number, y: number}> } Subsampled points (up to PARTICLE_COUNT)
21+ */
22+ export function subsampleParticlePoints ( articles , videoPoints = [ ] ) {
23+ const articlePts = articles . map ( a => ( { x : a . x || Math . random ( ) , y : a . y || Math . random ( ) } ) ) ;
24+ const videoPts = videoPoints . map ( v => ( { x : v . x , y : v . y } ) ) ;
25+
26+ const total = articlePts . length + videoPts . length ;
27+ if ( total <= PARTICLE_COUNT ) {
28+ return [ ...articlePts , ...videoPts ] ;
29+ }
30+
31+ const artBudget = Math . min ( articlePts . length , Math . floor ( PARTICLE_COUNT * 0.7 ) ) ;
32+ const vidBudget = Math . min ( videoPts . length , PARTICLE_COUNT - artBudget ) ;
33+ const artSample = articlePts . sort ( ( ) => Math . random ( ) - 0.5 ) . slice ( 0 , artBudget ) ;
34+ const vidSample = videoPts . sort ( ( ) => Math . random ( ) - 0.5 ) . slice ( 0 , vidBudget ) ;
35+ return [ ...artSample , ...vidSample ] . sort ( ( ) => Math . random ( ) - 0.5 ) ;
36+ }
37+
1438export class ParticleSystem {
1539 constructor ( ) {
1640 this . canvas = null ;
@@ -46,7 +70,12 @@ export class ParticleSystem {
4670 this . _tick = this . _tick . bind ( this ) ;
4771 }
4872
49- async init ( canvas , basePath ) {
73+ /**
74+ * Initialize with pre-computed particle points (no fetching).
75+ * @param {HTMLCanvasElement } canvas
76+ * @param {Array<{x: number, y: number}> } points - From subsampleParticlePoints()
77+ */
78+ initWithPoints ( canvas , points ) {
5079 this . canvas = canvas ;
5180 this . ctx = canvas . getContext ( '2d' ) ;
5281 this . _onResize ( ) ;
@@ -59,60 +88,18 @@ export class ParticleSystem {
5988 window . addEventListener ( 'mouseup' , this . _onMouseUp ) ;
6089 canvas . style . cursor = 'grab' ;
6190
62- try {
63- const url = `${ basePath } data/domains/all.json` ;
64- const res = await fetch ( url ) ;
65- if ( ! res . ok ) throw new Error ( `Failed to fetch: ${ res . status } ` ) ;
66- const bundle = await res . json ( ) ;
67- const articles = bundle . articles || [ ] ;
68-
69- // Also load video catalog and merge window coordinates as particles
70- let videoPoints = [ ] ;
71- try {
72- const vRes = await fetch ( `${ basePath } data/videos/catalog.json` ) ;
73- if ( vRes . ok ) {
74- const videos = await vRes . json ( ) ;
75- for ( const v of videos ) {
76- if ( ! v . windows ) continue ;
77- for ( const [ x , y ] of v . windows ) {
78- videoPoints . push ( { x, y } ) ;
79- }
80- }
81- }
82- } catch { /* video catalog optional */ }
83-
84- this . _initParticles ( articles , videoPoints ) ;
85- this . running = true ;
86- this . raf = requestAnimationFrame ( this . _tick ) ;
87- } catch ( err ) {
88- console . warn ( '[particles] Could not load article data:' , err ) ;
89- }
91+ this . _initFromPoints ( points ) ;
92+ this . running = true ;
93+ this . raf = requestAnimationFrame ( this . _tick ) ;
9094 }
9195
92- _initParticles ( articles , videoPoints = [ ] ) {
93- // Subsample with balanced representation: articles get at least 70% of slots
94- const articlePts = articles . map ( a => ( { x : a . x || Math . random ( ) , y : a . y || Math . random ( ) } ) ) ;
95- const videoPts = videoPoints . map ( v => ( { x : v . x , y : v . y } ) ) ;
96-
97- let shuffled ;
98- const total = articlePts . length + videoPts . length ;
99- if ( total <= PARTICLE_COUNT ) {
100- shuffled = [ ...articlePts , ...videoPts ] ;
101- } else {
102- // Articles get 70% of budget, videos 30% (prevents video-dominated clustering)
103- const artBudget = Math . min ( articlePts . length , Math . floor ( PARTICLE_COUNT * 0.7 ) ) ;
104- const vidBudget = Math . min ( videoPts . length , PARTICLE_COUNT - artBudget ) ;
105- const artSample = articlePts . sort ( ( ) => Math . random ( ) - 0.5 ) . slice ( 0 , artBudget ) ;
106- const vidSample = videoPts . sort ( ( ) => Math . random ( ) - 0.5 ) . slice ( 0 , vidBudget ) ;
107- shuffled = [ ...artSample , ...vidSample ] . sort ( ( ) => Math . random ( ) - 0.5 ) ;
108- }
109-
96+ _initFromPoints ( points ) {
11097 const w = this . canvas . width / ( window . devicePixelRatio || 1 ) ;
11198 const h = this . canvas . height / ( window . devicePixelRatio || 1 ) ;
11299
113100 let minX = Infinity , maxX = - Infinity , minY = Infinity , maxY = - Infinity ;
114101
115- const rawParticles = shuffled . map ( a => {
102+ const rawParticles = points . map ( a => {
116103 const pctX = a . x ;
117104 const pctY = a . y ;
118105 if ( pctX < minX ) minX = pctX ;
0 commit comments