Files
image2api/frontend/src/utils/canvas.js
T

165 lines
5.5 KiB
JavaScript

// 无限画布 — 纯函数层:坐标变换、几何、文档校验。
// 约定:world 坐标(浮点 px)经 viewport 变换到屏幕:
// screen = world * scale + t (t = viewport.x/y,即世界原点的屏幕位置)
// 整个世界层只做一个 CSS transform,GPU 合成,平移缩放零重排。
let idSeq = 0
/** 世界→屏幕 */
export function worldToScreen(x, y, vp) {
return { x: x * vp.scale + vp.x, y: y * vp.scale + vp.y }
}
/** 屏幕→世界 */
export function screenToWorld(sx, sy, vp) {
return { x: (sx - vp.x) / vp.scale, y: (sy - vp.y) / vp.scale }
}
export const MIN_SCALE = 0.05
export const MAX_SCALE = 4
/** 以屏幕点 m 为锚缩放到 newScale,返回新 viewport(平移量按锚点保持不动) */
export function zoomAt(vp, mx, my, newScale) {
const s = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale))
const k = s / vp.scale
return { scale: s, x: mx - (mx - vp.x) * k, y: my - (my - vp.y) * k }
}
/** 节点世界包围盒 */
export function nodeRect(n) {
return { x: n.x, y: n.y, w: n.w, h: n.h }
}
export function rectsIntersect(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
}
export function pointInRect(px, py, r) {
return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h
}
/** 视口剔除:返回与可见世界矩形(含 margin)相交的节点。纯几何,千级节点也是微秒。 */
export function nodesInViewport(nodes, vp, stageW, stageH, margin = 240) {
const vis = {
x: -vp.x / vp.scale - margin,
y: -vp.y / vp.scale - margin,
w: stageW / vp.scale + margin * 2,
h: stageH / vp.scale + margin * 2,
}
return nodes.filter((n) => rectsIntersect(nodeRect(n), vis))
}
/** 常用比例 → 世界像素尺寸(长边 512;图片 onload 后会按真实尺寸校正) */
export function ratioToSize(ratio) {
const map = {
'1:1': [512, 512],
'16:9': [896, 504],
'9:16': [504, 896],
'4:3': [592, 444],
'3:4': [444, 592],
'3:2': [624, 416],
'2:3': [416, 624],
'21:9': [960, 412],
}
const [w, h] = map[ratio] || [512, 512]
return { w, h }
}
export function newId(prefix) {
idSeq = (idSeq + 1) % 100000
return `${prefix}_${Date.now().toString(36)}${idSeq.toString(36)}${Math.random()
.toString(36)
.slice(2, 7)}`
}
/**
* 校验并规范化从后端来的文档。宁可丢弃坏节点也不让一个坏字段搞挂整张画布。
* 返回 { viewport, nodes, edges }。
*/
export function normalizeDoc(raw) {
const doc = {
viewport: { x: 0, y: 0, scale: 1 },
nodes: [],
edges: [],
}
if (!raw || typeof raw !== 'object') return doc
const vp = raw.viewport
if (vp && Number.isFinite(vp.x) && Number.isFinite(vp.y) && Number.isFinite(vp.scale)) {
doc.viewport = {
x: vp.x,
y: vp.y,
scale: Math.min(MAX_SCALE, Math.max(MIN_SCALE, vp.scale)),
}
}
const ids = new Set()
if (Array.isArray(raw.nodes)) {
for (const n of raw.nodes) {
if (!n || typeof n.id !== 'string' || ids.has(n.id)) continue
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) continue
if (!Number.isFinite(n.w) || n.w <= 0 || !Number.isFinite(n.h) || n.h <= 0) continue
const type = ['note', 'gen', 'video'].includes(n.type) ? n.type : 'image'
const node = {
id: n.id,
type,
x: n.x,
y: n.y,
w: Math.min(4096, n.w),
h: Math.min(4096, n.h),
z: Number.isFinite(n.z) ? n.z : 0,
}
if (type === 'note') node.text = typeof n.text === 'string' ? n.text.slice(0, 4000) : ''
if (type === 'image' || type === 'video') {
node.src = typeof n.src === 'string' ? n.src.slice(0, 500) : ''
if (!node.src) continue // 媒体节点必须有 src
if (typeof n.prompt === 'string') node.prompt = n.prompt.slice(0, 2000)
if (typeof n.model === 'string') node.model = n.model.slice(0, 120)
}
if (type === 'gen') {
node.status = n.status === 'failed' ? 'failed' : 'running'
if (typeof n.error === 'string') node.error = n.error.slice(0, 500)
}
ids.add(node.id)
doc.nodes.push(node)
}
}
if (Array.isArray(raw.edges)) {
const pairs = new Set() // 同一对节点只保留一条线(旧文档可能有重复)
for (const e of raw.edges) {
if (!e || typeof e.id !== 'string') continue
if (typeof e.from !== 'string' || typeof e.to !== 'string') continue
if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue
const pair = e.from < e.to ? `${e.from}|${e.to}` : `${e.to}|${e.from}`
if (pairs.has(pair)) continue
pairs.add(pair)
const edge = { id: e.id, from: e.from, to: e.to }
if (['top', 'right', 'bottom', 'left'].includes(e.fromSide)) edge.fromSide = e.fromSide
if (['top', 'right', 'bottom', 'left'].includes(e.toSide)) edge.toSide = e.toSide
if (Number.isFinite(e.bendX) && Number.isFinite(e.bendY)) {
edge.bendX = Math.max(-10000, Math.min(10000, e.bendX))
edge.bendY = Math.max(-10000, Math.min(10000, e.bendY))
}
doc.edges.push(edge)
}
}
return doc
}
/** 序列化(仅内容,不含 viewport 的可变性;viewport 单独存) */
export function serializeDoc(doc) {
return JSON.stringify({
version: 1,
viewport: doc.viewport,
nodes: doc.nodes,
edges: doc.edges,
})
}
/** 生成节点完成 → 转为图片节点(保留位置/尺寸/边) */
export function promoteGenNode(node, src, extras = {}) {
node.type = 'image'
node.src = src
delete node.status
delete node.error
Object.assign(node, extras)
}