svg.go #6

  • //
  • guest/
  • perforce_software/
  • p4survey/
  • internal/
  • topology/
  • svg.go
  • View
  • Commits
  • Open Download .zip Download (22 KB)
package topology

import (
	"errors"
	"fmt"
	"html"
	"math"
	"os/exec"
	"sort"
	"strconv"
	"strings"
)

const (
	svgMargin     = 48.0
	svgNodeWidth  = 220.0
	svgNodeHeight = 72.0
)

type svgNode struct {
	id      string
	kind    string
	service string
	address string
	lines   []string
	width   float64
	height  float64
	x       float64
	y       float64
}

type svgEdge struct {
	source string
	target string
	host   bool
}

const svgScript = `
var svgRoot = document.documentElement;
var nodes = {};
var edges = [];
var dragState = null;
var selection = [];

Array.prototype.forEach.call(svgRoot.querySelectorAll('g.node'), function (el) {
	nodes[el.getAttribute('data-id')] = el;
});
Array.prototype.forEach.call(svgRoot.querySelectorAll('line.edge'), function (el) {
	edges.push(el);
});

function addClass(el, name) {
	var current = el.getAttribute('class') || '';
	if (current.split(/\s+/).indexOf(name) === -1) {
		el.setAttribute('class', (current + ' ' + name).trim());
	}
}

function removeClass(el, name) {
	var current = el.getAttribute('class') || '';
	el.setAttribute('class', current.split(/\s+/).filter(function (part) {
		return part && part !== name;
	}).join(' '));
}

function clearSelection() {
	selection.forEach(function (el) { removeClass(el, 'selected'); });
	selection = [];
}

function toggleSelection(el) {
	var index = selection.indexOf(el);
	if (index === -1) {
		selection.push(el);
		addClass(el, 'selected');
	} else {
		selection.splice(index, 1);
		removeClass(el, 'selected');
	}
}

function nodeCentre(el) {
	var match = /translate\(\s*(-?[\d.]+)[ ,]+(-?[\d.]+)\s*\)/.exec(el.getAttribute('transform') || '');
	return match ? { x: parseFloat(match[1]), y: parseFloat(match[2]) } : { x: 0, y: 0 };
}

function borderPoint(fromEl, toEl) {
	var from = nodeCentre(fromEl);
	var to = nodeCentre(toEl);
	var halfWidth = parseFloat(fromEl.getAttribute('data-width')) / 2;
	var halfHeight = parseFloat(fromEl.getAttribute('data-height')) / 2;
	var dx = to.x - from.x;
	var dy = to.y - from.y;
	if (dx === 0 && dy === 0) { return from; }
	var scale = Math.min(
		Math.abs(dx) > 0.0001 ? halfWidth / Math.abs(dx) : Infinity,
		Math.abs(dy) > 0.0001 ? halfHeight / Math.abs(dy) : Infinity
	);
	return { x: from.x + dx * scale, y: from.y + dy * scale };
}

function refreshEdges() {
	edges.forEach(function (edge) {
		var source = nodes[edge.getAttribute('data-source')];
		var target = nodes[edge.getAttribute('data-target')];
		if (!source || !target) { return; }
		var start = borderPoint(source, target);
		var end = borderPoint(target, source);
		edge.setAttribute('x1', start.x.toFixed(1));
		edge.setAttribute('y1', start.y.toFixed(1));
		edge.setAttribute('x2', end.x.toFixed(1));
		edge.setAttribute('y2', end.y.toFixed(1));
	});
}

function toUserSpace(event) {
	var point = svgRoot.createSVGPoint();
	point.x = event.clientX;
	point.y = event.clientY;
	return point.matrixTransform(svgRoot.getScreenCTM().inverse());
}

svgRoot.addEventListener('mousedown', function (event) {
	var group = event.target.closest ? event.target.closest('g.node') : null;
	if (!group) { clearSelection(); return; }
	if (event.shiftKey) {
		toggleSelection(group);
		event.preventDefault();
		return;
	}
	if (selection.indexOf(group) === -1) { clearSelection(); }
	var moving = selection.length ? selection.slice() : [group];
	dragState = {
		pointer: toUserSpace(event),
		items: moving.map(function (el) {
			var centre = nodeCentre(el);
			addClass(el, 'dragging');
			return { el: el, x: centre.x, y: centre.y };
		})
	};
	event.preventDefault();
});

svgRoot.addEventListener('mousemove', function (event) {
	if (!dragState) { return; }
	var pointer = toUserSpace(event);
	var dx = pointer.x - dragState.pointer.x;
	var dy = pointer.y - dragState.pointer.y;
	dragState.items.forEach(function (item) {
		item.el.setAttribute('transform', 'translate(' + (item.x + dx).toFixed(1) + ',' + (item.y + dy).toFixed(1) + ')');
	});
	refreshEdges();
});

svgRoot.addEventListener('mouseup', function () {
	if (!dragState) { return; }
	dragState.items.forEach(function (item) { removeClass(item.el, 'dragging'); });
	dragState = null;
});

function saveDiagram() {
	clearSelection();
	var fallback = svgRoot.getAttribute('data-filename') || 'p4_topology.svg';
	var name = window.prompt('Save diagram as', fallback);
	if (name === null) { return; }
	name = name.trim() || fallback;
	if (!/\.svg$/i.test(name)) { name += '.svg'; }
	var markup = new XMLSerializer().serializeToString(svgRoot);
	var blob = new Blob([markup], { type: 'image/svg+xml;charset=utf-8' });
	var url = URL.createObjectURL(blob);
	var link = document.createElementNS('http://www.w3.org/1999/xhtml', 'a');
	link.setAttribute('href', url);
	link.setAttribute('download', name);
	svgRoot.appendChild(link);
	link.click();
	svgRoot.removeChild(link);
	URL.revokeObjectURL(url);
}

svgRoot.querySelector('#save-button').addEventListener('click', saveDiagram);
refreshEdges();
`

