Created
January 2, 2024 10:46
-
-
Save hyrious/5f5d3ff976e847ebcd22f10b6fbbaa90 to your computer and use it in GitHub Desktop.
hyrious.me/ink
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<title>Stroke Test</title> | |
<style> | |
* { box-sizing: border-box; } | |
body { margin: 0; } | |
.container { | |
position: absolute; | |
inset: 50px 100px; | |
border: 1px solid; | |
} | |
svg { | |
display: block; width: 100%; height: 100%; touch-action: none; | |
} | |
g, | |
path { | |
pointer-events: none; | |
} | |
.top-left, .top-right { | |
display: flex; align-items: center; | |
position: absolute; inset: 15px auto auto 25px; font-family: monospace; | |
line-height: 1.5; | |
} | |
.top-right { | |
inset: 15px 25px auto auto; | |
} | |
</style> | |
<div class="container"> | |
<svg id="target"> | |
<g id="g"></g> | |
</svg> | |
</div> | |
<div class="top-left"> | |
<samp id="predict">predict.length = 0</samp> | |
<samp id="size_label">, size = 8</samp> | |
<input id="size" type="range" min="1" max="32" value="8"> | |
</div> | |
<div class="top-right"> | |
<samp id="camera">tx = 0, ty = 0, scale = 100.00%</samp> | |
<a id="camera_reset" href="javascript:void reset_camera()">reset</a> | |
</div> | |
<pre id="log" style="z-index: -1;"></pre> | |
<script type="module"> | |
let svg = document.getElementById('target') | |
let g = document.getElementById('g') | |
let camera = document.getElementById('camera') | |
let camera_reset = document.getElementById('camera_reset') | |
let $size = document.getElementById('size') | |
let size_label = document.getElementById('size_label') | |
let $log = document.getElementById('log') | |
$size.oninput = () => { size_label.textContent = `, size = ${$size.value}` } | |
let tx = 0 | |
let ty = 0 | |
let scale = 1 | |
svg.addEventListener('wheel', e => { | |
e.preventDefault() | |
e.stopPropagation() | |
if (e.ctrlKey) { | |
let prev_scale = scale | |
// 缩放中心点 | |
rect ||= svg.getBoundingClientRect() | |
let x = (e.clientX - rect.left - tx) / scale | |
let y = (e.clientY - rect.top - ty) / scale | |
scale *= Math.exp(-e.deltaY * 0.01) | |
scale = Math.min(Math.max(0.125, scale), 4) | |
tx += (x * (prev_scale - scale)) | |
ty += (y * (prev_scale - scale)) | |
} else { | |
tx -= e.deltaX | |
ty -= e.deltaY | |
} | |
refresh_camera() | |
}, { passive: false }) | |
camera_reset.onclick = e => { | |
e.preventDefault() | |
tx = ty = 0 | |
scale = 1 | |
refresh_camera() | |
} | |
const refresh_camera = () => { | |
// must using high precision | |
g.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})` | |
camera.textContent = `tx = ${tx.toFixed(2)}, ty = ${ty.toFixed(2)}, scale = ${(scale * 100).toFixed(2)}%` | |
} | |
let pred = document.getElementById('predict') | |
let path = null, predict = null, $path = null, rect = null, scheduled = false | |
const path_push = (x_, y_) => { | |
if (path == null) return | |
let x = (x_ - tx) / scale | |
let y = (y_ - ty) / scale | |
path.push({ x, y }) | |
} | |
const predict_push = (x_, y_) => { | |
if (predict == null) return | |
let x = (x_ - tx) / scale | |
let y = (y_ - ty) / scale | |
predict.push({ x, y }) | |
} | |
const render = () => { | |
scheduled = false | |
if (path == null || $path == null || path.length < 2) return | |
const size = +$size.value | |
const uni = ({ x, y }) => { | |
const d = Math.hypot(x, y) | |
return { x: x / d, y: y / d } | |
} | |
const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y }); | |
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y }); | |
const mul = (a, n) => ({ x: a.x * n, y: a.y * n }); | |
const per = ({ x, y }) => ({ x: y, y: -x }); | |
const neg = ({ x, y }) => ({ x: -x, y: -y }); | |
const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); | |
// rotate [p] around [C] in radius [r] | |
const rot = ({ x, y }, C, r) => { | |
const s = Math.sin(r), c = Math.cos(r) | |
const px = x - C.x, py = y - C.y | |
const nx = px * c - py * s | |
const ny = px * s + py * c | |
return { x: nx + C.x, y: ny + C.y } | |
} | |
// dot product | |
const dpr = (a, b) => a.x * b.x + a.y * b.y | |
// A->B at t | |
const lerp = (a, b, t) => add(a, mul(sub(b, a), t)) | |
// A + B * c | |
const proj = (a, b, c) => add(a, mul(b, c)) | |
const M = ({ x, y }) => `M${x.toFixed(2)},${y.toFixed(2)}`; | |
const L = ({ x, y }) => `L${x.toFixed(2)},${y.toFixed(2)}`; | |
const Q = (c, { x, y }) => `Q${c.x.toFixed(2)},${c.y.toFixed(2)} ${x.toFixed(2)},${y.toFixed(2)}`; | |
// 简单二阶贝塞尔 | |
const make_bezier = (prev) => { | |
let d = M(prev), i = 1; | |
const push = (curr) => { | |
if (i) d += L(mid(prev, curr)) | |
d += Q(prev, mid(prev, curr)) | |
i = 0 | |
prev = curr | |
} | |
const done = (curr) => d + L(curr) | |
// push(1...n-th points) | |
// done(the n-th point), i.e. the last point need to be called twice | |
return { push, done } | |
} | |
// 简单 perfect freehand | |
// TODO: 这是个在线算法,可以利用中间信息优化渲染性能 | |
const make_d = (prev) => { | |
// const t = 0.5 // streamline factor, 0.5 = mid | |
let points = [{ | |
p: prev, // point | |
r: 0.5, // pressure = 0.5 for simulated | |
v: [1, 1], // vector = prev - curr, the first point is dummy | |
d: 0, // distance = hypot(prev, curr) | |
l: 0, // running length = sum(distance) | |
}] | |
let l = 0 | |
const push = (curr) => { | |
const p = mid(prev, curr) | |
const d = Math.hypot(p.x - prev.x, p.y - prev.y) | |
l += d | |
points.push({ p, r: 0.5, v: uni(sub(prev, p)), d, l }) | |
prev = p | |
} | |
const thinning = (p, t) => { p.r = Math.max(0.1, p.r - t) } | |
const done = (p) => { | |
const d = Math.hypot(p.x - prev.x, p.y - prev.y) | |
l += d | |
points.push({ p, r: 0.5, v: uni(sub(prev, p)), d, l }) | |
// assume the first 2 points' direction is the same: >o-->o | |
if (points.length > 1) { points[0].v = points[1].v } | |
// thinning the last 2 points | |
thinning(points.at(-1), 0.4) | |
thinning(points.at(-2), 0.2) | |
// Now we have all points, compute the outline polygon | |
const PI = Math.PI + 0.0001 | |
const leftPoints = [] | |
const rightPoints = [] | |
let radius = size | |
let prevPressure = points[0].r | |
let prevVector = points[0].v | |
// previous points | |
let pl = points[0].p | |
let pr = pl | |
let isPrevPointSharpCorner = false | |
for (let i = 0; i < points.length; i++) { | |
const { p, r, v, d, l } = points[i] | |
// pressure changing speed | |
const sp = Math.min(1, d / size) | |
// pressure changing rate | |
const rp = Math.min(1, 1 - sp) | |
// simulated pressure | |
const pressure = Math.min(1, prevPressure + (rp - prevPressure) * (sp * 0.275)) | |
radius = size * (0.5 * pressure) | |
const nextVector = (i < points.length - 1 ? points[i + 1] : points[i]).v | |
const nextDpr = i < points.length - 1 ? dpr(v, nextVector) : 1 | |
const prevDpr = dpr(v, prevVector) | |
const isPointSharpCorner = prevDpr < 0 && !isPrevPointSharpCorner | |
const isNextPointSharpCorner = nextDpr < 0 | |
if (isPointSharpCorner || isNextPointSharpCorner) { | |
const offset = mul(per(prevVector), radius) | |
for (let step = 1 / 13, t = 0; t <= 1; t += step) { | |
pl = rot(sub(p, offset), p, PI * t) | |
leftPoints.push(pl) | |
pr = rot(add(p, offset), p, PI * -t) | |
rightPoints.push(pr) | |
} | |
if (isNextPointSharpCorner) { | |
isPrevPointSharpCorner = true | |
} | |
continue | |
} | |
isPrevPointSharpCorner = false | |
if (i === points.length - 1) { | |
const offset = mul(per(v), radius) | |
leftPoints.push(sub(p, offset)) | |
rightPoints.push(add(p, offset)) | |
continue | |
} | |
const offset = mul(per(lerp(nextVector, v, nextDpr)), radius) | |
pl = sub(p, offset) | |
leftPoints.push(pl) | |
pr = add(p, offset) | |
rightPoints.push(pr) | |
prevPressure = r | |
prevVector = v | |
} | |
const startCap = [] | |
for (let step = 1 / 13, t = step; t < .88; t += step) { | |
startCap.push(rot(rightPoints[0], points[0].p, PI * t)) | |
} | |
// No end cap since we already set a thin end. | |
const outline = leftPoints.concat(rightPoints.reverse(), startCap) | |
const bezier = make_bezier(outline[0]) | |
for (let i = 1; i < outline.length; ++i) bezier.push(outline[i]) | |
return bezier.done(outline.at(-1)) | |
} | |
return { push, done } | |
} | |
let d = make_d(path[0]), last = path.length - 1 | |
try { | |
for (let i = 1; i <= last; i++) { | |
d.push(path[i]) | |
} | |
if (predict == null || predict.length == 0) { | |
d = d.done(path[last]) | |
} else { | |
last = predict.length - 1 | |
for (let i = 0; i <= last; i++) { | |
d.push(predict[i]) | |
} | |
d = d.done(predict[last]) | |
} | |
} catch (err) { | |
document.body.appendChild(document.createElement('pre')).textContent = err + '' | |
} | |
$path.setAttribute('d', d) | |
pred.textContent = `predict.length = ${predict ? predict.length : 0}` | |
} | |
svg.onpointerdown = e => { | |
e.preventDefault() | |
e.stopPropagation() | |
$log.textContent = `down ${e.pointerType}[${e.pointerId}]\n`; | |
// if (!e.isPrimary) return | |
svg.setPointerCapture(e.pointerId) | |
// 假设 bounding rect 在画图过程中不会随意改变,这样可以防止 pointermove 里面频繁 measure | |
rect = svg.getBoundingClientRect() | |
path = [] | |
$path = document.createElementNS('http://www.w3.org/2000/svg', 'path') | |
g.append($path) | |
} | |
let lastX = -1, lastY = -1 | |
svg.onpointermove = e => { | |
e.preventDefault() | |
e.stopPropagation() | |
if (path == null) return | |
// 过滤掉相同点,可能是 apple pencil 的 bug | |
if (lastX === e.clientX && lastY === e.clientY) return | |
lastX = e.clientX | |
lastY = e.clientY | |
$log.textContent += `move ${e.pointerType}[${e.pointerId}] (${e.clientX},${e.clientY},${e.pressure})\n`; | |
// 如果有,拿最精确的点 | |
if (e.getCoalescedEvents) { | |
e.getCoalescedEvents().forEach(e => { | |
let x = e.clientX - rect.left | |
let y = e.clientY - rect.top | |
path_push(x, y) | |
}) | |
} | |
// 否则拿普通事件 | |
else { | |
let x = e.clientX - rect.left | |
let y = e.clientY - rect.top | |
path_push(x, y) | |
} | |
// TODO: 这里可以对 path 做一层简单去抖处理,例如 | |
// - O(n) 删除重复 / 太近的点,但是要注意保留点数量代表的线宽信息 | |
// - O(k) 维护一个有限长度队列,取前 k 个点的平均,类似 SAI 抖动修正的效果 | |
// 以上都是增量算法,渲染时大概率不需要重绘整个 path | |
// 如果有,拿预测事件 | |
if (e.getPredictedEvents) { | |
predict = [] | |
e.getPredictedEvents().forEach(e => { | |
let x = e.clientX - rect.left | |
let y = e.clientY - rect.top | |
predict_push(x, y) | |
}) | |
predict = predict.slice(0, 1) | |
} | |
// 否则清空预测点 | |
else { | |
predict = null | |
} | |
// 下一微任务渲染 | |
if (scheduled) return | |
scheduled = true | |
queueMicrotask(render) | |
} | |
svg.onpointercancel = () => { | |
path = predict = null | |
if ($path == null) return | |
$path.remove() | |
$path = null | |
} | |
svg.onpointerup = svg.onpointerout = e => { | |
$log.textContent += `${e.type} ${e.pointerType}[${e.pointerId}]\n`; | |
$path = path = predict = null | |
} | |
svg.ontouchstart = svg.ontouchmove = svg.ontouchend = svg.ontouchcancel = e => { | |
e.preventDefault() | |
e.stopPropagation() | |
} | |
// document.addEventListener('gesturestart', e => e.preventDefault()) | |
// document.addEventListener('gesturechange', e => e.preventDefault()) | |
// document.addEventListener('gestureend', e => e.preventDefault()) | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment