先进 发布的文章

当项目不在vscode打开的根目录时,eslint可能会报这个错误: Cannot read file 'tsconfig.json' .eslint
因为eslint默认在根目录寻找 tsconfig.json
可配置 .eslint.js 解决问题

module.exports = {
  parserOptions: {
    project: 'tsconfig.json',
    sourceType: 'module',
    tsconfigRootDir: __dirname,
    createDefaultProgram: true,
  },
};

当项目中项目中导入第三方模块(node_modules)是esm时,会报SyntaxError: Unexpected token 'export'错误。
默认情况下,Jest 不会转换 node_modules,因为一般来说 node_modules 中的js是被转换为cjs兼容的。
所以要解决这个问题需要配置 jest,可以在package.json中进行配置

{
  "name": "test-demo",
  "jest": {
    "transformIgnorePatterns": [
      "node_modules/(?!(package-name1|package-name2))"
    ]
  }
}

还有一点需要注意,transformIgnorePatterns 不适用于.babelrc,仅适用于babel.config.js。

导语:可视化智能色彩是Tvsion中的一个子项目,主要功能是根据一个颜色智能生成和其搭配的色板。

一、需求分析
        输入一个颜色,找出n个和这个颜色最搭配的颜色,再通过分裂互补方法将其排序,然后每个颜色按照规则生成m个色阶。

寻找颜色流程图:

请输入图片描述

分裂互补排序流程图:

请输入图片描述

色阶生成流程图:

请输入图片描述

二、分步实现
1 寻找搭配颜色
        在《可视化色彩空间解析指南》中详细介绍了最佳人眼视觉空间CIELab颜色空间,根据设计师的要求,所以寻找颜色实际上是,在CIELab颜色空间中固定一个颜色,然后均匀的找出n个颜色,这样得到的颜色视觉效果最佳。

        CIELab颜色空间是一个不规则的三维立体空间,而且颜色差异计算不是常规的三维空间距离计算,所以无法用等差的方式均匀的找到n个点,开发时,借鉴了iwanthue中使用的k-means聚类算法,对其进行了固定主色、分级生成等的一些优化。

k-means聚类算法是最著名的划分聚类算法,运用到智能色彩中的操作步骤如下:

1.1 建立坐标系
        将CIELab颜色空间按照一定的数值等差划分为一个三维坐标系。

1.2 色差计算
        在这个坐标系中随机找n个点,计算坐标系的每个点和这n个点的色彩相识度。

iwanthue中计算方式是CIE(国际照明委员会)在1976年提出的公式:

        

但是这种方式计算结果不是很准确,所以CIE(国际照明委员会)在2000年又提出更加科学的计算方式:

        

deltaE2000的js实现如下