// RenderSVG renders P4 server and topology records as a standalone SVG.
func RenderSVG(title string, servers, records []map[string]string) (string, error) {
	return RenderSVGWithLayout(title, servers, records, "layered")
}

// RenderSVGWithLayout renders raw P4 topology data using the requested layout.
func RenderSVGWithLayout(title string, servers, records []map[string]string, layoutName string) (string, error) {
	nodesByID := make(map[string]*svgNode)
	addresses := make(map[string]string)
	addNode := func(id, service, address string) *svgNode {
		if id == "" {
			return nil
		}
		if node, ok := nodesByID[id]; ok {
			if node.service == "" {
				node.service = service
			}
			if node.address == "" {
				node.address = address
			}
			return node
		}
		node := &svgNode{id: id, service: service, address: address}
		nodesByID[id] = node
		return node
	}

	for _, record := range servers {
		id := record["ServerID"]
		address := firstValue(record, "Address", "ServerAddress")
		addNode(id, firstValue(record, "Services", "Service"), address)
		if address != "" {
			addresses[address] = id
		}
	}

	edgeSet := make(map[svgEdge]struct{})
	for _, record := range records {
		source := record["ServerID"]
		if source == "" {
			continue
		}
		addNode(source, record["Type"], record["ServerAddress"])
		target := record["TargetServerID"]
		if target == "" {
			target = addresses[record["TargetAddress"]]
		}
		if target == "" || target == source {
			continue
		}
		addNode(target, "", record["TargetAddress"])
		edgeSet[svgEdge{source: source, target: target}] = struct{}{}
	}

	nodes := make([]*svgNode, 0, len(nodesByID))
	for _, node := range nodesByID {
		nodes = append(nodes, node)
	}
	sort.Slice(nodes, func(i, j int) bool { return nodes[i].id < nodes[j].id })
	edges := make([]svgEdge, 0, len(edgeSet))
	for edge := range edgeSet {
		edges = append(edges, edge)
	}
	sort.Slice(edges, func(i, j int) bool {
		if edges[i].source == edges[j].source {
			return edges[i].target < edges[j].target
		}
		return edges[i].source < edges[j].source
	})

	if layoutName == "sfdp" {
		if err := layoutWithSFDP(nodes, edges); err != nil {
			return "", err
		}
	} else {
		layoutRawNodes(nodes)
	}
	return renderSVG(title, nodes, edges), nil
}

// RenderNormalizedSVG renders the enriched servers section of a saved
// topology JSON file with the same P4 and host nodes as the Python renderer.
func RenderNormalizedSVG(title string, servers []Server) (string, error) {
	return RenderNormalizedSVGWithLayout(title, servers, "layered")
}

