migrate layout engines
This commit is contained in:
parent
fcbe464a16
commit
e723881dfe
19 changed files with 106560 additions and 79 deletions
28
ci/peek-wasm-size.sh
Executable file
28
ci/peek-wasm-size.sh
Executable 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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
105805
d2js/js/src/elk.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export * from "./platform.node.js";
|
||||
export * from "./platform.node.js";
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
33
d2layouts/d2elklayout/elk.js
vendored
33
d2layouts/d2elklayout/elk.js
vendored
|
|
@ -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, {}));
|
||||
},
|
||||
{},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
286
d2layouts/d2elklayout/wasm.go
Normal file
286
d2layouts/d2elklayout/wasm.go
Normal 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
113
lib/jsrunner/goja.go
Normal 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
112
lib/jsrunner/js.go
Normal 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
28
lib/jsrunner/jsrunner.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue