最近在研究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) | - | - |
上面这个表格的意思是色彩从 #F00 变化到 #F00 ,7种颜色中间有6个变化过程,每个变化过程中需要变化的值按照最后一列所展示。
想清楚之后实现起来其实挺简单:
/** * 产生色环 * @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 --> RGB:(注意:这个函数我并没有测试过,仅仅按照公式进行书写,因为色相环采用直角坐标系,和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
-------------------------
参考文献: