cut / BeadQR /miniprogram /utils /pattern.js
Ethscriptions's picture
上传 BeadQR 微信小程序项目源码
9de7b70 verified
Raw History Blame Contribute Delete
14.7 kB
// Canvas 绘制:预览、PNG 图纸页、作品信息页、水印
// 性能要点:按颜色批量合并路径,每种颜色只 fill 一次;字体只设置一次;网格线合并成单条路径
const { lumaHex } = require('./color')
const BOARD = '#f1eee7' // 钉板底色
const PEG = '#d9d3c7' // 空钉位
const INK = '#1d2b2a'
const MUTED = '#6b7671'
const BRAND = '#0f766e'
function roundRectPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2)
ctx.moveTo(x + rr, y)
ctx.arcTo(x + w, y, x + w, y + h, rr)
ctx.arcTo(x + w, y + h, x, y + h, rr)
ctx.arcTo(x, y + h, x, y, rr)
ctx.arcTo(x, y, x + w, y, rr)
ctx.closePath()
}
function fitText(ctx, value, maxWidth) {
const text = String(value || '')
if (ctx.measureText(text).width <= maxWidth) return text
let end = text.length
while (end > 0 && ctx.measureText(`${text.slice(0, end)}…`).width > maxWidth) end -= 1
return `${text.slice(0, end)}…`
}
// 把同色格子分组:返回 Map<colorIndex, number[] cellIndex>
function groupCells(artwork, region) {
const { size, cells } = artwork
const r = region || { col: 0, row: 0, cols: size, rows: size }
const groups = new Map()
const empty = []
for (let y = r.row; y < r.row + r.rows; y += 1) {
for (let x = r.col; x < r.col + r.cols; x += 1) {
const c = cells[y * size + x]
if (c < 0) { empty.push(y * size + x); continue }
let list = groups.get(c)
if (!list) { list = []; groups.set(c, list) }
list.push(y * size + x)
}
}
return { groups, empty, region: r }
}
/**
* 绘制珠子
* opts: { x0, y0, cell, style: square|round|hollow, region }
*/
function drawBeads(ctx, artwork, opts) {
const { x0, y0, cell, style = 'square' } = opts
const { size, pal } = artwork
const { groups, empty, region } = groupCells(artwork, opts.region)
const ARC_CHUNK = 12
const pos = (i) => {
const x = i % size
const y = (i - x) / size
return [x0 + (x - region.col) * cell, y0 + (y - region.row) * cell]
}
ctx.fillStyle = BOARD
ctx.fillRect(x0, y0, region.cols * cell, region.rows * cell)
// 空钉位:小圆点,提示“这里不放豆”
if (empty.length) {
ctx.fillStyle = PEG
ctx.beginPath()
const r = cell * 0.14
empty.forEach((i, n) => {
const [x, y] = pos(i)
ctx.moveTo(x + cell / 2 + r, y + cell / 2)
ctx.arc(x + cell / 2, y + cell / 2, r, 0, Math.PI * 2)
if (n % ARC_CHUNK === ARC_CHUNK - 1) { ctx.fill(); ctx.beginPath() }
})
ctx.fill()
}
const radius = cell * 0.46
const hole = cell * 0.2
groups.forEach((list, c) => {
ctx.fillStyle = pal.hexes[c]
ctx.beginPath()
if (style === 'square') {
list.forEach((i) => {
const [x, y] = pos(i)
// 取整边界避免相邻方块之间出现发丝缝
const lx = Math.round(x); const ty = Math.round(y)
ctx.rect(lx, ty, Math.round(x + cell) - lx, Math.round(y + cell) - ty)
})
} else {
// 圆形子路径过多时渲染引擎会明显变慢,按小批提交(大图从数秒降到几十毫秒)
list.forEach((i, n) => {
const [x, y] = pos(i)
const cx = x + cell / 2; const cy = y + cell / 2
ctx.moveTo(cx + radius, cy)
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
if (style === 'hollow') {
ctx.moveTo(cx + hole, cy)
ctx.arc(cx, cy, hole, 0, Math.PI * 2, true)
}
if (n % ARC_CHUNK === ARC_CHUNK - 1) { ctx.fill(); ctx.beginPath() }
})
}
ctx.fill()
})
}
// 色号文字:先按深/浅文字颜色分两批
function drawCodes(ctx, artwork, opts) {
const { x0, y0, cell, style = 'square' } = opts
const { size, cells, pal } = artwork
const region = opts.region || { col: 0, row: 0, cols: size, rows: size }
const light = pal.hexes.map((hex) => lumaHex(hex) > 0.56)
const fontSize = Math.max(8, Math.round(cell * (style === 'hollow' ? 0.26 : 0.32)))
ctx.font = `bold ${fontSize}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const pass = (wantLight) => {
ctx.fillStyle = wantLight ? INK : '#ffffff'
for (let y = region.row; y < region.row + region.rows; y += 1) {
for (let x = region.col; x < region.col + region.cols; x += 1) {
const c = cells[y * size + x]
if (c < 0) continue
const useInk = style === 'hollow' ? true : light[c]
if (useInk !== wantLight) continue
ctx.fillText(pal.ids[c], x0 + (x - region.col + 0.5) * cell, y0 + (y - region.row + 0.5) * cell + fontSize * 0.04)
}
}
}
pass(true)
pass(false)
ctx.textAlign = 'start'
ctx.textBaseline = 'alphabetic'
}
function rgba(hex, alpha) {
const v = parseInt(hex.slice(1), 16)
return `rgba(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255},${alpha})`
}
// 网格 + 坐标:细线每格一条,粗线每 interval 格一条(按全局坐标对齐)
function drawGrid(ctx, opts) {
const { x0, y0, cell, region, interval = 10, color = BRAND, opacity = 60, labels = true } = opts
const w = region.cols * cell
const h = region.rows * cell
const alpha = opacity / 100
ctx.lineWidth = Math.max(1, cell * 0.02)
ctx.strokeStyle = rgba(color, alpha * 0.28)
ctx.beginPath()
for (let i = 0; i <= region.cols; i += 1) { const x = Math.round(x0 + i * cell) + 0.5; ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h) }
for (let i = 0; i <= region.rows; i += 1) { const y = Math.round(y0 + i * cell) + 0.5; ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y) }
ctx.stroke()
ctx.lineWidth = Math.max(2, cell * 0.06)
ctx.strokeStyle = rgba(color, alpha)
ctx.beginPath()
for (let i = 0; i <= region.cols; i += 1) {
const g = region.col + i
if (g % interval !== 0 && i !== 0 && i !== region.cols) continue
const x = x0 + i * cell; ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h)
}
for (let i = 0; i <= region.rows; i += 1) {
const g = region.row + i
if (g % interval !== 0 && i !== 0 && i !== region.rows) continue
const y = y0 + i * cell; ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y)
}
ctx.stroke()
if (!labels) return
const fontSize = Math.max(10, Math.round(Math.min(cell * 0.42, 34)))
ctx.font = `${fontSize}px sans-serif`
ctx.fillStyle = rgba(color, Math.max(0.7, alpha))
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const step = cell < 22 ? interval : (cell < 36 ? 5 : 1)
for (let i = 0; i < region.cols; i += 1) {
const g = region.col + i + 1
if (g % step !== 0 && g !== 1) continue
ctx.fillText(String(g), x0 + (i + 0.5) * cell, y0 - fontSize * 0.9)
}
for (let i = 0; i < region.rows; i += 1) {
const g = region.row + i + 1
if (g % step !== 0 && g !== 1) continue
ctx.fillText(String(g), x0 - fontSize * 1.1, y0 + (i + 0.5) * cell)
}
ctx.textAlign = 'start'
ctx.textBaseline = 'alphabetic'
}
// 屏幕预览:整张作品,不带坐标(清爽)
function renderPreview(canvas, artwork, pixelWidth, style) {
const size = artwork.size
const pad = Math.round(pixelWidth * 0.03)
const cell = (pixelWidth - pad * 2) / size
canvas.width = pixelWidth
canvas.height = pixelWidth
const ctx = canvas.getContext('2d')
ctx.fillStyle = BOARD
ctx.fillRect(0, 0, pixelWidth, pixelWidth)
drawBeads(ctx, artwork, { x0: pad, y0: pad, cell, style })
}
// —— PNG 图纸页 ——
function patternPageLayout(size, cell) {
const axis = Math.max(48, Math.round(cell * 1.1))
const pad = Math.max(48, Math.round(cell * 0.9))
const header = Math.max(120, Math.round(cell * 2.4))
const grid = size * cell
return { axis, pad, header, width: grid + axis + pad * 2, height: header + axis + grid + pad * 2 }
}
function drawPatternPage(ctx, artwork, opts) {
const { cell, style, grid, meta } = opts
const L = patternPageLayout(artwork.size, cell)
ctx.fillStyle = '#fbfaf7'
ctx.fillRect(0, 0, L.width, L.height)
// 头部
const unit = L.header / 120
ctx.fillStyle = INK
ctx.font = `bold ${Math.round(40 * unit)}px sans-serif`
ctx.textBaseline = 'middle'
ctx.fillText('豆维 · 拼豆图纸', L.pad, L.pad * 0.6 + L.header * 0.36)
ctx.font = `${Math.round(22 * unit)}px sans-serif`
ctx.fillStyle = MUTED
ctx.fillText(fitText(ctx, meta, L.width - L.pad * 2), L.pad, L.pad * 0.6 + L.header * 0.74)
ctx.textBaseline = 'alphabetic'
const x0 = L.pad + L.axis
const y0 = L.header + L.pad * 0.6 + L.axis
const region = { col: 0, row: 0, cols: artwork.size, rows: artwork.size }
drawBeads(ctx, artwork, { x0, y0, cell, style, region })
if (cell >= 16) drawCodes(ctx, artwork, { x0, y0, cell, style, region })
if (grid.enabled) drawGrid(ctx, { x0, y0, cell, region, interval: grid.interval, color: grid.color, opacity: grid.opacity })
return L
}
// —— 作品信息页(A4 比例,可分页)——
function buildInfoPages({ artwork, width, text, paletteLabel, mpCode, thumbnail }) {
const height = Math.round(width * 297 / 210)
const u = width / 1600
const pad = 96 * u
const colors = artwork.usage.slice().sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }))
const columns = 4
const gap = 24 * u
const cardW = (width - pad * 2 - gap * (columns - 1)) / columns
const cardH = 124 * u
const headerH = 300 * u
// 缩略图区:headerH+64 起,高 400;清单标题与说明各占一行,避免与上方重叠
const listTitleY = headerH + 64 * u + 400 * u + 96 * u
const firstTop = listTitleY + 88 * u
const nextTop = 200 * u
const bottom = height - 110 * u
const rowsFirst = Math.max(1, Math.floor((bottom - firstTop + gap) / (cardH + gap)))
const rowsNext = Math.max(1, Math.floor((bottom - nextTop + gap) / (cardH + gap)))
const pages = []
let rest = colors.length
let first = true
do {
const take = Math.min(rest, (first ? rowsFirst : rowsNext) * columns)
pages.push(take)
rest -= take
first = false
} while (rest > 0)
const drawCard = (ctx, color, x, y) => {
ctx.fillStyle = '#ffffff'
ctx.beginPath(); roundRectPath(ctx, x, y, cardW, cardH, 18 * u); ctx.fill()
ctx.strokeStyle = '#e7e2d8'; ctx.lineWidth = 2 * u; ctx.stroke()
const sw = 76 * u
ctx.fillStyle = color.hex
ctx.beginPath(); ctx.arc(x + 24 * u + sw / 2, y + cardH / 2, sw / 2, 0, Math.PI * 2); ctx.fill()
ctx.strokeStyle = 'rgba(0,0,0,0.12)'; ctx.stroke()
ctx.fillStyle = INK
ctx.font = `bold ${Math.round(34 * u)}px sans-serif`
ctx.fillText(color.id, x + 24 * u + sw + 20 * u, y + 56 * u)
ctx.fillStyle = BRAND
ctx.font = `${Math.round(26 * u)}px sans-serif`
ctx.fillText(`${color.count} 颗`, x + 24 * u + sw + 20 * u, y + 96 * u)
}
const draw = (ctx, page) => {
ctx.textAlign = 'start'
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = '#fbfaf7'
ctx.fillRect(0, 0, width, height)
let top = nextTop
if (page === 0) {
ctx.fillStyle = INK
ctx.fillRect(0, 0, width, headerH)
ctx.fillStyle = '#ffffff'
ctx.font = `bold ${Math.round(62 * u)}px sans-serif`
ctx.fillText('豆维 · 作品信息与用豆清单', pad, 132 * u)
ctx.fillStyle = '#9fd3c7'
ctx.font = `${Math.round(28 * u)}px sans-serif`
ctx.fillText('照着清单备豆,照着图纸拼,拼完用微信扫一扫', pad, 200 * u)
if (mpCode) {
const s = 200 * u
const x = width - pad - s
const y = (headerH - s) / 2
ctx.fillStyle = '#ffffff'
ctx.beginPath(); roundRectPath(ctx, x - 10 * u, y - 10 * u, s + 20 * u, s + 20 * u, 20 * u); ctx.fill()
ctx.drawImage(mpCode, x, y, s, s)
}
// 缩略图 + 参数
const thumbS = 400 * u
const ty = headerH + 64 * u
if (thumbnail) {
ctx.fillStyle = '#ffffff'
ctx.beginPath(); roundRectPath(ctx, pad, ty, thumbS, thumbS, 24 * u); ctx.fill()
const inner = thumbS - 32 * u
const cell = inner / artwork.size
drawBeads(ctx, artwork, { x0: pad + 16 * u, y0: ty + 16 * u, cell, style: 'square' })
}
const mx = thumbnail ? pad + thumbS + 56 * u : pad
const rows = [
['扫码内容', text],
['作品尺寸', `${artwork.size} × ${artwork.size} 格`],
['用豆总数', `${artwork.beads.toLocaleString()} 颗 · ${artwork.usage.length} 个色号`],
['二维码', `${artwork.qr.size} × ${artwork.qr.size} 格 · 纠错 ${artwork.qr.level}`],
['色库', paletteLabel],
]
rows.forEach(([k, v], idx) => {
const y = ty + 52 * u + idx * 74 * u
ctx.fillStyle = MUTED
ctx.font = `${Math.round(26 * u)}px sans-serif`
ctx.fillText(k, mx, y)
ctx.fillStyle = INK
ctx.font = `bold ${Math.round(28 * u)}px sans-serif`
ctx.fillText(fitText(ctx, v, width - pad - mx - 150 * u), mx + 150 * u, y)
})
ctx.fillStyle = INK
ctx.font = `bold ${Math.round(42 * u)}px sans-serif`
ctx.fillText('用豆清单', pad, listTitleY)
ctx.fillStyle = MUTED
ctx.font = `${Math.round(24 * u)}px sans-serif`
ctx.fillText('按色号排序 · 建议每色多备 5% 以防掉落', pad, listTitleY + 44 * u)
top = firstTop
} else {
ctx.fillStyle = INK
ctx.font = `bold ${Math.round(36 * u)}px sans-serif`
ctx.fillText(`用豆清单(续 ${page + 1}/${pages.length})`, pad, 120 * u)
}
const offset = pages.slice(0, page).reduce((s, v) => s + v, 0)
for (let i = 0; i < pages[page]; i += 1) {
const col = i % columns
const row = Math.floor(i / columns)
drawCard(ctx, colors[offset + i], pad + col * (cardW + gap), top + row * (cardH + gap))
}
ctx.fillStyle = '#a3aaa5'
ctx.font = `${Math.round(22 * u)}px sans-serif`
ctx.fillText(`豆维 · 可扫码的拼豆 第 ${page + 1} / ${pages.length} 页`, pad, height - 56 * u)
}
return { width, height, pageCount: pages.length, draw }
}
// 轻度斜向平铺水印
function drawWatermark(ctx, w, h, text) {
const value = String(text || '').trim()
if (!value) return
const fontSize = Math.max(18, Math.round(w * 0.018))
const stepX = Math.max(fontSize * value.length * 1.6, w / 4)
const stepY = Math.max(fontSize * 6, w / 6)
ctx.save()
ctx.globalAlpha = 0.08
ctx.fillStyle = INK
ctx.font = `${fontSize}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.translate(w / 2, h / 2)
ctx.rotate(-Math.PI / 9)
const r = Math.hypot(w, h) / 2
for (let y = -r; y <= r; y += stepY) for (let x = -r; x <= r; x += stepX) ctx.fillText(value, x, y)
ctx.restore()
}
module.exports = {
BOARD,
drawBeads,
drawCodes,
drawGrid,
renderPreview,
patternPageLayout,
drawPatternPage,
buildInfoPages,
drawWatermark,
}