2022-01-24 08:28:56 +00:00
|
|
|
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/// SPDX-FileCopyrightText: Copyright © 2022 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
|
|
|
|
|
|
|
import { makeTemplate } from "@syndicate-lang/html";
|
2022-01-26 21:12:06 +00:00
|
|
|
export const svg = makeTemplate('svg');
|
2022-01-24 08:28:56 +00:00
|
|
|
|
|
|
|
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]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-01-26 16:16:32 +00:00
|
|
|
static rTheta(r: number, theta /* radians */: number): Point {
|
|
|
|
return new Point(r * Math.cos(theta), r * Math.sin(theta));
|
|
|
|
}
|
|
|
|
|
2022-01-24 08:28:56 +00:00
|
|
|
toDOM(): DOMPoint {
|
|
|
|
return new DOMPoint(this.x, this.y);
|
|
|
|
}
|
|
|
|
|
2022-01-26 16:16:32 +00:00
|
|
|
get length(): number {
|
|
|
|
return Math.sqrt(this.x * this.x + this.y * this.y);
|
|
|
|
}
|
|
|
|
|
2022-01-24 08:28:56 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2022-01-26 16:16:32 +00:00
|
|
|
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,
|
2022-01-24 08:28:56 +00:00
|
|
|
});
|
2022-01-26 16:16:32 +00:00
|
|
|
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;
|
2022-01-24 08:28:56 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2022-01-26 16:16:32 +00:00
|
|
|
export function text(text: string, attributes?: Attributes): BoundedPict<SVGTextElement> {
|
|
|
|
return new BoundedPict(applyAttributes(svg.tag(SVGTextElement)`<text>${text}</text>`, attributes));
|
2022-01-24 08:28:56 +00:00
|
|
|
}
|
|
|
|
|
2022-01-26 16:16:32 +00:00
|
|
|
export function rect(extent0: Pointlike = Point.ZERO, attributes?: Attributes): BoundedPict<SVGRectElement> {
|
2022-01-24 08:28:56 +00:00
|
|
|
const extent = Point.from(extent0);
|
2022-01-26 16:16:32 +00:00
|
|
|
return new BoundedPict(
|
|
|
|
applyAttributes(svg.tag(SVGRectElement)`<rect width="${extent.x}" height="${extent.y}"/>`,
|
|
|
|
attributes),
|
|
|
|
Rect.originExtent(Point.ZERO, extent));
|
2022-01-24 08:28:56 +00:00
|
|
|
}
|
|
|
|
|
2022-01-26 16:16:32 +00:00
|
|
|
export function circle(radius: number, attributes?: Attributes): EllipsePict {
|
|
|
|
return new EllipsePict(applyAttributes(
|
|
|
|
svg.tag(SVGEllipseElement)`<ellipse rx="${radius}" ry="${radius}"/>`,
|
|
|
|
attributes));
|
2022-01-24 08:28:56 +00:00
|
|
|
}
|
|
|
|
|
2022-01-26 16:16:32 +00:00
|
|
|
export function alignTo(a1: Anchor, p1: BoundedPict<any>, a2: Anchor, p2: BoundedPict<any>) {
|
|
|
|
p1.bounds = a1(p1.bounds).set(a2(p2.bounds).get());
|
2022-01-24 08:28:56 +00:00
|
|
|
}
|
|
|
|
|
2022-01-26 16:16:32 +00:00
|
|
|
export function placard(p: BoundedPict<any>, attributes?: Attributes): BoundedPict<SVGSVGElement> {
|
2022-01-24 08:28:56 +00:00
|
|
|
const padding = new Point(12, 6);
|
2022-01-26 16:16:32 +00:00
|
|
|
const base = rect(p.bounds.extent.add(padding.scale(2)), attributes);
|
|
|
|
p.bounds = p.bounds.withTopLeft(padding);
|
|
|
|
return new BoundedPict(svg.tag(SVGSVGElement)`
|
2022-01-24 08:28:56 +00:00
|
|
|
<svg class="placard">
|
2022-01-26 16:16:32 +00:00
|
|
|
${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();
|
2022-01-24 08:28:56 +00:00
|
|
|
}
|