撤销画布变换以便书写文本

16
当使用画布进行转换时,生成的文本也会被转换。是否有一种方法可以防止某些转换(例如反射)影响文本?
例如,我设置了全局转换矩阵,使Y轴指向上方,X轴向右,而(0, 0)点位于屏幕中心(这是数学坐标系所期望的)。但是,这也使得文本倒置了。

const size = 200;

const canvas = document.getElementsByTagName('canvas')[0]
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');

ctx.setTransform(1, 0, 0, -1, size / 2, size / 2);

const triangle = [
  {x: -70, y: -70, label: 'A'},
  {x:  70, y: -70, label: 'B'},
  {x:   0, y:  70, label: 'C'},
];

// draw lines  
ctx.beginPath();
ctx.strokeStyle = 'black';
ctx.moveTo(triangle[2].x, triangle[2].y);
triangle.forEach(v => ctx.lineTo(v.x, v.y));
ctx.stroke();
ctx.closePath();
  
// draw labels
ctx.textAlign = 'center';
ctx.font = '24px Arial';
triangle.forEach(v => ctx.fillText(v.label, v.x, v.y - 8));
<canvas></canvas>

除手动重置转换矩阵外,是否有一种“智能”方式获取“正确”定向的文本?


这里是赏金更通用的示例,我不仅翻转了y轴,还进行了缩放和平移。如何在正确的方向和比例下绘制文本以显示在点旁边?https://jsfiddle.net/7ryfwvfm/2/ - Matsemann
5个回答

8

在Tai的回答基础上,你可能想考虑以下几点:

    const size = 200;

    const canvas = document.getElementsByTagName('canvas')[0]
    canvas.width = canvas.height = size;
    const ctx = canvas.getContext('2d');

    // Create a custom fillText funciton that flips the canvas, draws the text, and then flips it back
    ctx.fillText = function(text, x, y) {
      this.save();       // Save the current canvas state
      this.scale(1, -1); // Flip to draw the text
      this.fillText.dummyCtx.fillText.call(this, text, x, -y); // Draw the text, invert y to get coordinate right
      this.restore();    // Restore the initial canvas state
    }
    // Create a dummy canvas context to use as a source for the original fillText function
    ctx.fillText.dummyCtx = document.createElement('canvas').getContext('2d');

    ctx.setTransform(1, 0, 0, -1, size / 2, size / 2);

    const triangle = [
      {x: -70, y: -70, label: 'A'},
      {x:  70, y: -70, label: 'B'},
      {x:   0, y:  70, label: 'C'},
    ];

    // draw lines  
    ctx.beginPath();
    ctx.strokeStyle = 'black';
    ctx.moveTo(triangle[2].x, triangle[2].y);
    triangle.forEach(v => ctx.lineTo(v.x, v.y));
    ctx.stroke();
    ctx.closePath();
      
    // draw labels
    ctx.textAlign = 'center';
    ctx.font = '24px Arial';
    // For this particular example, multiplying x and y by small factors >1 offsets the labels from the triangle vertices
    triangle.forEach(v => ctx.fillText(v.label, 1.2*v.x, 1.1*v.y));

上述方法对于你的实际应用非常有用,如果你需要在绘制非文本对象和绘制文本之间来回切换,而不想记得反转画布。在当前示例中,这不是一个很大的问题,因为你先绘制三角形,然后再绘制所有文本,所以只需要一次翻转。但是,如果你有一个更复杂的不同应用程序,那么这可能会很麻烦。在上面的示例中,我用一个自定义方法替换了fillText方法,该方法翻转画布,绘制文本,然后再将其翻转回来,这样每次想要绘制文本时就不必手动操作了。

enter image description here

如果您不喜欢覆盖默认的fillText,那么您可以创建一个新名称的方法;这样,您也可以避免创建虚拟上下文,并在自定义方法中使用this.fillText

编辑:以上方法对于任意缩放和平移也适用。 scale(1,-1)只是将画布沿x轴反射:在此变换之后,原来在(x,y)处的点现在将位于(x,-y)处。这对于平移和缩放是成立的。如果您希望文本在缩放时保持恒定大小,则只需按缩放比例缩放字体大小即可。例如:

<html>
<body>
 <canvas id='canvas'></canvas>
</body>

