最近在研究canvas,想要弄一个canvas的所见所得工具。
在研究的过程中,猛然发现调色板不太好实现。
通过多方面研究及翻阅文献,发现网上对于调色板的实现大都是产生一个色块列表而已。
这种方式丑爆了好吧,而且选颜色麻烦死了,绿色还分那么多个块,怎么能好好选到自己心仪的颜色呢?
论插件来说的话,有一个插件还不错,基本和Photoshop的调色板差不多:
官网:spectrum
这款调色板还算比较符合我个人喜好,而且demo显示的功能也非常不错。
我没有下载,也没有去仔细研究它的实现方式,粗看了一下不是使用canvas的。
可是,这种UI并不是我心目中的best。
我想要达到的效果是类似painter里面的调色板UI:
这个不但简洁,而且色环的表达方式非常符合现实色彩的展现。
最终效果:
实现思路:
一、 画色环,最难的部分,想了很多种办法啦,最后还是通过基于像素画曲线产生。
二、 画方形灰度色块,矩形容易画,难在渐变的算法,没有找到文献可以研究。
三、 画透明度滑动条,这个简单。
四、 画预览窗,最简单。
具体实现:
一、 画色环。
首先,canvas 画圆的话,首选 arc 方法,不过渐变填充却只能线性或者径向,没有办法沿着路径渐变。
所以这里不能使用渐变进行色彩填充,需要基于像素画出一段段曲线并着色。
画完一圈之后半径减少 1px 之后(实际项目中是0,5px),画第二圈,直到预期的内径大小。
这里重点要搞清楚着实过程色彩的变化规律(算法)。
预览 | web | rgb | 过程序号 | 属性变化 |
---|---|---|---|---|
#FF0000 | (255,0,0) | 1 | g++ | |
#FFFF00 | (255,255,0) | 2 | r-- | |
#00FF00 | (0,255,0) | 3 | b++ | |
#00FFFF | (0,255,255) | 4 | g-- | |
#0000FF | (0,0,255) | 5 | r++ | |
#FF00FF | (255,0,255) | 6 | b-- | |
#FF0000 | (255,0,0) | - | - |
想清楚之后实现起来其实挺简单:
/**
* 产生色环
* @params: ctx canvas_context 已经初始化后的 canvas context
* @params: x float 圆心 x 坐标
* @params: y float 圆心 y 坐标
* @params: outterRadius float 圆的外径
* @params: innerRadius float 圆的内径
* @params: wearProof float 细腻度(>0,越小越细腻)
* @returs: false
*/
var colorRing = function(ctx, x, y, outterRadius, innerRadius, wearProof){
for (var i = outterRadius; i >= innerRadius; i-=wearProof) {
var r=255,g=0,b=0,flag=1; // rgb 对应红绿蓝三色的数值, flag 指色彩渐变过程序号
for (var j = 0; j < Math.PI*2; j+=Math.PI/720) {
ctx.strokeStyle = 'rgb('+r+','+g+','+b+')';
ctx.beginPath();
ctx.arc(x,y,i,j,j+Math.PI/720,false);
ctx.stroke();
// 变化规则
switch(flag){
case 1:
if(g>=255){g=255;r=254;flag=2;break;}
g++;break;
case 2:
if(r<=0){r=0;b=1;flag=3;break;}
r--;break;
case 3:
if(b>=255){b=255;g=254;flag=4;break;}
b++;break;
case 4:
if(g<=0){g=0;r=1;flag=5;break;}
g--;break;
case 5:
if(r>=255){r=255;b=254;flag=6;break;}
r++;break;
case 6:
if(b<=0){flag=null;break;}
b--;break;
default:break;
}
};
};
return false;
}
P.S.:这里的函数我还没有封装起来,我打算封装成JQ插件,所以我的项目最终的代码会稍微有点区别(下同)。
二、 画方形灰度色块。
这个姑且称为灰度色块啦,其实是颜色的微调,因为色彩是rgb三维的,仅仅有二维的东西无法表达,所以需要表达第三维的变化。
这个色块由于三个顶点的颜色值是不同的:
左上角固定白色,右上角固定为当前选择的颜色,左下和右下固定为黑色。
所以应该是一个渐变的过程,但是如何渐变呢?着实困难。
打开Photoshop,一个个像素点地研究,发现左侧边缘和右侧边缘都是递减的变化,而横向的变化规律不明显。
如下图(以#F00色彩为例):
所以,算法就是水平方向上做渐变(lineargradient),垂直方向做等分分割。
/**
* 产生中间方形灰度选择块
* @params: ctx canvas_context 已经初始化后的 canvas context
* @params: x float 左上顶点 x 坐标
* @params: y float 左上顶点 y 坐标
* @params: w float 色块的宽
* @params: h float 色块的高
* @params: baseColor string/dict 定义基准色(右上角的色彩),接受一个色彩字符串或者含有 R/G/B 元素的字典
* @returs: false
*/
var colorPalatte = function(ctx, x, y, w, h, baseColor){
var r,g,b;
var unitI = h/255;
baseColor = colorStringToRGB(baseColor); // 处理字符串类型的色彩,转化为 {R:#,G:#,B:#}
if(!baseColor)
return false;
for (var i = 0; i < h; i+=unitI) {
var lg6 = ctx.createLinearGradient(x,y,x+w,y);
r=g=b=Math.floor(255-i*255/h); // 左侧边缘色彩
lg6.addColorStop(0,'rgb('+r+','+g+','+b+')');
r=baseColor.R-i*255/h; // 右侧边缘色彩
g=baseColor.G-i*255/h; // 因为i被等分了,
b=baseColor.B-i*255/h; // 所以需要反转单位
r=r<0?0:r;g=g<0?0:g;b=b<0?0:b; // 保证不能小于0,因为是减法,所以也不可能大于 255
r=Math.floor(r);g=Math.floor(g);b=Math.floor(b); //rgb 函数只接受整数
lg6.addColorStop(1,'rgb('+r+','+g+','+b+')');
ctx.strokeStyle = lg6;
ctx.beginPath();
ctx.moveTo(x,y+i);
ctx.lineTo(x+w,y+i);
ctx.stroke();
};
return false;
}
三、 画透明度滑动条。
其实就是画一个渐变条罢了,不多说。
不过为了好看,加上方格背景能更好地表示“透明”这个概念。
/**
* 产生透明度滑动条
* @params: ctx canvas_context 已经初始化后的 canvas context
* @params: x float 左上顶点 x 坐标
* @params: y float 左上顶点 y 坐标
* @params: w float 滑动条的宽
* @params: h float 滑动条的高
* @params: baseColor string/dict 定义基准色(右侧的色彩),接受一个色彩字符串或者含有 R/G/B 元素的字典
* @returs: false
*/
var colorSlider = function(ctx, x, y, w, h, baseColor){
baseColor = colorStringToRGB(baseColor); // 处理字符串类型的色彩,转化为 {R:#,G:#,B:#}
if(!baseColor)
return false;
// 画背景透明方格
ctx.fillStyle = 'rgba(0,0,0,0.3)';
var _halfH = Math.floor(h/2),_gridCnt = Math.floor(w/_halfH);
for (var i = 0; i < _gridCnt; i+=2) {
if( (x+i*_halfH) < (x+w) )
ctx.fillRect(x+i*_halfH,y,_halfH,_halfH);
if( (x+(i+1)*_halfH) < (x+w) )
ctx.fillRect(x+(i+1)*_halfH,y+_halfH,_halfH,_halfH);
};
// 产生透明条
var lg6 = ctx.createLinearGradient(x,y,w,y);
lg6.addColorStop(0,'rgba('+baseColor.R+','+baseColor.G+','+baseColor.B+',0)');
lg6.addColorStop(1,'rgba('+baseColor.R+','+baseColor.G+','+baseColor.B+',1)');
ctx.fillStyle = lg6;
ctx.strokeStyle = '#000000';
ctx.fillRect(x,y,w,h);
ctx.strokeRect(x,y,w,h);
return false;
}
四、 画预览窗。
这个不多说了,自己体会一下。
/**
* 产生预览
* @params: ctx canvas_context 已经初始化后的 canvas context
* @params: x float 左上顶点 x 坐标
* @params: y float 左上顶点 y 坐标
* @params: w float 预览的宽
* @params: h float 预览的高
* @params: currentColor string/dict 定义当前颜色,接受一个色彩字符串或者含有 R/G/B/A 元素的字典
* @params: newColor string/dict 定义新选择的颜色,接受一个色彩字符串或者含有 R/G/B/A 元素的字典
* @returs: false
*/
var colorPreview = function(ctx, x, y, w, h, currentColor, newColor){
currentColor = colorStringToRGB(currentColor); // 处理字符串类型的色彩,转化为 {R:#,G:#,B:#}
if(!currentColor)
return false;
newColor = colorStringToRGB(newColor); // 处理字符串类型的色彩,转化为 {R:#,G:#,B:#}
if(!newColor)
return false;
// 产生预览(当前颜色)
ctx.fillStyle = 'rgba('+currentColor.R+','+currentColor.G+','+currentColor.B+','+(currentColor.A?currentColor.A:1)+')';
ctx.fillRect(x,y,w/2,h);
// 产生预览(新颜色)
ctx.fillStyle = 'rgba('+newColor.R+','+newColor.G+','+newColor.B+','+(newColor.A?newColor.A:1)+')';
ctx.fillRect(x+w/2,y,w/2,h);
// 边框
ctx.strokeStyle = '#000000';
ctx.strokeRect(x,y,w,h);
return false;
}
对了,这中间还用到一个自定义函数:colorStringToRGB:
/**
* 处理字符串类型的色彩,转化为 {R:#,G:#,B:#}
* @params: baseColor string 十六进制色彩字符串
* @returs: {R:#,G:#,B:#} dict #为对应的十进制数值
*/
var colorStringToRGB = function(baseColor){
if( typeof baseColor === 'string' ){
// 形如 #FF0000 的色彩字符串
baseColor = baseColor.replace('#','');
if(baseColor.length != 3 && baseColor.length != 6){
console.log('Error color string format');
return null;
}
if(baseColor.length == 3){
var tmpArr = baseColor.split('');
baseColor = '';
for (var i = 0; i < tmpArr.length; i++) {
baseColor += tmpArr[i]+tmpArr[i];
};
}
baseColor = {
R: parseInt(baseColor.slice(0,2), 16),
G: parseInt(baseColor.slice(2,4), 16),
B: parseInt(baseColor.slice(4,6), 16),
}
}
return baseColor;
}
以上就是使用canvas画出调色板的实现方法。
要使用的话,还需要一些基本的参数传入,下面是demo:
P.S.:本人是JQ狗,基本上都会用jqery做东西,所以这个是打算做成JQ插件的,目前demo已经去除对JQ的依赖,后续源码可能会加入JQ,注意了。
=================================================================
2016-05-18 更新
在写插件的过程中,发现获取颜色很困难,如果使用canvas自带的getImageData来获取的颜色点很不精准,圆心偏差在3°左右。
然后就去翻资料,要通过计算出来才行。
偶然发现HSB的资料(参考文献1、参考文献2),才发现原来外面的圈圈其实就是HSB中的H参数,术语叫做“色相环”。
有了这个资料就好办多了。
重新定义取色的逻辑,并且所有涉及颜色的地方采用计算的方式取数。
最后经过测试,如果手工输入颜色值,大约会有0.01度的偏差,这个肉眼是看不出来的,而且也不会在结果里面体现。
说了那么多,RGB和HSB(因为这里有两个B,所有HSB下文有HSV表示,是一个意思。)的转换方式如下:
RGB --> HSV:
其中:max为RGB颜色中三个分量数值最大的那个;min就是最小的那个。
r/g/b三个字母就是对应RGB颜色中三个分量。
HSV -->RGB:
解释一下,下面的 hi 其实就是 h/60 的整数部分(向下取整),f 就是 h/60 的小数部分。其它都很好理解。
按照上述公式,可以写出基于 javascript 的代码如下:
RGB --> HSV:(以下函数已经使用在实际的案例中了)
/**
* 处理字符串类型的色彩,转化为 {R:#,G:#,B:#}
* @params: color string 十六进制色彩字符串
* @returs: {R:#,G:#,B:#} dict #为对应的十进制数值
*/
_colorStringToRGB = function(color){
var oriColor = color;
if( typeof color === 'string' && color.charAt(0) === '#' ){
// 形如 #FF0000 的色彩字符串
color = color.replace('#','');
if(color.length != 3 && color.length != 6){
console.error('Error HEX color string: '+oriColor);
return null;
};
if(color.length == 3){
var tmpArr = color.split('');
color = '';
for (var i = 0; i < tmpArr.length; i++) {
color += tmpArr[i]+tmpArr[i];
};
};
color = {
R: parseInt(color.slice(0,2), 16),
G: parseInt(color.slice(2,4), 16),
B: parseInt(color.slice(4,6), 16),
};
}else if( typeof color === 'string' && color.slice(0, 3).toLowerCase() === 'rgb' ){
// 形如 rgb() / rgba()
var matchArr = color.match(/rgba?\( *(\d+) *, *(\d+) *, *(\d+) *(?:, *(1|0\.\d+) *)?\)/i);
if(!matchArr)
return null;
color = {
R: matchArr[1]*1,
G: matchArr[2]*1,
B: matchArr[3]*1,
};
if(matchArr[4] !== undefined)
color.A = matchArr[4]*1;
};
return color;
};
/**
* 处理{R:#,G:#,B:#},转化为字符串类型的色彩
* @params: {R:#,G:#,B:#} dict #为对应的十进制数值
* @returs: Color string 十六进制色彩字符串
*/
_RGBToColorString = function(rgb){
if( typeof rgb === 'object' && rgb.R !== undefined ){
var r, g, b, colorString;
// 形如 {R:#,G:#,B:#}
r = (rgb.R).toString(16);
r < 16 && (r = '0' + r);
g = rgb.G.toString(16);
g < 16 && (g = '0' + g);
b = rgb.B.toString(16);
b < 16 && (b = '0' + b);
colorString = '#' + r + g + b;
return colorString;
};
return rgb;
};
/**
* 处理{R:#,G:#,B:#}/colorString,转化为 {H:#,S:#,V:#} 色彩值
* @params: rgb dict/string
* @returs: {H:#,S:#,V:#} dict
*/
_RGBToHSV = function(rgb){
var color
if(typeof rgb == 'string' && rgb.charAt(0) == '#')
color = _colorStringToRGB(rgb);
else if(typeof rgb === 'object' && rgb.R !== undefined)
color = rgb;
else
return undefined;
var r = color.R, g = color.G, b = color.B;
var max = r>g?(r>b?r:b):(g>b?g:b),
min = r<g?(r<b?r:b):(g<b?g:b),
h, s, v;
// rgb --> hsv(hsb)
if(max == min){
h = 0; // 定义里面应该是undefined的,不过为了简化运算,还是赋予0算了。
}else if(max == r){
h = 60*(g-b)/(max-min);
if(g<b)
h += 360;
}else if(max == g){
h = 60*(b-r)/(max-min)+120;
}else if(max == b){
h = 60*(r-g)/(max-min)+240;
};
if( max == 0)
s = 0;
else
s = (max - min)/max;
v = max;
return {H: h,S: s,V: v};
}
HSV -->
R
GB
:(注意:这个函数我并没有测试过,仅仅按照公式进行书写,因为色相环采用直角坐标系,和H的定义还是有点区别的,所以我并没有用。)
/**
* 处理{H:#,S:#,V:#}/colorString,转化为 {R:#,G:#,B:#} 色彩值
* @params: hsv{H:#,S:#,V:#} dict
* @returs: rgb{R:#,G:#,B:#} dict
*/
_HSVToRGB = function(hsv){
if(!(typeof hsv === 'object' && hsv.H !== undefined))
return undefined;
var h = hsv.H, s = hsv.S, v = hsv.V,
r, g, b;
var hi = Math.floor(h/60),
f = h/60 - hi,
p = v * (1 - s),
q = v * (1 - f * s ),
t = v * (1 - (1 - f) * s);
switch(hi){
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: r,G: g,B: b};
}
在实际项目中,由于我并不知道HSV的值,而仅仅知道当前选取点的坐标(x, y),所以,采用HSV转RGB的算法并不可取,因此有了下面的直角坐标转RGB的代码片段:
/**
* 根据给出的坐标,计算色相环上的点的颜色
* @params: pos{x:#,y:#} dict
* @params: center{x:#,y:#} dict
* @returs: rgb{R:#,G:#,B:#} dict
*/
_posToRGB = function(pos, center){
var newColor;
// 计算色相环的值
var x = pos.x, y = pos.y, // 选色点的坐标(已经经过处理,此处相对于色相环所在矩形的左上角)
b = x-center.x, a = y-center.y, // a/b的位置看图, >0/=0/<0 均有可能
alpha, r, g, b; // alpha 是圆心角的弧度 的绝对值(方便起见,采用正数进行运算)
// 处理 b 为0的情况(不能做除数)
if(b === 0)
alpha = Math.PI/2;
else
alpha = Math.abs(Math.atan(a/b));
// 开始枚举
if(a>=0 && b>0 && alpha<=Math.PI/3){
r = 255;
g = alpha*255*3/Math.PI;
b = 0;
}else if(a>0 && b>=0 && Math.PI/3<alpha){
r = 255*2 - alpha*255*3/Math.PI;
g = 255;
b = 0;
}else if(a>0 && b<0 && Math.PI/3<alpha){
r = alpha*255*3/Math.PI - 255;
g = 255;
b = 0;
}else if(a>=0 && b<0 && alpha<=Math.PI/3){
r = 0;
g = 255;
b = 255 - alpha*255*3/Math.PI;
}else if(a<0 && b<0 && alpha<=Math.PI/3){
r = 0;
g = 255 - alpha*255*3/Math.PI;
b = 255;
}else if(a<0 && b<0 && Math.PI/3<alpha){
r = alpha*255*3/Math.PI - 255;
g = 0;
b = 255;
}else if(a<0 && b>=0 && Math.PI/3<alpha){
r = 255*2 - alpha*255*3/Math.PI;
g = 0;
b = 255;
}else if(a<0 && b>0 && alpha<=Math.PI/3){
r = 255;
g = 0;
b = alpha*255*3/Math.PI;
}
// 取整数--这个地方就是误差来源
r=Math.floor(r);g=Math.floor(g);b=Math.floor(b);
newColor = {R: r, G: g, B: b};
return newColor;
}
这里面枚举的情况有点多,用图表示会比较好:
根据这个图形,然后按照之前画色环中的颜色变化规律,就可以得到上述代码了。
完整的项目当前是存放在git上面,有兴趣可以看看:
csdn code: colorPalatte
github: colorPalatte
-------------------------
参考文献: