Files
rough-notation/src/render.ts
2024-02-24 16:17:06 +01:00

287 lines
8.3 KiB
TypeScript

import type { OpSet, ResolvedOptions } from 'roughjs/bin/core'
import { ellipse, line, linearPath, rectangle } from 'roughjs/bin/renderer'
import { RoughGenerator } from 'roughjs/bin/generator'
import type { Point } from 'roughjs/bin/geometry'
import type { BracketType, FullPadding, Rect, RoughAnnotationConfig } from './types'
import { SVG_NS } from './constants'
type RoughOptionsType = 'highlight' | 'single' | 'double'
let defaultOptions: ResolvedOptions | null = null
function getDefaultOptions(): ResolvedOptions {
if (!defaultOptions) {
const gen = new RoughGenerator()
defaultOptions = gen.defaultOptions
}
return defaultOptions
}
function getOptions(type: RoughOptionsType, seed: number, overrides?: Partial<ResolvedOptions>): ResolvedOptions {
return {
...getDefaultOptions(),
maxRandomnessOffset: 2,
roughness: type === 'highlight' ? 3 : 1.5,
bowing: 1,
stroke: '#000',
strokeWidth: 1.5,
curveTightness: 0,
curveFitting: 0.95,
curveStepCount: 9,
fillStyle: 'hachure',
fillWeight: -1,
hachureAngle: -41,
hachureGap: -1,
dashOffset: -1,
dashGap: -1,
zigzagOffset: -1,
// combineNestedSvgPaths: false,
disableMultiStroke: type !== 'double',
disableMultiStrokeFill: false,
seed,
...overrides,
}
}
function parsePadding(config: RoughAnnotationConfig): FullPadding {
const p = config.padding
if (p || (p === 0)) {
if (typeof p === 'number') {
return [p, p, p, p]
}
else if (Array.isArray(p)) {
const pa = p as number[]
if (pa.length) {
switch (pa.length) {
case 4:
return [...pa] as FullPadding
case 1:
return [pa[0], pa[0], pa[0], pa[0]]
case 2:
return [...pa, ...pa] as FullPadding
case 3:
return [...pa, pa[1]] as FullPadding
default:
return [pa[0], pa[1], pa[2], pa[3]]
}
}
}
}
return [5, 5, 5, 5]
}
export function renderAnnotation(
svg: SVGSVGElement,
rect: Rect,
config: RoughAnnotationConfig,
animationGroupDelay: number,
animationDuration: number,
seed: number,
): Promise<void> {
const opList: OpSet[] = []
let strokeWidth = config.strokeWidth || 2
const padding = parsePadding(config)
const animate = (config.animate === undefined) ? true : (!!config.animate)
const iterations = config.iterations || 2
const rtl = config.rtl ? 1 : 0
const o = getOptions('single', seed, config)
switch (config.type) {
case 'underline': {
const y = rect.y + rect.h + padding[2]
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(rect.x + rect.w, y, rect.x, y, o))
else
opList.push(line(rect.x, y, rect.x + rect.w, y, o))
}
break
}
case 'strike-through': {
const y = rect.y + (rect.h / 2)
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(rect.x + rect.w, y, rect.x, y, o))
else
opList.push(line(rect.x, y, rect.x + rect.w, y, o))
}
break
}
case 'box': {
const x = rect.x - padding[3]
const y = rect.y - padding[0]
const width = rect.w + (padding[1] + padding[3])
const height = rect.h + (padding[0] + padding[2])
for (let i = 0; i < iterations; i++)
opList.push(rectangle(x, y, width, height, o))
break
}
case 'bracket': {
const brackets: BracketType[] = Array.isArray(config.brackets) ? config.brackets : (config.brackets ? [config.brackets] : ['right'])
const lx = rect.x - padding[3] * 2
const rx = rect.x + rect.w + padding[1] * 2
const ty = rect.y - padding[0] * 2
const by = rect.y + rect.h + padding[2] * 2
for (const br of brackets) {
let points: Point[]
switch (br) {
case 'bottom':
points = [
[lx, rect.y + rect.h],
[lx, by],
[rx, by],
[rx, rect.y + rect.h],
]
break
case 'top':
points = [
[lx, rect.y],
[lx, ty],
[rx, ty],
[rx, rect.y],
]
break
case 'left':
points = [
[rect.x, ty],
[lx, ty],
[lx, by],
[rect.x, by],
]
break
case 'right':
points = [
[rect.x + rect.w, ty],
[rx, ty],
[rx, by],
[rect.x + rect.w, by],
]
break
}
if (points)
opList.push(linearPath(points, false, o))
}
break
}
case 'crossed-off': {
const x = rect.x
const y = rect.y
const x2 = x + rect.w
const y2 = y + rect.h
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(x2, y2, x, y, o))
else
opList.push(line(x, y, x2, y2, o))
}
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(x, y2, x2, y, o))
else
opList.push(line(x2, y, x, y2, o))
}
break
}
case 'circle': {
const doubleO = getOptions('double', seed, config)
const width = rect.w + (padding[1] + padding[3])
const height = rect.h + (padding[0] + padding[2])
const x = rect.x - padding[3] + (width / 2)
const y = rect.y - padding[0] + (height / 2)
const fullItr = Math.floor(iterations / 2)
const singleItr = iterations - (fullItr * 2)
for (let i = 0; i < fullItr; i++)
opList.push(ellipse(x, y, width, height, doubleO))
for (let i = 0; i < singleItr; i++)
opList.push(ellipse(x, y, width, height, o))
break
}
case 'highlight': {
const o = getOptions('highlight', seed, config)
strokeWidth = rect.h * 0.95
const y = rect.y + (rect.h / 2)
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(rect.x + rect.w, y, rect.x, y, o))
else
opList.push(line(rect.x, y, rect.x + rect.w, y, o))
}
break
}
}
if (opList.length) {
const pathStrings = opsToPath(opList)
const lengths: number[] = []
const pathElements: SVGPathElement[] = []
let totalLength = 0
const setAttr = (p: SVGPathElement, an: string, av: string) => p.setAttribute(an, av)
for (const d of pathStrings) {
const path = document.createElementNS(SVG_NS, 'path')
setAttr(path, 'd', d)
setAttr(path, 'fill', 'none')
setAttr(path, 'stroke', config.color || 'currentColor')
setAttr(path, 'stroke-width', `${strokeWidth}`)
if (config.opacity !== undefined)
setAttr(path, 'style', `opacity:${config.opacity}`)
if (animate) {
const length = path.getTotalLength()
lengths.push(length)
totalLength += length
}
svg.appendChild(path)
pathElements.push(path)
}
if (animate) {
let durationOffset = 0
for (let i = 0; i < pathElements.length; i++) {
const path = pathElements[i]
const length = lengths[i]
const duration = totalLength ? (animationDuration * (length / totalLength)) : 0
const delay = animationGroupDelay + durationOffset
const style = path.style
style.strokeDashoffset = `${length}`
style.strokeDasharray = `${length}`
style.animation = `rough-notation-dash ${duration}ms ease-out ${delay}ms forwards`
durationOffset += duration
}
return sleep(animationDuration + animationGroupDelay)
}
}
return sleep(0)
}
function sleep(ms: number) {
return new Promise<void>(resolve => setTimeout(resolve, ms))
}
function opsToPath(opList: OpSet[]): string[] {
const paths: string[] = []
for (const drawing of opList) {
let path = ''
for (const item of drawing.ops) {
const data = item.data
switch (item.op) {
case 'move':
if (path.trim())
paths.push(path.trim())
path = `M${data[0]} ${data[1]} `
break
case 'bcurveTo':
path += `C${data[0]} ${data[1]}, ${data[2]} ${data[3]}, ${data[4]} ${data[5]} `
break
case 'lineTo':
path += `L${data[0]} ${data[1]} `
break
}
}
if (path.trim())
paths.push(path.trim())
}
return paths
}