Sorcerer's IsleCode Scatter / files

  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}();