Skip to content

Custom rendering with xdot draw-ops

getDrawOps lays out a graph, renders it to xdot format, and returns a flat array of typed draw operations. Use it to drive a custom renderer (canvas, WebGL, PDF, native UI) without parsing SVG.

Signature

ts
function getDrawOps(g: Graph, opts?: { engine?: string }): XdotOp[];

Basic usage

ts
import { createGraph, getDrawOps } from 'graphviz-ts';

const b = createGraph({ directed: true });
b.addNode('a', { shape: 'ellipse', label: 'Start' });
b.addNode('b', { shape: 'box',     label: 'End'   });
b.addEdge('a', 'b');

const ops = getDrawOps(b.graph);

for (const op of ops) {
  switch (op.kind) {
    case 'fill_color':
      // set fill color before drawing a filled shape
      setFillColor(op.color);
      break;
    case 'pen_color':
      setStrokeColor(op.color);
      break;
    case 'font':
      setFont(op.font.name, op.font.size);
      break;
    case 'filled_ellipse':
      fillEllipse(op.ellipse.x, op.ellipse.y, op.ellipse.w, op.ellipse.h);
      break;
    case 'unfilled_ellipse':
      strokeEllipse(op.ellipse.x, op.ellipse.y, op.ellipse.w, op.ellipse.h);
      break;
    case 'filled_polygon':
      fillPolygon(op.polygon.pts);
      break;
    case 'unfilled_polygon':
      strokePolygon(op.polygon.pts);
      break;
    case 'text':
      drawText(op.text.x, op.text.y, op.text.text, op.text.align);
      break;
  }
}

Op kinds

The XdotOp union discriminates on op.kind:

kindPayload fieldDescription
'filled_ellipse'op.ellipse: XdotRectFill an ellipse
'unfilled_ellipse'op.ellipse: XdotRectStroke an ellipse
'filled_polygon'op.polygon: XdotPolylineFill a closed polygon
'unfilled_polygon'op.polygon: XdotPolylineStroke a closed polygon
'filled_bezier'op.bezier: XdotPolylineFill a closed bezier curve
'unfilled_bezier'op.bezier: XdotPolylineStroke an open bezier curve
'polyline'op.polyline: XdotPolylineDraw a polyline
'text'op.text: XdotTextDraw a text label
'fill_color'op.color: stringSet current fill color
'pen_color'op.color: stringSet current stroke color
'grad_fill_color'op.gradColor: XdotColorSet gradient fill
'grad_pen_color'op.gradColor: XdotColorSet gradient stroke
'font'op.font: XdotFontSet current font
'style'op.style: stringSet current draw style
'image'op.image: XdotImageDraw an embedded image
'fontchar'op.fontchar: numberSet font char bitmask

Key types

ts
interface XdotRect     { x: number; y: number; w: number; h: number }
interface XdotPolyline { pts: XdotPoint[] }
interface XdotPoint    { x: number; y: number; z: number }
interface XdotText     { x: number; y: number; align: 'left'|'center'|'right'; width: number; text: string }
interface XdotFont     { size: number; name: string }

Coordinates

xdot coordinates are in points, in graphviz's native y-up frame (origin at the lower-left). Flip y coordinates before drawing on a y-down surface:

ts
// In y-down canvas rendering, where canvasHeight is bounds.height from getLayout:
const screenY = canvasHeight - op.ellipse.y;

Ops arrive in paint order: graph background first, then nodes, then edges.

Current limitation

xdot renderer is integration-incomplete

getDrawOps reliably surfaces:

  • Node shape ops (filled_ellipse, filled_polygon, unfilled_polygon, etc.)
  • Text and label ops (text)
  • Font ops (font)
  • Color-setting ops (fill_color, pen_color)

Edge draw ops (filled_bezier, unfilled_bezier, polyline for splines and arrowheads) are not yet emitted. Custom node pen/fill colors are not applied to the emitted color ops.

The geometry is fully computed (the SVG renderer draws the same graph correctly). Only the xdot emission path is incomplete. A faithful fix is tracked as a follow-on. Until then, use render(g, 'svg') when complete visual fidelity is required.

Canvas example (node labels only)

ts
import { parse, getDrawOps } from 'graphviz-ts';

const g = parse(`digraph { a [label="A"]; b [label="B"]; a -> b }`);
const ops = getDrawOps(g);

// Draw on an HTML canvas
const canvas = document.querySelector('canvas')!;
const ctx = canvas.getContext('2d')!;

let font = '14px sans-serif';
for (const op of ops) {
  if (op.kind === 'font') {
    font = `${op.font.size}px ${op.font.name}`;
  } else if (op.kind === 'text') {
    ctx.font = font;
    ctx.fillText(op.text.text, op.text.x, canvas.height - op.text.y);
  } else if (op.kind === 'filled_ellipse') {
    ctx.beginPath();
    ctx.ellipse(
      op.ellipse.x, canvas.height - op.ellipse.y,
      op.ellipse.w / 2, op.ellipse.h / 2,
      0, 0, 2 * Math.PI,
    );
    ctx.fill();
  }
}