type ColorType = [number, number, number];
const deltaE2000 = (lab1: ColorType, lab2: ColorType) => {
const deg2rad = (a: number) => a * .017453292519943295;
const rad2deg = (a: number) => a * 57.29577951308232;

const { sqrt, pow, atan2, abs, cos, sin, exp } = Math;

const [l1, a1, b1] = lab1,

[l2, a2, b2] = lab2,
avg_lp = (l1 + l2) / 2,
c1 = sqrt(pow(a1, 2) + pow(b1, 2)),
c2 = sqrt(pow(a2, 2) + pow(b2, 2)),
avg_c = (c1 + c2) / 2,
g = (1 - sqrt(pow(avg_c, 7) / (pow(avg_c, 7) + pow(25, 7)))) / 2,
a1p = a1 * (1 + g),
a2p = a2 * (1 + g),
c1p = sqrt(pow(a1p, 2) + pow(b1, 2)),
c2p = sqrt(pow(a2p, 2) + pow(b2, 2)),
avg_cp = (c1p + c2p) / 2;

let h1p = rad2deg(atan2(b1, a1p));
if (h1p < 0) {

h1p += 360;

}

let h2p = rad2deg(atan2(b2, a2p));
if (h2p < 0) {

h2p += 360;

}

const avg_hp = abs(h1p - h2p) > 180 ? (h1p + h2p + 360) / 2 : (h1p + h2p) / 2,

t = 1 - 0.17 * cos(deg2rad(avg_hp - 30)) + 0.24 * cos(deg2rad(2 * avg_hp)) + 0.32 * cos(deg2rad(3 * avg_hp + 6)) - 0.2 * cos(deg2rad(4 * avg_hp - 63));

let delta_hp = h2p - h1p;

if (abs(delta_hp) > 180) {

if (h2p <= h1p) {
  delta_hp += 360;
}
else {
  delta_hp -= 360;
}

}

const delta_lp = l2 - l1,

delta_cp = c2p - c1p;

delta_hp = 2 sqrt(c1p c2p) * sin(deg2rad(delta_hp) / 2);

const s_l = 1 + ((0.015 * pow(avg_lp - 50, 2)) / sqrt(20 + pow(avg_lp - 50, 2))),

s_c = 1 + 0.045 * avg_cp,
s_h = 1 + 0.015 * avg_cp * t,

delta_ro = 30 * exp(-(pow((avg_hp - 275) / 25, 2))),
r_c = 2 * sqrt(pow(avg_cp, 7) / (pow(avg_cp, 7) + pow(25, 7))),
r_t = -r_c * sin(2 * deg2rad(delta_ro));

const kl = 1, kc = 1, kh = 1;

return sqrt(pow(delta_lp / (s_l * kl), 2)

+ pow(delta_cp / (s_c * kc), 2)
+ pow(delta_hp / (s_h * kh), 2)
+ r_t * (delta_cp / (s_c * kc)) * (delta_hp / (s_h * kh)));

}
 

1.3 聚类
        将更相似的颜色划分到一起,得到n个聚类块,把n个点换成n个聚类块的中心,如此聚类计算多次,n个点会比较均匀的分布在CIELab颜色空间中。

1.4 初始点选取优化
        如果初始的n个点用随机的方式选取,有时计算结果会不太理想,所以可引入k-means++算法。

1.5 色域空间分级优化
        最开始是从色域空间中之间寻找n个点,然后对其中的某些色相进行单独重新聚类,但是这样的结果有时会分布的不太均匀,导致某些色相的颜色出现比较多,后面设计师提出在第一次聚类时就先把色域空间进行划分,然后对这些色域空间分别进行聚类计算,得到的结果相比之前均匀性更加稳定。

2 分裂互补排序
        最开始使用的排序方式是按色相从小到大排序,后面对其进行改进,使用了开源项目珊瑚中的颜色排列方式—分裂互补排序,首先计算出主色的标准邻近色,将n个颜色和标准邻近色按deltaE从小到大进行排序,然后计算出主色的标准互补色,将n个颜色中最接近标准互补色的颜色放到最后。

js代码实现如下:

import chroma from 'chroma-js';
type ColorType = [number, number, number];
// 分裂互补排序
const sortColorsByComplementary = (colors: string[], color: string) => {
const colorHsv = chroma(color).hsv();

// 标准互补色
const complementary: ColorType = [...colorHsv];
complementary[0] = (colorHsv[0] + 180) % 360;

// 标准邻近色
const nearby: ColorType = [...colorHsv];
nearby[0] = (colorHsv[0] + 330) % 360;

const hex2Lab = (str: string) => chroma(str).lab();
const hsv2Lab = (hcl: ColorType) => chroma(hcl, 'hsv').lab();

// 计算颜色差异为上文提到的deltaE2000
const { distance } = utils;

// 寻找最接近互补色的颜色
const complementaryLab = hsv2Lab(complementary);
let complementaryColor = colors[0];
let d1 = distance(complementaryLab, hex2Lab(colors[0]));
colors.forEach(c => {

const dc = distance(complementaryLab, hex2Lab(c));
if (dc < d1) {
  d1 = dc;
  complementaryColor = c;
}

});

// 按与标准邻近色deltaE小->大排序,去除最接近互补色的颜色
const nearbyLab = hsv2Lab(nearby);
const res = colors.sort((a, b) => {

const da = distance(nearbyLab, hex2Lab(a));
const db = distance(nearbyLab, hex2Lab(b));
return da - db;

}).filter(c => c !== complementaryColor);

// 插入主色
res.unshift(chroma(color).hex());
// 插入最接近互补色的颜色
res.push(complementaryColor);

return res;
};
 

