JavaScript preprocessors can help make authoring JavaScript easier and more convenient. For instance, CoffeeScript can help prevent easy-to-make mistakes and offer a cleaner syntax and Babel can bring ECMAScript 6 features to browsers that only support ECMAScript 5.
Any URL's added here will be added as <script>
s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.
You can apply a script from anywhere on the web to your Pen. Just put a URL to it here and we'll add it, in the order you have them, before the JavaScript in the Pen itself.
If the script you link to has the file extension of a preprocessor, we'll attempt to process it before applying.
You can also link to another Pen here, and we'll pull the JavaScript from that Pen and include it. If it's using a matching preprocessor, we'll combine the code before preprocessing, so you can use the linked Pen as a true dependency.
HTML Settings
Here you can Sed posuere consectetur est at lobortis. Donec ullamcorper nulla non metus auctor fringilla. Maecenas sed diam eget risus varius blandit sit amet non magna. Donec id elit non mi porta gravida at eget metus. Praesent commodo cursus magna, vel scelerisque nisl consectetur et.
<main id="main"></main>
html {
font-family: -apple-system, BlinkMacSystemFont,
"Roboto", "Segoe UI", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", sans-serif;
font-size: 22px;
}
body {
margin: 0;
background: #F9F9F9;
color: #424242;
}
section {
padding: 0 1em;
color: #465775;
}
button {
transition: opacity 0.4s;
font-family: 'Roboto Mono', monospace;
background: transparent;
padding: 4px 16px;
margin: 16px;
color: #424242;
border: 2px solid #424242;
outline: none;
border-radius: 12px;
cursor: pointer;
opacity: 0.2;
&:hover {
opacity: 1;
}
}
const { throttle, debounce, attempt } = _
const config = {
scale: 'major pentatonic',
key: 'C',
totalPoints: 6,
viscosity: 10,
mouseDist: 20,
damping: 0.08,
debug: false,
spaghettiColor: '#EF6F6C',
}
const LOCAL_KEY = 'SPAGHETTI_STRINGS'
const CODEPEN_LOGO = [{"a":{"x":124,"y":182},"b":{"x":222,"y":134}},{"a":{"x":222,"y":134},"b":{"x":319,"y":187}},{"a":{"x":319,"y":187},"b":{"x":217,"y":238}},{"a":{"x":217,"y":238},"b":{"x":123,"y":181}},{"a":{"x":123,"y":181},"b":{"x":123,"y":245}},{"a":{"x":123,"y":245},"b":{"x":219,"y":308}},{"a":{"x":219,"y":308},"b":{"x":219,"y":238}},{"a":{"x":220,"y":309},"b":{"x":317,"y":256}},{"a":{"x":317,"y":256},"b":{"x":316,"y":189}},{"a":{"x":123,"y":244},"b":{"x":222,"y":200}},{"a":{"x":222,"y":200},"b":{"x":222,"y":134}},{"a":{"x":223,"y":200},"b":{"x":314,"y":254}}]
/**
* Helper for dynamically creating elements, this is immediately invoked,
* returning an object of functions for creating elements
*/
const element = (() => {
// An incomplete list of elements to loop over, these will form our helper functions
const elements = ['canvas', 'button', 'div']
const attributeExceptions = [
'role',
]
const appendText = (el, text) => {
const textNode = document.createTextNode(text)
el.appendChild(textNode)
}
const appendArray = (el, children) => {
children.forEach((child) => {
if (Array.isArray(child)) {
appendArray(el, child)
} else if (child instanceof window.Element) {
el.appendChild(child)
} else if (typeof child === 'string') {
appendText(el, child)
}
})
}
const setStyles = (el, styles) => {
if (!styles) {
el.removeAttribute('styles')
return
}
Object.keys(styles).forEach((styleName) => {
if (styleName in el.style) {
el.style[styleName] = styles[styleName]
} else {
console.warn(`${styleName} is not a valid style for a <${el.tagName.toLowerCase()}>`)
}
})
}
const makeElement = (type, textOrPropsOrChild, ...otherChildren) => {
const el = document.createElement(type)
if (Array.isArray(textOrPropsOrChild)) {
appendArray(el, textOrPropsOrChild)
} else if (textOrPropsOrChild instanceof window.Element) {
el.appendChild(textOrPropsOrChild)
} else if (typeof textOrPropsOrChild === 'string') {
appendText(el, textOrPropsOrChild)
} else if (typeof textOrPropsOrChild === 'object') {
Object.keys(textOrPropsOrChild).forEach((propName) => {
if (propName in el || attributeExceptions.includes(propName)) {
const value = textOrPropsOrChild[propName]
if (propName === 'style') {
setStyles(el, value)
} else if (value) {
el[propName] = value
}
} else {
console.warn(`${propName} is not a valid property of a <${type}>`)
}
})
}
if (otherChildren) appendArray(el, otherChildren)
return el
}
// Return a make element for each element defined in the elements array
return elements
.map(tag => ({ [tag]: (...args) => makeElement(tag, ...args) }))
.reduce((collection, tag) => ({ ...collection, ...tag }), {})
})()
/**
* Helper class for tracking mouse events on an element
*/
class MousedownEvents {
constructor(mousedownEl, mousedownCallback, mouseupCallback) {
this.mousedownEl = mousedownEl
this.down = false
this.downPos = { x: -1, y: -1 }
this.upPos = { x: -1, y: -1 }
this.onmousedown = this.onmousedown.bind(this, mousedownCallback)
this.onmouseup = this.onmouseup.bind(this, mouseupCallback)
this.onleavedocument = this.onleavedocument.bind(this)
this.addEventListeners()
}
addEventListeners() {
this.mousedownEl.addEventListener('mousedown', this.onmousedown)
this.mousedownEl.addEventListener('mouseup', this.onmouseup)
document.addEventListener('mouseout', this.onleavedocument)
}
removeEventListeners() {
this.mousedownEl.removeEventListener('mousedown', this.onmousedown)
this.mousedownEl.removeEventListener('mouseup', this.onmouseup)
document.removeEventListener('mouseout', this.onleavedocument)
}
destroy() {
this.removeEventListeners()
}
onleavedocument(e) {
const from = e.relatedTarget || e.toElement
if (!from || from.nodeName === 'HTML') {
this.onmouseup(e)
}
}
onmousedown(callback, e) {
if (callback) callback()
this.down = true
this.downPos.x = e.pageX
this.downPos.y = e.pageY
}
onmouseup(callback, e) {
if (this.down) {
this.down = false
this.upPos.x = e.pageX
this.upPos.y = e.pageY
if (callback) callback()
}
}
}
/**
* Helper class for continuosly tracking the mouse, throttled by TRACK_THROTTLE milliseconds
* @extends MousedownEvents
*/
class MouseTracker extends MousedownEvents {
static getDirection(current, initial) {
if (current < initial) {
return 1
} else if (current > initial) {
return -1
}
return 0
}
constructor(...args) {
super(...args)
const TRACK_THROTTLE = 20
this.current = { x: 0, y: 0 }
this.last = { x: -1, y: -1 }
this.direction = { x: 0, y: 0 }
this.travel = 0
this.speed = 0
this.trackMouse = throttle(this.trackMouse.bind(this), TRACK_THROTTLE)
this.init()
}
init() {
this.trackMouseSpeed()
document.addEventListener('mousemove', this.trackMouse)
}
destroy() {
super.destroy()
document.removeEventListener('mousemove', this.trackMouse)
}
trackMouse(e) {
this.direction.x = MouseTracker.getDirection(this.current.x, e.pageX)
this.direction.y = MouseTracker.getDirection(this.current.y, e.pageY)
this.current = { x: e.pageX, y: e.pageY }
if (this.last.x > -1) {
this.travel += Math.max(
Math.abs(this.current.x - this.last.x),
Math.abs(this.current.y - this.last.y),
)
}
this.last = this.current
}
trackMouseSpeed() {
let lastStamp
const timeout = 200
const track = () => {
const timestamp = Date.now()
if (lastStamp && lastStamp !== timestamp) {
this.speed = Math.round((this.travel / (timestamp - lastStamp)) * 1000)
this.travel = 0
}
lastStamp = timestamp
setTimeout(track, timeout)
}
track()
}
}
/**
* Helpers for setting up the keyboard
*/
const audioHelpers = (() => {
const baseNotes = ['C', 'D', 'E', 'F', 'G', 'A', 'B']
const scales = {
'major': '1 2 3 4 5 6 7',
'dorian': '1 2 3b 4 5 6 7b',
'melodic minor': '1 2 3b 4 5 6 7',
'major pentatonic': '1 2 3 5 6',
'lydian pentatonic': '1 3 4# 5 7',
'major flat two pentatonic': '1 2b 3 5 6',
'whole tone pentatonic': '1 3 5b 6b 7b',
'lydian dominant pentatonic': '1 3 4# 5 7b',
'minor #7 pentatonic': '1 3b 4 5 7',
'hirajoshi': '1 2 3b 5 6b',
'major blues': '1 2 3b 3 5 6',
'minor blues': '1 3b 4 5b 5 7b',
'egyptian': '1 2 4 5 7b',
'bebop': '1 2 3 4 5 6 7b 7',
}
const getNotes = (key, scale) => {
const keyIndex = baseNotes.indexOf(key)
const notes = baseNotes.slice(keyIndex, baseNotes.length).concat(baseNotes.slice(0, keyIndex))
return scales[scale].split(' ').map((note) => {
if (isNaN(note)) {
return note.split('').reduce((prev, current) => {
if (!isNaN(prev)) {
if (current === 'b') {
return (notes[parseInt(prev, 10) - 2]) + current
} else if (current === '#') {
return (notes[parseInt(prev, 10)]) + current
}
}
return current
}, '')
}
return notes[parseInt(note, 10) - 1]
})
}
const getKeyboard = (key, scale, low, high) => {
const notes = getNotes(key, scale)
let keyboardNotes = []
for (let i = low; i < high + 1; i += 1) {
keyboardNotes = keyboardNotes.concat(notes.map(note => note + i))
}
return keyboardNotes
}
return { getKeyboard }
})()
class VectorHelpers {
/*
* Get the angle of a vector determined by two points
* Measured counter-clockwise from the positive x-axis
* Returns a radian (0 - 2π)
*/
static getAngle(a, b) {
const angle = -Math.atan2(b.y - a.y, b.x - a.x)
if (angle < 0) {
return angle + (Math.PI * 2)
}
return angle
}
static getPointOnVector(a, b, segment) {
return {
x: (segment * b.x) + ((1 - segment) * a.x),
y: (segment * b.y) + ((1 - segment) * a.y),
}
}
static getPointFromAngle(a, angle, distance) {
return {
x: a.x + (Math.cos(angle) * distance),
y: a.y - (Math.sin(angle) * distance),
}
}
static getLength(a, b) {
return Math.sqrt(((b.x - a.x) ** 2) + ((b.y - a.y) ** 2))
}
static getTriangleArea(a, b, c) {
const area = ((a.x * b.y) + (b.x * c.y) + (c.x * a.y)) -
(a.x * c.y) - (b.x * a.y) - (c.x * b.y)
return Math.sqrt(area ** 2)
}
static getRectangleArea(a, b, c) {
return VectorHelpers.getLength(a, b) * VectorHelpers.getLength(b, c)
}
static isPointInRectangle(p, a, b, c, d) {
const lines = [
{ a, b },
{ a: b, b: c },
{ a: c, b: d },
{ a: d, b: a },
]
return lines.every((line) => {
const A = -(line.b.y - line.a.y)
const B = line.b.x - line.a.x
const C = -((A * line.a.x) + (B * line.a.y))
const D = (A * p.x) + (B * p.y) + C
return D > 0
})
}
}
class Hitbox {
constructor(options) {
const { center, length, height, angle } = options
this.length = length
this.height = height
this.angle = angle
this.coords = center
this.hitting = false
}
set coords(center) {
const { angle, length, height } = this
const p1 = VectorHelpers.getPointFromAngle(center, angle + Math.PI, length / 2)
const p2 = VectorHelpers.getPointFromAngle(center, angle, length / 2)
this.a = VectorHelpers.getPointFromAngle(p1, angle + (Math.PI / 4), height)
this.b = VectorHelpers.getPointFromAngle(p2, angle + (Math.PI / 4), height)
this.c = VectorHelpers.getPointFromAngle(p2, angle - (Math.PI / 4), height)
this.d = VectorHelpers.getPointFromAngle(p1, angle - (Math.PI / 4), height)
}
get coords() {
const { a, b, c, d } = this
return { a, b, c, d }
}
hitTest(position, enterCallback, insideCallback, leaveCallback) {
const { a, b, c, d } = this.coords
if (VectorHelpers.isPointInRectangle(position, a, b, c, d)) {
if (!this.hitting) {
attempt(enterCallback)
} else {
attempt(insideCallback)
}
this.hitting = true
} else if (this.hitting) {
this.hitting = false
attempt(leaveCallback)
}
return this.hitting
}
}
class InteractiveVertex {
constructor(options) {
const { anchor, canvas, mouse, x, y, angle, vertexSeparation, hitCallback } = options
this.canvas = canvas
this.mouse = mouse
this.vertexSeparation = vertexSeparation
this.current = { x, y }
this.initial = { x, y }
this.control = { x, y }
this.velocity = { x: 0, y: 0 }
this.hitbox = anchor ?
null :
new Hitbox({
center: this.current,
angle,
length: vertexSeparation,
height: config.mouseDist,
})
this.hitCallback = hitCallback
this.handleHit = throttle(this.handleHit.bind(this), 400, { trailing: false })
}
handleDrag() {
this.current.x = ((this.mouse.current.x - this.initial.x) * 0.8) + this.initial.x
this.current.y = ((this.mouse.current.y - this.initial.y) * 0.8) + this.initial.y
}
handleHit() {
this.velocity.x = (this.mouse.direction.x * this.mouse.speed) / 20
this.velocity.y = (this.mouse.direction.y * this.mouse.speed) / 20
attempt(this.hitCallback)
}
render() {
this.applyForce('x')
this.applyForce('y')
if (!this.hitbox || !this.hitbox.hitting) {
this.dampen('x')
this.dampen('y')
}
if (this.hitbox) {
this.hitbox.coords = { x: this.current.x, y: this.current.y }
}
if (!this.mouse.down && this.hitbox && this.mouse.speed) {
this.hitbox.hitTest(
this.mouse.current,
null,
this.handleDrag.bind(this),
this.handleHit.bind(this),
)
}
}
dampen(axis) {
this.velocity[axis] += (this.initial[axis] - this.current[axis]) / config.viscosity
}
applyForce(axis) {
if (this.velocity[axis] < -0.05 || this.velocity[axis] > 0.05) {
this.velocity[axis] *= (1 - config.damping)
this.current[axis] += this.velocity[axis]
} else {
this.velocity[axis] = 0
}
}
}
const MIN_STRING_LENGTH = 30
const RANGE = { start: 0, end: 600 }
// const LO_RANGE = { start: 600, end: 1200 }
const synth = new Tone.PolySynth(4, Tone.Synth).toMaster()
const keyboard = audioHelpers.getKeyboard(config.key, config.scale, 2, 5).reverse()
class SpaghettiAudio {
static onhit(note) {
synth.triggerAttackRelease(note, '8n')
}
static getCanvas() {
const style = {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
}
return element.canvas({ style })
}
static getUIContainer(ui) {
const style = {
position: 'fixed',
top: 0,
left: 0,
zIndex: 10,
}
return element.div({ style },
ui,
)
}
constructor(options = {}) {
this.config = Object.assign(config, options)
this.canvas = SpaghettiAudio.getCanvas()
this.ui = this.getUI()
this.renderLoopId = null
this.strings = []
this.context = this.canvas.getContext('2d')
this.mouse = new MouseTracker(this.canvas, null, this.addNewString.bind(this))
this.onResize = debounce(this.onResize.bind(this), 300)
this.initialise()
}
getUI() {
const ui = []
const buttonStyle = {
display: 'inline-block',
}
if (this.config.clearButton) {
const clear = element.button({
className: 'spaghetti-clear',
style: buttonStyle,
onclick: e => this.clearStrings(e),
},
'clear',
)
ui.push(clear)
}
return SpaghettiAudio.getUIContainer(ui)
}
set store(string) {
if (!this.config.localStorage) {
return
}
let strings = this.store
if (string instanceof Array) {
strings = strings.concat(string)
} else {
strings.push(string)
}
localStorage.setItem(LOCAL_KEY, JSON.stringify(strings))
}
get store() {
if (!this.config.localStorage) {
return []
}
return JSON.parse(localStorage.getItem(LOCAL_KEY)) || []
}
clearStrings() {
if (this.config.localStorage) {
localStorage.setItem(LOCAL_KEY, JSON.stringify([]))
}
this.strings = []
}
addNewString(a = {}, b = {}) {
a.x = a.x || this.mouse.downPos.x
a.y = a.y || this.mouse.downPos.y
b.x = b.x || this.mouse.upPos.x
b.y = b.y || this.mouse.upPos.y
this.store = { a, b }
this.buildString(a, b)
}
buildString(a, b) {
const points = []
const length = VectorHelpers.getLength(a, b)
if (length < MIN_STRING_LENGTH) {
return
}
const angle = VectorHelpers.getAngle(a, b)
const vertexSeparation = length / (this.config.totalPoints - 1)
const note = length > RANGE.end ?
keyboard[keyboard.length - 1] :
keyboard[Math.round(keyboard.length * (length / RANGE.end))]
for (let i = 0; i <= this.config.totalPoints - 1; i += 1) {
const { x, y } = VectorHelpers.getPointOnVector(
a, b, i / (this.config.totalPoints - 1),
)
points.push(new InteractiveVertex({
anchor: i === 0 || i === this.config.totalPoints - 1,
canvas: this.canvas,
mouse: this.mouse,
x,
y,
angle,
vertexSeparation,
hitCallback: SpaghettiAudio.onhit.bind(null, note),
}))
}
this.strings.push({
length,
vertexSeparation,
points,
note,
})
}
addEventListeners() {
window.addEventListener('resize', this.onResize)
}
removeEventListeners() {
window.removeEventListener('resize', this.onResize)
}
initialise() {
const persistedStrings = this.store
if (!persistedStrings || !persistedStrings.length) {
// We're new here, let's draw the codepen logo
CODEPEN_LOGO.forEach(({ a, b }) => this.buildString(a, b))
this.store = CODEPEN_LOGO
} else {
persistedStrings.forEach(({ a, b }) => this.buildString(a, b))
}
this.addEventListeners()
this.start()
}
start() {
cancelAnimationFrame(this.renderLoopId)
this.canvas.width = window.innerWidth
this.canvas.height = window.innerHeight
this.render()
}
render() {
this.renderLoopId = requestAnimationFrame(this.render.bind(this))
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.strings.forEach((string) => {
this.drawString(string.points)
if (this.config.debug) {
this.debug(string)
}
})
if (this.mouse.down) {
this.canvas.style.cursor = 'move'
this.drawLine(this.mouse.downPos, this.mouse.current)
} else {
this.canvas.style.cursor = 'crosshair'
}
}
destroy() {
this.removeEventListeners()
this.mouse.destroy()
cancelAnimationFrame(this.renderLoopId)
}
onResize() {
this.start()
}
drawLine(p1, p2) {
const { context } = this
context.strokeStyle = this.config.spaghettiColor
context.lineWidth = 4
context.beginPath()
context.moveTo(p1.x, p1.y)
context.lineTo(p2.x, p2.y)
context.stroke()
}
drawString(points) {
const { context } = this
for (let i = 1; i <= this.config.totalPoints - 2; i += 1) {
points[i].render()
}
context.strokeStyle = this.config.spaghettiColor
context.lineWidth = 4
context.beginPath()
for (let i = 0; i <= this.config.totalPoints - 1; i += 1) {
const p = points[i]
if (i > 0 && i < points.length - 1) {
p.control.x = (p.current.x + points[i + 1].current.x) / 2
p.control.y = (p.current.y + points[i + 1].current.y) / 2
}
context.bezierCurveTo(
p.current.x, p.current.y, p.control.x, p.control.y, p.control.x, p.control.y,
)
}
context.stroke()
}
debug({ note, points }) {
const { context } = this
context.font = '16px Arial'
context.fillStyle = '#424242'
context.fillText(note, points[0].initial.x - 20, points[0].initial.y - 10)
for (let i = 0; i <= this.config.totalPoints - 1; i += 1) {
const p = points[i]
context.fillStyle = '#000'
context.beginPath()
context.rect(p.current.x - 2, p.current.y - 2, 4, 4)
context.fill()
context.fillStyle = '#fff'
context.beginPath()
context.rect(p.control.x - 1, p.control.y - 1, 2, 2)
context.fill()
if (p.hitbox) {
context.strokeStyle = 'rgba(0, 0, 255, 0.2)'
context.beginPath()
context.moveTo(p.hitbox.a.x, p.hitbox.a.y)
context.lineTo(p.hitbox.b.x, p.hitbox.b.y)
context.lineTo(p.hitbox.c.x, p.hitbox.c.y)
context.lineTo(p.hitbox.d.x, p.hitbox.d.y)
context.closePath()
context.stroke()
}
}
}
}
let localStorageEnabled = true
try {
localStorage.setItem('testing', "")
localStorage.removeItem('testing')
} catch(e) {
localStorageEnabled = false
}
// Let's go!
const spaghettiAudio = new SpaghettiAudio({
localStorage: localStorageEnabled,
clearButton: true,
})
const container = document.querySelector('#main')
container.appendChild(spaghettiAudio.canvas)
container.appendChild(spaghettiAudio.ui)
Also see: Tab Triggers