/// SPDX-License-Identifier: GPL-3.0-or-later /// SPDX-FileCopyrightText: Copyright © 2022 Tony Garnock-Jones import { makeTemplate } from "@syndicate-lang/html"; export const svg = makeTemplate('svg'); export type Pointlike = { x: number, y: number } | [number, number]; export type Rectlike = { x: number, y: number, width: number, height: number } | [number, number, number, number]; export class Point { readonly x: number; readonly y: number; constructor (x: number, y: number) { this.x = x; this.y = y; } static from(p: Pointlike): Point { if ('x' in p) { return new Point(p.x, p.y); } else { return new Point(p[0], p[1]); } } static rTheta(r: number, theta /* radians */: number): Point { return new Point(r * Math.cos(theta), r * Math.sin(theta)); } toDOM(): DOMPoint { return new DOMPoint(this.x, this.y); } get length(): number { return Math.sqrt(this.x * this.x + this.y * this.y); } add(p0: Pointlike): Point { const p = Point.from(p0); return new Point(this.x + p.x, this.y + p.y); } sub(p0: Pointlike): Point { const p = Point.from(p0); return new Point(this.x - p.x, this.y - p.y); } scale(n: number): Point { return new Point(this.x * n, this.y * n); } max(p0: Pointlike): Point { const p = Point.from(p0); return new Point(Math.max(this.x, p.x), Math.max(this.y, p.y)); } min(p0: Pointlike): Point { const p = Point.from(p0); return new Point(Math.min(this.x, p.x), Math.min(this.y, p.y)); } withX(x: number): Point { return new Point(x, this.y); } withY(y: number): Point { return new Point(this.x, y); } static ZERO: Point = new Point(0, 0); } export class Rect { readonly x: number; readonly y: number; readonly width: number; readonly height: number; constructor (x: number, y: number, width: number, height: number) { this.x = x; this.y = y; this.width = width; this.height = height; } static from(r: Rectlike): Rect { if ('x' in r) { return new Rect(r.x, r.y, r.width, r.height); } else { return new Rect(r[0], r[1], r[2], r[3]); } } static originCorner(o0: Pointlike, c0: Pointlike): Rect { const o = Point.from(o0); const c = Point.from(c0); return new Rect(o.x, o.y, c.x - o.x, c.y - o.y); } static originExtent(o0: Pointlike, e0: Pointlike): Rect { const o = Point.from(o0); const e = Point.from(e0); return new Rect(o.x, o.y, e.x, e.y); } toDOM(): DOMRect { return new DOMRect(this.x, this.y, this.width, this.height); } get extent(): Point { return this.bottomRight.sub(this.topLeft); } withExtent(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(this.x, this.y, p.x, p.y); } get left(): number { return this.x; } get midX(): number { return this.x + this.width / 2; } get right(): number { return this.x + this.width; } get top(): number { return this.y; } get midY(): number { return this.y + this.height / 2; } get bottom(): number { return this.y + this.height; } withLeft(n: number): Rect { return new Rect(n, this.y, this.width, this.height); } withMidX(n: number): Rect { return new Rect(n - this.width / 2, this.y, this.width, this.height); } withRight(n: number): Rect { return new Rect(n - this.width, this.y, this.width, this.height); } withTop(n: number): Rect { return new Rect(this.x, n, this.width, this.height); } withMidY(n: number): Rect { return new Rect(this.x, n - this.height / 2, this.width, this.height); } withBottom(n: number): Rect { return new Rect(this.x, n - this.height, this.width, this.height); } withWidth(n: number): Rect { return new Rect(this.x, this.y, n, this.height); } withHeight(n: number): Rect { return new Rect(this.x, this.y, this.width, n); } get topLeft(): Point { return new Point(this.x, this.y); } get topMid(): Point { return this.topLeft.add(this.topRight).scale(0.5); } get topRight(): Point { return new Point(this.x + this.width, this.y); } get bottomLeft(): Point { return new Point(this.x, this.y + this.height); } get bottomMid(): Point { return this.bottomLeft.add(this.bottomRight).scale(0.5); } get bottomRight(): Point { return new Point(this.x + this.width, this.y + this.height); } get midLeft(): Point { return this.topLeft.add(this.bottomLeft).scale(0.5); } get center(): Point { return this.topLeft.add(this.bottomRight).scale(0.5); } get midRight(): Point { return this.topRight.add(this.bottomRight).scale(0.5); } withTopLeft(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x, p.y, this.width, this.height); } withTopMid(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x - this.width / 2, p.y, this.width, this.height); } withTopRight(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x - this.width, p.y, this.width, this.height); } withBottomLeft(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x, p.y - this.height, this.width, this.height); } withBottomMid(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x - this.width / 2, p.y - this.height, this.width, this.height); } withBottomRight(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x - this.width, p.y - this.height, this.width, this.height); } withMidLeft(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x, p.y - this.height / 2, this.width, this.height); } withCenter(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x - this.width / 2, p.y - this.height / 2, this.width, this.height); } withMidRight(p0: Pointlike): Rect { const p = Point.from(p0); return new Rect(p.x - this.width, p.y - this.height / 2, this.width, this.height); } union(r: Rect): Rect { const x = Math.min(this.x, r.x); const y = Math.min(this.y, r.y); const w = Math.max(this.right, r.right) - x; const h = Math.max(this.bottom, r.bottom) - y; return new Rect(x, y, w, h); } } export abstract class Pict { readonly node: E; constructor (node: E) { this.node = node; } abstract get bounds(): Rect; } export class BoundedPict extends Pict { _bounds: Rect; constructor (node: E, bounds?: Rectlike) { super(node); this._bounds = Rect.from(bounds ?? bbox(node)); } get bounds(): Rect { return this._bounds; } set bounds(r: Rect) { const oldBounds = this._bounds; this._bounds = r; this.applyBounds(oldBounds); } updateBounds(f: (b: Rect) => Rect): this { this.bounds = f(this.bounds); return this; } applyBounds(_oldBounds: Rect) { const b = this.bounds; applyAttributes(this.node, { "x": b.x, "y": b.y, "width": b.width, "height": b.height }); } } export class LinelikePict extends Pict { _start: Point; _end: Point; constructor (node: E, start?: Pointlike, end?: Pointlike) { super(node); this._start = Point.from(start ?? Point.ZERO); this._end = Point.from(end ?? Point.ZERO); } get bounds(): Rect { return bbox(this.node); } get start(): Point { return this._start; } set start(p: Pointlike) { this._start = Point.from(p); this.applyPoints(); } get end(): Point { return this._end; } set end(p: Pointlike) { this._end = Point.from(p); this.applyPoints(); } applyPoints(): this { applyAttributes(this.node, { "x1": this._start.x, "y1": this._start.y, "x2": this._end.x, "y2": this._end.y, }); return this; } } export class CubicBezierPict extends LinelikePict { _c1: Point; _c2: Point; constructor ( n: SVGPathElement, start: Pointlike, c1: Pointlike, c2: Pointlike, end: Pointlike, ) { super(n, start, end); this._c1 = Point.from(c1); this._c2 = Point.from(c2); } get c1(): Point { return this._c1; } set c1(p: Pointlike) { this._c1 = Point.from(p); this.applyPoints(); } get c2(): Point { return this._c2; } set c2(p: Pointlike) { this._c2 = Point.from(p); this.applyPoints(); } applyPoints(): this { applyAttributes(this.node, { "d": `M ${this._start.x} ${this._start.y} C ${this._c1.x} ${this._c1.y} ${this._c2.x} ${this._c2.y} ${this._end.x} ${this._end.y}`, }); return this; } } export class EllipsePict extends BoundedPict { applyBounds(_oldBounds: Rect) { const c = this.bounds.center; const r = c.sub(this.bounds.topLeft); applyAttributes(this.node, { "cx": c.x, "cy": c.y, "rx": r.x, "ry": r.y }); } } export class PictGroup extends BoundedPict { readonly children: BoundedPict[] = []; constructor() { super(new SVGGElement()); } add(p: BoundedPict) { this.children.push(p); this.node.appendChild(p.node); p.updateBounds(this.place); if (this.children.length === 1) { this._bounds = p.bounds; } else { this._bounds = this._bounds.union(p.bounds); } } applyBounds(_oldBounds: Rect) { const p = this.bounds.topLeft; applyAttributes(this.node, { "transform": `translate(${p.x} ${p.y})` }); } place(r: Rect): Rect { return r; } } export type Prop = (v: V) => { get: () => T, set: (t: TLike) => V, }; export type Anchor = Prop; function anchor(g: keyof V, s: keyof V): Prop { return (v: V) => ({ get: (): T => { return (v as any)[g]; }, set: (t: TLike): V => { return (v as any)[s](t); }, }); } export const TOP_LEFT = anchor('topLeft', 'withTopLeft'); export const TOP_MID = anchor('topMid', 'withTopMid'); export const TOP_RIGHT = anchor('topRight', 'withTopRight'); export const BOTTOM_LEFT = anchor('bottomLeft', 'withBottomLeft'); export const BOTTOM_MID = anchor('bottomMid', 'withBottomMid'); export const BOTTOM_RIGHT = anchor('bottomRight', 'withBottomRight'); export const MID_LEFT = anchor('midLeft', 'withMidLeft'); export const CENTER = anchor('center', 'withCenter'); export const MID_RIGHT = anchor('midRight', 'withMidRight'); export type Attributes = { [key: string]: string | number | undefined | null }; let playground: SVGSVGElement = null as any; export function init() { playground = document.getElementById('playground')! as any; } export function temporarilyInDom(e: SVGElement, f: () => R, parent = playground): R { parent.appendChild(e); const result = f(); parent.removeChild(e); return result; } export function clientRect(e: SVGElement, parent = playground) { return temporarilyInDom(e, () => Rect.from(e.getBoundingClientRect()), parent); } export function bbox(e: SVGGraphicsElement, parent = playground) { return temporarilyInDom(e, () => Rect.from(e.getBBox()), parent); } export function textLength(e: SVGTextContentElement, parent = playground) { return temporarilyInDom(e, () => e.getComputedTextLength(), parent); } export function applyAttributes(n: N, attributes?: Attributes): N { if (attributes !== void 0) { for (const key in attributes) { const val = attributes[key]; if (val !== void 0 && val !== null) { n.setAttribute(key, '' + val); } } } return n; } export function text(text: string, attributes?: Attributes): BoundedPict { return new BoundedPict(applyAttributes(svg.tag(SVGTextElement)`${text}`, attributes)); } export function rect(extent0: Pointlike = Point.ZERO, attributes?: Attributes): BoundedPict { const extent = Point.from(extent0); return new BoundedPict( applyAttributes(svg.tag(SVGRectElement)``, attributes), Rect.originExtent(Point.ZERO, extent)); } export function circle(radius: number, attributes?: Attributes): EllipsePict { return new EllipsePict(applyAttributes( svg.tag(SVGEllipseElement)``, attributes)); } export function alignTo(a1: Anchor, p1: BoundedPict, a2: Anchor, p2: BoundedPict) { p1.bounds = a1(p1.bounds).set(a2(p2.bounds).get()); } export function placard(p: BoundedPict, attributes?: Attributes): BoundedPict { const padding = new Point(12, 6); const base = rect(p.bounds.extent.add(padding.scale(2)), attributes); p.bounds = p.bounds.withTopLeft(padding); return new BoundedPict(svg.tag(SVGSVGElement)` ${base.node} ${p.node} `, base.bounds); } export function line( start?: Pointlike, end?: Pointlike, attributes?: Attributes, ): LinelikePict { const n = applyAttributes(svg.tag(SVGLineElement)``, attributes) return new LinelikePict(n, start, end).applyPoints(); } export function cubicBezier( s: Pointlike, c1: Pointlike, c2: Pointlike, e: Pointlike, attributes?: Attributes, ): CubicBezierPict { const n = applyAttributes(svg.tag(SVGPathElement)``, attributes); return new CubicBezierPict(n, s, c1, c2, e).applyPoints(); }