<script>

 const canvas = document.getElementById('canvas');
 const ctx = canvas.getContext('2d');
 var framesPerSec = 100;
 var msBetweenFrames = 1000/framesPerSec;
 ctx.font = '12px Arial';

 function getRandomCamera() {
  return {x: ((Math.random() > 0.5) ? -1 : 1) * Math.random()*5,
       y: ((Math.random() > 0.5) ? -1 : 1) * Math.random()*5+5,
       zoom: Math.random()*20+0.1,
    };
 }

 var camera = getRandomCamera();
 moveCamera();

 function moveCamera() {
  var newCamera = getRandomCamera();
  var transitionFrames = Math.random()*500+100;
  var animationTime = transitionFrames*msBetweenFrames;

  var cameraSteps = { x: (newCamera.x-camera.x)/transitionFrames,
          y: (newCamera.y-camera.y)/transitionFrames,
          zoom: (newCamera.zoom-camera.zoom)/transitionFrames };
  
  for (var t=0; t<animationTime; t+=msBetweenFrames) {
   window.setTimeout(updateCanvas, t);
  }
  window.setTimeout(moveCamera, animationTime);

  function updateCanvas() {
   camera.x += cameraSteps.x;
   camera.y += cameraSteps.y;
   camera.zoom += cameraSteps.zoom;
   redrawCanvas();
  }
 }

 ctx.drawText = function(text, x, y) {
  this.save();
  this.transform(1 / camera.zoom, 0, 0, -1 / camera.zoom, x, y);
  this.fillText(text, 0, 0);
  this.restore();
 }

 function redrawCanvas() {

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.save();
  ctx.translate(canvas.width / 2 - (camera.x * camera.zoom),
              canvas.height / 2 + (camera.y * camera.zoom));
  ctx.scale(camera.zoom, -camera.zoom);

  for (var i = 0; i < 10; i++) {
      ctx.beginPath();
      ctx.arc(5, i * 2, .5, 0, 2 * Math.PI);
      ctx.drawText(i, 7, i*2-0.5);
      ctx.fill();
  }

  ctx.restore();
 }

</script>

</html>

编辑:根据Blindman67的建议修改了文本缩放方法。同时通过使摄像机运动逐渐变化来改善演示。


在问题的注释中,我想添加一个更一般的例子并进行解释。 - Matsemann
即使是您的更一般情况,相同的一般方法也应该适用。请参见我在上面编辑中添加的示例。 - cjg
2
你的字体缩放方法在像素大小变得太小时会失败,而且一些浏览器会忽略canvas字体大小的小数部分。另外,调整字体大小可能比仅仅缩放字体更慢。以下是更有效(和可靠)的drawText函数:c.save(); c.setTransform(1 / camera.zoom, 0, 0, -1 / camera.zoom, x, -y); c.fillText(text, 0, 0); c.restore(); 如果缩放或字体在调用之间发生了更改,则此函数不会失败,并且速度更快。 - Blindman67
@Blindman67,谢谢!你的方法明显更好。我已经更新了答案以包含它。 - cjg
感谢您添加了任意翻译和缩放的示例。我已将赏金授予您。 - Matsemann

7

我的解决方案是旋转画布,然后绘制文本。

ctx.scale(1,-1); // rotate the canvas
triangle.forEach(v => {
ctx.fillText(v.label, v.x, -v.y + 25); // draw with a bit adapt position
});

希望这能帮到您:)

const size = 200;

const canvas = document.getElementsByTagName('canvas')[0]
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');

ctx.setTransform(1, 0, 0, -1, size / 2, size / 2);

const triangle = [
  {x: -70, y: -70, label: 'A'},
  {x:  70, y: -70, label: 'B'},
  {x:   0, y:  70, label: 'C'},
];

// draw lines  

ctx.beginPath();
ctx.strokeStyle = 'black';
ctx.moveTo(triangle[2].x, triangle[2].y);
triangle.forEach(v => ctx.lineTo(v.x, v.y));
ctx.stroke();
ctx.closePath();

// draw labels
ctx.textAlign = 'center';
ctx.font = '24px Arial';
ctx.scale(1,-1);
triangle.forEach(v => {
ctx.fillText(v.label, v.x, -v.y + 25);
});
<canvas></canvas>


在问题的注释中,我想添加一个更一般的例子并进行解释。 - Matsemann

1
我建议采用一种方法,将您的绘图“状态”存储而不是实际像素,并定义一个draw方法,以便在任何时间点渲染此状态。
您将不得不为您的点实现自己的scaletranslate方法,但我认为最终它是值得的。
所以,要点如下:
- 存储“要绘制的内容”列表(带标签的点) - 公开修改这些“内容”的scaletranslate方法 - 公开呈现这些“内容”的draw方法
例如,我创建了一个名为Figure的类,显示了这些功能的1.0实现。我创建一个引用画布的新实例。然后,通过传递xylabel添加点。 scaletransform更新这些点的xy属性。 draw循环遍历这些点来a)绘制“点”,并b)绘制标签。

