migrate layout engines

This commit is contained in:
Alexander Wang 2025-01-14 11:55:48 -07:00
parent fcbe464a16
commit e723881dfe
No known key found for this signature in database
GPG key ID: BE3937D0D52D8927
19 changed files with 106560 additions and 79 deletions

28
ci/peek-wasm-size.sh Executable file
View file

@ -0,0 +1,28 @@
#!/bin/bash
OUTPUT_FILE="main.wasm"
SOURCE_PACKAGE="./d2js"
echo "Building WASM file..."
GOOS=js GOARCH=wasm go build -ldflags='-s -w' -trimpath -o "$OUTPUT_FILE" "$SOURCE_PACKAGE"
if [ $? -eq 0 ]; then
echo "Build successful."
if [ -f "$OUTPUT_FILE" ]; then
FILE_SIZE_BYTES=$(stat -f%z "$OUTPUT_FILE")
FILE_SIZE_MB=$(echo "scale=2; $FILE_SIZE_BYTES / 1024 / 1024" | bc)
echo "File size of $OUTPUT_FILE: $FILE_SIZE_MB MB"
else
echo "File $OUTPUT_FILE not found!"
exit 1
fi
echo "Deleting $OUTPUT_FILE..."
rm "$OUTPUT_FILE"
echo "File deleted."
else
echo "Build failed."
exit 1
fi

View file

@ -107,6 +107,52 @@ func GetRefRanges(args []js.Value) (interface{}, error) {
}, nil
}
func GetELKGraph(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
}
var input CompileRequest
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
}
if input.FS == nil {
return nil, &WASMError{Message: "missing 'fs' field in input JSON", Code: 400}
}
if _, ok := input.FS["index"]; !ok {
return nil, &WASMError{Message: "missing 'index' file in input fs", Code: 400}
}
fs, err := memfs.New(input.FS)
if err != nil {
return nil, &WASMError{Message: fmt.Sprintf("invalid fs input: %s", err.Error()), Code: 400}
}
g, _, err := d2compiler.Compile("", strings.NewReader(input.FS["index"]), &d2compiler.CompileOptions{
UTF16Pos: true,
FS: fs,
})
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 400}
}
ruler, err := textmeasure.NewRuler()
if err != nil {
return nil, &WASMError{Message: fmt.Sprintf("text ruler cannot be initialized: %s", err.Error()), Code: 500}
}
err = g.SetDimensions(nil, ruler, nil)
if err != nil {
return nil, err
}
elk, err := d2elklayout.ConvertGraph(context.Background(), g, nil)
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 400}
}
return elk, nil
}
func Compile(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing JSON argument", Code: 400}

View file

@ -15,6 +15,7 @@ func main() {
api.Register("getParentID", d2wasm.GetParentID)
api.Register("getObjOrder", d2wasm.GetObjOrder)
api.Register("getRefRanges", d2wasm.GetRefRanges)
api.Register("getELKGraph", d2wasm.GetELKGraph)
api.Register("compile", d2wasm.Compile)
api.Register("render", d2wasm.Render)
api.Register("getBoardAtPosition", d2wasm.GetBoardAtPosition)

View file

@ -93,6 +93,7 @@ async function buildAndCopy(buildType) {
resolve(ROOT_DIR, "wasm/wasm_exec.js"),
join(config.outdir, "wasm_exec.js")
);
await copyFile(resolve(ROOT_DIR, "src/elk.js"), join(config.outdir, "elk.js"));
}
}

