syndicate-render-msd/src/pict.ts

447 lines
14 KiB
TypeScript

/// SPDX-License-Identifier: GPL-3.0-or-later
/// SPDX-FileCopyrightText: Copyright © 2022 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
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<E extends SVGGraphicsElement> {
readonly node: E;
constructor (node: E) {
this.node = node;
}
abstract get bounds(): Rect;
}
export class BoundedPict<E extends SVGGraphicsElement> extends Pict<E> {
_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<E extends SVGGeometryElement> extends Pict<E> {
_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<SVGPathElement> {
_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<SVGEllipseElement> {
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<SVGGElement> {
readonly children: BoundedPict<any>[] = [];
constructor() {
super(new SVGGElement());
}
add(p: BoundedPict<any>) {
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, T, TLike> = (v: V) => {
get: () => T,
set: (t: TLike) => V,
};
export type Anchor = Prop<Rect, Point, Pointlike>;
function anchor<V, T, TLike>(g: keyof V, s: keyof V): Prop<V, T, TLike> {
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<Rect, Point, Pointlike>('topLeft', 'withTopLeft');
export const TOP_MID = anchor<Rect, Point, Pointlike>('topMid', 'withTopMid');
export const TOP_RIGHT = anchor<Rect, Point, Pointlike>('topRight', 'withTopRight');
export const BOTTOM_LEFT = anchor<Rect, Point, Pointlike>('bottomLeft', 'withBottomLeft');
export const BOTTOM_MID = anchor<Rect, Point, Pointlike>('bottomMid', 'withBottomMid');
export const BOTTOM_RIGHT = anchor<Rect, Point, Pointlike>('bottomRight', 'withBottomRight');
export const MID_LEFT = anchor<Rect, Point, Pointlike>('midLeft', 'withMidLeft');
export const CENTER = anchor<Rect, Point, Pointlike>('center', 'withCenter');
export const MID_RIGHT = anchor<Rect, Point, Pointlike>('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<R>(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 extends Element>(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<SVGTextElement> {
return new BoundedPict(applyAttributes(svg.tag(SVGTextElement)`<text>${text}</text>`, attributes));
}
export function rect(extent0: Pointlike = Point.ZERO, attributes?: Attributes): BoundedPict<SVGRectElement> {
const extent = Point.from(extent0);
return new BoundedPict(
applyAttributes(svg.tag(SVGRectElement)`<rect width="${extent.x}" height="${extent.y}"/>`,
attributes),
Rect.originExtent(Point.ZERO, extent));
}
export function circle(radius: number, attributes?: Attributes): EllipsePict {
return new EllipsePict(applyAttributes(
svg.tag(SVGEllipseElement)`<ellipse rx="${radius}" ry="${radius}"/>`,
attributes));
}
export function alignTo(a1: Anchor, p1: BoundedPict<any>, a2: Anchor, p2: BoundedPict<any>) {
p1.bounds = a1(p1.bounds).set(a2(p2.bounds).get());
}
export function placard(p: BoundedPict<any>, attributes?: Attributes): BoundedPict<SVGSVGElement> {
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)`
<svg class="placard">
${base.node}
${p.node}
</svg>`, base.bounds);
}
export function line(
start?: Pointlike,
end?: Pointlike,
attributes?: Attributes,
): LinelikePict<SVGLineElement> {
const n = applyAttributes(svg.tag(SVGLineElement)`<line/>`, 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)`<path/>`, attributes);
return new CubicBezierPict(n, s, c1, c2, e).applyPoints();
}