const Figure = function(canvas) {
  const ctx = canvas.getContext('2d');
  const origin = {
    x: canvas.width / 2,
    y: canvas.height / 2
  };
  const shift = p => Object.assign(p, {
    x: origin.x + p.x,
    y: origin.y - p.y
  });

  let points = [];

  this.addPoint = (x, y, label) => {
    points = points.concat({
      x,
      y,
      label
    });
  }

  this.translate = (tx, ty) => {
    points = points.map(
      p => Object.assign(p, {
        x: p.x + tx,
        y: p.y + ty
      })
    );
  };

  this.scale = (sx, sy) => {
    points = points.map(
      p => Object.assign(p, {
        x: p.x * sx,
        y: p.y * sy
      })
    );
  };

  this.draw = function() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.beginPath();

    const sPoints = points.map(shift);

    sPoints.forEach(p => drawDot(ctx, 5, p.x, p.y));
    sPoints.forEach(p => drawLabel(ctx, p.label, p.x + 5, p.y));

    ctx.fill();
  }
}

const init = () => {
  const canvas = document.getElementById('canvas');
  const fig = new Figure(canvas);

  // Generate some test data
  for (let i = 0, labels = "ABCD"; i < labels.length; i += 1) {
    fig.addPoint(i * 3, (i + 1) * 10, labels[i]);
  }

  const sX = parseFloat(document.querySelector(".js-scaleX").value);
  const sY = parseFloat(document.querySelector(".js-scaleY").value);
  const tX = parseFloat(document.querySelector(".js-transX").value);
  const tY = parseFloat(document.querySelector(".js-transY").value);

  fig.scale(sX, sY);
  fig.translate(tX, tY);
  fig.draw();
}

Array
  .from(document.querySelectorAll("input"))
  .forEach(el => el.addEventListener("change", init));

init();



// Utilities for drawing
function drawDot(ctx, d, x, y) {
  ctx.arc(x, y, d / 2, 0, 2 * Math.PI);
}

function drawLabel(ctx, label, x, y) {
  ctx.fillText(label, x, y);
}
canvas {
  background: #efefef;
  margin: 1rem;
}

input {
  width: 50px;
}
<div>
  <p>
    Scales first, translates second (hard coded, can be changed)
  </p>
  <label>Scale x <input type="number" class="js-scaleX" value="1"></label>
  <label>Scale y <input type="number" class="js-scaleY" value="1"></label>
  <br/>
  <label>Translate x <input type="number" class="js-transX" value="0"></label>
  <label>translate y <input type="number" class="js-transY" value="0"></label>
</div>
<canvas id="canvas" width="250" height="250"></canvas>

注意:使用输入框可以看到这个功能的实例。我选择立即“提交”缩放和平移的更改,所以顺序很重要!您可能需要按全屏键以查看画布和输入框。

0

替代方案

  var x = 100;
  var y = 100;
  var pixelRatio = 2;
  var transform = {"x": 0, "y": 0, "k": 1}

  context.save();
  context.setTransform(pixelRatio, 0.0, 0.0, pixelRatio, 0.0, 0.0);
  context.translate(transform.x, 0);
  context.scale(transform.k, 1);

  context.save();
  // get Transformed Point
  var context_transform = context.getTransform();
  var pt = context_transform.transformPoint({
        x: x,
        y: y
      });

  // Reset previous transforms
  context.setTransform(pixelRatio, 0.0, 0.0, pixelRatio, -pt.x, -pt.y);
  

  // draw with the values as usual
  context.textAlign = "left";
  context.font = "14px Arial";
  context.fillText("Hello", pt.x, pt.y);
  context.restore();

  context.restore();

0
将接受的答案稍作修改以使其性能更高。使用ctx.save/restore备份和还原整个上下文在这种情况下不必要,也可以用简单的方式表示在翻转y轴与不翻转y轴之间交替切换(例如创建可重复使用的库)。
改为:
ctx.save();
ctx.scale(1, -1);
ctx.strokeText(text, x, -y);
ctx.restore();

你可以做:

// Somewhere in your library
let flipy = -1; // If not flipping y then set to 1

// To draw text
ctx.scale(1, flipy);                // If flipy is 1 then no effect
ctx.strokeText(text, x, y * flipy); // If flipy is 1 then keep current y
ctx.scale(1, flipy);                // Undo the previous flip

这只改变和恢复转换矩阵,而不是整个上下文。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接