PixiJs:巡线机器节点路径绘制

536 阅读7分钟

  当前业务线,有一种终端机器,是通过配置路径、分配任务的方式,来帮助用户做一些重复性工作。之前路径的展现方式,是链式的节点展现,类似下面这种:

image.png
这种方式不能直观的展示出路径是个什么样子,现在需要根据配置的路径节点,来呈现路径的整体面貌: image.png
虽然节点类型有十几种,但总体上可以归结为2种:直线行走节点和转向节点。

基于V8版本

基础图形

app和canvas

import { Application } from 'pixi.js'

;(async () => {
  const app = new Application()
  await app.init({ background: '#1099bb', resizeTo: window })

  document.body.appendChild(app.canvas)
})()

app.init是异步的,所以需要使用async包裹起来,这样的话生成的app就变成了局部变量。而且通常也会把它封装成一个组件,要不然直接指明width和height:app.init({width: 600, height: 400}),要不然resizeTo指向组件根元素

<div ref="canvasRef"></div>

const canvasRef = ref<HTMLDivElement>()
onMounted(async () => {
  const app = new Application()
  await app.init({ background: '#1099bb', resizeTo: canvasRef.value })

  canvasRef.value!.appendChild(app.canvas)
})

const circle = new Graphics().circle(0, 0, 12).fill(0xffffff).stroke({width: 1, color: '#ff0000')

app.stage.addChild(circle)

这个circle,其实就是new Graphics()的返回值,一个graphics中可以绘制多个图形:

const graphics = new Graphics()
const circle = graphics.circle(0, 0, 12).fill(0xffffff).stroke({width: 1, color: '#ff0000')
const rect = graphics.rect(10, 10, 50, 50).fill('#00ff00').stroke({width: 2, color: 0x0000ff)
// circle === rect === graphics
app.stage.appChild(graphics)

文本

const text = new Text({
  text: '这是一段文本',
  style: {
    fontSize: 14,
    fill: 0xff00ff,
  },
  x: 100,
  y: 100,
})
text.anchor.set(0.5)

app.stage.addChild(text)

文本是有锚点的,0.5表示中心点,上面这个文本的中心点,在 (100,100)

折线

const points = [{x: 100, y: 100}, {x: 100, y: 200}, {x: 200, y: 200}]

const graphics = new Graphics()

graphics.moveTo(points[0].x, points[0].y)
for (let i = 1, len = points.length; i < len; i++) {
  graphics.lineTo(points[i].x, points[i].y)
}
graphics.stroke({ width: 2, color: 0xff0000 })

app.stage.addChild(graphics)

折线可以通过moveTolineTo来绘制,也可以通过绘制多边形的边来实现:

// close需要设置为false
const path = new Graphics().poly([100, 100, 100, 200, 200, 200], false)
                .stroke({ width: 2, color: 'red' })

整体思路

要求:1. 生成的节点路径,初始时处于中心位置,大小合适;2. 平移和缩放,缩放时,节点大小和线宽不变
第一个要求的实现思路:

  1. 根据获取到的节点数据,生成各个点的坐标
  2. 获取路径范围
  3. 和canvas大小比较,得出缩放比例
  4. 根据缩放比例,计算缩放后的范围
  5. 计算全局坐标系下的范围
  6. 全局范围的中心点,和canvas中心点比较,计算出差值
  7. 设置container的位置
  8. 根据缩放比例,计算出缩放后每个点的坐标
  9. 绘制图形

image.png
第二个要求的实现思路:

  1. 事件中获取全局坐标
  2. 转成局部坐标
  3. 根据滚动方向,设置缩放值(0.9 | 1 / 0.9)
  4. 局部坐标scale缩放
  5. 全局坐标 - 局部坐标,得出差值
  6. 最新缩放值 = 初始缩放值 * 此次设定的缩放值(0.9 | 1 / 0.9)
  7. 根据最新缩放值,计算缩放后每个点的坐标
  8. 清除上次绘制的内容,重新绘制
  9. 设置container的位置

image.png

节点路径

0. Container

由于我们需要满足平移操作,所以使用一个Container来作为父级,通过调整container的位置来实现所有内容的移动

const container = new Container({x: 0, y: 0})

container.x = xxx
container.y = yyy
// 或者
container.position.x = xxx
container.position.y = yyy
// 或者
container.position.set(xxx, yyy)

1. 模拟数据

路径节点大体上就2种:直线节点和转向节点,而且转向通常都是90°。我们就用90°来模拟一条50个节点的路径

  • line节点有length,turn节点有angle
  • 默认初始方向为0°(朝向右)
  • 第一个节点位置为(0, 0)
  • 由于转向节点只转向,下一个节点的位置和转向节点完全重叠,所以对下一个节点做偏移
const RADIUS = 12

type PointType = {
  x: number
  y: number
  offsetX: number
  offsetY: number
  angle: number
}
const nodes = getNodes() // 模拟接口获取到的节点数据
const initialPoints = getPoints(nodes) // 初始坐标

function getPoints(nodes: ReturnType<typeof getNodes>): PointType[] {
  const points: PointType[] = []
  nodes.forEach((node, i) => {
    const point = { x: 0, y: 0, offsetX: 0, offsetY: 0, angle: 0 }
    if (i === 0) {
      point.angle = node.angle ?? 0
    } else {
      const prevNode = nodes[i - 1]
      const prevPoint = points[i - 1]
      point.x = prevPoint.x + Math.round((prevNode.length ?? 0) * Math.cos((prevPoint.angle / 180) * Math.PI))
      point.y = prevPoint.y - Math.round((prevNode.length ?? 0) * Math.sin((prevPoint.angle / 180) * Math.PI))
      
      if (prevNode.type === 'turn') {
        point.offsetX = Math.round(Math.min((node.length ?? 0) / 2, RADIUS) * Math.cos((prevPoint.angle / 180) * Math.PI))
        point.offsetY = -Math.round(Math.min((node.length ?? 0) / 2, RADIUS) * Math.sin((prevPoint.angle / 180) * Math.PI))
      }
      
      point.angle = prevPoint.angle + (node.angle ?? 0)
    }
    points.push(point)
  })
  return points
}

// 生成不连续的转向节点
function getNodes() {
  let lastNum = 0
  const nodes = [...new Array(50)].map(() => {
    const n = Math.random()
    if ((lastNum && lastNum >= 0.4) || n < 0.4) {
        lastNum = n
      return {
        type: 'line',
        length: Math.round(Math.random() * 100) + 20,
      }
    }
    lastNum = n
    return {
      type: 'turn',
      angle: n < 0.7 ? 90 : -90,
    }
  })

  return nodes
}

2. 绘制路径

function drawPath(points: PointType[]) {
    const graphics = new Graphics()
    
    graphics.moveTo(points[0].x, points[0].y)
    for (let i = 1, len = points.length; i < len; i++) {
      graphics.lineTo(points[i].x + points[i].offsetX, points[i].y + points[i].offsetY)
    }
    graphics.stroke({ width: 2, color: 0xff0000 })

    container.addChild(graphics)
}

3. 绘制节点和文本

  • 圆和节点序号文本,属于一个整体
  • graphics的位置是以左上角为锚点(graphics本身无法设置锚点)
  • 圆的中心点设置为(0, 0),文本添加到graphics中,文本anchor设置为中心点
function drawNodes(points: PointType[]) {
    points.forEach((point, i) => {
      drawNode({ x: point.x + point.offsetX, y: point.y + point.offsetY }, `${i + 1}`)
    })
}
function drawNode(position: { x: number; y: number }, text: string) {
    const graphics = new Graphics(position)
    graphics.circle(0, 0, RADIUS).fill(0xffffff).stroke({ width: 2, color: 0xff0000 })

    const nodeText = new Text({
      text: text,
      style: {
        fontSize: 14,
        fill: 0xff00ff,
      },
    })
    nodeText.anchor.set(0.5)
    graphics.addChild(nodeText)
    graphics.cursor = 'pointer'

    container.addChild(graphics)
}

4.图形

image.png

5. 调整

  • 生成路径的中心点,就是地图区域的中心点
  • 所有的节点都可见
  • 节点大小和路径宽度不变(类似我们使用地图时,缩放后,地图上的线和圆点,大小不变),也就是只有长度变化

1. 计算范围

function getBounds(points: PointType[]) {
  let minX = 0
  let minY = 0
  let maxX = 0
  let maxY = 0
  points.forEach(({ x, y }) => {
    if (x < minX) {
      minX = x
    } else if (x > maxX) {
      maxX = x
    }
    if (y < minY) {
      minY = y
    } else if (y > maxY) {
      maxY = y
    }
  })
  return {
    minX,
    minY,
    maxX,
    maxY,
  }
}

2. 计算缩放比例和中心点差值

function getTransform(bounds: BoundsType) {
    const { width, height } = app.screen.getBounds() // global
    const { maxX, maxY, minX, minY } = bounds // local

    const scale = Math.min((width - RADIUS * 2) / (maxX - minX), (height - RADIUS * 2) / (maxY - minY))

    // 局部坐标转全局坐标
    const globalMaxPoint = container.localTransform.apply(new Point(maxX * scale, maxY * scale))
    const globalMinPoint = container.localTransform.apply(new Point(minX * scale, minY * scale))
    // 中心点差值
    const dx = Math.round(width / 2 - (globalMaxPoint.x + globalMinPoint.x) / 2)
    const dy = Math.round(height / 2 - (globalMaxPoint.y + globalMinPoint.y) / 2)

    return { scale, dx, dy }
 }

3. 计算缩放后的坐标

function getScaledPoints(points: PointType[], scale = 1) {
    return points.map(({ x, y, offsetX, offsetY, angle }) => ({
      x: Math.round(x * scale),
      y: Math.round(y * scale),
      offsetX: Math.round(Math.max(Math.min(offsetX * scale, RADIUS), RADIUS * -1)), // 偏移限制
      offsetY: Math.round(Math.max(Math.min(offsetY * scale, RADIUS), RADIUS * -1)),
      angle,
    }))
}

4. 重绘和平移

container.removeChildren().forEach(child => {
  child.destroy({ children: true })
})
drawMap(points) // drawPath(points),drawNodes(points)

container.position.set(container.x + dx, container.y + dy)

5. 整体初始化渲染

let initialPoints: PointType[] = []
let scale = 1
initialRender()
function initialRender() {
    initialPoints = getPoints(nodes) // 初始坐标
    const bounds = getBounds(initialPoints) // 范围
    const { scale: initialScale, dx, dy } = getTransform(bounds) // 转换
    scale = initialScale

    const points = getScaledPoints(initialPoints, scale) // 缩放后的坐标
    drawMap(points) // 绘制

    container.position.set(container.x + dx, container.y + dy) // 位置
}

image.png

事件

1. 平移

平移就是计算前后两个点的差值,新位置 = 当前位置+差值:
我们是对container做平移,因为所有的内容都在container中

app.stage.eventMode = 'static'
app.stage.hitArea = app.screen

let lastPoint = { x: 0, y: 0 }
let isDown = false

app.stage.on('pointerdown', e => {
    isDown = true
    lastPoint = { x: e.global.x, y: e.global.y }
})
app.stage.on('pointermove', e => {
    if (isDown) {
      const point = { x: e.global.x, y: e.global.y }
      const dx = point.x - lastPoint.x
      const dy = point.y - lastPoint.y
      lastPoint = point
      
      container.position.set(container.x + dx, container.y + dy)
    }
})
app.stage.on('pointerup', e => {
    isDown = false
})

2. 缩放

app.stage.on('wheel', e => {
    // 清除上次绘制内容
    container.removeChildren().forEach(child => {
      child.destroy({ children: true })
    })
    // 全局坐标转局部坐标
    const localPoint = container.localTransform.applyInverse(e.global)
    const curScale = e.deltaY > 0 ? 0.9 : 1 / 0.9
    // 全局坐标减去局部坐标缩放倍数,相对于左上角(0, 0)原点的偏移值
    const dx = e.global.x - localPoint.x * curScale
    const dy = e.global.y - localPoint.y * curScale

    scale *= curScale // 最终缩放倍数
    // 缩放后的坐标
    const points = getScaledPoints(initialPoints, scale)
    // 重新绘制
    drawMap(points)
    // 设置偏移,dx、dy都是相对(0, 0)点的
    container.position.set(dx, dy)
})

总结

pixijs.gif 通过对坐标点的缩放和整体的平移,我们实现了一个可以展示为合适大小、缩放不失真的节点路径图。
下一篇会对路径补充一些细节,如路径上的箭头指示,完成节点、进行中节点、未执行节点不同样式,进行中节点动画效果等。