3 生成色阶
        目前生成色阶使用的是用主色lch的l亮度变低得到一个暗的颜色,用主色lch中的l亮度变亮得到一个亮的颜色,加上主色三个颜色,通过人眼最佳分辨颜色变化中均分为n个色阶,使用的是chroma辅助计算,后期会进行自研优化。

代码如下:

import chroma, { deltaE } from 'chroma-js';

const genColorByDarken = (color: string, step: number) => {
const colorIsWhite = chroma(color).hex() !== '#ffffff';
const number = colorIsWhite ? step + 1 : step;
const darkNum = 17, brightenNum = 9999;

const darkenColor = chroma(color).set('lch.l', darkNum).hex();
const brightenColor = chroma(color).set('lch.l', brightenNum).hex();
const steps = Array(number).fill(1).map((_, i) => i / number);
const scaleFn = chroma.scale(sortColors([brightenColor, color, darkenColor])).mode('hsl').correctLightness();

const res = steps.map(number => scaleFn(number).hex());

// 当主色不为白色时去掉顶部的白色
if (colorIsWhite) {

res.shift();

}
return res;
};

  之前总结了一些前端截图方案,其中有一个是手动调用canvas的api进行绘制,多次使用之后发现每次都要写很复杂的代码,所以就把主要逻辑封装了一下,改成了通过json配置文字和图片内容,虽然网上已经有一些了,但是都比较臃肿,有很多功能用不到,这里实现的是最轻量的。

// 兼容低版本浏览器
if (typeof Object.assign != 'function') {
  Object.assign = function (target) {
    'use strict';
    if (target == null) {
      throw new TypeError('Cannot convert undefined or null to object');
    }

    target = Object(target);
    for (var index = 1; index < arguments.length; index++) {
      var source = arguments[index];
      if (source != null) {
        for (var key in source) {
          if (Object.prototype.hasOwnProperty.call(source, key)) {
            target[key] = source[key];
          }
        }
      }
    }
    return target;
  };
}

