简单使用贝塞尔曲线构造弯曲箭头

Jan 8, 2024

贝塞尔曲线构造弯曲箭头

b.gif

前言

构造控制点在曲线上的箭头,控制点用来控制箭头弯曲程度。

知识点

  • Bezier
  • 平面直角坐标系中,点绕点旋转后的坐标位置

主要是三个点,一个是利用贝塞尔曲线的CS构造曲线,一个是计算旋转角度进行旋转,一个是利用S关于C对称生成的控制点和箭头终点的连线作为箭头的中线。

image.png

Bezier

关于贝塞尔曲线,其实有很多资料,这里主要使用的是CS。 个人简单理解记忆,二次贝塞尔曲线,三点确定曲线,“切图”组合(QT)。 三次贝塞尔曲线,四点确定曲线,“厕所”组合(CS)。

命令全称翻译代码
Ccurveto三次贝塞尔曲线到(x1 y1, x2 y2, x y) +
Ssmooth curveto光滑三次贝塞尔曲线到(x2 y2, x y) +

角度

这里主要使用Math.atan2()计算。

atan2方法返回一个 -pi 到 pi 之间的数值,表示点 (x, y) 对应的偏移角度。这是一个逆时针角度,以弧度为单位,正 X 轴和点 (x, y) 与原点连线 之间。注意此函数接受的参数:先传递 y 坐标,然后是 x 坐标。 逆时针就是从x轴正方向旋转,先经过y轴正方向,再到x轴负方向,再到y轴负方向,最后到x轴正方向。 因为SVG和Canvas都是左上角为原点,向右为x轴正方向,向下为y轴正方向。正好跟数学中的平面直接坐标系y轴方向相反。所以其逆时针是从右 → 向下 ↓ 到左 ← 到 ↑ 。 通过使用 Math.atan2() 得出其旋转角。

angle.gif

function calAngleControlAndEnd(start: any, end: any) {
  const distanceX = end[0] - start[0]
  const distanceY = end[1] - start[1]
  const baseAngle = Math.atan2(distanceY, distanceX)
  return baseAngle
}

旋转

由上 Math.atan2() 是逆时针旋转,那么这里计算点绕点逆时针旋转角度后的坐标位置.

image.png

由上图可得

sin α = (y1 - y) / r cos α = (x1 - x) / r

sin (α + β) = (y2 - y) / r cos (α + β) = (x2 - x) / r

将r调换等号两边位置得:

r _ sin α = y1 - y r _ cos α = x1 - x

r _ sin (α + β) = y2 - y r _ cos (α + β) = x2 - x

利用初中三角函数以及结合律得:

微信图片_20221114190100.jpg

构造步骤

以上是本文的基础知识点。下面开始构造!

曲线

本来是打算只利用三次贝塞尔曲线绘制,但是其控制点不在实线上。

C.gif

如果要修改其弯曲度,感觉上没有那么优雅,然后看到了https://excalidraw.com/的直线效果,就很香。

bezier curve.gif

最后结合C、S实现了。

CC.png

SS.png

C就已经可以控制曲线了。S也是生成曲线。 不过其控制点都是虚,不在曲线上。 如果想要一个控制点在曲线上的,拖动控制点即可控制,那么我们需要将其结合起来。

bezierCS.gif

由上面动图可以知道,当CS配合使用的时候,只需要移动C的最后的实际关键点的位置,和最后一个实际关键点控制点跟它保持相同的相对位置 就能构造出一个控制点在其实线上的贝塞尔曲线。

CS Line.gif

代码如下:

<script setup lang="ts">
const middle = ref(null)
const end = ref(null)
const pointStart = ref([0, 100])
const pointEnd = ref([800, 100])
const pointMiddle = ref([600, 0])
const p = computed(() => {
  return `
    M ${pointStart.value[0]} ${pointStart.value[1]}
    C ${pointStart.value[0]} ${pointStart.value[1]} ${pointMiddle.value[0] - 100} ${pointMiddle.value[1]} ${pointMiddle.value[0]} ${pointMiddle.value[1]}
    S ${pointEnd.value[0]} ${pointEnd.value[1]} ${pointEnd.value[0]} ${pointEnd.value[1]}
  `
})
let isDragging = false
function handleMove(e) {
  if (isDragging)
    pointMiddle.value = [e.pageX, e.pageY]
}
function handleDown() {
  isDragging = true
}
function handleUp() {
  isDragging = false
}
</script>

