Installation and setup

Install SnapLine and render a first node graph in Vanilla, Svelte, or React.

Install the core package plus the adapter you use.

npm install @snap-engine/core @snap-engine/snapline
npm install @snap-engine/asset-base-svelte @snap-engine/snapline-svelte
npm install react react-dom @snap-engine/snapline-react

First graph

<script lang="ts">
  import { Engine } from "@snap-engine/asset-base-svelte";
  import {
    Connector,
    ControlledGraph,
    Node,
    Select,
  } from "@snap-engine/snapline-svelte";
  import type { LineChangeRequest, LineRecord } from "@snap-engine/snapline";

  // Your document owns the lines; each gesture proposes one atomic change.
  let lines = $state<LineRecord[]>([]);
  function applyRequest(request: LineChangeRequest) {
    lines = [
      ...lines
        .filter((record) => !request.remove.includes(record.id))
        .map((record) => {
          const update = request.update.find((u) => u.id === record.id);
          return update
            ? { ...record, toConnectorId: update.toConnectorId }
            : record;
        }),
      ...request.add,
    ];
  }
</script>

<Engine>
  <Select />
  <ControlledGraph {lines} onLineChangeRequest={applyRequest} />
  <Node id="source" x={80} y={90} className="node">
    <strong>Source</strong>
    <Connector id="source:value" name="value" rules={{ maxIncoming: 0 }} />
  </Node>
  <Node id="result" x={360} y={180} className="node">
    <strong>Result</strong>
    <Connector id="result:value" name="value" rules={{ maxOutgoing: 0, maxIncoming: 1 }} />
  </Node>
</Engine>
import { useCallback, useState } from "react";
import {
  Connector,
  ControlledGraph,
  Engine,
  Node,
  Select,
} from "@snap-engine/snapline-react";
import type { LineChangeRequest, LineRecord } from "@snap-engine/snapline";

export function Graph() {
  // Your document owns the lines; each gesture proposes one atomic change.
  const [lines, setLines] = useState<LineRecord[]>([]);
  const applyRequest = useCallback((request: LineChangeRequest) => {
    setLines((current) => [
      ...current
        .filter((record) => !request.remove.includes(record.id))
        .map((record) => {
          const update = request.update.find((u) => u.id === record.id);
          return update
            ? { ...record, toConnectorId: update.toConnectorId }
            : record;
        }),
      ...request.add,
    ]);
  }, []);
  return (
    <Engine>
      <Select />
      <ControlledGraph lines={lines} onLineChangeRequest={applyRequest} />
      <Node id="source" x={80} y={90} className="node">
        <strong>Source</strong>
        <Connector id="source:value" name="value" rules={{ maxIncoming: 0 }} />
      </Node>
      <Node id="result" x={360} y={180} className="node">
        <strong>Result</strong>
        <Connector id="result:value" name="value" rules={{ maxOutgoing: 0, maxIncoming: 1 }} />
      </Node>
    </Engine>
  );
}
import { Engine } from "@snap-engine/core";
import { CollisionEngine } from "@snap-engine/core/collision";
import {
  ConnectorMirror,
  NodeMirror,
  attachControlledGraph,
  type LineRecord,
} from "@snap-engine/snapline";

const engine = new Engine();
engine.setCollisionEngine(new CollisionEngine());
engine.assignDom(document.querySelector("#graph")!);

// A vanilla app owns the graph with a plain module: hold the records,
// apply each atomic proposal, and push the latest snapshot back.
let lines: LineRecord[] = [];
const handle = attachControlledGraph(engine, {
  onLineChangeRequest(request) {
    lines = [
      ...lines
        .filter((record) => !request.remove.includes(record.id))
        .map((record) => {
          const update = request.update.find((u) => u.id === record.id);
          return update
            ? { ...record, toConnectorId: update.toConnectorId }
            : record;
        }),
      ...request.add,
    ];
    handle.setCanonicalGraph({ lines });
  },
});
handle.setCanonicalGraph({ lines });

const source = new NodeMirror(engine, null, { id: "source" });
source.element = document.querySelector("#source")!;
source.worldTransform = { x: 80, y: 90 };
source.remeasureDomGeometry();

const output = new ConnectorMirror(engine, source, {
  id: "source:value",
  name: "value",
  rules: { maxIncoming: 0 },
});
source.addConnectorObject(output);

Adapters create and destroy core objects automatically unless you supply an existing object. Topology is always controlled: your line records are the single source of truth, gestures arrive as one atomic LineChangeRequest each, and adopting a proposed line’s id settles the dragged line in place. Vanilla integrations assign committed DOM elements and call remeasureDomGeometry() after layout.