Compare commits
2 Commits
35e8dbd845
...
f5a04ff608
Author | SHA1 | Date |
---|---|---|
Tony Garnock-Jones | f5a04ff608 | |
Tony Garnock-Jones | 6165c10a6c |
24
index.html
24
index.html
|
@ -15,6 +15,30 @@
|
|||
<p>Interaction Diagrams for Syndicated Actors</p>
|
||||
<div id="container">
|
||||
<svg id="playground" viewBox="0 0 100 100" width="100" height="100">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="15" markerHeight="15" refX="3.75" refY="7.5"
|
||||
orient="auto-start-reverse">
|
||||
<polygon points="0 0, 15 7.5, 0 15, 3.75 7.5, 0 0" />
|
||||
</marker>
|
||||
<linearGradient id="swimlane-fadeout" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop stop-color="var(--lifeline-color)" stop-opacity="1" offset="0%"/>
|
||||
<stop stop-color="var(--lifeline-color)" stop-opacity="0" offset="100%"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- <path d="M 0 30 c 17.7 17.7 232.3 -17.7 250 0" -->
|
||||
<!-- stroke-width="2" -->
|
||||
<!-- fill="none" -->
|
||||
<!-- stroke="#000" -->
|
||||
<!-- marker-end="url(#arrowhead)"/> -->
|
||||
<!-- <line x1="0" y1="50" x2="250" y2="50" stroke="#000" stroke-width="2" marker-end="url(#arrowhead)" /> -->
|
||||
|
||||
<!-- <path d="M16.7,178 c87.6-46.9,162.9-185.4,227-136.4C307.2,90.1,195,158.5,111,108.9C71,85.2,92.2,30.7,126,7" -->
|
||||
<!-- stroke-width="2" -->
|
||||
<!-- fill="none" -->
|
||||
<!-- stroke="#000" -->
|
||||
<!-- marker-end="url(#arrowhead)"/> -->
|
||||
|
||||
<g id="underlay"></g>
|
||||
<g id="overlay"></g>
|
||||
|
||||
|
|
|
@ -19,8 +19,8 @@
|
|||
"@syndicate-lang/ws-relay": "^0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preserves/core": ">=0.19",
|
||||
"@preserves/schema": ">=0.20",
|
||||
"@preserves/core": ">=0.20.2",
|
||||
"@preserves/schema": ">=0.21.2",
|
||||
"@syndicate-lang/ts-plugin": "^0.11",
|
||||
"@syndicate-lang/tsc": "^0.11",
|
||||
"rollup": "^2.60",
|
||||
|
|
282
src/index.ts
282
src/index.ts
|
@ -1,18 +1,23 @@
|
|||
/// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/// SPDX-FileCopyrightText: Copyright © 2022 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
||||
|
||||
import { Dataspace, Cap, Value, IdentitySet, Ref, Bytes, Observe, Turn, Reader, genericEmbeddedTypeDecode, Schemas, GenericEmbedded, Dictionary, fromJS } from "@syndicate-lang/core";
|
||||
import { Dataspace, IdentitySet, Ref, Bytes, Observe, Turn, Reader, genericEmbeddedTypeDecode, Schemas, GenericEmbedded, Dictionary, fromJS } from "@syndicate-lang/core";
|
||||
import { QuasiValue as Q } from "@syndicate-lang/core";
|
||||
const Trace = Schemas.trace;
|
||||
import * as P from './pict';
|
||||
|
||||
import { boot as bootHtml, Anchor, makeTemplate, UIEvent, UIFragment } from "@syndicate-lang/html";
|
||||
// const svg = makeTemplate('svg');
|
||||
import { boot as bootHtml, UIFragment } from "@syndicate-lang/html";
|
||||
|
||||
assertion type URLContent(url: string, content: Bytes | false);
|
||||
|
||||
const SWIMLANE_WIDTH = 100;
|
||||
const LIFELINE_WIDTH = 10;
|
||||
const LIFELINE_WIDTH = 5;
|
||||
const ACTIVATION_WIDTH = LIFELINE_WIDTH * 2;
|
||||
const VERTICAL_GAP = 20;
|
||||
const POST_TURN_GAP = VERTICAL_GAP * 3;
|
||||
const PLAYGROUND_PADDING = 10;
|
||||
const TAIL_LENGTH = 60;
|
||||
const TERMINATOR_SIZE = new P.Point(LIFELINE_WIDTH * 3, LIFELINE_WIDTH);
|
||||
|
||||
export function main() {
|
||||
Dataspace.boot(ds => {
|
||||
|
@ -57,14 +62,6 @@ function bootRenderer(ds: Ref) {
|
|||
spawn named 'renderer' {
|
||||
const r = new Renderer(ds);
|
||||
at ds {
|
||||
// const ui = new Anchor();
|
||||
// field y: number = 50;
|
||||
// assert ui.html('#playground', svg`<text y="${y.value}">foo</text>`);
|
||||
// on message UIEvent(ui.fragmentId, '.', 'click', $_e) => {
|
||||
// y.value += 20;
|
||||
// }
|
||||
// // Turn.active.every(1000 / 30.0, () => y.value += 5);
|
||||
|
||||
const traceUrl = 'd2';
|
||||
during URLContent(traceUrl, false) => {
|
||||
alert("Couldn't fetch trace file: " + JSON.stringify(traceUrl));
|
||||
|
@ -89,10 +86,10 @@ class Swimlane {
|
|||
readonly renderer: Renderer;
|
||||
readonly laneNumber: number;
|
||||
readonly startY: number;
|
||||
readonly entries: Array<Schemas.trace.TraceEntry<GenericEmbedded>> = [];
|
||||
readonly entries: Array<P.BoundedPict<any>> = [];
|
||||
readonly lifeline = P.rect([LIFELINE_WIDTH, 0], {"class": "lifeline"});
|
||||
|
||||
// readonly lifeline = svg.tag(SVGRectElement)`<rect class="lifeline"/>`;
|
||||
tail: P.BoundedPict<SVGRectElement> | null =
|
||||
P.rect([LIFELINE_WIDTH, TAIL_LENGTH], {"class": "lifeline-tail"});
|
||||
|
||||
constructor (actorId: Schemas.trace.ActorId<GenericEmbedded>, laneNumber: number, renderer: Renderer) {
|
||||
this.actorId = actorId;
|
||||
|
@ -100,37 +97,47 @@ class Swimlane {
|
|||
this.laneNumber = laneNumber;
|
||||
this.startY = renderer.ypos;
|
||||
|
||||
new P.Point(this.midX - LIFELINE_WIDTH / 2, this.startY).apply(this.lifeline);
|
||||
this.renderer.ypos += 10;
|
||||
this.lifeline.setAttribute('height', '' + (this.renderer.ypos - this.startY));
|
||||
this.renderer.underlay.appendChild(this.lifeline);
|
||||
this.lifeline.updateBounds(b => b.withTopMid([this.midX, this.startY]));
|
||||
this.updateLifelineHeight();
|
||||
|
||||
this.renderer.underlay.appendChild(this.lifeline.node);
|
||||
this.renderer.underlay.appendChild(this.tail!.node);
|
||||
}
|
||||
|
||||
updateLifelineHeight() {
|
||||
this.lifeline.updateBounds(b => b.withHeight(this.renderer.ypos - b.top));
|
||||
this.tail?.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos]));
|
||||
}
|
||||
|
||||
get midX(): number {
|
||||
return this.laneNumber * SWIMLANE_WIDTH;
|
||||
}
|
||||
|
||||
addTraceEntry(entry: Schemas.trace.TraceEntry<GenericEmbedded>) {
|
||||
addEntry(klass: string | null, entry: P.BoundedPict<any>): P.BoundedPict<any> {
|
||||
this.entries.push(entry);
|
||||
|
||||
// const n = this.renderer.placard(
|
||||
// 'test1', this.renderer.textNode('label', fromJS(entry.item).asPreservesText()));
|
||||
// const b = this.renderer.measureBBox(n);
|
||||
// n.setAttribute('x', '' + (this.laneNumber * SWIMLANE_WIDTH - b.width / 2));
|
||||
// n.setAttribute('y', '' + this.renderer.ypos);
|
||||
|
||||
const n = P.placard(P.text(fromJS(entry.item).asPreservesText(), {"class": "label"}),
|
||||
{"class": "test1"});
|
||||
const b = P.bbox(n);
|
||||
b.withTopMid([this.midX, this.renderer.ypos]).apply(n);
|
||||
|
||||
this.renderer.ypos += b.height + 10;
|
||||
this.renderer.overlay.appendChild(n);
|
||||
this.lifeline.setAttribute('height', '' + (this.renderer.ypos - this.startY));
|
||||
if (entry.item._variant === "stop") {
|
||||
this.renderer.releaseSwimlane(this.laneNumber);
|
||||
if (klass !== null) {
|
||||
const pin = P.circle(2, {"class": "pin"});
|
||||
pin.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos]));
|
||||
this.renderer.advance(pin.bounds.height);
|
||||
this.renderer.overlay.appendChild(pin.node);
|
||||
}
|
||||
|
||||
const n = klass === null ? entry : P.placard(entry, {"class": klass});
|
||||
n.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos]));
|
||||
this.renderer.overlay.appendChild(n.node);
|
||||
this.renderer.advance(n.bounds.height);
|
||||
this.updateLifelineHeight();
|
||||
|
||||
this.renderer.adjustViewport();
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
finish() {
|
||||
this.renderer.underlay.removeChild(this.tail!.node);
|
||||
this.tail = null;
|
||||
this.renderer.releaseSwimlane(this.laneNumber);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,8 +147,9 @@ class Renderer {
|
|||
readonly playground: SVGSVGElement = document.getElementById('playground')! as any;
|
||||
readonly underlay: SVGGElement = this.playground.getElementById('underlay')! as any;
|
||||
readonly overlay: SVGGElement = this.playground.getElementById('overlay')! as any;
|
||||
readonly swimlanes: Dictionary<GenericEmbedded, Swimlane> = new Dictionary();
|
||||
readonly swimlanes = new Dictionary<GenericEmbedded, Swimlane>();
|
||||
readonly busyLanes = new IdentitySet<number>();
|
||||
readonly turnMap = new Dictionary<GenericEmbedded, P.Point>();
|
||||
ypos = 0;
|
||||
|
||||
constructor (ds: Ref) {
|
||||
|
@ -155,44 +163,14 @@ class Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
// temporarilyInDom<R>(e: SVGElement, f: () => R): R {
|
||||
// this.playground.appendChild(e);
|
||||
// const result = f();
|
||||
// this.playground.removeChild(e);
|
||||
// return result;
|
||||
// }
|
||||
|
||||
// measureClientRect(e: SVGElement) {
|
||||
// return this.temporarilyInDom(e, () => e.getBoundingClientRect());
|
||||
// }
|
||||
|
||||
// measureBBox(e: SVGGraphicsElement) {
|
||||
// return this.temporarilyInDom(e, () => e.getBBox());
|
||||
// }
|
||||
|
||||
// measureTextLength(e: SVGTextContentElement) {
|
||||
// return this.temporarilyInDom(e, () => e.getComputedTextLength());
|
||||
// }
|
||||
|
||||
// textNode(klass: string, text: string): SVGTextElement {
|
||||
// return svg.tag(SVGTextElement)`<text class="${klass}">${text}</text>`;
|
||||
// }
|
||||
|
||||
// placard(klass: string, n: SVGGraphicsElement): SVGSVGElement {
|
||||
// const b = this.measureBBox(n);
|
||||
// const [px, py] = [12, 6];
|
||||
// const base = svg.tag()`<rect class="${klass}" width="${b.width + px * 2}" height="${b.height + py * 2}"/>`;
|
||||
// return svg.tag(SVGSVGElement)`<svg class="placard">${base}<g transform="translate(${px} ${py})">${n}</g></svg>`;
|
||||
// }
|
||||
|
||||
adjustViewport() {
|
||||
const bbox = this.playground.getBBox();
|
||||
this.playground.viewBox.baseVal.x = bbox.x - 10;
|
||||
this.playground.viewBox.baseVal.y = bbox.y - 10;
|
||||
this.playground.viewBox.baseVal.width = bbox.width + 20;
|
||||
this.playground.viewBox.baseVal.height = bbox.height + 20;
|
||||
this.playground.width.baseVal.value = bbox.width + 20;
|
||||
this.playground.height.baseVal.value = bbox.height + 20;
|
||||
this.playground.viewBox.baseVal.x = bbox.x - PLAYGROUND_PADDING;
|
||||
this.playground.viewBox.baseVal.y = bbox.y - PLAYGROUND_PADDING;
|
||||
this.playground.viewBox.baseVal.width = bbox.width + PLAYGROUND_PADDING * 2;
|
||||
this.playground.viewBox.baseVal.height = bbox.height + PLAYGROUND_PADDING * 2;
|
||||
this.playground.width.baseVal.value = bbox.width + PLAYGROUND_PADDING * 2;
|
||||
this.playground.height.baseVal.value = bbox.height + PLAYGROUND_PADDING * 2;
|
||||
}
|
||||
|
||||
allocateSwimlane(): number {
|
||||
|
@ -215,7 +193,163 @@ class Renderer {
|
|||
return s;
|
||||
}
|
||||
|
||||
advance(delta: number) {
|
||||
this.ypos += delta;
|
||||
}
|
||||
|
||||
connect(ps: P.Point, pe: P.Point) {
|
||||
if (pe.x == ps.x) {
|
||||
const pullMagnitude = pe.sub(ps).scale(0.1).length;
|
||||
const pullAngle = Math.PI;
|
||||
const pull = P.Point.rTheta(pullMagnitude + 30, pullAngle);
|
||||
// TODO: compute marker size from the actual definition
|
||||
const arrowheadSpace = P.Point.rTheta(15 - 3.75, pullAngle);
|
||||
this.overlay.appendChild(
|
||||
P.cubicBezier(ps,
|
||||
ps.add(pull),
|
||||
pe.add(pull),
|
||||
pe.add(arrowheadSpace),
|
||||
{"class": "arrow"}).node);
|
||||
} else {
|
||||
const pullMagnitude = pe.sub(ps).scale(0.1).length;
|
||||
const pullAngle = pe.x > ps.x ? Math.PI/4 : 3*Math.PI/4;
|
||||
const pull = P.Point.rTheta(pullMagnitude, pullAngle);
|
||||
// TODO: compute marker size from the actual definition
|
||||
const arrowheadSpace = P.Point.rTheta(15 - 3.75, pullAngle);
|
||||
const pe1 = pe.sub(arrowheadSpace);
|
||||
this.overlay.appendChild(
|
||||
P.cubicBezier(ps,
|
||||
ps.add(pull),
|
||||
pe1.sub(pull),
|
||||
pe1,
|
||||
{"class": "arrow"}).node);
|
||||
}
|
||||
}
|
||||
|
||||
rewindTo<R>(pos: number, f: () => R): R {
|
||||
const saved = this.ypos;
|
||||
this.ypos = pos;
|
||||
try {
|
||||
return f();
|
||||
} finally {
|
||||
this.ypos = saved;
|
||||
}
|
||||
}
|
||||
|
||||
addTraceEntry(entry: Schemas.trace.TraceEntry<GenericEmbedded>) {
|
||||
this.swimlaneFor(entry.actor).addTraceEntry(entry);
|
||||
switch (entry.item._variant) {
|
||||
case "start": {
|
||||
this.advance(LIFELINE_WIDTH);
|
||||
const swimlane = this.swimlaneFor(entry.actor);
|
||||
swimlane.addEntry(null, P.rect(TERMINATOR_SIZE, {"class": "terminator"}));
|
||||
this.advance(LIFELINE_WIDTH);
|
||||
swimlane.addEntry("start", P.text(fromJS(entry.item).asPreservesText()));
|
||||
break;
|
||||
}
|
||||
case "turn": {
|
||||
const swimlane = this.swimlaneFor(entry.actor);
|
||||
const activation = P.rect([ACTIVATION_WIDTH, 0], {"class": "activation"});
|
||||
this.advance(LIFELINE_WIDTH);
|
||||
activation.updateBounds(b => b.withTopMid([swimlane.midX, this.ypos]));
|
||||
|
||||
let causingTurnId: Schemas.trace.TurnId<GenericEmbedded> | null = null;
|
||||
switch (entry.item.value.cause._variant) {
|
||||
case "turn":
|
||||
causingTurnId = entry.item.value.cause.id;
|
||||
break;
|
||||
case "delay":
|
||||
causingTurnId = entry.item.value.cause.causingTurn;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (causingTurnId !== null) {
|
||||
this.connect(this.turnMap.get(fromJS(causingTurnId))!,
|
||||
new P.Point(swimlane.midX, this.ypos));
|
||||
}
|
||||
|
||||
this.advance(LIFELINE_WIDTH);
|
||||
swimlane.addEntry("turn-start", P.text(
|
||||
entry.item.value.id.asPreservesText() + ': ' + fromJS(entry.item.value.cause).asPreservesText()));
|
||||
|
||||
entry.item.value.actions.forEach(a => {
|
||||
let entryClass: string;
|
||||
switch (a._variant) {
|
||||
case "dequeue":
|
||||
case "dequeueInternal":
|
||||
entryClass = "event"; break;
|
||||
case "enqueue":
|
||||
case "enqueueInternal":
|
||||
case "link":
|
||||
case "spawn":
|
||||
entryClass = "action"; break;
|
||||
case "facetStart":
|
||||
case "facetStop":
|
||||
entryClass = "internal"; break;
|
||||
case "linkedTaskStart":
|
||||
entryClass = "start"; break;
|
||||
}
|
||||
this.advance(LIFELINE_WIDTH);
|
||||
const fPos = this.ypos;
|
||||
const e = swimlane.addEntry(entryClass, P.text(fromJS(a).asPreservesText()));
|
||||
switch (a._variant) {
|
||||
case "link": {
|
||||
const other = this.swimlaneFor(a.childActor);
|
||||
const f = this.rewindTo(fPos, () =>
|
||||
other.addEntry(entryClass, P.text(
|
||||
`linked to ${fromJS(a.parentActor).asPreservesText()}`)));
|
||||
// TODO: compute marker size from the actual definition
|
||||
const arrowheadSpace = new P.Point(
|
||||
(15 - 3.75) * (other.midX > swimlane.midX ? -1 : 1),
|
||||
0);
|
||||
const ps = other.midX > swimlane.midX
|
||||
? e.bounds.midRight.sub(arrowheadSpace)
|
||||
: e.bounds.midLeft.sub(arrowheadSpace);
|
||||
const pe = other.midX > swimlane.midX
|
||||
? f.bounds.midLeft.add(arrowheadSpace)
|
||||
: f.bounds.midRight.sub(arrowheadSpace);
|
||||
this.overlay.appendChild(P.line(ps, pe, {"class": "doublearrow"}).node);
|
||||
break;
|
||||
}
|
||||
case "spawn": {
|
||||
if (!a.link) {
|
||||
const other = this.swimlaneFor(a.id);
|
||||
this.connect(new P.Point(swimlane.midX, this.ypos),
|
||||
new P.Point(other.midX, this.ypos));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
this.advance(LIFELINE_WIDTH);
|
||||
this.turnMap.set(fromJS(entry.item.value.id), new P.Point(swimlane.midX, this.ypos));
|
||||
activation.updateBounds(b => b.withHeight(this.ypos - b.top));
|
||||
this.underlay.appendChild(activation.node);
|
||||
this.advance(POST_TURN_GAP - VERTICAL_GAP);
|
||||
swimlane.updateLifelineHeight();
|
||||
break;
|
||||
}
|
||||
case "stop": {
|
||||
this.advance(VERTICAL_GAP);
|
||||
const swimlane = this.swimlaneFor(entry.actor);
|
||||
switch (entry.item.status._variant) {
|
||||
case "ok":
|
||||
swimlane.addEntry("stop-ok", P.text("✓"));
|
||||
break;
|
||||
case "Error":
|
||||
swimlane.addEntry("stop-error", P.text(
|
||||
"ERROR: " + fromJS(entry.item.status.value).asPreservesText(),
|
||||
{"class": "stop-error"}));
|
||||
break;
|
||||
}
|
||||
this.advance(LIFELINE_WIDTH);
|
||||
swimlane.addEntry(null, P.rect(TERMINATOR_SIZE, {"class": "terminator"}));
|
||||
this.advance(LIFELINE_WIDTH);
|
||||
swimlane.finish();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
225
src/pict.ts
225
src/pict.ts
|
@ -26,10 +26,18 @@ export class Point {
|
|||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -51,10 +59,6 @@ export class Point {
|
|||
return new Point(Math.min(this.x, p.x), Math.min(this.y, p.y));
|
||||
}
|
||||
|
||||
apply<N extends SVGElement>(n: N): N {
|
||||
return applyAttributes(n, { "x": this.x, "y": this.y });
|
||||
}
|
||||
|
||||
withX(x: number): Point { return new Point(x, this.y); }
|
||||
withY(y: number): Point { return new Point(this.x, y); }
|
||||
|
||||
|
@ -170,13 +174,158 @@ export class Rect {
|
|||
return new Rect(p.x - this.width, p.y - this.height / 2, this.width, this.height);
|
||||
}
|
||||
|
||||
apply<N extends SVGElement>(n: N): N {
|
||||
return applyAttributes(n, {
|
||||
"x": this.x,
|
||||
"y": this.y,
|
||||
"width": this.width,
|
||||
"height": 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -243,31 +392,55 @@ export function applyAttributes<N extends Element>(n: N, attributes?: Attributes
|
|||
return n;
|
||||
}
|
||||
|
||||
export function text(text: string, attributes?: Attributes): SVGTextElement {
|
||||
return applyAttributes(svg.tag(SVGTextElement)`<text>${text}</text>`, attributes);
|
||||
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): SVGRectElement {
|
||||
export function rect(extent0: Pointlike = Point.ZERO, attributes?: Attributes): BoundedPict<SVGRectElement> {
|
||||
const extent = Point.from(extent0);
|
||||
return applyAttributes(
|
||||
svg.tag(SVGRectElement)`<rect width="${extent.x}" height="${extent.y}"/>`,
|
||||
attributes);
|
||||
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): SVGCircleElement {
|
||||
return applyAttributes(svg.tag(SVGCircleElement)`<circle r="${radius}"/>`, attributes);
|
||||
export function circle(radius: number, attributes?: Attributes): EllipsePict {
|
||||
return new EllipsePict(applyAttributes(
|
||||
svg.tag(SVGEllipseElement)`<ellipse rx="${radius}" ry="${radius}"/>`,
|
||||
attributes));
|
||||
}
|
||||
|
||||
export function alignTo(a: SVGGraphicsElement, n: Anchor, b: SVGGraphicsElement) {
|
||||
n(bbox(a)).set(n(bbox(b)).get()).apply(a);
|
||||
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(n: SVGGraphicsElement, attributes?: Attributes): SVGSVGElement {
|
||||
export function placard(p: BoundedPict<any>, attributes?: Attributes): BoundedPict<SVGSVGElement> {
|
||||
const padding = new Point(12, 6);
|
||||
const base = rect(bbox(n).extent.add(padding.scale(2)), attributes);
|
||||
return svg.tag(SVGSVGElement)`
|
||||
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}
|
||||
<g transform="translate(${padding.x} ${padding.y})">${n}</g>
|
||||
</svg>`;
|
||||
${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();
|
||||
}
|
||||
|
|
90
style.css
90
style.css
|
@ -1,3 +1,17 @@
|
|||
:root {
|
||||
--lifeline-color: #bebebe;
|
||||
--lifeline-start-color: #98fb98;
|
||||
--lifeline-stop-ok-color: #98fb98;
|
||||
--lifeline-stop-error-color: #ff4400;
|
||||
--lifeline-stop-error-text-color: #ffffff;
|
||||
--event-color: #ffa500;
|
||||
--action-color: #ffffff;
|
||||
--internal-color: #e0e0e0;
|
||||
--activation-color: #ffffff;
|
||||
--turn-start-color: var(--event-color);
|
||||
--arrow-color: #000000;
|
||||
}
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@ -21,8 +35,9 @@ body {
|
|||
}
|
||||
|
||||
body > p {
|
||||
padding: 0 0.33em;
|
||||
margin: 0.33em 0;
|
||||
padding: 0.33em 0.33em;
|
||||
margin: 0;
|
||||
border-bottom: solid black 1px;
|
||||
}
|
||||
|
||||
body > div {
|
||||
|
@ -34,25 +49,86 @@ body > div {
|
|||
svg {
|
||||
background: white;
|
||||
font-family: monospace;
|
||||
border-top: solid black 1px;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
svg rect.lifeline {
|
||||
fill: #ddd;
|
||||
fill: var(--lifeline-color);
|
||||
}
|
||||
|
||||
svg text.label {
|
||||
svg rect.terminator {
|
||||
fill: var(--lifeline-color);
|
||||
}
|
||||
|
||||
svg rect.lifeline-tail {
|
||||
fill: url(#swimlane-fadeout);
|
||||
}
|
||||
|
||||
svg rect.activation {
|
||||
fill: var(--activation-color);
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
svg text {
|
||||
white-space: pre;
|
||||
dominant-baseline: text-before-edge;
|
||||
}
|
||||
|
||||
rect.test1 {
|
||||
fill: #fff;
|
||||
svg rect.start {
|
||||
fill: var(--lifeline-start-color);
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
svg rect.stop-ok {
|
||||
fill: var(--lifeline-stop-ok-color);
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
svg rect.stop-error {
|
||||
fill: var(--lifeline-stop-error-color);
|
||||
stroke: #000;
|
||||
}
|
||||
svg text.stop-error {
|
||||
fill: var(--lifeline-stop-error-text-color);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
svg rect.event {
|
||||
fill: var(--event-color);
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
svg rect.turn-start {
|
||||
fill: var(--turn-start-color);
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
svg rect.action {
|
||||
fill: var(--action-color);
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
svg rect.internal {
|
||||
fill: var(--internal-color);
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
svg.placard {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
svg .arrow {
|
||||
fill: none;
|
||||
stroke: var(--arrow-color);
|
||||
stroke-width: 1;
|
||||
marker-end: url(#arrowhead);
|
||||
}
|
||||
|
||||
svg .doublearrow {
|
||||
fill: none;
|
||||
stroke: var(--arrow-color);
|
||||
stroke-width: 1;
|
||||
marker-start: url(#arrowhead);
|
||||
marker-end: url(#arrowhead);
|
||||
}
|
||||
|
|
90
yarn.lock
90
yarn.lock
|
@ -23,17 +23,17 @@
|
|||
"@nodelib/fs.scandir" "2.1.5"
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@preserves/core@>=0.19", "@preserves/core@^0.19.0":
|
||||
version "0.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@preserves/core/-/core-0.19.0.tgz#7105a50beeb5d9531515547ca197a2c122c84935"
|
||||
integrity sha512-Evsv3PGI51cJlTOr/6v52fTV6cjU11Pq6ogXBIPzfwmlMZ6kB5mx7EweFVwAf0dy5yCgBkq7ILSTCk4ZoV+x1g==
|
||||
"@preserves/core@>=0.20.2", "@preserves/core@^0.20.2":
|
||||
version "0.20.2"
|
||||
resolved "https://registry.yarnpkg.com/@preserves/core/-/core-0.20.2.tgz#37afa1f1770d0e667b4d384f717e648af745a7fa"
|
||||
integrity sha512-6mZbhYuzAvgA4u2UpuyW2JI5in4h693AdrDTLXfrpv542tFFcI9XxGyL7AJ/CnwDxaX/0d9lRt7uTOrfJJ0uMw==
|
||||
|
||||
"@preserves/schema@>=0.20":
|
||||
version "0.20.0"
|
||||
resolved "https://registry.yarnpkg.com/@preserves/schema/-/schema-0.20.0.tgz#e1dcb498655114fef06678ff6298b953d127f919"
|
||||
integrity sha512-TmNCKJwYSN7qksa0wRWfSkchuNIfGUBJLkZp5hBX/6uqtY1RWGa9Q4nICaNuljw+pKaCFlWqpG0ebP5cmJMCGw==
|
||||
"@preserves/schema@>=0.21.2":
|
||||
version "0.21.2"
|
||||
resolved "https://registry.yarnpkg.com/@preserves/schema/-/schema-0.21.2.tgz#b8aff321e4971a7ff9275f33c499fab068da06bb"
|
||||
integrity sha512-RZIIc2ETvRLC3sjjfFlyjJK3+6dlOK0N94E7lWPxK9c226vF4MU0CePi9ryRPBihgn5VdsjhCCfWXxt+te3IIw==
|
||||
dependencies:
|
||||
"@preserves/core" "^0.19.0"
|
||||
"@preserves/core" "^0.20.2"
|
||||
"@types/glob" "^7.1"
|
||||
"@types/minimatch" "^3.0"
|
||||
chalk "^4.1"
|
||||
|
@ -51,52 +51,52 @@
|
|||
estree-walker "^1.0.1"
|
||||
picomatch "^2.2.2"
|
||||
|
||||
"@syndicate-lang/compiler@^0.11.1":
|
||||
version "0.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/compiler/-/compiler-0.11.1.tgz#da7c5c9ab23d37cda6d09692080cc4a9e078ffa6"
|
||||
integrity sha512-nFyCmEZW6p8Oq/SWb/kXonfWH2nD4l2m9xIMj31tjC6waAE2n0+NQjYY4ieS7k9nWZ2cFYNhgSgVnIJ89P+bDw==
|
||||
"@syndicate-lang/compiler@^0.11.3":
|
||||
version "0.11.3"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/compiler/-/compiler-0.11.3.tgz#77b491c896bf0a83776cb99224968fd979a5e086"
|
||||
integrity sha512-2ru1hCsaBFQCrPMocUDmyT6GE3IYuqUMYtHwx2pwegH8xPdzz33aShrAmfSRcv56xC+kkGXpj6hS3e+pM2ks6A==
|
||||
|
||||
"@syndicate-lang/core@^0.11", "@syndicate-lang/core@^0.11.1":
|
||||
version "0.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/core/-/core-0.11.1.tgz#32d0191822cb4c126549a3f0067bb1d166f39eb5"
|
||||
integrity sha512-3ofAypJ18ddSOtdGyXycDy4BAW/zMaOUEwndp/ADjmxIRDW6ja4GA5TZ0vIQjPfH7brSQhhECivt7aLf5HLzfw==
|
||||
"@syndicate-lang/core@^0.11", "@syndicate-lang/core@^0.11.4":
|
||||
version "0.11.4"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/core/-/core-0.11.4.tgz#995e2ad3d2594929b6b01f0a3c9c045f7fbcbe5b"
|
||||
integrity sha512-6abI79+rbR0wC2kJeM/MUJNy8WQYTrtQwEcQOEXCCsfoj7KZ75wkzmC05B09Fu2URJpFzFRRaVKRyImx3oDWXA==
|
||||
dependencies:
|
||||
"@preserves/core" ">=0.19"
|
||||
"@preserves/schema" ">=0.20"
|
||||
"@preserves/core" ">=0.20.2"
|
||||
"@preserves/schema" ">=0.21.2"
|
||||
|
||||
"@syndicate-lang/html@^0.11":
|
||||
version "0.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/html/-/html-0.11.2.tgz#80d9b754fa561711de6eb78ab30f0968c168d570"
|
||||
integrity sha512-QVDoTM1E9su/pLgSF28Ei3gycExndxdgLk8GmpOatvYTuO+651Y/8ZU7kKHqKzdLSHew5DNTIVze0Sh3iAcR3Q==
|
||||
version "0.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/html/-/html-0.11.5.tgz#f1ed2aab0b37e848ffe88fbbd450d4140c670602"
|
||||
integrity sha512-Y440cq+niz9SaoU5z79SCq44Q4SU59ILRW/gmwByYWTt3zt35zV8nYwn/QhylNU4OvrCkLNImIYtV4f8TSrNAg==
|
||||
dependencies:
|
||||
"@syndicate-lang/core" "^0.11.1"
|
||||
"@syndicate-lang/core" "^0.11.4"
|
||||
|
||||
"@syndicate-lang/ts-plugin@^0.11":
|
||||
version "0.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/ts-plugin/-/ts-plugin-0.11.2.tgz#89a6e5d61ad82d57a209ed239cbf8ed949dd6005"
|
||||
integrity sha512-XvgW3Hnwd+ANtR3nJg8YwXMQw9IVSUn0ucNz7+wGoR0kOLkg5U6S8vwQTpYjVv4F942MZH2m5wwhGsaIA0KPqQ==
|
||||
version "0.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/ts-plugin/-/ts-plugin-0.11.5.tgz#afc62b4c73af28fab9c33b30cd78227983ab9f5f"
|
||||
integrity sha512-LMZO1W9Qa4OvMMMInFXre9Lb9xcFVMG7EqrY3VpmH5OVV+/0CznaNKCmy7EwJJ/Oumuxniu9vSYZ6Mfs9W3ZQg==
|
||||
dependencies:
|
||||
"@syndicate-lang/compiler" "^0.11.1"
|
||||
"@syndicate-lang/core" "^0.11.1"
|
||||
"@syndicate-lang/compiler" "^0.11.3"
|
||||
"@syndicate-lang/core" "^0.11.4"
|
||||
|
||||
"@syndicate-lang/tsc@^0.11":
|
||||
version "0.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/tsc/-/tsc-0.11.2.tgz#ebae716b7c093aaa3d4a919b0888acecf4ecd5f6"
|
||||
integrity sha512-HpexzKZXPaXE0b3Iv8pHSDQ0jH64xDRgwYntB4yyp0Jt3MkvbagIIvtEbyG3gXu0moiFGUTvAp2inBRt2cQ2jA==
|
||||
version "0.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/tsc/-/tsc-0.11.5.tgz#67155080f357628b12014b842e00ceae8579e9d8"
|
||||
integrity sha512-hOwQp7rFiwNaLj6r0LtdUEKe5eql8elWQuQDUbSz5DJ0mHuURDu4BdEH2yLdQEn1lyHz50R4Ze8UcLli4sIabA==
|
||||
dependencies:
|
||||
"@syndicate-lang/compiler" "^0.11.1"
|
||||
"@syndicate-lang/core" "^0.11.1"
|
||||
"@syndicate-lang/compiler" "^0.11.3"
|
||||
"@syndicate-lang/core" "^0.11.4"
|
||||
glob "^7.1.6"
|
||||
yargs "^16.2.0"
|
||||
|
||||
"@syndicate-lang/ws-relay@^0.11":
|
||||
version "0.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/ws-relay/-/ws-relay-0.11.2.tgz#1174eb0f652a3286e858e612ea97d7f80a39ef11"
|
||||
integrity sha512-S6kJ/yv1hYgs5vRUdqFhwAZPaIwdQKw9UDWZHgRc+SW9j8k0EWYh9D/ocsv3ZHAO2a8zhku9/LTz5QHhdk1HVg==
|
||||
version "0.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@syndicate-lang/ws-relay/-/ws-relay-0.11.5.tgz#b5a72e884679c8166c85b4e694bbfeb00977b4e5"
|
||||
integrity sha512-XbdYO6h1RXIS3hor4vUtO1y4HjGt1NF//JImdVrWmjCu2rWAMspa99LOZe/XhUK2wk6BLfxk2fZEA3fCz6Yiiw==
|
||||
dependencies:
|
||||
"@preserves/core" ">=0.19"
|
||||
"@preserves/schema" ">=0.20"
|
||||
"@syndicate-lang/core" "^0.11.1"
|
||||
"@preserves/core" ">=0.20.2"
|
||||
"@preserves/schema" ">=0.21.2"
|
||||
"@syndicate-lang/core" "^0.11.4"
|
||||
|
||||
"@types/estree@0.0.39":
|
||||
version "0.0.39"
|
||||
|
@ -117,9 +117,9 @@
|
|||
integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
|
||||
|
||||
"@types/node@*":
|
||||
version "17.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.10.tgz#616f16e9d3a2a3d618136b1be244315d95bd7cab"
|
||||
integrity sha512-S/3xB4KzyFxYGCppyDt68yzBU9ysL88lSdIah4D6cptdcltc4NCPCAMc0+PCpg/lLIyC7IPvj2Z52OJWeIUkog==
|
||||
version "17.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.12.tgz#f7aa331b27f08244888c47b7df126184bc2339c5"
|
||||
integrity sha512-4YpbAsnJXWYK/fpTVFlMIcUIho2AYCi4wg5aNPrG1ng7fn/1/RZfCIpRCiBX+12RVa34RluilnvCqD+g3KiSiA==
|
||||
|
||||
aggregate-error@^3.0.0:
|
||||
version "3.1.0"
|
||||
|
@ -615,9 +615,9 @@ rollup-plugin-sourcemaps@^0.6:
|
|||
source-map-resolve "^0.6.0"
|
||||
|
||||
rollup@^2.60:
|
||||
version "2.66.0"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.66.0.tgz#ee529ea15a20485d579039637fec3050bad03bbb"
|
||||
integrity sha512-L6mKOkdyP8HK5kKJXaiWG7KZDumPJjuo1P+cfyHOJPNNTK3Moe7zCH5+fy7v8pVmHXtlxorzaBjvkBMB23s98g==
|
||||
version "2.66.1"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.66.1.tgz#366b0404de353c4331d538c3ad2963934fcb4937"
|
||||
integrity sha512-crSgLhSkLMnKr4s9iZ/1qJCplgAgrRY+igWv8KhG/AjKOJ0YX/WpmANyn8oxrw+zenF3BXWDLa7Xl/QZISH+7w==
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
|
|
Loading…
Reference in New Issue