import dat from 'dat.gui/build/dat.gui'
import queryString from 'query-string'
import debounce from 'debounce'
import seedrandom from 'seedrandom'
import logger from './utils/logger'
import Color from './utils/Color'
import Is from './utils/Is'
import Mathematics from './utils/Mathematics'
import Stats from './utils/Stats'
import Vector from './utils/Vector'
import GyroNorm from 'gyronorm'
import 'whatwg-fetch' /* global fetch */
/**
* A playground for creative coding
*/
class Sandpit {
static get CANVAS () { return '2d' }
static get WEBGL () { return 'webgl' }
static get EXPERIMENTAL_WEBGL () { return 'experimental-webgl' }
/**
* @param {(string|object)} container - The container for the canvas to be added to
* @param {string} type - Defines whether the context is 2d or 3d
* @param {object} options - Optionally decide to ignore rescaling for retina displays,
* disable putting settings into the query string, or add stats to the dom
*/
constructor (container, type, options) {
logger.info('⛱ Welcome to Sandpit')
this._queryable = options && options.hasOwnProperty('queryable') ? options.queryable : true
this._retina = options && options.hasOwnProperty('retina') ? options.retina : true
this._stats = options && options.hasOwnProperty('stats') ? options.stats : false
this._setupContext(container, type, this._retina)
}
/**
* Set up the canvas
* @private
*/
_setupContext (container, type, retina) {
// Check that the correct container type has been passed
if (typeof container !== 'string' && typeof container !== 'object') {
throw new Error('Please provide a string or object reference to the container, like ".container", or document.querySelector(".container")')
}
// Check that the type is set
if (typeof type !== 'string' || (type !== Sandpit.CANVAS && type !== Sandpit.WEBGL)) {
throw new Error('Please provide a context type - either `Sandpit.CANVAS` or `Sandpit.WEBGL`')
}
// Either find the container, or just use the object
let _container
if (typeof container === 'string') {
_container = document.querySelector(container)
} else if (typeof container === 'object') {
_container = container
}
// Check the container is a dom element
if (Is.element(_container)) {
// Check the container is a canvas element
// and if so, use it instead of making a new one
if (Is.canvas(_container)) {
this._canvas = _container
} else {
this._canvas = document.createElement('canvas')
_container.appendChild(this._canvas)
}
// Set the width and height
this._canvas.width = this._canvas.clientWidth
this._canvas.height = this._canvas.clientHeight
// Grab the context
if (type === Sandpit.CANVAS) {
this._context = this._canvas.getContext(type)
} else if (type === Sandpit.WEBGL || type === Sandpit.EXPERIMENTAL_WEBGL) {
this._context = this._canvas.getContext(Sandpit.WEBGL) || this._canvas.getContext(Sandpit.EXPERIMENTAL_WEBGL)
}
this._type = type
// Deal with retina displays
if (type === Sandpit.CANVAS && window.devicePixelRatio !== 1 && this._retina) {
this._handleRetina()
}
// Sets up stats, if they are enabled
if (this._stats) this.setupStats()
} else {
throw new Error('The container is not a HTMLElement')
}
}
/**
* Resizes the canvas for retina
* @private
*/
_handleRetina () {
const ratio = window.devicePixelRatio
// Increaser the canvas by the ratio
this._canvas.width = this._canvas.clientWidth * ratio
this._canvas.height = this._canvas.clientHeight * ratio
// Scale the canvas to the new ratio
this._context.scale(ratio, ratio)
}
/**
* Sets up the settings gui via dat.gui
* @param {object} settings - An object of key value pairs
* for the setting name and default value
* @param {boolean} queryable - Whether or not to store settings
* in the query string for easy sharing
* @private
*/
_setupSettings () {
this._settings = {}
this._clearGui = true
this._resetGui = true
// Destroy the gui if new settings are being passed in
if (this._gui) {
this._gui.domElement.removeEventListener('touchmove', this._preventDefault)
this._gui.destroy()
}
this._gui = new dat.GUI()
this._gui.domElement.addEventListener('touchmove', this._preventDefault, false)
// If queryable is true, set up the query string management
// for storing settings
if (this._queryable) {
if (window.location.search) {
let params = queryString.parse(window.location.search)
Object.keys(params).forEach((key) => {
// Check if the param is a float, and if so, parse it
if (parseFloat(params[key])) {
params[key] = parseFloat(params[key])
}
// If a setting matches the param, use the param
if (this.defaults[key]) {
let param = params[key]
// Convert string to boolean if 'true' or 'false'
if (param === 'true') param = true
if (param === 'false') param = false
if (typeof this.defaults[key].value !== 'object') {
// If sticky is true, stick with the default setting
// otherwise set the default to the param
if (!this.defaults[key].sticky) {
this.defaults[key].value = param
}
} else {
// If the param is an object, store the
// name in a selected property
if (!this.defaults[key].sticky) {
this.defaults[key].selected = param
} else {
// If sticky is true, force the default setting
this.defaults[key].selected = this.defaults[key].value[Object.keys(this.defaults[key].value)[0]]
}
}
}
})
}
}
// Create settings folder and add each item to it
const group = this._gui.addFolder('Settings')
Object.keys(this.defaults).forEach(name => {
let options = false
let value = this.defaults[name].value
if (value || typeof this.defaults[name] === 'object') {
// If it's an object, supply the array or object,
// and grab the right value
if (typeof value === 'object') {
options = value
// If a selected option is available via the query
// string, use that
if (this.defaults[name].selected) {
this._settings[name] = this.defaults[name].selected
} else {
// If not, grab the first item in the object or array
this._settings[name] = Is.array(value)
? value[0]
: value[Object.keys(value)[0]]
}
} else {
// If it's not an object, pass the setting on
this._settings[name] = this.defaults[name].value
}
// If it's a colour, use a different method
let guiField = this.defaults[name].color
? group.addColor(this._settings, name)
: group.add(this._settings, name, options)
// Check for min, max and step, and add to the gui field
if (this.defaults[name].min !== undefined) guiField = guiField.min(this.defaults[name].min)
if (this.defaults[name].max !== undefined) guiField = guiField.max(this.defaults[name].max)
if (this.defaults[name].step !== undefined) guiField = guiField.step(this.defaults[name].step)
if (this.defaults[name].editable === false) {
guiField.domElement.style.pointerEvents = 'none'
guiField.domElement.style.opacity = 0.5
}
// Handle the change event
guiField.onChange(debounce((value) => {
this._change(name, value)
}), 300)
} else {
// Handle properties that aren't tied to value -
// usually settings that relate to the gui itself
switch (name) {
case 'clear':
this._clearGui = this.defaults[name]
break
case 'reset':
this._resetGui = this.defaults[name]
break
default:
break
}
}
})
// Open the settings drawer
group.open()
// Hide controls for mobile
if (this.width <= 767) {
this._gui.close()
}
// If queryable is enabled, serialize the final settings
// and push them to the query string
if (this._queryable) {
const query = queryString.stringify(this._settings)
window.history.replaceState({}, null, `${this._getPathFromUrl()}?${query}`)
// Adds a clear and reset button to the gui interface,
// if they aren't disabled in the settings
if (this._clearGui) this._gui.add({clear: () => { this.clear() }}, 'clear')
if (this._resetGui) this._gui.add({reset: () => { this._reset() }}, 'reset')
}
}
/**
* Resets the settings in the query string, and offers a hook
* to do something more fancy with sandpit.reset
* @private
*/
_reset () {
if (this._queryable || this.reset) {
if (this.reset) {
// If there's a reset method available, run that
this.reset()
} else {
// If queryable, clear the query string
window.history.replaceState({}, null, this._getPathFromUrl())
// Reload the video
window.location.reload()
}
}
}
/**vst
* Handles a changed setting
* @param {string} name - Setting name
* @param {*} value - The new setting value
* @private
*/
_change (name, value) {
logger.info(`Update fired on ${name}: ${value}`)
if (this._queryable) {
const query = queryString.stringify(this._settings)
window.history.pushState({}, null, `${this._getPathFromUrl()}?${query}`)
}
// If there is a change hook, use it
if (this.change) {
this.change(name, value)
}
}
/**
* Sets up the primary animation loop
* @private
*/
_setupLoop () {
this._time = 0
this._loop()
}
/**
* The primary animation loop
* @private
*/
_loop () {
// Start stat recording for this frame
if (this.stats) this.stats.begin()
// Clear the canvas if autoclear is set
if (this._autoClear) this.clear()
// Loop!
if (this.loop) this.loop()
// Increment time
this._time++
// End stat recording for this frame
if (this.stats) this.stats.end()
this._animationFrame = window.requestAnimationFrame(this._loop.bind(this))
}
/**
* Sets up event management
* @private
*/
_setupEvents () {
this._events = {}
this._setupResize()
this._setupInput()
// Loop through and add event listeners
Object.keys(this._events).forEach(event => {
if (this._events[event].disable) {
// If the disable property exists, add prevent default to it
this._events[event].disable.addEventListener(event, this._preventDefault, false)
}
this._events[event].context.addEventListener(event, this._events[event].event.bind(this), false)
})
}
/**
* Sets up the resize event, optionally using a user defined option
* @private
*/
_setupResize () {
this._resizeEvent = this.resize ? this.resize : this.resizeCanvas
this._events['resize'] = {event: this._resizeEvent, context: window}
}
/**
* Hooks up the mouse events
* @private
*/
_setupMouse () {
this._events['mousemove'] = {event: this._handleMouseMove, context: document}
this._events['mousedown'] = {event: this._handleMouseDown, context: document}
this._events['mouseenter'] = {event: this._handleMouseEnter, context: document}
this._events['mouseleave'] = {event: this._handleMouseLeave, context: document}
this._events['mouseup'] = {event: this._handleMouseUp, context: document}
}
/**
* Hooks up the touch events
* @private
*/
_setupTouches () {
const body = document.querySelector('body')
this._events['touchmove'] = {event: this._handleTouchMove, disable: document, context: body}
this._events['touchstart'] = {event: this._handleTouchStart, disable: document, context: body}
this._events['touchend'] = {event: this._handleTouchEnd, disable: document, context: body}
}
/**
* Stops an event bubbling up
* @private
*/
_stopPropagation (event) {
event.stopPropagation()
}
/**
* Stops an event firing its default behaviour
* @private
*/
_preventDefault (event) {
event.preventDefault()
}
/**
* Hooks up the accelerometer events
* @private
*/
_setupAccelerometer () {
this._gyroscope = new GyroNorm()
this._gyroscope.init().then(() => {
this._gyroscope.start(this._handleAccelerometer.bind(this))
}).catch((e) => {
logger.warn('Accelerometer is not supported by this device')
})
}
/**
* Defines the input object and sets up the mouse, accelerometer and touches
* @param {event} event
* @private
*/
_setupInput () {
this.input = {}
this._setupMouse()
this._setupTouches()
this._setupAccelerometer()
}
/**
* Handles the mousemove event
* @param {event} event
* @private
*/
_handleMouseMove (event) {
event.touches = {}
event.touches[0] = event
this._handleTouches(event)
if (this.move) this.move(event)
}
/**
* Handles the mousedown event
* @param {event} event
* @private
*/
_handleMouseDown (event) {
event.touches = {}
event.touches[0] = event
this._handleTouches(event)
if (this.touch) this.touch(event)
}
/**
* Handles the mouseup event
* @param {event} event
* @private
*/
_handleMouseUp (event) {
this._handleRelease()
if (this.release) this.release(event)
}
/**
* Handles the mouseenter event
* @param {event} event
* @private
*/
_handleMouseEnter (event) {
this.input.inFrame = true
}
/**
* Handles the mouseleave event
* @param {event} event
* @private
*/
_handleMouseLeave (event) {
this.input.inFrame = false
}
/**
* Handles the touchmove event
* @param {event} event
* @private
*/
_handleTouchMove (event) {
// So, event.preventDefault() seems required to prevent pinching,
// but sometimes pinches still rarely happen (3 - 5 fingers), so
// is there a way to avoid this? Currently added a
// focusTouchesOnCanvas() method to blow away all other
// touch events, for use outside the demo environment,
// but this isn't really a viable solution. If possible,
// I'd use the bit below, but I've commented it out for now:
// this._focusTouchesOnCanvas ? event.preventDefault() : event.stopPropagation()
event.preventDefault()
this._handleTouches(event)
if (this.move) this.move(event)
}
/**
* Handles the touchstart event
* @param {event} event
* @private
*/
_handleTouchStart (event) {
this._focusTouchesOnCanvas ? event.preventDefault() : event.stopPropagation()
this._handleTouches(event)
if (this.touch) this.touch(event)
}
/**
* Handles the touchend event
* @param {event} event
* @private
*/
_handleTouchEnd (event) {
this._focusTouchesOnCanvas ? event.preventDefault() : event.stopPropagation()
this._handleTouches(event)
if (this.release) this.release(event)
}
/**
* Handles the accelerometer event, using the
* GyroNorm.js library
* @param {event} event
* @private
*/
_handleAccelerometer (data) {
this.input.accelerometer = data
// Apply some helpers to more easily
// access x, y and rotation
this.input.accelerometer.xAxis = data.do.beta
this.input.accelerometer.yAxis = data.do.gamma
this.input.accelerometer.rotation = data.do.alpha
this.input.accelerometer.gamma = data.do.gamma
this.input.accelerometer.beta = data.do.beta
this.input.accelerometer.alpha = data.do.alpha
// Fire the accelerometer event, if available
if (this.accelerometer) this.accelerometer()
}
/**
* Handles a set of touches
* @param {object} touches - An object containing touch information, in
* the format {0: TouchItem, 1: TouchItem}
* @private
*/
_handleTouches (event) {
// Delete the length parameter from touches,
// so we can loop through it
delete event.touches.length
if (Object.keys(event.touches).length) {
let touches = Object.keys(event.touches).map((key, i) => {
// Set the X & Y for input from the first touch
if (i === 0) {
this.input.x = event.touches[key].pageX
this.input.y = event.touches[key].pageY
}
let touch = {}
// If there is previous touch, store it as a helper
if (this.input.touches && this.input.touches[key]) {
touch.previousX = this.input.touches[key].x
touch.previousY = this.input.touches[key].y
}
// Store the x and y
touch.x = event.touches[key].pageX
touch.y = event.touches[key].pageY
// If force is available, add it
if (event.touches[key].force) touch.force = event.touches[key].force
return touch
})
// Update the touches
this.input.touches = touches
} else {
this._handleRelease()
}
}
/**
* Deletes the appropriate data from inputs on release
* @param {object} pointer - An object containing pointer information,
* in the format of {pageX: x, pageY: y}
* @private
*/
_handleRelease () {
delete this.input.x
delete this.input.y
delete this.input.touches
}
/**
* Creates the settings GUI
* @param {object} settings - An object containing key value pairs for each setting
* @param {boolean} queryable - Enables query string storage of settings
* @return {object} Context
*/
set settings (settings) {
// Sets up settings
if (settings && Object.keys(settings).length) {
this.defaults = settings
this._setupSettings()
}
}
/**
* Returns the settings object
* @return {object} settings
*/
get settings () {
return this._settings
}
/**
* Defines whether or not to return debugger messages from Sandpit
* @param {boolean} boolean
* @return {object} Context
*/
set debug (boolean) {
logger.active = boolean
}
/**
* Checks if debugger is active
* @return {boolean} active
*/
get debug () {
return logger.active
}
/**
* Sets whether the canvas autoclears after each render
* @param {boolean} boolean
*/
set autoClear (boolean) {
this._autoClear = boolean
}
/**
* Checks if autoclear is on
* @return {boolean} active
*/
get autoClear () {
return this._autoClear
}
/**
* Clears the canvas, and if change is set,
* fires change
* @param {boolean} boolean
*/
clear () {
if (this._type === Sandpit.CANVAS) {
this._context.clearRect(0, 0, this.width, this.height)
} else if (this._type === Sandpit.WEBGL || this._type === Sandpit.EXPERIMENTAL_WEBGL) {
this._context.clearColor(0, 0, 0, 0)
this._context.clear(this._context.COLOR_BUFFER_BIT | this._context.DEPTH_BUFFER_BIT)
}
if (this.change) this.change()
}
/**
* Grab the current path from the url
* @private
*/
_getPathFromUrl () {
return window.location.toString().split(/[?#]/)[0]
}
/**
* Clear the query string
*/
clearQueryString () {
window.history.replaceState({}, null, this._getPathFromUrl())
}
/**
* Sets whether to or not to ignore the touch events for
* multitouch, bypassing pinch to zoom, but meaning no
* other touch events will work
* @param {boolean} boolean
*/
set focusTouchesOnCanvas (boolean) {
this._focusTouchesOnCanvas = boolean
}
/**
* Checks if touches are focused on the canvas
* @return {boolean} active
*/
get focusTouchesOnCanvas () {
return this._focusTouchesOnCanvas
}
/**
* Returns the canvas context
* @return {object} Context
*/
get context () {
return this._context
}
/**
* Returns the canvas object
* @return {canvas} Canvas
*/
get canvas () {
return this._canvas
}
/**
* Returns the frame increment
* @returns {number} Canvas width
*/
get time () {
return this._time
}
/**
* Returns the canvas width
* @returns {number} Canvas width
*/
get width () {
return this._canvas.clientWidth
}
/**
* Sets the canvas width
* @param {number} width - The width to make the canvas
*/
set width (width) {
this._canvas.width = width
}
/**
* Returns the canvas height
* @returns {number} Canvas height
*/
get height () {
return this._canvas.clientHeight
}
/**
* Sets the canvas height
* @param {number} height - The height to make the canvas
*/
set height (height) {
this._canvas.height = height
}
/**
* Fills the canvas with the provided colour
* @param {string} color - The color to fill with, in string format
* (for example, '#000', 'rgba(0, 0, 0, 0.5)')
*/
fill (color) {
this._fill = color
if (this._type === Sandpit.CANVAS) {
this._context.fillStyle = this._fill
this._context.fillRect(0, 0, this.width, this.height)
} else if (this._type === Sandpit.WEBGL || this._type === Sandpit.EXPERIMENTAL_WEBGL) {
// TODO: Use fill to update the clearColor of a 3D canvas
logger.warn('fill() is currently only supported in 2D')
}
}
/**
* Returns a promise using fetch
* https://github.com/github/fetch
* @param {string} url - The url to fetch
*/
get (url) {
return new Promise((resolve, reject) => {
fetch(url)
.then((response) => {
resolve(response.text())
}).catch((error) => {
logger.info('Get fail', error)
reject()
})
})
}
/**
* Returns a random number generator based on a seed
* @param {string} seed - The seed with which to create the random number
* @returns {function} A function that returns a random number
*/
random (seed = '123456') {
return seedrandom(seed)
}
/**
* Resizes the canvas to the window width and height
*/
resizeCanvas () {
if (this._type === Sandpit.CANVAS && window.devicePixelRatio !== 1 && this._retina) {
this._handleRetina()
} else {
this._canvas.width = this._canvas.clientWidth
this._canvas.height = this._canvas.clientHeight
}
if (this._type === Sandpit.WEBGL || this._type === Sandpit.EXPERIMENTAL_WEBGL) {
this._context.viewport(0, 0, this._context.drawingBufferWidth, this._context.drawingBufferHeight)
}
if (this.change) this.change()
}
/**
* Handle the stats object
* @param {object} stats - a Stats.js object, which can be imported
* from Sandpit with `import { Stats } from 'sandpit'`
*/
setupStats () {
if (!this.stats) {
this.stats = new Stats()
document.querySelector('body').appendChild(this.stats.dom)
}
}
/**
* Sets up resizing and input events and starts the loop
*/
start () {
// Sets up the events
this._setupEvents()
// Sets up setup
if (this.setup) this.setup()
// Loop!
if (!this.loop) logger.warn('Looks like you need to define a loop')
this._setupLoop()
}
/**
* Stops the loop and removes event listeners
*/
stop () {
// Stop the animation frame loop
window.cancelAnimationFrame(this._animationFrame)
// Delete element, if initiated
if (this.canvas) {
this.canvas.outerHTML = ''
delete this.canvas
}
if (this.stats) {
document.querySelector('body').removeChild(this.stats.dom)
delete this.stats
}
// Remove Gui, if initiated
if (this._gui) {
this._gui.domElement.removeEventListener('touchmove', this._preventDefault)
this._gui.destroy()
}
// Remove all event listeners
Object.keys(this._events).forEach(event => {
if (this._events[event].disable) {
this._events[event].disable.removeEventListener(event, this._preventDefault)
}
this._events[event].context.removeEventListener(event, this._events[event].event.bind(this))
})
}
}
export { Is, Mathematics, Color, Vector, Stats }
export default Sandpit