// RenderNormalizedSVGWithLayout renders normalized topology data using the
// requested layout backend.
func RenderNormalizedSVGWithLayout(title string, servers []Server, layoutName string) (string, error) {
	byID := make(map[string]Server, len(servers))
	for _, server := range servers {
		if server.ServerID != "" {
			byID[server.ServerID] = server
		}
	}
	nodes := make([]*svgNode, 0, len(byID)*2)
	nodesByID := make(map[string]*svgNode, len(byID)*2)
	for _, server := range servers {
		if server.ServerID == "" {
			continue
		}
		lines := []string{"p4server: " + server.ServerID, "type: " + valueOr(server.ServerType, "unknown"), "port: " + valueOr(server.Port, "-")}
		if server.Target != "" {
			lines = append(lines, "target: "+server.Target)
		}
		if server.Version != "" {
			lines = append(lines, "version: "+server.Version)
		}
		for _, note := range server.Notes {
			lines = append(lines, "note: "+note)
		}
		node := &svgNode{id: server.ServerID, kind: "p4", service: server.ServerType, lines: lines, width: 330, height: 26 + float64(len(lines))*19}
		nodes = append(nodes, node)
		nodesByID[node.id] = node
		if server.HostTarget == "" {
			hostLines := []string{"host server", "address: " + valueOr(server.Host, "unknown"), "ip: " + valueOr(server.IP, "unknown")}
			host := &svgNode{id: "host-" + server.ServerID, kind: "host", lines: hostLines, width: 250, height: 83}
			nodes = append(nodes, host)
			nodesByID[host.id] = host
		}
	}

	edges := make([]svgEdge, 0, len(servers)*2)
	for _, server := range servers {
		if server.ServerID == "" {
			continue
		}
		hostID := "host-" + valueOr(server.HostTarget, server.ServerID)
		if _, ok := nodesByID[hostID]; ok {
			edges = append(edges, svgEdge{source: server.ServerID, target: hostID, host: true})
		}
		if server.Target != "" {
			if _, ok := nodesByID[server.Target]; ok {
				edges = append(edges, svgEdge{source: server.ServerID, target: server.Target})
			}
		}
	}
	if layoutName == "sfdp" {
		if err := layoutWithSFDP(nodes, edges); err != nil {
			return "", err
		}
	} else {
		layoutNormalizedNodes(nodes, servers, byID)
	}
	return renderSVG(title, nodes, edges), nil
}

func layoutNormalizedNodes(nodes []*svgNode, servers []Server, byID map[string]Server) {
	depths := make(map[string]int, len(byID))
	visiting := make(map[string]bool, len(byID))
	var depthFor func(string, map[string]bool) int
	depthFor = func(id string, seen map[string]bool) int {
		if depth, ok := depths[id]; ok {
			return depth
		}
		server, ok := byID[id]
		if !ok || server.Target == "" || seen[id] || visiting[id] {
			depths[id] = 0
			return 0
		}
		visiting[id] = true
		seen[id] = true
		depths[id] = depthFor(server.Target, seen) + 1
		delete(seen, id)
		delete(visiting, id)
		return depths[id]
	}

	layers := make(map[int][]Server)
	maxDepth := 0
	for _, server := range servers {
		if server.ServerID != "" {
			depth := depthFor(server.ServerID, make(map[string]bool))
			layers[depth] = append(layers[depth], server)
			maxDepth = max(maxDepth, depth)
		}
	}
	components := layoutComponents(servers, byID)
	children := make(map[string][]string)
	for _, server := range servers {
		if _, ok := byID[server.Target]; ok {
			children[server.Target] = append(children[server.Target], server.ServerID)
		}
	}
	for depth := 0; depth <= maxDepth; depth++ {
		sortServers(layers[depth], nil, components)
	}

	positions := make(map[string]float64, len(byID))
	setPositions := func() {
		for depth := 0; depth <= maxDepth; depth++ {
			for index, server := range layers[depth] {
				positions[server.ServerID] = float64(index)
			}
		}
	}
	for sweep := 0; sweep < 4; sweep++ {
		setPositions()
		for depth := 1; depth <= maxDepth; depth++ {
			sortServers(layers[depth], func(server Server) []float64 {
				if _, ok := positions[server.Target]; ok {
					return []float64{positions[server.Target]}
				}
				return nil
			}, components)
		}
		setPositions()
		for depth := maxDepth - 1; depth >= 0; depth-- {
			sortServers(layers[depth], func(server Server) []float64 {
				values := make([]float64, 0, len(children[server.ServerID]))
				for _, child := range children[server.ServerID] {
					values = append(values, positions[child])
				}
				return values
			}, components)
		}
	}

	byNodeID := make(map[string]*svgNode, len(nodes))
	for _, node := range nodes {
		byNodeID[node.id] = node
	}
	rowHeight := 250.0
	for _, node := range nodes {
		if node.kind == "p4" {
			_, height := dimensions(node)
			hostHeight := 0.0
			if host := byNodeID["host-"+node.id]; host != nil {
				_, hostHeight = dimensions(host)
			}
			rowHeight = math.Max(rowHeight, height+45+hostHeight+80)
		}
	}
	y := 0.0
	for depth := maxDepth; depth >= 0; depth-- {
		layer := layers[depth]
		columns := min(8, len(layer))
		for start := 0; start < len(layer); start += columns {
			end := min(start+columns, len(layer))
			row := layer[start:end]
			rowWidth := -70.0
			for _, server := range row {
				width, _ := dimensions(byNodeID[server.ServerID])
				rowWidth += width + 70
			}
			x := -rowWidth / 2
			for _, server := range row {
				node := byNodeID[server.ServerID]
				width, height := dimensions(node)
				node.x = x + width/2
				node.y = y
				x += width + 70
				if host := byNodeID["host-"+server.ServerID]; host != nil {
					_, hostHeight := dimensions(host)
					host.x = node.x
					host.y = node.y + height/2 + 45 + hostHeight/2
				}
			}
			y += rowHeight
		}
		y += 80
	}
}

