1// Scatter v0.1 | (c) Peter Boughton | License: LGPLv3 | Website: https://www.sorcerersisle.com/software/scatter
2"use strict";
3
4var Scatter = function()
5{
6
7 var DefaultOptions =
8 { Mode : 'radial'
9 , InitialMode : void 0
10 , InitialSelect : -1
11 , SelectedClass : 'selected'
12 , SelectedScale : 1.5
13 , Scale : 1.0
14 , MaxRotation : 10
15 , Shuffle : 'auto'
16 , ShuffleModes : ['random','radial']
17 , ContainerEvents : { 'click' : 'arrange' }
18 , ChildEvents : { 'click' : 'select' }
19
20 // Mode-specific options
21 , GridAlignCenter : true
22 , GridSpacing : 1
23 , GridSpacingX : void 0
24 , GridSpacingY : void 0
25 , PileOffsetFactor : 3
26 , RadialWidthFactor : 0.85
27 , RadialHeightFactor : 1.2
28 , RadialMinDistance : 0.5
29 , FixedRelativeTo : 'center'
30 , FixedPositions : void 0
31 };
32
33 var Scatter = function Scatter( Target , InitOptions )
34 {
35 var Instance = this;
36 var Container;
37 var Options = {};
38 var Positions = [];
39 var SelectedIndex;
40
41
42 Instance.configure = function( NewOptions )
43 {
44 if ( typeof NewOptions !== 'object' )
45 throw new TypeError('Invalid argument');
46
47 Options = resolveOptions( Options , NewOptions );
48 };
49
50
51 Instance.arrange = function( Mode , Reset )
52 {
53 if ( typeof Mode === 'undefined' || typeof Mode === 'object' )
54 Mode = Options.Mode;
55
56 Positions = choosePositions( Mode , Container , Options , Positions );
57
58 if ( ( typeof Reset !== 'boolean' || Reset ) && typeof SelectedIndex !== 'undefined' )
59 Container.children[SelectedIndex].classList.remove(Options.SelectedClass);
60
61 if ( Positions.length !== Container.children.length )
62 throw new Error('Found '+Container.children.length+' children; have '+Positions.length+' positions');
63
64 for ( var i=0 ; i<Container.children.length ; ++i )
65 translateCenter( Container.children[i] , Positions[i] , Options.Scale );
66 };
67
68
69 Instance.select = function( Index )
70 {
71 if ( typeof Index !== 'number' )
72 {
73 if ( Index.stopPropagation )
74 Index.stopPropagation();
75
76 Index = Array.prototype.indexOf.call( Container.children , Index.currentTarget || Index );
77 }
78
79 if ( typeof Index !== 'number' )
80 throw new Error('Invalid Index');
81
82 if ( typeof SelectedIndex !== 'undefined' )
83 {
84 if ( SelectedIndex === Index )
85 return;
86
87 Instance.discard();
88 }
89
90 if ( Index < 0 || Index >= Container.children.length )
91 throw new Error('Index out of bounds ('+Index+' not within 0..'+Container.children.length+')');
92
93 SelectedIndex = Index;
94 translateCenter( Container.children[Index] , getCenterCoords(Container) , Options.SelectedScale );
95 Container.children[Index].style.zIndex = getHighestZ(Container)+1;
96 Container.children[Index].classList.add(Options.SelectedClass);
97 };
98
99
100 Instance.discard = function( Reset )
101 {
102 if (typeof SelectedIndex === 'undefined')
103 return;
104
105 Container.children[SelectedIndex].classList.remove(Options.SelectedClass);
106 translateCenter( Container.children[SelectedIndex] , Positions[SelectedIndex] , Options.Scale );
107
108 if ( typeof Reset !== 'boolean' || Reset )
109 SelectedIndex = undefined;
110 };
111
112
113 Instance.prev = function()
114 {
115 if ( typeof SelectedIndex === 'undefined' || SelectedIndex === 0 )
116 return Instance.select(Container.children.length-1);
117
118 return Instance.select(SelectedIndex-1);
119 };
120
121
122 Instance.next = function()
123 {
124 if ( typeof SelectedIndex === 'undefined' || SelectedIndex === Container.children.length-1 )
125 return Instance.select(0);
126
127 return Instance.select(SelectedIndex+1);
128 };
129
130
131 Container = getContainer( Target );
132
133 Options = resolveOptions( DefaultOptions , InitOptions );
134
135 prepareElements( Container , Options , this );
136
137 if ( typeof Options.InitialMode !== 'undefined' )
138 Instance.arrange(Options.InitialMode);
139
140 if ( Options.InitialSelect !== -1)
141 Instance.select(Options.InitialSelect);
142 }
143
144
145 function getContainer( Target )
146 {
147 if ( typeof Target === 'object' && Target.nodeType == Node.ELEMENT_NODE )
148 return Target;
149
150 if ( typeof Target !== 'string' )
151 throw new Error('Invalid Target, expected Element or Selector');
152
153 var TargetElement = document.querySelector(Target);
154
155 if ( TargetElement === null )
156 throw new Error('Selector ['+Target+'] did not match.');
157
158 return TargetElement;
159 }
160
161
162 function resolveOptions( Current , New )
163 {
164 var Result = {};
165
166 Object.assign( Result , Current );
167
168 for ( var CurOpt in New )
169 {
170 Result[CurOpt] = ( typeof Result[CurOpt] === 'object' && ! Result[CurOpt] instanceof Array )
171 ? resolveOptions(Result[CurOpt],New[CurOpt])
172 : New[CurOpt]
173 ;
174 }
175
176 if ( Result.Mode === 'fixed' && ! Result.FixedPositions instanceof Array )
177 throw new Error('Invalid Options, Mode "fixed" requires array of FixedPositions');
178
179 return Result;
180 }
181
182
183 function prepareElements( Container , Options , Instance )
184 {
185 if ( getComputedStyle(Container).position == 'static' )
186 Container.style.position = 'relative';
187
188 var ContainerCenter = getCenterCoords(Container);
189
190 for ( var i=0 ; i<Container.children.length ; ++i )
191 {
192 Container.children[i].style.position = 'absolute';
193 positionCenter( Container.children[i] , ContainerCenter , Options.Scale );
194
195 for ( var CurType in Options.ChildEvents )
196 {
197 var CurListener = Options.ChildEvents[CurType];
198 Container.children[i].addEventListener
199 ( CurType
200 , (typeof CurListener === 'string') ? Instance[ CurListener ] : CurListener
201 );
202 }
203 }
204
205 for ( var CurType in Options.ContainerEvents )
206 {
207 var CurListener = Options.ContainerEvents[CurType];
208 Container.addEventListener
209 ( CurType
210 , (typeof CurListener === 'string') ? Instance[ CurListener ] : CurListener
211 );
212 }
213 }
214
215
216 function getCenterCoords( Element )
217 {
218 var c = getDocumentPosition(Element);
219 return { left:c.left+c.width/2 , top:c.top+c.height/2 , angle:0 };
220 }
221
222
223 function positionCenter( Element , Pos , Scale )
224 {
225 var ParentPos = getDocumentPosition(Element.parentElement);
226
227 Element.style.left = (Pos.left - ParentPos.left - Element.offsetWidth/2) + 'px';
228 Element.style.top = (Pos.top - ParentPos.top - Element.offsetHeight/2) + 'px';
229 Element.style.transform = 'scale('+Scale+')';
230 }
231
232
233 function translateCenter( Element , Pos , Scale )
234 {
235 var ParentPos = getDocumentPosition(Element.parentElement);
236
237 var x = (Pos.left - ParentPos.left - Element.offsetWidth/2) + 'px';
238 var y = (Pos.top - ParentPos.top - Element.offsetHeight/2) + 'px';
239
240 Element.style.left = '0px';
241 Element.style.top = '0px';
242 Element.style.transform = 'translate('+x+','+y+') rotate('+Pos.angle+'deg) scale('+Scale+')';
243 }
244
245
246 function choosePositions ( Mode , Container , Options , Positions )
247 {
248 if ( Container.children.length === 0 )
249 throw new RangeError('No children');
250
251 if ( typeof Mode === 'undefined' || typeof Mode === 'object' )
252 Mode = Options.Mode;
253
254 var Result = [];
255
256 var Bounds = getDocumentPosition(Container);
257
258 if ( Mode === 'fixed' )
259 {
260 if ( ! Options.FixedPositions instanceof Array )
261 throw new TypeError('Missing or invalid FixedPositions option');
262
263 // Accept 2/3 item array, or object with named keys...
264 var k = ( Options.FixedPositions[0] instanceof Array )
265 ? [0,1,2] : ['left','top','angle']
266 ;
267
268 for ( var i=0 ; i<Options.FixedPositions.length ; ++i )
269 {
270 var CurPos =
271 { left : Options.FixedPositions[i][k[0]] + Bounds.left
272 , top : Options.FixedPositions[i][k[1]] + Bounds.top
273 , angle : Options.FixedPositions[i][k[2]] || 0
274 };
275
276 if ( Options.FixedRelativeTo == 'center' )
277 {
278 CurPos.left += Bounds.width/2;
279 CurPos.top += Bounds.height/2;
280 }
281 Result.push(CurPos);
282 }
283
284 if ( typeof Options.Shuffle === 'boolean' ? Options.Shuffle : ( Options.ShuffleModes.indexOf(Mode) !== -1 ) )
285 shuffle(Result);
286
287 return Result;
288 }
289
290 if ( Mode === 'radial' )
291 {
292 var ContainerCenter = getCenterCoords(Container);
293 var WidthRatio = (Bounds.width/Bounds.height)*Options.RadialWidthFactor;
294 var HeightRatio = (Bounds.height/Bounds.width)*Options.RadialHeightFactor;
295 }
296 else if ( Mode === 'grid' )
297 {
298 var AspectRatio = Bounds.width/Bounds.height;
299 var MaxPerRow = Container.children.length / AspectRatio;
300 var ColIndex = 0;
301 var CellSize =
302 { X : Container.children[0].offsetWidth * Options.Scale
303 , Y : Container.children[0].offsetHeight * Options.Scale
304 };
305 var CurLeft = CellSize.X/2;
306 var CurTop = -CellSize.Y/2 - (Options.GridSpacingY||Options.GridSpacing);
307 var MaxRight=0;
308 var MaxBottom=0;
309 }
310
311 for ( var Index=0 ; Index < Container.children.length ; ++Index )
312 {
313 switch (Mode)
314 {
315 case 'center':
316 case 'stack':
317 case 'pile':
318 Result[Index] =
319 { left : Bounds.left + Bounds.width/2
320 , top : Bounds.top + Bounds.height/2
321 , angle : 0
322 };
323
324 if ( Mode !== 'center' )
325 {
326 Result[Index].angle = getRandomOffset(Options.MaxRotation);
327 }
328
329 if ( Mode == 'pile' )
330 {
331 var ItemRadius =
332 { X : Container.children[Index].offsetWidth/2 * Options.Scale
333 , Y : Container.children[Index].offsetHeight/2 * Options.Scale
334 };
335
336 Result[Index].left += getRandomOffset(ItemRadius.X/Options.PileOffsetFactor);
337 Result[Index].top += getRandomOffset(ItemRadius.Y/Options.PileOffsetFactor);
338 }
339 break;
340
341 case 'random':
342 Result[Index] =
343 { left : Bounds.left + Math.random()*Bounds.width
344 , top : Bounds.top + Math.random()*Bounds.height
345 , angle : getRandomOffset(Options.MaxRotation)
346 };
347 break;
348
349 case 'radial':
350 var Angle = 2*Math.PI * (Index / Container.children.length);
351 var Distance = Math.max(Options.RadialMinDistance,Math.random())* Math.min(Bounds.width,Bounds.height)/2;
352
353 Result[Index] =
354 { left : ContainerCenter.left + (Math.cos(Angle)*Distance*WidthRatio)
355 , top : ContainerCenter.top + (Math.sin(Angle)*Distance*HeightRatio)
356 , angle : getRandomOffset(Options.MaxRotation)
357 };
358 break;
359
360 case 'grid':
361 CurLeft += Container.children[Index].offsetWidth * Options.Scale;
362
363 if ( CurLeft+CellSize.X > Bounds.width || ColIndex > MaxPerRow || Index === 0 )
364 {
365 MaxRight = CurLeft-CellSize.X/2;
366 CurLeft = CellSize.X/2;
367 CurTop += CellSize.Y + (Options.GridSpacingY||Options.GridSpacing);
368 ColIndex = 0;
369 MaxBottom = CurTop+CellSize.Y/2;
370 }
371 else
372 {
373 ++ColIndex;
374 CurLeft += (Options.GridSpacingX||Options.GridSpacing);
375 }
376
377 Result[Index] =
378 { left : Bounds.left + CurLeft
379 , top : Bounds.top + CurTop
380 , angle : getRandomOffset(Options.MaxRotation)
381 };
382 break;
383
384 default:
385 throw new Error('Unknown Mode option ['+Options.Mode+']');
386 break;
387 }
388 }
389
390 if ( Mode === 'grid' && Options.GridAlignCenter )
391 {
392 for ( var Index = 0 ; Index < Result.length ; ++Index )
393 {
394 Result[Index].left += (Bounds.width-MaxRight)/2;
395 Result[Index].top += (Bounds.height-MaxBottom)/2;
396 }
397 }
398
399 if ( typeof Options.Shuffle === 'boolean' ? Options.Shuffle : ( Options.ShuffleModes.indexOf(Mode) !== -1 ) )
400 shuffle(Result);
401
402 return Result;
403 }
404
405
406 function getDocumentPosition(Element)
407 {
408 var r1 = Element.getBoundingClientRect();
409 var r2 = document.documentElement.getBoundingClientRect();
410
411 var Result = {};
412
413 for ( var key in r1 )
414 Result[key] = r1[key];
415
416 Result.left -= r2.left;
417 Result.right -= r2.left;
418 Result.top -= r2.top;
419 Result.bottom -= r2.top;
420
421 return Result;
422 }
423
424
425 function getHighestZ(Element)
426 {
427 return Math.max.apply
428 ( null
429 , Array.from
430 ( Element.children
431 , function(Child){ return getComputedStyle(Child)['z-index']/1||0; }
432 )
433 );
434 }
435
436
437 function getRandomOffset(Limit)
438 {
439 return (Math.random()*Limit*2) - Limit;
440 }
441
442
443 function shuffle( Collection )
444 {
445 // Fisher-Yates shuffle algorithm
446 for ( var OldIndex = Collection.length-1 ; OldIndex > 0 ; --OldIndex )
447 {
448 var NewIndex = Math.floor( Math.random() * (OldIndex+1) );
449 var Swap = Collection[OldIndex];
450 Collection[OldIndex] = Collection[NewIndex];
451 Collection[NewIndex] = Swap;
452 }
453 }
454
455
456 return Scatter;
457}();