<template>
  <svg @mousemove="handleMove" @mouseup="handleUp">
    <path :d="p" fill="transparent" stroke="black" />
    <circle :cx="pointStart[0]" :cy="pointStart[1]" r="10" stroke-width="0.2" fill="red" />
    <circle ref="end" :cx="pointEnd[0]" :cy="pointEnd[1]" r="10" stroke-width="0.2" fill="red" />
    <circle :cx="pointMiddle[0] - 100" :cy="pointMiddle[1] " r="10" stroke-width="0.2" fill="green" />
    <circle :cx="pointMiddle[0] + 100" :cy="pointMiddle[1] " r="10" stroke-width="0.2" fill="blue" />
    <circle ref="middle" :cx="pointMiddle[0]" :cy="pointMiddle[1]" r="20" stroke-width="0.2" fill="red" @mousedown="handleDown" />
  </svg>
</template>

<style scoped>
body {
  background: #ddd;
  display: flex;
  justify-content: center;
}
svg {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh;
  background-color: #ffffff;
  touch-action: none;
}
</style>

箭头

上面构造出了曲线,接下来构造箭头。

image.png

如上图所示,点O就是箭头终点,而箭翼两点相对于箭头终点的位置也有规律可循。先计算构造出平行于x轴方向的箭翼的坐标,再计算出箭头起点与终点的角度,使用Math.atan2()计算得出夹角范围在[-π,π],且x轴正方向是0,然后再将上一步计算的坐标按照角度旋转即可得出最终的箭翼的坐标。

计算初始的箭头坐标

// 计算箭头两翼的
function calArrowHeadCoords(point: number[], h = 50) {
  // 设置角度为30度
  const x = h * Math.sqrt(3)
  return [
    [point[0] + x, point[1] + h],
    [point[0] + x, point[1] - h],
  ]
}

计算角度

// 计算S控制点与终点的角度
function calAngleControlAndEnd(start: any, end: any) {
  const distanceX = end[0] - start[0]
  const distanceY = end[1] - start[1]
  const baseAngle = Math.atan2(distanceY, distanceX)
  return baseAngle
}

在平面直角坐标系中,点绕点旋转后的坐标

function calculateCoords(start: number[], end: number[], angle) {
  const x = start[0]
  const y = start[1]
  const x1 = end[0]
  const y1 = end[1]

  const sin = Math.sin(angle)
  const cos = Math.cos(angle)

  const x2 = x + (x1 - x) * cos - (y1 - y) * sin
  const y2 = y + (y1 - y) * cos + (x1 - x) * sin
  return [x2, y2]
}

将以上三个方法结合使用,构造箭头

const arrowHeadPath = ref()
watch(() => pointMiddle.value, () => {
  const angle = calAngleControlAndEnd([pointEnd.value[0], pointEnd.value[1]], [pointMiddle.value[0] + 100, pointMiddle.value[1]])
  const coords = calArrowHeadCoords(pointEnd.value)
  const c = coords.map((item) => {
    return calculateCoords(pointEnd.value, item, angle)
  })
  arrowHeadPath.value = `
    M ${pointEnd.value[0]} ${pointEnd.value[1]}
    L ${c[0][0]} ${c[0][1]}
    M ${pointEnd.value[0]} ${pointEnd.value[1]}
    L ${c[1][0]} ${c[1][1]}
  `
}, {
  immediate: true,
})

b.gif

全部代码

<script setup lang="ts">
const middle = ref(null)
const end = ref(null)
const pointStart = ref([50, 200])
const pointEnd = ref([800, 200])
const pointMiddle = ref([600, 100])
const p = computed(() => {
  return `
    M ${pointStart.value[0]} ${pointStart.value[1]}
    C ${pointStart.value[0]} ${pointStart.value[1]} ${pointMiddle.value[0] - 100} ${pointMiddle.value[1]} ${pointMiddle.value[0]} ${pointMiddle.value[1]}
    S ${pointEnd.value[0]} ${pointEnd.value[1]} ${pointEnd.value[0]} ${pointEnd.value[1]}
  `
})

