Todo:
- Optimise for touch
- 3d view of color distribution in different color spaces
- contrast ratio from selected color to all other colors
- save & export palette
- alternate palette views
.js-colors | |
.js-palette | |
app-wrap('title'='Der Bunt', 'value'='#ffffff') | |
.settings-background | |
fan(':colors'='colors') | |
.settings | |
label.settings__entry | |
h3.settings__label Method | |
select('v-model'='currentSpace', 'v-on:change'='updatePalette') | |
option('v-for'='space in spacesList', value='{{space}}') {{space}} | |
div | |
label.settings__entry | |
h3.settings__label colors | |
span.settings__value {{maxColors}} | |
input.settings__input(type='range', min=2, 'v-bind:max'='colorsLimit', step=1, 'v-bind:value'='maxColors', 'v-model'='maxColors', 'v-on:input'='updatePalette') | |
label.settings__entry('v-for'='attr in currentSettings.attr') | |
h3.settings__label {{attr.name}} | |
span.settings__value {{attr.value}} | |
template(v-if='attr.type == "color"') | |
input.settings__input(type='color', 'v-bind:value'='attr.value', 'v-on:input'='updatePalette', 'v-model'='attr.value') | |
template(v-if='attr.type == "select"') | |
select(type='{{attr.type}}', 'v-bind:value'='attr.value', 'v-on:change'='updatePalette', 'v-model'='attr.value') | |
option(v-for='val in attr.values', value='{{val}}') {{val}} | |
template(v-if='!attr.type') | |
input.settings__input(type='range', 'v-bind:min'='attr.min', 'v-bind:max'='attr.max', 'v-bind:step'='attr.step', 'v-bind:value'='attr.value', 'v-model'='attr.value', 'v-on:input'='updatePalette') | |
label.settings__entry('v-if'='space.hasStart') | |
h3.settings__label start color | |
input.settings__input(type='text', placeholder='HEX, RGB, HSL, Name, HSV etc..', 'v-on:change'='updatePalette') | |
fan(':colors'='colors') |
let currentColor = { | |
title: 'Der Bunt', | |
value: '#ffffff', | |
index: 15, | |
total: 16, | |
}; | |
let appWrap = Vue.extend({ | |
template: '<div class="app-wrap background" v-bind:style="{background: value}">' | |
+ '<header class="app-wrap__header">' | |
+ '<h1 class="app-wrap__title js-title">{{title}}</h1>' | |
+ '<h2 class="app-wrap__sub js-value">{{value}}</h2>' | |
+ '<header>' | |
+ '<slot />' | |
+ '</div>', | |
data: () => { | |
return currentColor; | |
}, | |
}); | |
Vue.component('app-wrap', appWrap); | |
var blades = { | |
hovered: null | |
}; | |
let blade = Vue.extend({ | |
template: '<article class="blade" v-on:click="setActive"' | |
+ 'v-on:mouseover="hover(index)" v-bind:style="style()">' | |
+ '<h2 class="blade__value"><strong>{{color}}</strong></h2>' | |
+ '<h3 class="blade__label"><span class="blade__label--inner">{{label}}</span></h3>' | |
+ '</article>', | |
props: { | |
color: String, | |
label: String, | |
index: Number, | |
total: Number, | |
hoverindex: Number | |
}, | |
data: function(){ | |
return { | |
shared: blades, | |
isHovered: false | |
} | |
}, | |
methods: { | |
style: function(){ | |
let rotation = (this.index + 1) * (360 / this.total); | |
const scale = this.index * 2; | |
const X = this.hoverindex == this.index ? '-10%' : 0; | |
/*if (this.hoverindex - 1 === this.index || (this.hoverindex === 0 && this.index == this.total - 1)) { | |
rotation -= this.total * .3; | |
} else if (this.hoverindex + 1 === this.index || (this.hoverindex === this.total - 1 && this.index === 0)){ | |
rotation += this.total * .3; | |
}*/ | |
return { | |
'transform': `rotate(${rotation}deg) translate3d(0,${X},${scale + 20}px)`, | |
'background-color': this.color, | |
'color': this.color, | |
//'height': 42 - ((this.total / 33) * 13) + 'vh' | |
} | |
}, | |
setActive: function(event){ | |
currentColor.title = this.label; | |
currentColor.value = this.color; | |
currentColor.index = this.index; | |
//this.$dispatch('colorChange', this.index, this.label, this.color); | |
}, | |
hover: function(index){ | |
//this.shared.hovered = index; | |
//this.style(); | |
} | |
} | |
}); | |
Vue.component('blade', blade); | |
let fan = Vue.extend({ | |
template: '<section class="fan" v-bind:style="setRotation()">' | |
+ '<blade v-for="color in colors" track-by="$index" v-bind:color="color.hex" v-bind:label="color.name" v-bind:index="$index" v-bind:total="colors.length" />' | |
+ '</section>', | |
props: { | |
colors: Array, | |
label: String, | |
active: Number, | |
}, | |
data: () => { | |
return currentColor | |
}, | |
watch: { | |
'index': function (val, oldVal) { | |
this.setRotation(val); | |
} | |
}, | |
created: function () { | |
/*this.$on('colorChange', (index, label, color) => { | |
//this.index = index; | |
if( this.total != this.colors.length ){ | |
this.hoverindex.blades.hovered = null; | |
} | |
this.total = this.colors.length; | |
this.setRotation(); | |
return true; | |
});*/ | |
}, | |
methods: { | |
setRotation: function () { | |
const rotation = (this.index + 1) * (360 / this.total); | |
return { | |
transform: `translate3d(0,0,0) rotate(${-rotation || 0}deg)` | |
} | |
} | |
} | |
}); | |
Vue.component('fan', fan); | |
function colorConv(space, ...color) { | |
let husl, c; | |
switch (space) { | |
case 'HSLuv': | |
var hsl = chroma(color, 'hsl').hsl(); | |
husl = hsluv.hsluvToHex([hsl[0], hsl[1] * 100, hsl[2] * 100]); | |
case 'HSLuvP': | |
var hsl = chroma(color, 'hsl').hsl(); | |
husl = husl || hsluv.hpluvToHex([hsl[0], hsl[1] * 100, hsl[2] * 100]); | |
c = chroma(husl, 'hex'); | |
break; | |
case 'lch': | |
c = chroma(color[1], color[2], color[0], 'lch'); | |
break; | |
case 'cubehelix': | |
c = chroma.cubehelix() | |
.start(color[0]) | |
.rotations(color[1]) | |
.hue(color[2]) | |
.gamma(color[3]) | |
.lightness([color[4], color[5]]); | |
c = c(color[0]/360); | |
break; | |
case 'scale': | |
let mode = color[3]; | |
if (color[3] === 'edg') { | |
mode = 'hsv'; | |
} | |
let carr = chroma.scale([color[1], color[2]]).mode(mode).colors(33); | |
if (color[3] === 'edg') { | |
let carrRGB = chroma.scale([color[1], color[2]]).mode('rgb').colors(33); | |
let colrRGB = carr.map((col, i) => { | |
return chroma.average([col, carrRGB[i]]); | |
}); | |
carr = chroma.scale(colrRGB).colors(33); | |
} | |
c = chroma(carr[Math.ceil(32 * (color[0] / 360))]); | |
break; | |
default: | |
c = chroma(color, space); | |
} | |
const hex = c.hex(); | |
return { | |
color: c, | |
hex: hex, | |
css: c.css('hsl'), | |
name: getClosestNamedColor( hex ).name | |
} | |
}; | |
let colorSpaces = [ | |
{ | |
name: ['hsl', 'HSLuv', 'HSLuvP'], | |
hasStart: true, | |
attr: [ | |
{ | |
name: 'hue', | |
min: 0, | |
max: 360, | |
step: 1, | |
value: 0, | |
}, | |
{ | |
name: 'saturation', | |
min: 0, | |
max: 1, | |
step: 0.01, | |
value: 1, | |
}, | |
{ | |
name: 'light', | |
min: 0, | |
max: 1, | |
step: 0.01, | |
value: .8, | |
} | |
] | |
}, | |
{ | |
name: 'cubehelix', | |
hasStart: true, | |
attr: [ | |
{ | |
name: 'start', | |
min: 0, | |
max: 360, | |
step: 1, | |
value: 0, | |
}, | |
{ | |
name: 'rotations', | |
min: -2, | |
max: 2, | |
step: 0.01, | |
value: -1.5, | |
}, | |
{ | |
name: 'hue', | |
min: 0, | |
max: 1, | |
step: 0.01, | |
value: 1, | |
}, | |
{ | |
name: 'gamma', | |
min: 0, | |
max: 1, | |
step: 0.01, | |
value: 1, | |
}, | |
{ | |
name: 'lightness min', | |
min: 0, | |
max: .9, | |
step: 0.01, | |
value: .2, | |
}, | |
{ | |
name: 'lightness max', | |
min: .1, | |
max: 1, | |
step: 0.01, | |
value: .8, | |
} | |
] | |
}, | |
{ | |
name: 'lch', | |
hasStart: false, | |
attr: [ | |
{ | |
name: 'h', | |
min: 0, | |
max: 360, | |
step: 1, | |
value: 20, | |
}, | |
{ | |
name: 'l', | |
min: 0, | |
max: 100, | |
step: 1, | |
value: 75, | |
}, | |
{ | |
name: 'c', | |
min: 0, | |
max: 100, | |
step: 1, | |
value: 100, | |
} | |
] | |
}, | |
{ | |
name: 'scale', | |
hasStart: false, | |
attr: [ | |
{ | |
name: 'shift', | |
min: 0, | |
max: 360, | |
step: 1, | |
value: 0, | |
}, | |
{ | |
name: 'start', | |
value: '#72ffd7', | |
type: 'color', | |
}, | |
{ | |
name: 'stop', | |
value: '#f03b50', | |
type: 'color', | |
}, | |
{ | |
name: 'space', | |
value: 'lab', | |
values: ['lab', 'hsl', 'hsv', 'hsi', 'lch', 'rgb', 'lrgb', 'edg', 'num'], | |
type: 'select', | |
} | |
] | |
} | |
]; | |
let palette = new Vue({ | |
el: '.js-palette', | |
data: { | |
activeColor: 0, | |
rawcolors: [], | |
startColor: null, | |
maxColors: 16, | |
colorsLimit: 33, | |
currentSpace: 'HSLuvP', | |
spaces: colorSpaces, | |
}, | |
computed: { | |
colors: { | |
get: function(){ | |
return this.rawcolors; | |
}, | |
set: function(colors){ | |
const currentSpace = this.currentSpace; | |
this.rawcolors = colors.map(function(color){ | |
var colorConvArgs = color; | |
colorConvArgs.unshift(currentSpace); | |
return colorConv.apply(null, colorConvArgs); | |
}); | |
} | |
}, | |
currentSettings: function() { | |
return this.spaces.find((space) => { | |
return (this.currentSpace == space.name || space.name.indexOf(this.currentSpace) !== -1); | |
}); | |
}, | |
spacesList: function(){ | |
let list = []; | |
this.spaces.forEach((space) => { | |
list = list.concat(typeof space.name === 'string' ? [space.name] : space.name); | |
}); | |
return list; | |
} | |
}, | |
methods: { | |
updatePalette: function() { | |
let colors = []; | |
const currentSpace = this.currentSpace; | |
let systemData = this.currentSettings; | |
for(let i = 0; i < this.maxColors; i++){ | |
let color = [(((i/this.maxColors) * 360) + systemData.attr[0].value) % 360]; | |
systemData.attr.forEach((attr, i) => { | |
if (i) | |
color.push(attr.value); | |
}); | |
colors.push(color); | |
} | |
this.colors = colors; | |
currentColor.total = colors.length; | |
currentColor.index = colors.length - 1; | |
} | |
} | |
}); | |
palette.updatePalette(); |
<script src="//cdn.rawgit.com/gka/chroma.js/master/chroma.min.js"></script> | |
<script src="//cdn.rawgit.com/dtao/nearest-color/master/nearestColor.js"></script> | |
<script src="//codepen.io/meodai/pen/VLVRYw"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.26/vue.min.js"></script> | |
<script src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/102565/hsluv.min.js"></script> |
$c-black: #212121; | |
$c-white: #fff; | |
$bg: $c-white; | |
$golden: 1.61803398875; | |
// <link href="https://fonts.googleapis.com/css?family=Inconsolata" rel="stylesheet"> | |
@import 'https://fonts.googleapis.com/css?family=Inconsolata'; | |
$t-code: 'Inconsolata', ipm, Menlo, 'Courier New', monospace; | |
//@import 'https://fonts.googleapis.com/css?family=Space+Mono'; | |
//$t-code: 'Space Mono', ipm, Menlo, 'Courier New', monospace; | |
body, html { | |
font-family: $t-code; | |
height: 100%; | |
font-size: calc(0.5rem + 1.4vh); | |
} | |
.background { | |
position: absolute; | |
top: 0; right: 0; bottom: 0; left: 0; | |
overflow: hidden; | |
transition: 200ms background-color linear 500ms; | |
will-change: background-color; | |
} | |
.app-wrap { | |
&__header { | |
padding: 1rem; | |
} | |
&__title { | |
//font-family: $t-copy; | |
font-size: 2rem; | |
margin-bottom: 0.15em; | |
} | |
&__sub { | |
font-family: $t-code; | |
} | |
} | |
.fan { | |
position: absolute; | |
top: 50vh; right: 50vw; | |
perspective: 600; | |
transition: 450ms transform cubic-bezier(0.370, 0.000, 0.250, 0.980); | |
} | |
.blade { | |
position: absolute; | |
cursor: pointer; | |
display: flex; | |
flex-direction: column; | |
height: 40vh; width: 10vh; | |
top: -40vh; left: 0; | |
box-shadow: 0 0 0 1px rgba($c-white,.15), | |
0 0 15px rgba($c-black,.1); | |
transform: translate3d(0,0,0) rotate(0deg); | |
transform-origin: 1vh 39vh; | |
border-radius: .5vh; | |
overflow: hidden; | |
transition: 200ms 200ms transform ease-in-out; | |
transition: 200ms 200ms transform cubic-bezier(0.250, 0.250, 0.275, 1.265); | |
&__label, &__value { | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
padding: 1vh; | |
line-height: 1.2; | |
} | |
&__label { | |
color: $c-white; | |
font-size: 1.6vh; | |
padding-top: .75vh; | |
&--inner { | |
display: block; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
} | |
} | |
&__value { | |
font-size: 1.8vh; | |
font-weight: 500; | |
line-height: .75; | |
text-transform: uppercase; | |
background: $c-white; | |
color: currentColor; | |
} | |
} | |
.settings, | |
.settings-background { | |
position: fixed; | |
top: 0; right: 0; bottom: 0; | |
z-index: 10; | |
width: 250px; | |
transform: translateZ(1000px); | |
} | |
.settings { | |
box-sizing: border-box; | |
padding: 1rem; | |
backdrop-filter: blur(5px); | |
background-color: rgba($c-white,.2); | |
box-shadow: -1px 0 0 rgba($c-black,.1); | |
overflow: auto; | |
&__entry { | |
display: flex; | |
align-items: center; | |
flex-wrap: wrap; | |
margin-bottom: 1.5rem; | |
} | |
&__label { | |
flex-grow: 1; | |
width: 80%; | |
flex-basis: 80%; | |
font-size: 1rem; | |
margin-bottom: .5rem; | |
} | |
&__input { | |
width: 70%; | |
} | |
&__value { | |
font-family: $t-code; | |
font-size: 0.8rem; | |
text-align: right; | |
width: 20%; | |
} | |
} | |
.settings-background { | |
pointer-events: none; | |
transform: translateZ(999px); | |
filter: blur(4px); | |
overflow: hidden; | |
.blade { | |
box-shadow: 0 0 0 1px rgba($c-white,.15); | |
} | |
} | |
input { | |
background-color: transparent; | |
} | |
input[type=range], | |
input[type=color] { | |
-webkit-appearance: none; | |
width: 100%; | |
} | |
// range sliders | |
input[type=range] { | |
margin: 0 0 0.5rem 0; | |
} | |
input[type=range]:focus { | |
outline: none; | |
&::-webkit-slider-thumb { | |
//height: .65rem; | |
//background-color: $c-white; | |
clip-path: polygon(100% 0%, 0% 0%, 50% 100%, 50% 100%); | |
//clip-path: polygon(50% 0%, 50% 0%, 0% 100%, 100% 100%); | |
} | |
} | |
@mixin slider-track { | |
width: 100%; | |
height: 1rem; | |
cursor: pointer; | |
animate: 0.2s; | |
background: transparent; | |
color: transparent; | |
border-radius: 0; | |
border: solid $c-black; | |
border-width: 0 0 1px; | |
} | |
@mixin slider-thumb { | |
border: 2px solid transparent; | |
height: .75rem; width: .5rem; | |
border-radius: 0; | |
background: $c-black; | |
cursor: pointer; | |
-webkit-appearance: none; | |
margin-top: 0.25rem; | |
transition: 150ms background-color, 200ms clip-path, 200ms -webkit-clip-path; | |
clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); | |
} | |
input[type=range]::-webkit-slider-runnable-track { | |
@include slider-track; | |
} | |
input[type=range]::-webkit-slider-thumb { | |
@include slider-thumb; | |
} | |
input[type=range]:focus::-webkit-slider-runnable-track { | |
//background: $c-black; | |
} | |
input[type=range]::-moz-range-track { | |
@include slider-track; | |
} | |
input[type=range]::-moz-range-thumb { | |
@include slider-thumb; | |
} | |
input[type=range]::-ms-track { | |
@include slider-track; | |
} | |
input[type=range]::-ms-fill-lower { | |
background: $c-black; | |
border: none; | |
border-radius: 100%; | |
} | |
input[type=range]::-ms-fill-upper { | |
background: $c-black; | |
border-radius: 100%; | |
box-shadow: none; | |
} | |
input[type=range]::-ms-thumb { | |
@include slider-thumb; | |
} | |
input[type=range]:focus::-ms-fill-lower { | |
//background: $c-black; | |
} | |
input[type=range]:focus::-ms-fill-upper { | |
//background: $c-black; | |
} | |
select { | |
font-family: $t-code; | |
width: 100%; | |
box-sizing: border-box; | |
font-size: 0.8rem; | |
-webkit-appearance: none; | |
border: 0; | |
box-shadow: 0 1px 0 0 $c-black; | |
border-radius: 0; | |
padding: 0.25rem 1rem 0.25rem 0.25rem; | |
background-color: transparent; | |
background-size: auto 40%; | |
background-repeat: no-repeat; | |
background-position: 98% 50%; | |
background-image: url('data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%20%3C%21--%20Generator%3A%20IcoMoon.io%20--%3E%20%3C%21DOCTYPE%20svg%20PUBLIC%20%22-//W3C//DTD%20SVG%201.1//EN%22%20%22http%3A//www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd%22%3E%20%3Csvg%20width%3D%22512%22%20height%3D%22512%22%20viewBox%3D%220%200%20512%20512%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20xmlns%3Axlink%3D%22http%3A//www.w3.org/1999/xlink%22%20fill%3D%22%23000000%22%3E%3Cpath%20d%3D%22M%2096.00%2C96.00l-96.00%2C96.00l%20256.00%2C256.00l%20256.00-256.00l-96.00-96.00L%20256.00%2C256.00L%2096.00%2C96.00z%22%20%3E%3C/path%3E%3C/svg%3E'); | |
transition: 150ms background-color; | |
&:focus { | |
outline: none; | |
background-color: rgba($c-white,1); | |
} | |
} |