func layoutComponents(servers []Server, byID map[string]Server) map[string]string {
	components := make(map[string]string, len(servers))
	var componentFor func(string, map[string]bool) string
	componentFor = func(id string, seen map[string]bool) string {
		if component, ok := components[id]; ok {
			return component
		}
		server, ok := byID[id]
		if !ok || server.Target == "" || seen[id] {
			components[id] = id
			return id
		}
		seen[id] = true
		components[id] = componentFor(server.Target, seen)
		delete(seen, id)
		return components[id]
	}
	for _, server := range servers {
		componentFor(server.ServerID, make(map[string]bool))
	}
	return components
}

func sortServers(servers []Server, neighbors func(Server) []float64, components map[string]string) {
	sort.SliceStable(servers, func(i, j int) bool {
		left, right := servers[i], servers[j]
		if neighbors != nil {
			leftValues, rightValues := neighbors(left), neighbors(right)
			if len(leftValues) > 0 && len(rightValues) > 0 {
				leftMedian, rightMedian := median(leftValues), median(rightValues)
				if leftMedian != rightMedian {
					return leftMedian < rightMedian
				}
			} else if len(leftValues) > 0 || len(rightValues) > 0 {
				return len(leftValues) > 0
			}
		}
		if components[left.ServerID] != components[right.ServerID] {
			return components[left.ServerID] < components[right.ServerID]
		}
		return left.ServerID < right.ServerID
	})
}

func median(values []float64) float64 {
	sorted := append([]float64(nil), values...)
	sort.Float64s(sorted)
	return sorted[len(sorted)/2]
}

func firstValue(record map[string]string, keys ...string) string {
	for _, key := range keys {
		if value := strings.TrimSpace(record[key]); value != "" {
			return value
		}
	}
	return ""
}

func layoutRawNodes(nodes []*svgNode) {
	columns := max(1, int(math.Ceil(math.Sqrt(float64(len(nodes))))))
	for index, node := range nodes {
		width, height := dimensions(node)
		column := index % columns
		row := index / columns
		node.x = svgMargin + width/2 + float64(column)*(svgNodeWidth+70)
		node.y = svgMargin + height/2 + float64(row)*(svgNodeHeight+70)
	}
}

