aframe-colorwheel-component
Version:
A-Frame Color Wheel component, designed for A-Frame Material
671 lines (594 loc) • 21.2 kB
JavaScript
/**
* Colorwheel for A-FRAME Material
* @author Mo Kargas (DEVLAD) mo@devlad.com
*/
const Event = require('./src/utils')
import copy from 'copy-to-clipboard'
import difference from 'lodash.difference'
AFRAME.registerComponent('colorwheel', {
dependencies: ['raycaster'],
tweenDuration: 280,
tweenEasing: TWEEN.Easing.Cubic.Out,
padding: 0.15,
hsv: {
h: 0.0,
s: 0.0,
v: 1.0
},
defaultMaterial: {
color: '#ffffff',
flatShading: true,
transparent: true,
shader: 'flat',
fog: false,
side: 'double'
},
color: '#ffffff',
schema: {
disabled: {
type: 'boolean',
default: false
},
backgroundColor: {
type: 'color',
default: '#FFF'
},
//Size of the colorWheel. NOTE: Assumed in metres.
wheelSize: {
type: 'number',
default: 0.4
},
//Show color choice in an element
showSelection: {
type: 'boolean',
default: true
},
selectionSize: {
type: 'number',
default: 0.10
},
showHexValue: {
type: 'boolean',
default: false
},
showSwatches: {
type: 'boolean',
default: false
},
swatches: {
type: 'array',
default: ['#000000', '#FFFFFF', '#ff0045', '#2aa8dc', '#ffed00', '#4c881d', '#b14bff']
}
},
init: function() {
const that = this,
padding = this.padding,
defaultMaterial = this.defaultMaterial
this.swatchReady = false
//Background color of this interface
//TODO: Expose sizing for deeper customisation?
this.backgroundWidth = this.backgroundHeight = this.data.wheelSize * 2
this.brightnessSliderHeight = (this.data.wheelSize + padding) * 2
this.brightnessSliderWidth = 0.10
//Check if we have the a-rounded component
if (AFRAME.components.hasOwnProperty('rounded')) {
this.background = document.createElement('a-rounded')
this.background.setAttribute('radius', 0.02)
this.background.setAttribute('position', {
x: -(this.data.wheelSize + padding),
y: -(this.data.wheelSize + padding),
z: -0.001
})
} else {
this.background = document.createElement('a-plane')
this.background.setAttribute('position', {
x: 0,
y: 0,
z: -0.001
})
}
this.background.setAttribute('width', this.backgroundWidth + 2 * padding)
this.background.setAttribute('height', this.backgroundHeight + 2 * padding)
this.background.setAttribute('material', 'shader', 'flat')
this.background.setAttribute('side', 'double')
this.el.appendChild(this.background)
//Show Swatches
this.swatchContainer = document.createElement('a-plane')
this.swatchContainer.setAttribute('class', 'swatch-container')
this.swatchContainer.setAttribute('material', this.defaultMaterial)
this.swatchContainer.addEventListener('loaded', this.onSwatchReady.bind(this))
//Give swatch panel a rakish angle
this.swatchContainer.setAttribute('rotation', {
x: -30,
y: 0,
z: 0
})
this.el.appendChild(this.swatchContainer)
//Show hex value display
if (this.data.showHexValue) {
let hexValueHeight = 0.1,
hexValueWidth = 2 * (this.data.wheelSize + padding)
this.hexValueText = document.createElement('a-entity')
//A basic geo is required for interactions
this.hexValueText.setAttribute('geometry', {
primitive: 'plane',
width: hexValueWidth - this.brightnessSliderWidth,
height: hexValueHeight
})
this.hexValueText.setAttribute('material', defaultMaterial)
this.hexValueText.setAttribute('position', {
x: -this.brightnessSliderWidth,
y: this.data.wheelSize + hexValueHeight,
z: 0.0
})
this.hexValueText.setAttribute('material', 'opacity', 0)
this.hexValueText.setAttribute('text', {
width: hexValueWidth,
height: hexValueHeight,
align: 'right',
baseline: 'center',
wrapCount: 20.4,
color: '#666'
})
//Copy value to clipboard on click
this.hexValueText.addEventListener('click', this.onHexValueClicked.bind(this))
this.el.appendChild(this.hexValueText)
}
//Circle for colorwheel
this.colorWheel = document.createElement('a-circle')
this.colorWheel.setAttribute('radius', this.data.wheelSize)
this.colorWheel.setAttribute('material', defaultMaterial)
this.colorWheel.setAttribute('position', {
x: 0,
y: 0,
z: 0.001
})
this.el.appendChild(this.colorWheel)
//Plane for the brightness slider
this.brightnessSlider = document.createElement('a-plane')
this.brightnessSlider.setAttribute('width', this.brightnessSliderWidth)
this.brightnessSlider.setAttribute('height', this.brightnessSliderHeight)
this.brightnessSlider.setAttribute('material', defaultMaterial)
this.brightnessSlider.setAttribute('position', {
x: this.data.wheelSize + this.brightnessSliderWidth,
y: 0,
z: 0.001
})
this.el.appendChild(this.brightnessSlider)
//Plane the color selection element will inhabit
if (this.data.showSelection) {
this.selectionEl = document.createElement('a-circle')
this.selectionEl.setAttribute('radius', this.data.selectionSize)
this.selectionEl.setAttribute('material', defaultMaterial)
//Place in top left, lift slightly
this.selectionEl.setAttribute('position', {
x: -this.data.wheelSize,
y: this.data.wheelSize,
z: 0.001
})
this.el.appendChild(this.selectionEl)
}
//Color 'cursor'. We'll use this to indicate a rough color selection
this.colorCursorOptions = {
cursorRadius: 0.025,
cursorSegments: 32,
cursorColor: new THREE.Color(0x000000)
}
this.colorCursorOptions.cursorMaterial = new THREE.MeshBasicMaterial({
color: this.colorCursorOptions.cursorColor,
transparent: true
});
this.colorCursor = document.createElement('a-entity')
this.brightnessCursor = document.createElement('a-entity')
let geometry = new THREE.TorusBufferGeometry(this.colorCursorOptions.cursorRadius, this.colorCursorOptions.cursorRadius - 0.02, this.colorCursorOptions.cursorSegments, this.colorCursorOptions.cursorSegments / 4)
this.colorCursor.setObject3D('mesh', new THREE.Mesh(geometry, this.colorCursorOptions.cursorMaterial))
this.brightnessCursor.setObject3D('mesh', new THREE.Mesh(geometry, this.colorCursorOptions.cursorMaterial))
this.el.appendChild(this.colorCursor)
this.brightnessSlider.appendChild(this.brightnessCursor)
this.brightnessCursor.setAttribute('position', {
x: 0,
y: this.brightnessSliderHeight / 2,
z: 0
})
//Handlers
this.bindMethods()
//TODO: Replace setTimeout as it can be unreliable
setTimeout(() => {
that.el.initColorWheel()
that.el.initBrightnessSlider()
that.el.refreshRaycaster()
if (that.data.showSwatches) that.el.generateSwatches(that.data.swatches)
that.colorWheel.addEventListener('click', this.onColorWheelClicked.bind(this))
that.brightnessSlider.addEventListener('click', this.onBrightnessSliderClicked.bind(this))
}, 5)
},
//Util to animate between positions. Item represents a mesh or object containing a position
setPositionTween: function(item, fromPosition, toPosition) {
this.tween = new TWEEN.Tween(fromPosition).to(toPosition, this.tweenDuration).onUpdate(function() {
item.position.x = this.x
item.position.y = this.y
item.position.z = this.z
}).easing(this.tweenEasing).start()
return this.tween
},
//Util to animate between colors. Item represents a mesh or object's material
setColorTween: function(item, fromColor, toColor) {
this.tween = new TWEEN.Tween(new THREE.Color(fromColor)).to(toColor, this.tweenDuration).onUpdate(function() {
item.color.r = this.r
item.color.g = this.g
item.color.b = this.b
}).easing(this.tweenEasing).start()
return this.tween
},
onColorWheelClicked: function(evt) {
if (this.data.disabled) return;
this.el.onHueDown(evt.detail.intersection.point)
},
onBrightnessSliderClicked: function(evt) {
if (this.data.disabled) return;
this.el.onBrightnessDown(evt.detail.intersection.point)
},
onHexValueClicked: function() {
copy(this.hexValueText.getAttribute('text').value)
},
generateSwatches: function(swatchData) {
//Generate clickable swatch elements from a given array
if (swatchData === undefined) return
const containerWidth = (this.data.wheelSize + this.padding) * 2,
containerHeight = 0.15,
swatchWidth = containerWidth / swatchData.length
this.swatchContainer.setAttribute('width', containerWidth)
this.swatchContainer.setAttribute('height', containerHeight)
this.swatchContainer.setAttribute('position', {
x: 0,
y: -this.backgroundHeight + containerHeight,
z: 0.03
})
//Loop through swatches and create elements
for (let i = 0; i < swatchData.length; i++) {
const color = swatchData[i]
let swatch = document.createElement('a-plane')
swatch.setAttribute('material', this.defaultMaterial)
swatch.setAttribute('width', swatchWidth)
swatch.setAttribute('height', containerHeight)
swatch.setAttribute('color', color)
swatch.setAttribute('class', 'swatch')
swatch.setAttribute('position', {
x: -(containerWidth - swatchWidth) / 2 + i * swatchWidth,
y: 0,
z: 0.001 //prevent z-fighting
})
swatch.addEventListener('click', this.onSwatchClicked.bind(this, color))
this.swatchContainer.appendChild(swatch)
}
this.el.refreshRaycaster()
},
bindMethods: function() {
this.el.generateSwatches = this.generateSwatches.bind(this)
this.el.initColorWheel = this.initColorWheel.bind(this)
this.el.initBrightnessSlider = this.initBrightnessSlider.bind(this)
this.el.updateColor = this.updateColor.bind(this)
this.el.onHueDown = this.onHueDown.bind(this)
this.el.onBrightnessDown = this.onBrightnessDown.bind(this)
this.el.refreshRaycaster = this.refreshRaycaster.bind(this)
this.el.clearSwatches = this.clearSwatches.bind(this)
},
onSwatchReady: function() {
this.swatchReady = true
},
clearSwatches: function() {
if (this.swatchReady) while (this.swatchContainer.firstChild) this.swatchContainer.removeChild(this.swatchContainer.firstChild)
},
refreshRaycaster: function() {
const raycasterEl = AFRAME.scenes[0].querySelector('[raycaster]')
raycasterEl.components.raycaster.refreshObjects()
},
initBrightnessSlider: function() {
/*
* NOTE:
*
* In A-Painter, the brightness slider is actually a model submesh / element.
* Here we generate it using GLSL and add it to our plane material
*/
const vertexShader = `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}
`
const fragmentShader = `
uniform vec3 color1;
uniform vec3 color2;
varying vec2 vUv;
void main(){
vec4 c1 = vec4(color1, 1.0);
vec4 c2 = vec4(color2, 1.0);
vec4 color = mix(c2, c1, smoothstep(0.0, 1.0, vUv.y));
gl_FragColor = color;
}
`
let material = new THREE.ShaderMaterial({
uniforms: {
color1: {
type: 'c',
value: new THREE.Color(0xFFFFFF)
},
color2: {
type: 'c',
value: new THREE.Color(0x000000)
}
},
vertexShader: vertexShader,
fragmentShader: fragmentShader
})
this.brightnessSlider.getObject3D('mesh').material = material;
this.brightnessSlider.getObject3D('mesh').material.needsUpdate = true;
},
initColorWheel: function() {
const colorWheel = this.colorWheel.getObject3D('mesh')
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
}
`;
const fragmentShader = `
#define M_PI2 6.28318530718
uniform float brightness;
varying vec2 vUv;
vec3 hsb2rgb(in vec3 c){
vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0 );
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
return c.z * mix( vec3(1.0), rgb, c.y);
}
void main() {
vec2 toCenter = vec2(0.5) - vUv;
float angle = atan(toCenter.y, toCenter.x);
float radius = length(toCenter) * 2.0;
vec3 color = hsb2rgb(vec3((angle / M_PI2) + 0.5, radius, brightness));
gl_FragColor = vec4(color, 1.0);
}
`;
let material = new THREE.ShaderMaterial({
uniforms: {
brightness: {
type: 'f',
value: this.hsv.v
}
},
vertexShader: vertexShader,
fragmentShader: fragmentShader
});
colorWheel.material = material
colorWheel.material.needsUpdate = true
},
onSwatchClicked: function(color) {
const colorWheel = this.colorWheel.getObject3D('mesh'),
brightnessCursor = this.brightnessCursor.getObject3D('mesh'),
brightnessSlider = this.brightnessSlider.getObject3D('mesh')
let rgb = this.hexToRgb(color)
this.hsv = this.rgbToHsv(rgb.r, rgb.g, rgb.b)
let angle = this.hsv.h * 2 * Math.PI,
radius = this.hsv.s * this.data.wheelSize
let x = radius * Math.cos(angle),
y = radius * Math.sin(angle),
z = colorWheel.position.z
let colorPosition = new THREE.Vector3(x, y, z)
colorWheel.localToWorld(colorPosition)
//We can reuse hueDown for this
this.onHueDown(colorPosition)
//Need to do the reverse of onbrightnessdown
let offset = this.hsv.v * this.brightnessSliderHeight
let bY = offset - this.brightnessSliderHeight
let brightnessPosition = new THREE.Vector3(0, bY, 0)
this.setPositionTween(brightnessCursor, brightnessCursor.position, brightnessPosition)
colorWheel.material.uniforms['brightness'].value = this.hsv.v
},
onBrightnessDown: function(position) {
const brightnessSlider = this.brightnessSlider.getObject3D('mesh'),
brightnessCursor = this.brightnessCursor.getObject3D('mesh'),
colorWheel = this.colorWheel.getObject3D('mesh')
brightnessSlider.updateMatrixWorld()
brightnessSlider.worldToLocal(position)
//Brightness is a value between 0 and 1. The parent plane is centre registered, hence offset
let cursorOffset = position.y + this.brightnessSliderHeight / 2
let brightness = cursorOffset / this.brightnessSliderHeight
let updatedPosition = {
x: 0,
y: position.y - this.brightnessSliderHeight / 2,
z: 0
}
//Set brightness cursor position to offset position
// Uncomment to remove anims: brightnessCursor.position.copy(updatedPosition)
this.setPositionTween(brightnessCursor, brightnessCursor.position, updatedPosition)
//Update material brightness
colorWheel.material.uniforms['brightness'].value = brightness
this.hsv.v = brightness
this.el.updateColor()
},
onHueDown: function(position) {
const colorWheel = this.colorWheel.getObject3D('mesh'),
colorCursor = this.colorCursor.getObject3D('mesh'),
radius = this.data.wheelSize
colorWheel.updateMatrixWorld()
colorWheel.worldToLocal(position)
// Uncomment to remove anims: this.colorCursor.getObject3D('mesh').position.copy(position)
this.setPositionTween(colorCursor, colorCursor.position, position)
//Determine Hue and Saturation value from polar co-ordinates
let polarPosition = {
r: Math.sqrt(position.x * position.x + position.y * position.y),
theta: Math.PI + Math.atan2(position.y, position.x)
}
let angle = ((polarPosition.theta * (180 / Math.PI)) + 180) % 360
this.hsv.h = angle / 360
this.hsv.s = polarPosition.r / radius
this.el.updateColor()
},
updateColor: function() {
let rgb = this.hsvToRgb(this.hsv),
color = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
hex = `#${new THREE.Color( color ).getHexString()}`
const selectionEl = this.selectionEl.getObject3D('mesh'),
colorCursor = this.colorCursor.getObject3D('mesh'),
brightnessCursor = this.brightnessCursor.getObject3D('mesh')
//Update indicator element of selected color
if (this.data.showSelection) {
//Uncomment for no tweens: selectionEl.material.color.set(color)
this.setColorTween(selectionEl.material, selectionEl.material.color, new THREE.Color(color))
selectionEl.material.needsUpdate = true
}
//Change cursor colors based on brightness
if (this.hsv.v >= 0.5) {
this.setColorTween(colorCursor.material, colorCursor.material.color, new THREE.Color(0x000000))
this.setColorTween(brightnessCursor.material, brightnessCursor.material.color, new THREE.Color(0x000000))
} else {
this.setColorTween(colorCursor.material, colorCursor.material.color, new THREE.Color(0xFFFFFF))
this.setColorTween(brightnessCursor.material, brightnessCursor.material.color, new THREE.Color(0xFFFFFF))
}
//showHexValue set to true, update text
if (this.data.showHexValue) this.hexValueText.setAttribute('text', 'value', hex)
//Notify listeners the color has changed.
let eventDetail = {
style: color,
rgb: rgb,
hsv: this.hsv,
hex: hex
};
Event.emit(this.el, 'changecolor', eventDetail)
Event.emit(document.body, 'didchangecolor', eventDetail)
},
hexToRgb: function(hex) {
let rgb = hex.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i, (m, r, g, b) => '#' + r + r + g + g + b + b)
.substring(1).match(/.{2}/g)
.map(x => parseInt(x, 16))
return {
r: rgb[0],
g: rgb[1],
b: rgb[2]
}
},
rgbToHsv: function(r, g, b) {
var max = Math.max(r, g, b);
var min = Math.min(r, g, b);
var d = max - min;
var h;
var s = (max === 0 ? 0 : d / max);
var v = max;
if (arguments.length === 1) {
g = r.g;
b = r.b;
r = r.r;
}
switch (max) {
case min:
h = 0;
break;
case r:
h = (g - b) + d * (g < b ? 6 : 0);
h /= 6 * d;
break;
case g:
h = (b - r) + d * 2;
h /= 6 * d;
break;
case b:
h = (r - g) + d * 4;
h /= 6 * d;
break;
}
return {
h: h,
s: s,
v: v / 255
};
},
hsvToRgb: function(hsv) {
var r, g, b, i, f, p, q, t;
var h = THREE.Math.clamp(hsv.h, 0, 1);
var s = THREE.Math.clamp(hsv.s, 0, 1);
var v = hsv.v;
i = Math.floor(h * 6);
f = h * 6 - i;
p = v * (1 - s);
q = v * (1 - f * s);
t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
r = v;
g = t;
b = p;
break;
case 1:
r = q;
g = v;
b = p;
break;
case 2:
r = p;
g = v;
b = t;
break;
case 3:
r = p;
g = q;
b = v;
break;
case 4:
r = t;
g = p;
b = v;
break;
case 5:
r = v;
g = p;
b = q;
break;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
},
update: function(oldData) {
if (!oldData) return
if (this.data.backgroundColor !== oldData.backgroundColor) this.background.setAttribute('color', this.data.backgroundColor)
let swatchesChanged = difference(oldData.swatches, this.data.swatches).length > 0
if (swatchesChanged && this.data.showSwatches && this.data.swatches.filter(item => item.length === 7).length === this.data.swatches.length) {
if (this.swatchReady) {
this.el.clearSwatches()
this.el.generateSwatches(this.data.swatches)
}
}
},
tick: function() {},
remove: function() {
const that = this
//Kill any listeners
this.colorWheel.removeEventListener('click', this.onColorWheelClicked)
this.brightnessSlider.removeEventListener('click', this.onBrightnessSliderClicked)
this.swatchContainer.removeEventListener('loaded', this.onSwatchReady)
this.hexValueText.removeEventListener('click', this.onHexValueClicked)
if (this.swatchContainer) this.swatchContainer.getObject3D('mesh').children.forEach(child => child.removeEventListener('click', that))
},
pause: function() {},
play: function() {}
});
AFRAME.registerPrimitive('a-colorwheel', {
defaultComponents: {
colorwheel: {}
},
mappings: {
disabled: 'colorwheel.disabled',
backgroundcolor: 'colorwheel.backgroundColor',
showselection: 'colorwheel.showSelection',
wheelsize: 'colorwheel.wheelSize',
selectionsize: 'colorwheel.selectionSize',
showhexvalue: 'colorwheel.showHexValue',
showswatches: 'colorwheel.showSwatches',
swatches: 'colorwheel.swatches'
}
});