// 计算箭头两翼的
function calArrowHeadCoords(point: number[], h = 50) {
  // 设置角度为30度
  const x = h * Math.sqrt(3)
  return [
    [point[0] + x, point[1] + h],
    [point[0] + x, point[1] - h],
  ]
}

// 计算S控制点与终点的角度
function calAngleControlAndEnd(start: any, end: any) {
  const distanceX = end[0] - start[0]
  const distanceY = end[1] - start[1]
  const baseAngle = Math.atan2(distanceY, distanceX)
  return baseAngle
}

const arrowHeadPath = ref()
watch(() => pointMiddle.value, () => {
  const angle = calAngleControlAndEnd([pointEnd.value[0], pointEnd.value[1]], [pointMiddle.value[0] + 100, pointMiddle.value[1]])
  const coords = calArrowHeadCoords(pointEnd.value)
  const c = coords.map((item) => {
    return calculateCoords(pointEnd.value, item, angle)
  })
  arrowHeadPath.value = `
    M ${pointEnd.value[0]} ${pointEnd.value[1]}
    L ${c[0][0]} ${c[0][1]}
    M ${pointEnd.value[0]} ${pointEnd.value[1]}
    L ${c[1][0]} ${c[1][1]}
  `
}, {
  immediate: true,
})

function calculateCoords(start: number[], end: number[], angle) {
  const x = start[0]
  const y = start[1]
  const x1 = end[0]
  const y1 = end[1]

  const sin = Math.sin(angle)
  const cos = Math.cos(angle)

  const x2 = x + (x1 - x) * cos - (y1 - y) * sin
  const y2 = y + (y1 - y) * cos + (x1 - x) * sin
  return [x2, y2]
}

const defaultArrowHeadPath = computed(() => {
  const coords = calArrowHeadCoords(pointEnd.value)

  return `
  M ${pointEnd.value[0]} ${pointEnd.value[1]}
  L ${coords[0][0]} ${coords[0][1]}
  M ${pointEnd.value[0]} ${pointEnd.value[1]}
  L ${coords[1][0]} ${coords[1][1]}
  `
})

let isDragging = false
function handleMove(e) {
  if (isDragging)
    pointMiddle.value = [e.pageX, e.pageY]
}
function handleDown() {
  isDragging = true
}
function handleUp() {
  isDragging = false
}
</script>

<template>
  <svg @mousemove="handleMove" @mouseup="handleUp">
    <path :d="p" fill="transparent" stroke="purple" />
    <path :d="arrowHeadPath" fill="transparent" stroke="purple" />
    <path :d="defaultArrowHeadPath" fill="transparent" stroke="black" />

    <circle :cx="pointStart[0]" :cy="pointStart[1]" r="10" stroke-width="0.2" fill="red" />
    <circle ref="end" :cx="pointEnd[0]" :cy="pointEnd[1]" r="10" stroke-width="0.2" fill="red" />
    <circle :cx="pointMiddle[0] - 100" :cy="pointMiddle[1] " r="10" stroke-width="0.2" fill="green" />
    <circle :cx="pointMiddle[0] + 100" :cy="pointMiddle[1] " r="10" stroke-width="0.2" fill="blue" />
    <circle ref="middle" :cx="pointMiddle[0]" :cy="pointMiddle[1]" r="20" stroke-width="0.2" fill="red" @mousedown="handleDown" />
  </svg>
</template>

<style scoped>
body {
  background: #ddd;
  display: flex;
  justify-content: center;
}
svg {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh;
  background-color: #ffffff;
  touch-action: none;
}
</style>

结语

主要是为了回顾一下SVG和一些简单的三角函数。这里也只有两个知识点,一是贝塞尔曲线CS命令的应用,一是点绕点逆时针旋转后的坐标位置计算。

张鑫旭老师的贝塞尔曲线讲解 : https://www.zhangxinxu.com/wordpress/2014/06/deep-understand-svg-path-bezier-curves-command/

发布日期: 2024/01/08