func layoutWithSFDP(nodes []*svgNode, edges []svgEdge) error {
	if len(nodes) == 0 {
		return nil
	}
	var dot strings.Builder
	dot.WriteString("digraph topology {\n  graph [overlap=prism, sep=\"+40,40\"];\n  node [shape=box, fixedsize=true];\n")
	nodeNames := make(map[string]string, len(nodes))
	byName := make(map[string]*svgNode, len(nodes))
	for index, node := range nodes {
		name := fmt.Sprintf("n%d", index)
		width, height := dimensions(node)
		nodeNames[node.id] = name
		byName[name] = node
		fmt.Fprintf(&dot, "  %s [width=%.3f, height=%.3f];\n", name, width/72, height/72)
	}
	for _, edge := range edges {
		if source, ok := nodeNames[edge.source]; ok {
			if target, ok := nodeNames[edge.target]; ok {
				fmt.Fprintf(&dot, "  %s -> %s;\n", source, target)
			}
		}
	}
	dot.WriteString("}\n")

	command := exec.Command("sfdp", "-Tplain")
	command.Stdin = strings.NewReader(dot.String())
	output, err := command.CombinedOutput()
	if err != nil {
		if errors.Is(err, exec.ErrNotFound) {
			return fmt.Errorf("Graphviz sfdp is not available on PATH; install Graphviz or use --topology-layout=layered")
		}
		return fmt.Errorf("run Graphviz sfdp: %w: %s", err, strings.TrimSpace(string(output)))
	}

	positions := make(map[string]struct{ x, y float64 }, len(nodes))
	maxY := math.Inf(-1)
	for _, line := range strings.Split(string(output), "\n") {
		fields := strings.Fields(line)
		if len(fields) < 4 || fields[0] != "node" {
			continue
		}
		x, xErr := strconv.ParseFloat(fields[2], 64)
		y, yErr := strconv.ParseFloat(fields[3], 64)
		if xErr != nil || yErr != nil {
			return fmt.Errorf("parse Graphviz sfdp node position")
		}
		if _, ok := byName[fields[1]]; !ok {
			continue
		}
		positions[fields[1]] = struct{ x, y float64 }{x: x * 72, y: y * 72}
		maxY = math.Max(maxY, y*72)
	}
	if len(positions) != len(nodes) {
		return fmt.Errorf("Graphviz sfdp did not position every node")
	}
	for name, position := range positions {
		node := byName[name]
		node.x = position.x
		node.y = maxY - position.y
	}
	return nil
}

func renderSVG(title string, nodes []*svgNode, edges []svgEdge) string {
	width, height := fitCanvas(nodes)
	byID := make(map[string]*svgNode, len(nodes))
	for _, node := range nodes {
		byID[node.id] = node
	}

	var svg strings.Builder
	fmt.Fprintf(&svg, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%.0f\" height=\"%.0f\" viewBox=\"0 0 %.0f %.0f\" data-filename=\"p4_topology.svg\">\n", width, height, width, height)
	fmt.Fprintf(&svg, "  <title>%s</title>\n", html.EscapeString(title))
	svg.WriteString("  <defs><marker id=\"arrow\" viewBox=\"0 -5 10 10\" refX=\"9\" refY=\"0\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto\"><path d=\"M0,-5L10,0L0,5\" fill=\"#52616b\"/></marker></defs>\n")
	svg.WriteString("  <style>.edge{stroke:#52616b;stroke-width:1.6;marker-end:url(#arrow)}.edge.host-edge{stroke:#9aa7b1;stroke-dasharray:5 4;marker-end:none}.node{cursor:move}.node rect{stroke:#31424d;stroke-width:2;rx:6}.host rect{stroke:#66737c}.node text{font-family:sans-serif;fill:#17212b;font-size:13px;pointer-events:none}.node .heading{font-weight:bold}.node.dragging rect{stroke-dasharray:5 3}.node.selected rect{stroke-width:4}#save-button{cursor:pointer}#save-button rect{fill:#17212b}#save-button text{fill:#fff;font-family:sans-serif;font-size:13px}#hint{font-family:sans-serif;font-size:12px;fill:#52616b}</style>\n")
	svg.WriteString("  <rect width=\"100%\" height=\"100%\" fill=\"#f4f7f9\"/>\n")
	for _, edge := range edges {
		from, to := byID[edge.source], byID[edge.target]
		x1, y1 := borderPoint(from, to)
		x2, y2 := borderPoint(to, from)
		cssClass := "edge"
		if edge.host {
			cssClass = "edge host-edge"
		}
		fmt.Fprintf(&svg, "  <line class=\"%s\" data-source=\"%s\" data-target=\"%s\" x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\"/>\n", cssClass, html.EscapeString(edge.source), html.EscapeString(edge.target), x1, y1, x2, y2)
	}
	for _, node := range nodes {
		lines := nodeText(node)
		nodeWidth, nodeHeight := dimensions(node)
		fmt.Fprintf(&svg, "  <g class=\"node %s\" data-id=\"%s\" data-width=\"%.0f\" data-height=\"%.0f\" transform=\"translate(%.1f %.1f)\"><rect x=\"%.0f\" y=\"%.0f\" width=\"%.0f\" height=\"%.0f\" fill=\"%s\"/>", node.kind, html.EscapeString(node.id), nodeWidth, nodeHeight, node.x, node.y, -nodeWidth/2, -nodeHeight/2, nodeWidth, nodeHeight, nodeFill(node))
		for index, line := range lines {
			class := ""
			if index == 0 {
				class = " class=\"heading\""
			}
			fmt.Fprintf(&svg, "<text%s x=\"%.0f\" y=\"%.0f\">%s</text>", class, -nodeWidth/2+14, -nodeHeight/2+26+float64(index)*19, html.EscapeString(line))
		}
		svg.WriteString("</g>\n")
	}
	svg.WriteString("  <g id=\"save-button\" transform=\"translate(20,20)\"><rect width=\"120\" height=\"30\" rx=\"6\"/><text x=\"20\" y=\"20\">Save layout</text></g>\n")
	svg.WriteString("  <text id=\"hint\" x=\"152\" y=\"40\">Drag to move. Shift-click to select several, then drag together.</text>\n")
	svg.WriteString("  <script type=\"application/ecmascript\"><![CDATA[")
	svg.WriteString(svgScript)
	svg.WriteString("]]></script>\n")
	svg.WriteString("</svg>\n")
	return svg.String()
}