// 通过配置实现canvas绘制图片,callback可以拿到base64的图片
function drawCanvas(canvas, data, callback, callbackError) {
  var defaultData = {
    width: 750,
    height: 10,
    lineHeight: 1.5,
    color: '#263238',
    textAlign: 'left',
    fontSize: 14,
    fontFamily: '"PingFang SC",tahoma,arial,"helvetica neue","hiragino sans gb","microsoft yahei",sans-serif',
    autoHeight: false,
  };

  var ctx = canvas.getContext('2d');

  var newData = Object.assign({}, defaultData, data);

  var currentY = 0,
    loadElementLength = 0,
    loadImgLength = 0,
    elementLength = 0,
    imgElementLength = 0;

  // 设置canvas宽高
  canvas.width = newData.width;
  canvas.height = 2000;

  ctx.fillStyle = newData.bgColor || '#fff';
  ctx.fillRect(0, 0, newData.width, 2000);

  // 指定宽高内绘制图片(background-size:cover方式)
  var drawImgCover = function (img, contW, contH, startX, startY) {
    if (img.width / img.height >= contW / contH) {
      var dH = img.height;
      var dW = Math.ceil(contW / contH * dH);
      ctx.drawImage(img, (img.width - dW) / 2, 0, dW, img.height, startX, startY, contW, contH);
    } else {
      var dW = img.width;
      var dH = Math.ceil(contH / contW * dW);
      ctx.drawImage(img, 0, (img.height - dH) / 2, img.width, dH, startX, startY, contW, contH);
    }
  };

  // 自动换行
  var canvasTextAutoLine = function (str, initX, initY, width, height, lineHeight) {
    var lineWidth = 0;
    var lastSubStrIndex = 0;
    var strLen = str.length;
    for (var i = 0; i < strLen; i++) {
      lineWidth += ctx.measureText(str[i]).width;
      if (lineWidth > width) {
        ctx.fillText(str.substring(lastSubStrIndex, i), initX, initY);
        initY += lineHeight;
        console.log(initY, height);
        if (newData.autoHeight && initY >= height) {
          currentY += lineHeight;
        }
        lineWidth = ctx.measureText(str[i]).width;
        lastSubStrIndex = i;
      }
      if (i == str.length - 1) {
        ctx.fillText(str.substring(lastSubStrIndex, i + 1), initX, initY);
      }
    }
  };

  // 绘制圆角并裁剪
  var drawRoundedRect = function (x, y, width, height, r) {
    ctx.save();
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + width, y, x + width, y + r, r);
    ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
    ctx.arcTo(x, y + height, x, y + height - r, r);
    ctx.arcTo(x, y, x + r, y, r);
    ctx.closePath();
    ctx.clip();
  };

  // 居中写字
  var centerText = function (str, startY) {
    var textW = ctx.measureText(str).width;
    var x = (newData.width - textW) / 2;
    ctx.fillText(str, x, startY);
  };

  // 居中图片
  var centerImg = function (img, startY) {
    var w = img.width;
    var x = (newData.width - w) / 2;
    ctx.drawImage(img, x, startY);
  };

  // 最大长度
  var ellipsis = function (str, maxWidth) {
    var strLen = str.length;
    for (var i = 0; i < strLen; i++) {
      if (ctx.measureText(str.substr(0, i + 1)).width > maxWidth) {
        return `${str.substr(0, i)}...`;
      }
    }
    return str;
  };

  // 导出
  var output = function () {
    // 获取可变部分图像
    var contW = newData.width,
      contH = newData.height;

    if (newData.autoHeight) {
      contH += currentY;
    }
    console.log(contH, currentY);
    var main = ctx.getImageData(0, 0, contW, contH);

    // 重新建空画布
    canvas.width = contW;
    canvas.height = contH;
    ctx.clearRect(0, 0, contW, 2000); //清空

    // 绘制可变部分图像
    ctx.putImageData(main, 0, 0);

    // 导出图片url
    var dataUrl = canvas.toDataURL('image/png', 1);
    setTimeout(function () {
      callback && callback(dataUrl);
    }, 100);
  };

  var checkOutput = function () {
    loadElementLength++;
    if (loadElementLength === elementLength) {
      output();
    }
  };

  // 加载图片
  loadImage = function (imgArr, callback) {
    imgArr.forEach(function (item) {
      if (!item) return;
      var Img = new Image();
      Img.crossOrigin = "anonymous";
      Img.src = item.content;

      Img.onload = function () {
        item.imgObj = this;
        loadImgLength++;
        if (loadImgLength === imgElementLength) {
          callback && callback();
        };
      }

      Img.onerror = function (err) {
        callbackError && callbackError(err);
        return false;
      };
    });
  };

  // 一个字一个字绘制
  drawTextArr = function (contentArr, x, y, w, lineHeight, color, fontSize, fontfamily) {
    if (!contentArr || !contentArr.length) return;
    var lineWidth = 0;
    var initX = x;
    var initY = y;

    contentArr.forEach(function (item) {
      ctx.fillStyle = item.color || color;
      var font = (item.fontSize || fontSize) + 'px ' + (item.fontfamily || fontfamily);
      if (item.bold) {
        font = item.bold + ' ' + ctx.font;
      }
      ctx.font = font;
      var strtxt = item.content;

      for (let i = 0; i < strtxt.length; i++) {
        lineWidth += ctx.measureText(strtxt[i]).width;
        if (lineWidth > w) {
          initY += lineHeight;
          if (newData.autoHeight) {
            currentY += lineHeight;
          }
          lineWidth = ctx.measureText(strtxt[i]).width;
        }
        ctx.fillText(strtxt[i], initX + lineWidth - ctx.measureText(strtxt[i]).width, initY);
      }
    });
  };

  // 绘制图片
  var drawImage = function (element) {
    var x = element.x || 0,
      y = element.y || 0,
      w = element.width || 0,
      h = element.height || 0;

    if (currentY !== 0) {
      y += currentY;
    }

    var Img = element.imgObj;

    ctx.save();
    if (element.autoHeight) {
      var newH = Img.height * (w / Img.width);
      if (newData.autoHeight) {
        currentY += newH - h;
      }
      h = newH;
    }
    ctx.drawImage(Img, x, y, w, h);
    ctx.restore();

    if (element.r > 0) {
      drawRoundedRect(x, y, w, h, element.r);
    }
    checkOutput();
  };

  // 绘制文本
  var drawText = function (element) {
    var fontfamily = element.fontfamily || newData.fontFamily,
      fontSize = element.fontSize || newData.fontSize,
      cont = element.content,
      w = element.width || 0,
      h = element.height || 0,
      x = element.x || 0,
      y = element.y || 0,
      color = element.color || newData.color,
      font = fontSize + 'px ' + fontfamily,
      lh = element.lineHeight || newData.lineHeight;
    var lineHeight = lh * fontSize;

    if (element.bold) {
      font = element.bold + ' ' + font;
    }

    if (currentY !== 0) {
      y += currentY;
    }

    if (element.maxWidth) {
      cont = ellipsis(cont, element.maxWidth);
    }

    y += fontSize + (lineHeight - fontSize) / 4;

    ctx.beginPath();
    ctx.fillStyle = color;
    ctx.font = font;
    if (typeof cont !== 'string') {
      drawTextArr(cont, x, y, w, lineHeight, color, fontSize, fontfamily);
    } else if (element.textAlign === 'center') {
      centerText(cont, y);
    } else if (element.autoHeight) {
      canvasTextAutoLine(cont, x, y, w, y + h, lineHeight);
    } else {
      ctx.fillText(cont, x, y);
      ctx.fill();
    }
    ctx.closePath();
    checkOutput();
  };

  if (newData.elements && newData.elements.length) {
    elementLength = newData.elements.length;
    var imgArr = newData.elements.map(function (item) {
      if (item.type === 'img') {
        imgElementLength++;
        return item;
      }
    });
    loadImage(imgArr, function () {
      newData.elements.forEach(function (item, index) {
        if (item.type === 'img') {
          item.imgObj = imgArr[index].imgObj;
          drawImage(item);
        } else {
          drawText(item);
        }
      });
    });
  }
}