105805
d2js/js/src/elk.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -42,6 +42,7 @@ export class D2 {
async init() {
this.worker = await createWorker();
const elkContent = await loadFile("./elk.js");
const wasmExecContent = await loadFile("./wasm_exec.js");
const wasmBinary = await loadFile("./d2.wasm");
@ -63,6 +64,7 @@ export class D2 {
data: {
wasm: wasmBinary,
wasmExecContent: isNode ? wasmExecContent.toString() : null,
elkContent: isNode ? elkContent.toString() : null,
wasmExecUrl: isNode
? null
: URL.createObjectURL(

View file

@ -1,6 +1,9 @@
import { wasmBinary, wasmExecJs } from "./wasm-loader.browser.js";
import workerScript from "./worker.js" with { type: "text" };
import elkScript from "./elk.js" with { type: "text" };
// For the browser version, we build the wasm files into a file (wasm-loader.browser.js)
// and loading a file just reads the text, so there's no external dependency calls
export async function loadFile(path) {
if (path === "./d2.wasm") {
return wasmBinary.buffer;
@ -8,11 +11,11 @@ export async function loadFile(path) {
if (path === "./wasm_exec.js") {
return new TextEncoder().encode(wasmExecJs).buffer;
}
throw new Error(`Unexpected file request: ${path}`);
return null;
}
export async function createWorker() {
let blob = new Blob([wasmExecJs, workerScript], {
let blob = new Blob([wasmExecJs, elkScript, workerScript], {
type: "text/javascript;charset=utf-8",
});

View file

@ -1 +1 @@
export * from "./platform.node.js";
export * from "./platform.node.js";

View file

@ -1,5 +1,6 @@
let currentPort;
let d2;
let elk;
export function setupMessageHandler(isNode, port, initWasm) {
currentPort = port;
@ -14,6 +15,7 @@ export function setupMessageHandler(isNode, port, initWasm) {
eval(data.wasmExecContent);
}
d2 = await initWasm(data.wasm);
elk = new ELK();
currentPort.postMessage({ type: "ready" });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
@ -22,6 +24,19 @@ export function setupMessageHandler(isNode, port, initWasm) {
case "compile":
try {
// We use Go to get the intermediate ELK graph
// Then natively run elk layout
// This is due to elk.layout being an async function, which a
// single-threaded WASM call cannot complete without giving control back
// So we compute it, store it here, then during elk layout, instead
// of computing again, we use this variable (and unset it for next call)
if (data.options.layout === "elk") {
const elkGraph = await d2.getELKGraph(JSON.stringify(data));
const elkGraph2 = JSON.parse(elkGraph).data;
const layout = await elk.layout(elkGraph2);
globalThis.elkResult = layout;
}
const result = await d2.compile(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);

View file

@ -1 +1,63 @@
// Replaced at build time
import { parentPort } from "node:worker_threads";
let currentPort;
let d2;
export function setupMessageHandler(isNode, port, initWasm) {
currentPort = port;
const handleMessage = async (e) => {
const { type, data } = e;
switch (type) {
case "init":
try {
if (isNode) {
eval(data.wasmExecContent);
}
d2 = await initWasm(data.wasm);
currentPort.postMessage({ type: "ready" });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
case "compile":
try {
const result = await d2.compile(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);
currentPort.postMessage({ type: "result", data: response.data });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
case "render":
try {
const result = await d2.render(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);
currentPort.postMessage({ type: "result", data: atob(response.data) });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
}
};
if (isNode) {
port.on("message", handleMessage);
} else {
port.onmessage = (e) => handleMessage(e.data);
}
}
async function initWasmNode(wasmBinary) {
const go = new Go();
const result = await WebAssembly.instantiate(wasmBinary, go.importObject);
go.run(result.instance);
return global.d2;
}
setupMessageHandler(true, parentPort, initWasmNode);

View file

@ -2,6 +2,7 @@ import { parentPort } from "node:worker_threads";
let currentPort;
let d2;
let elk;
export function setupMessageHandler(isNode, port, initWasm) {
currentPort = port;
@ -14,8 +15,10 @@ export function setupMessageHandler(isNode, port, initWasm) {
try {
if (isNode) {
eval(data.wasmExecContent);
eval(data.elkContent);
}
d2 = await initWasm(data.wasm);
elk = new ELK();
currentPort.postMessage({ type: "ready" });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
@ -24,6 +27,12 @@ export function setupMessageHandler(isNode, port, initWasm) {
case "compile":
try {
if (data.options.layout === "elk") {
const elkGraph = await d2.getELKGraph(JSON.stringify(data));
const elkGraph2 = JSON.parse(elkGraph).data;
const layout = await elk.layout(elkGraph2);
globalThis.elkResult = layout;
}
const result = await d2.compile(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);

View file

@ -9,6 +9,13 @@ describe("D2 Unit Tests", () => {
await d2.worker.terminate();
}, 20000);
test("elk layout works", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y", { layout: "elk" });
expect(result.diagram).toBeDefined();
await d2.worker.terminate();
}, 20000);
test("render works", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y");

View file

@ -11,8 +11,6 @@ import (
"log/slog"
"github.com/dop251/goja"
"oss.terrastruct.com/util-go/xdefer"
"oss.terrastruct.com/util-go/go2"
@ -20,6 +18,7 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/jsrunner"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/shape"
@ -80,11 +79,11 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
defer xdefer.Errorf(&err, "failed to dagre layout")
debugJS := false
vm := goja.New()
if _, err := vm.RunString(dagreJS); err != nil {
runner := jsrunner.NewJSRunner()
if _, err := runner.RunString(dagreJS); err != nil {
return err
}
if _, err := vm.RunString(setupJS); err != nil {
if _, err := runner.RunString(setupJS); err != nil {
return err
}
@ -136,7 +135,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
configJS := setGraphAttrs(rootAttrs)
if _, err := vm.RunString(configJS); err != nil {
if _, err := runner.RunString(configJS); err != nil {
return err
}
@ -183,11 +182,11 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
log.Debug(ctx, "script", slog.Any("all", setupJS+configJS+loadScript))
}
if _, err := vm.RunString(loadScript); err != nil {
if _, err := runner.RunString(loadScript); err != nil {
return err
}
if _, err := vm.RunString(`dagre.layout(g)`); err != nil {
if _, err := runner.RunString(`dagre.layout(g)`); err != nil {
if debugJS {
log.Warn(ctx, "layout error", slog.Any("err", err))
}
@ -195,7 +194,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
for i := range g.Objects {
val, err := vm.RunString(fmt.Sprintf("JSON.stringify(g.node(g.nodes()[%d]))", i))
val, err := runner.RunString(fmt.Sprintf("JSON.stringify(g.node(g.nodes()[%d]))", i))
if err != nil {
return err
}
@ -216,7 +215,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
for i, edge := range g.Edges {
val, err := vm.RunString(fmt.Sprintf("JSON.stringify(g.edge(g.edges()[%d]))", i))
val, err := runner.RunString(fmt.Sprintf("JSON.stringify(g.edge(g.edges()[%d]))", i))
if err != nil {
return err
}

View file

@ -5,15 +5,7 @@
define([], f);
} else {
var g;
if (typeof window !== "undefined") {
g = window;
} else if (typeof global !== "undefined") {
g = global;
} else if (typeof self !== "undefined") {
g = self;
} else {
g = this;
}
g = this;
g.ELK = f();
}
})(function () {
@ -337,9 +329,6 @@
// -------------- FAKE ELEMENTS GWT ASSUMES EXIST --------------
var $wnd = { Error: {} };
if (typeof window !== "undefined") $wnd = window;
else if (typeof global !== "undefined") $wnd = global; // nodejs
else if (typeof self !== "undefined") $wnd = self; // web worker
var $moduleName, $moduleBase;
@ -59795,13 +59784,8 @@
}, 0);
};
}
if (typeof document === uke && typeof self !== uke) {
var i = new h(self);
self.onmessage = i.saveDispatch;
} else if (typeof module !== uke && module.exports) {
Object.defineProperty(exports, "__esModule", { value: true });
module.exports = { default: j, Worker: j };
}
Object.defineProperty(exports, "__esModule", { value: true });
module.exports = { default: j, Worker: j };
}
function aae(a) {
if (a.N) return;
@ -105682,16 +105666,7 @@
// -------------- RUN GWT INITIALIZATION CODE --------------
gwtOnLoad(null, "elk", null);
}.call(this));
}.call(
this,
typeof global !== "undefined"
? global
: typeof self !== "undefined"
? self
: typeof window !== "undefined"
? window
: {}
));
}.call(this, {}));
},
{},
],

View file

@ -8,15 +8,12 @@ import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"github.com/dop251/goja"
"oss.terrastruct.com/util-go/xdefer"
"oss.terrastruct.com/util-go/go2"
@ -24,6 +21,7 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/jsrunner"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
)
@ -162,18 +160,20 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
defer xdefer.Errorf(&err, "failed to ELK layout")
vm := goja.New()
runner := jsrunner.NewJSRunner()
console := vm.NewObject()
if err := vm.Set("console", console); err != nil {
return err
}
if runner.Engine() == jsrunner.Goja {
console := runner.NewObject()
if err := runner.Set("console", console); err != nil {
return err
}
if _, err := vm.RunString(elkJS); err != nil {
return err
}
if _, err := vm.RunString(setupJS); err != nil {
return err
if _, err := runner.RunString(elkJS); err != nil {
return err
}
if _, err := runner.RunString(setupJS); err != nil {
return err
}
}
elkGraph := &ELKGraph{
@ -443,41 +443,30 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
return err
}
loadScript := fmt.Sprintf(`var graph = %s`, raw)
var val jsrunner.JSValue
if runner.Engine() == jsrunner.Goja {
loadScript := fmt.Sprintf(`var graph = %s`, raw)
if _, err := vm.RunString(loadScript); err != nil {
return err
}
if _, err := runner.RunString(loadScript); err != nil {
return err
}
val, err := vm.RunString(`elk.layout(graph)
val, err = runner.RunString(`elk.layout(graph)
.then(s => s)
.catch(err => err.message)
`)
} else {
val, err = runner.MustGet("elkResult")
}
if err != nil {
return err
}
p := val.Export()
result, err := runner.WaitPromise(ctx, val)
if err != nil {
return err
return fmt.Errorf("ELK layout error: %v", err)
}
promise := p.(*goja.Promise)
for promise.State() == goja.PromiseStatePending {
if err := ctx.Err(); err != nil {
return err
}
continue
}
if promise.State() == goja.PromiseStateRejected {
return errors.New("ELK: something went wrong")
}
result := promise.Result().Export()
var jsonOut map[string]interface{}
switch out := result.(type) {
case string:

View file

@ -0,0 +1,286 @@
//go:build js && wasm
package d2elklayout
import (
"context"
"fmt"
"math"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/util-go/xdefer"
)
// This is mostly copy paste from Layout until elk.layout step
func ConvertGraph(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (_ *ELKGraph, err error) {
if opts == nil {
opts = &DefaultOpts
}
defer xdefer.Errorf(&err, "failed to ELK layout")
elkGraph := &ELKGraph{
ID: "",
LayoutOptions: &elkOpts{
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
EdgeNode: edge_node_spacing,
HierarchyHandling: "INCLUDE_CHILDREN",
FixedAlignment: "BALANCED",
ConsiderModelOrder: "NODES_AND_EDGES",
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
NodeSizeConstraints: "MINIMUM_SIZE",
ContentAlignment: "H_CENTER V_CENTER",
ConfigurableOpts: ConfigurableOpts{
Algorithm: opts.Algorithm,
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
SelfLoopSpacing: opts.SelfLoopSpacing,
},
},
}
if elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
// +5 for a tiny bit of padding
elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(g.Root, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
}
switch g.Root.Direction.Value {
case "down":
elkGraph.LayoutOptions.Direction = Down
case "up":
elkGraph.LayoutOptions.Direction = Up
case "right":
elkGraph.LayoutOptions.Direction = Right
case "left":
elkGraph.LayoutOptions.Direction = Left
default:
elkGraph.LayoutOptions.Direction = Down
}
// set label and icon positions for ELK
for _, obj := range g.Objects {
positionLabelsIcons(obj)
}
adjustments := make(map[*d2graph.Object]geo.Spacing)
elkNodes := make(map[*d2graph.Object]*ELKNode)
elkEdges := make(map[*d2graph.Edge]*ELKEdge)
// BFS
var walk func(*d2graph.Object, *d2graph.Object, func(*d2graph.Object, *d2graph.Object))
walk = func(obj, parent *d2graph.Object, fn func(*d2graph.Object, *d2graph.Object)) {
if obj.Parent != nil {
fn(obj, parent)
}
for _, ch := range obj.ChildrenArray {
walk(ch, obj, fn)
}
}
walk(g.Root, nil, func(obj, parent *d2graph.Object) {
incoming := 0.
outgoing := 0.
for _, e := range g.Edges {
if e.Src == obj {
outgoing++
}
if e.Dst == obj {
incoming++
}
}
if incoming >= 2 || outgoing >= 2 {
switch g.Root.Direction.Value {
case "right", "left":
if obj.Attributes.HeightAttr == nil {
obj.Height = math.Max(obj.Height, math.Max(incoming, outgoing)*port_spacing)
}
default:
if obj.Attributes.WidthAttr == nil {
obj.Width = math.Max(obj.Width, math.Max(incoming, outgoing)*port_spacing)
}
}
}
if obj.HasLabel() && obj.HasIcon() {
// this gives shapes extra height for their label if they also have an icon
obj.Height += float64(obj.LabelDimensions.Height + label.PADDING)
}
margin, _ := obj.SpacingOpt(label.PADDING, label.PADDING, false)
width := margin.Left + obj.Width + margin.Right
height := margin.Top + obj.Height + margin.Bottom
adjustments[obj] = margin
n := &ELKNode{
ID: obj.AbsID(),
Width: width,
Height: height,
}
if len(obj.ChildrenArray) > 0 {
n.LayoutOptions = &elkOpts{
ForceNodeModelOrder: true,
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
HierarchyHandling: "INCLUDE_CHILDREN",
FixedAlignment: "BALANCED",
EdgeNode: edge_node_spacing,
ConsiderModelOrder: "NODES_AND_EDGES",
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
NodeSizeConstraints: "MINIMUM_SIZE",
ContentAlignment: "H_CENTER V_CENTER",
ConfigurableOpts: ConfigurableOpts{
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
SelfLoopSpacing: opts.SelfLoopSpacing,
Padding: opts.Padding,
},
}
if n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(obj, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
}
switch elkGraph.LayoutOptions.Direction {
case Down, Up:
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(height)), int(math.Ceil(width)))
case Right, Left:
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(width)), int(math.Ceil(height)))
}
} else {
n.LayoutOptions = &elkOpts{
SelfLoopDistribution: "EQUALLY",
}
}
if obj.IsContainer() {
padding := parsePadding(opts.Padding)
padding = adjustPadding(obj, width, height, padding)
n.LayoutOptions.Padding = padding.String()
}
if obj.HasLabel() {
n.Labels = append(n.Labels, &ELKLabel{
Text: obj.Label.Value,
Width: float64(obj.LabelDimensions.Width),
Height: float64(obj.LabelDimensions.Height),
})
}
if parent == g.Root {
elkGraph.Children = append(elkGraph.Children, n)
} else {
elkNodes[parent].Children = append(elkNodes[parent].Children, n)
}
if obj.SQLTable != nil {
n.LayoutOptions.PortConstraints = "FIXED_POS"
columns := obj.SQLTable.Columns
colHeight := n.Height / float64(len(columns)+1)
n.Ports = make([]*ELKPort, 0, len(columns)*2)
var srcSide, dstSide PortSide
switch elkGraph.LayoutOptions.Direction {
case Left:
srcSide, dstSide = West, East
default:
srcSide, dstSide = East, West
}
for i, col := range columns {
n.Ports = append(n.Ports, &ELKPort{
ID: srcPortID(obj, col.Name.Label),
Y: float64(i+1)*colHeight + colHeight/2,
LayoutOptions: &elkOpts{PortSide: srcSide},
})
n.Ports = append(n.Ports, &ELKPort{
ID: dstPortID(obj, col.Name.Label),
Y: float64(i+1)*colHeight + colHeight/2,
LayoutOptions: &elkOpts{PortSide: dstSide},
})
}
}
elkNodes[obj] = n
})
var srcSide, dstSide PortSide
switch elkGraph.LayoutOptions.Direction {
case Up:
srcSide, dstSide = North, South
default:
srcSide, dstSide = South, North
}
ports := map[struct {
obj *d2graph.Object
side PortSide
}][]*ELKPort{}
for ei, edge := range g.Edges {
var src, dst string
switch {
case edge.SrcTableColumnIndex != nil:
src = srcPortID(edge.Src, edge.Src.SQLTable.Columns[*edge.SrcTableColumnIndex].Name.Label)
case edge.Src.SQLTable != nil:
p := &ELKPort{
ID: fmt.Sprintf("%s.%d", srcPortID(edge.Src, "__root__"), ei),
LayoutOptions: &elkOpts{PortSide: srcSide},
}
src = p.ID
elkNodes[edge.Src].Ports = append(elkNodes[edge.Src].Ports, p)
k := struct {
obj *d2graph.Object
side PortSide
}{edge.Src, srcSide}
ports[k] = append(ports[k], p)
default:
src = edge.Src.AbsID()
}
switch {
case edge.DstTableColumnIndex != nil:
dst = dstPortID(edge.Dst, edge.Dst.SQLTable.Columns[*edge.DstTableColumnIndex].Name.Label)
case edge.Dst.SQLTable != nil:
p := &ELKPort{
ID: fmt.Sprintf("%s.%d", dstPortID(edge.Dst, "__root__"), ei),
LayoutOptions: &elkOpts{PortSide: dstSide},
}
dst = p.ID
elkNodes[edge.Dst].Ports = append(elkNodes[edge.Dst].Ports, p)
k := struct {
obj *d2graph.Object
side PortSide
}{edge.Dst, dstSide}
ports[k] = append(ports[k], p)
default:
dst = edge.Dst.AbsID()
}
e := &ELKEdge{
ID: edge.AbsID(),
Sources: []string{src},
Targets: []string{dst},
}
if edge.Label.Value != "" {
e.Labels = append(e.Labels, &ELKLabel{
Text: edge.Label.Value,
Width: float64(edge.LabelDimensions.Width),
Height: float64(edge.LabelDimensions.Height),
LayoutOptions: &elkOpts{
InlineEdgeLabels: true,
},
})
}
elkGraph.Edges = append(elkGraph.Edges, e)
elkEdges[edge] = e
}
for k, ports := range ports {
width := elkNodes[k.obj].Width
spacing := width / float64(len(ports)+1)
for i, p := range ports {
p.X = float64(i+1) * spacing
}
}
return elkGraph, nil
}

113
lib/jsrunner/goja.go Normal file
View file

@ -0,0 +1,113 @@
//go:build !js && !wasm
package jsrunner
import (
"context"
"errors"
"fmt"
"os"
"github.com/dop251/goja"
)
type gojaRunner struct {
vm *goja.Runtime
}
type gojaValue struct {
val goja.Value
vm *goja.Runtime
}
func NewJSRunner() JSRunner {
return &gojaRunner{vm: goja.New()}
}
// UNUSED
func (g *gojaRunner) MustGet(key string) (JSValue, error) {
return nil, nil
}
func (g *gojaRunner) Engine() Engine {
return Goja
}
func (g *gojaRunner) RunString(code string) (JSValue, error) {
val, err := g.vm.RunString(code)
if err != nil {
return nil, err
}
return &gojaValue{val: val, vm: g.vm}, nil
}
func (v *gojaValue) String() string {
return v.val.String()
}
func (v *gojaValue) Export() interface{} {
return v.val.Export()
}
func (g *gojaRunner) NewObject() JSObject {
return &gojaValue{val: g.vm.NewObject(), vm: g.vm}
}
func (g *gojaRunner) Set(name string, value interface{}) error {
if name == "console" {
console, err := g.createConsole()
if err != nil {
return err
}
return g.vm.Set(name, console)
}
return g.vm.Set(name, value)
}
func (g *gojaRunner) WaitPromise(ctx context.Context, val JSValue) (interface{}, error) {
gVal := val.(*gojaValue)
p := gVal.val.Export()
promise := p.(*goja.Promise)
for promise.State() == goja.PromiseStatePending {
if err := ctx.Err(); err != nil {
return nil, err
}
continue
}
if promise.State() == goja.PromiseStateRejected {
return nil, errors.New("Promise rejected")
}
return promise.Result().Export(), nil
}
func (g *gojaRunner) createConsole() (*goja.Object, error) {
vm := g.vm
console := vm.NewObject()
if err := console.Set("log", vm.ToValue(func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
fmt.Println(args...)
return nil
})); err != nil {
return nil, err
}
if err := console.Set("error", vm.ToValue(func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
fmt.Fprintln(os.Stderr, args...)
return nil
})); err != nil {
return nil, err
}
return console, nil
}

112
lib/jsrunner/js.go Normal file
View file

@ -0,0 +1,112 @@
//go:build js && wasm
package jsrunner
import (
"context"
"fmt"
"sync"
"syscall/js"
)
var (
instance JSRunner
once sync.Once
)
type jsRunner struct {
global js.Value
}
type jsValue struct {
val js.Value
}
func NewJSRunner() JSRunner {
once.Do(func() {
instance = &jsRunner{global: js.Global()}
})
return instance
}
func (j *jsRunner) Engine() Engine {
return Native
}
func (j *jsRunner) MustGet(key string) (JSValue, error) {
result := j.global.Get("elkResult")
if result.IsUndefined() {
return nil, fmt.Errorf("key %q not found in global scope", key)
}
defer j.global.Set("elkResult", js.Undefined())
return &jsValue{val: result}, nil
}
func (j *jsRunner) RunString(code string) (JSValue, error) {
result := j.global.Call("eval", code)
return &jsValue{val: result}, nil
}
func (v *jsValue) String() string {
switch v.val.Type() {
case js.TypeString:
return v.val.String()
default:
return v.val.Call("toString").String()
}
}
func (v *jsValue) Export() interface{} {
switch v.val.Type() {
case js.TypeString:
return v.val.String()
case js.TypeNumber:
return v.val.Float()
case js.TypeBoolean:
return v.val.Bool()
case js.TypeObject:
if v.val.InstanceOf(js.Global().Get("Array")) {
length := v.val.Length()
arr := make([]interface{}, length)
for i := 0; i < length; i++ {
arr[i] = (&jsValue{val: v.val.Index(i)}).Export()
}
return arr
}
obj := make(map[string]interface{})
keys := js.Global().Get("Object").Call("keys", v.val)
length := keys.Length()
for i := 0; i < length; i++ {
key := keys.Index(i).String()
val := v.val.Get(key)
obj[key] = (&jsValue{val: val}).Export()
}
return obj
default:
return nil
}
}
// TODO probably don't need
func (j *jsRunner) NewObject() JSObject {
return &jsValue{val: js.Global().Get("Object").New()}
}
func (j *jsRunner) Set(name string, value interface{}) error {
if name == "console" {
js.Global().Set("console", js.Global().Get("console"))
return nil
}
if jsObj, ok := value.(JSObject); ok {
jsVal := jsObj.(*jsValue)
js.Global().Set(name, jsVal.val)
return nil
}
js.Global().Set(name, js.ValueOf(value))
return nil
}
func (j *jsRunner) WaitPromise(ctx context.Context, val JSValue) (interface{}, error) {
return val.Export(), nil
}

28
lib/jsrunner/jsrunner.go Normal file
View file

@ -0,0 +1,28 @@
package jsrunner
import "context"
type Engine int
const (
Goja Engine = iota
Native
)
type JSRunner interface {
RunString(code string) (JSValue, error)
NewObject() JSObject
Set(name string, value interface{}) error
WaitPromise(ctx context.Context, val JSValue) (interface{}, error)
Engine() Engine
MustGet(string) (JSValue, error)
}
type JSValue interface {
String() string
Export() interface{}
}
type JSObject interface {
JSValue
}