func nodeText(node *svgNode) []string {
	if len(node.lines) > 0 {
		return node.lines
	}
	lines := []string{node.id}
	if node.service != "" {
		return append(lines, node.service)
	}
	if node.address != "" {
		return append(lines, node.address)
	}
	return lines
}

func dimensions(node *svgNode) (float64, float64) {
	width, height := node.width, node.height
	if width == 0 {
		width = svgNodeWidth
	}
	if height == 0 {
		height = svgNodeHeight
	}
	return width, height
}

func nodeFill(node *svgNode) string {
	if node.kind == "host" {
		return "#ffffff"
	}
	service := strings.ToLower(node.service)
	switch {
	case strings.Contains(service, "commit"):
		return "#d7f0ff"
	case strings.Contains(service, "edge"):
		return "#dff7df"
	case strings.Contains(service, "standby"), strings.Contains(service, "replica"):
		return "#fff1cc"
	case strings.Contains(service, "broker"):
		return "#ffe0cc"
	case strings.Contains(service, "proxy"):
		return "#f4ddff"
	default:
		return "#d9eaf7"
	}
}

func fitCanvas(nodes []*svgNode) (float64, float64) {
	if len(nodes) == 0 {
		return 760, 520
	}
	minX, maxX := math.Inf(1), math.Inf(-1)
	minY, maxY := math.Inf(1), math.Inf(-1)
	for _, node := range nodes {
		width, height := dimensions(node)
		minX, maxX = math.Min(minX, node.x-width/2), math.Max(maxX, node.x+width/2)
		minY, maxY = math.Min(minY, node.y-height/2), math.Max(maxY, node.y+height/2)
	}
	for _, node := range nodes {
		node.x += svgMargin - minX
		node.y += svgMargin - minY
	}
	return math.Max(760, maxX-minX+2*svgMargin), math.Max(520, maxY-minY+2*svgMargin)
}

func borderPoint(source, target *svgNode) (float64, float64) {
	dx, dy := target.x-source.x, target.y-source.y
	if dx == 0 && dy == 0 {
		return source.x, source.y
	}
	width, height := dimensions(source)
	scale := math.Min(width/2/math.Abs(dx), height/2/math.Abs(dy))
	if math.IsInf(scale, 0) {
		if dx == 0 {
			scale = height / 2 / math.Abs(dy)
		} else {
			scale = width / 2 / math.Abs(dx)
		}
	}
	return source.x + dx*scale, source.y + dy*scale
}
# Change User Description Committed
#6 33869 Robert Cowham Remove libraries which didn't work
#5 33868 Robert Cowham Call out to Graphiz sfdp if installed
#4 33867 Robert Cowham Trying layout options in pure Go
#3 33865 Robert Cowham Save enriched server info for compatibility with python version
#2 33864 Robert Cowham Make topology compatible with python version
#1 33863 Robert Cowham Sample topology implementation