使用

function drawShare(canvas, data, callback, callbackError) {
  var newData = {
    width: 686,
    height: 954,
    color: '#263238',
    bgColor: '#fff',
    autoHeight: true,
    elements: [
      {
        type: 'img',
        x: 0,
        y: 0,
        width: 686,
        height: 514,
        autoHeight: true,
        content: data.pic,
      },
      {
        type: 'text',
        width: 622,
        height: 72,
        x: 32,
        y: 546,
        fontSize: 48,
        bold: 500,
        autoHeight: true,
        content: data.title,
      },
      {
        type: 'text',
        width: 622,
        height: 112,
        x: 32,
        y: 634,
        color: '#3C5A6E',
        fontSize: 32,
        autoHeight: true,
        lineHeight: 1.75,
        content: data.info,
      },
      {
        type: 'img',
        x: 32,
        y: 776,
        width: 622,
        height: 2,
        content: 'images/achievement/draw-share-line.png',
      },
      {
        type: 'img',
        x: 24,
        y: 802,
        width: 128,
        height: 128,
        content: data.qrcode,
      },
      {
        type: 'text',
        x: 176,
        y: 814,
        color: '#3C5A6E',
        fontSize: 24,
        content: '长按或扫描二维码',
      },
      {
        type: 'text',
        x: 176,
        y: 850,
        color: '#3C5A6E',
        fontSize: 24,
        content: '查看公益项目',
      },
      {
        type: 'img',
        x: 176,
        y: 894,
        width: 269,
        height: 36,
        content: 'images/achievement/draw-share-logo.png',
      },
      {
        type: 'img',
        x: 476,
        y: 780,
        width: 208,
        height: 176,
        content: 'images/achievement/draw-share-flower.png',
      },
    ],
  };
  drawCanvas(canvas, newData, callback, callbackError);
}