Skip to content

Instantly share code, notes, and snippets.

@weslord
Last active September 13, 2018 08:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save weslord/355d8fdd7011f657b836d5591c9e30df to your computer and use it in GitHub Desktop.
Save weslord/355d8fdd7011f657b836d5591c9e30df to your computer and use it in GitHub Desktop.
A D3-based Force Directed Graph (for planning Dwarf Fortress layouts)

Fort Directed Graph

A D3-based Force Directed Graph for planning and optimizing fortress layouts (ie adjacencies) in Dwarf Fortress.

Usage

  • Drag nodes to re-arrange them
  • Click to select nodes or connections
  • Edit the properties of selected nodes in the inspector on the right
  • Double click empty space to add new nodes
  • Ctrl- or alt-click and drag to create connections between nodes
  • P to 'pin' selected node in place
  • D or delete key to delete selected object
  • Esc to deselect

Additional Notes

A D3-based Force Directed Graph (for planning Dwarf Fortress layouts)
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>Dwarf Force-tress</title>
<link rel='stylesheet' href='style.css'>
<script src='https://d3js.org/d3.v5.min.js'></script>
</head>
<body>
</body>
<script src='main.js'></script>
</html>
{
"vertices":[
{
"id":0,
"title":"Stone",
"type":"stockpile"
},
{
"id":1,
"title":"Mason",
"type":"workshop"
},
{
"id":2,
"title":"Craftsdwarf",
"type":"workshop"
},
{
"id":3,
"title":"Carpenter",
"type":"workshop"
},
{
"id":4,
"title":"Wood",
"type":"stockpile"
},
{
"id":5,
"title":"Wood Furnace",
"type":"workshop"
},
{
"id":6,
"title":"Charcoal",
"type":"stockpile"
},
{
"id":7,
"title":"Ash",
"type":"stockpile"
},
{
"id":8,
"title":"Trees",
"type":"resource"
},
{
"id":9,
"title":"Mining",
"type":"resource"
},
{
"id":10,
"title":"Furniture",
"type":"stockpile"
},
{
"id":11,
"title":"Bowyer",
"type":"workshop"
},
{
"id":12,
"title":"Farm Plot",
"type":"workshop"
},
{
"id":13,
"title":"Brewery",
"type":"workshop"
},
{
"id":14,
"title":"Farmer's Workshop",
"type":"workshop"
},
{
"id":15,
"title":"Kitchen",
"type":"workshop"
},
{
"id":16,
"title":"Plants",
"type":"stockpile"
},
{
"id":17,
"title":"Seeds",
"type":"stockpile"
},
{
"id":18,
"title":"Alcohol",
"type":"stockpile"
}
],
"edges":[
{
"source":0,
"target":1
},
{
"source":0,
"target":2
},
{
"source":8,
"target":4
},
{
"source":9,
"target":0
},
{
"source":4,
"target":5
},
{
"source":4,
"target":3
},
{
"source":5,
"target":7
},
{
"source":5,
"target":6
},
{
"source":4,
"target":2
},
{
"source":3,
"target":10
},
{
"source":1,
"target":10
},
{
"source":4,
"target":11
},
{
"source":12,
"target":16
},
{
"source":13,
"target":17
},
{
"source":17,
"target":12
},
{
"source":16,
"target":13
},
{
"source":16,
"target":14
},
{
"source":16,
"target":15
},
{
"source":13,
"target":18
},
{
"source":18,
"target":15
}
]
}
'use strict';
{ // INIT
var windowWidth = window.innerWidth,
windowHeight = window.innerHeight;
var width = windowWidth - 258,
height = windowHeight - 10;
var color = d3.scaleOrdinal(d3.schemeCategory10);
var svg = d3.select('body').append('svg')
.attr('width', width)
.attr('height', height);
var world = svg.append('g')
.attr('id', 'world')
.attr('transform', 'translate('+width/2+','+height/2+')');
svg
.call(d3.zoom()
.scaleExtent([1/8, 2])
.on('zoom', zoomed))
.call(d3.zoom().transform, d3.zoomIdentity.translate(width/2, height/2))
.on('dblclick.zoom', null);
// from http://bl.ocks.org/rkirsling/5001347
// define arrow markers for graph links
svg.append('svg:defs').append('svg:marker')
.attr('id', 'end-arrow')
.attr('viewBox', '0 -5 15 10')
.attr('refX', 7.5)
.attr('markerWidth', 7.5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M 0 -5 L 10 0 L 0 5')
.attr('style', 'fill: #000; stroke: none');
var buildings = world.selectAll('g');
var lines = world.selectAll('g');
var linePreview = world
.append('path');
var inspector = {};
inspector.body = d3.select('body').append('div')
.attr('id', 'inspector');
inspector.title = inspector.body.append('input')
.on('change', function () {
if (this.value == '') {
this.value = selected.title;
} else {
selected ? selected.title = this.value : null;
updateInspector(selected);
update();
}
});
inspector.type = inspector.body.append('input')
.on('change', function () {
selected ? selected.type = this.value : null;
update();
});
inspector.from = inspector.body.append('div').classed('from', true);
inspector.to = inspector.body.append('div').classed('to', true);
inspector.focus = function () {
d3.event.stopPropagation();
inspector.title.node().select();
};
var vertices = [];
var edges = [];
d3.text('industries.json').then(function(g){
importGraph(g);
});
var source = null,
target = null,
selected = null,
line = null; // rename this (represents an edge)
var dragging = true;
var ctrlPressed = false;
window.addEventListener("resize", resize);
svg.on('dblclick', newVertexAtMouse);
svg.on('click', selectObj);
d3.select(window)
.on('keydown', windowKeydown)
.on('keyup', windowKeyup);
var simulation = d3.forceSimulation()
.force('x', d3.forceX(0))
.force('y', d3.forceY(0))
.force('link', d3.forceLink().id(function(d) {return d.id;}))
.force('charge', d3.forceManyBody().strength(-200))
.on('tick', tick);
simulation.force('x').strength(0.02);
simulation.force('y').strength(0.03);
update();
}
function update() {
lines = lines.data(edges, function(d) {
return d.index;
});
lines.exit().remove();
var enter = lines.enter().append('g')
.on('click', selectObj)
.on('mouseover', lineHover)
.on('mouseout', lineUnHover);
enter.append('path')
.style('marker-end', 'url(#end-arrow)');
lines = lines.merge(enter);
buildings = buildings.data(vertices, function(d) {
return d.id;
});
buildings.exit().remove();
enter = buildings.enter().append('g')
.on('click', selectObj)
.on('dblclick', inspector.focus)
.on('mouseover', bldgHover)
.on('mouseout', bldgUnHover)
.call(d3.drag()
.on('start', bldgDragStart)
.on('drag', bldgDragProgress)
.on('end', bldgDragEnd)
);
enter.append('text');
enter.append('rect');
buildings = buildings.merge(enter);
buildings.classed('selected', function (d) {
return d.selected;
});
lines.classed('selected', function (d) {
return d.selected;
});
buildings.selectAll('text')
.text(function(d) {return d.title;})
.attr('height', 10)
.attr('transform', function() {
var b = this.getBBox();
return 'translate(-'+ b.width/2 +','+ 10/2 +')';
});
buildings.selectAll('rect')
.each(function(d) {
const b = this.parentNode.querySelector('text').getBBox();
d.width = b.width + 5;
d.height = 20;
d3.select(this)
.attr('width', d.width)
.attr('height', d.height)
.attr('transform', 'translate(-'+ d.width / 2 +','+ -10 +')')
.attr('stroke', color(d.type))
.attr('rx', 5)
.attr('ry', 5);
});
lines.lower();
d3.selectAll('text').raise();
simulation.nodes(vertices);
simulation.force('link')
.links(edges)
.distance(100)
.strength(0.2);
}
function tick() {
lines.each(drawPath);
buildings.attr('transform', function(d) {
return 'translate(' + d.x + ',' + d.y + ')';
});
}
function drawPath(d) {
let path;
if (this.children) {
path = this.children[0];
} else {
path = this.node();
}
const x1 = d.source.x;
const y1 = d.source.y;
const y2 = d.target.y;
const x2 = d.target.x;
const w2 = d.target.width/2 + 3;
const h2 = d.target.height/2 + 3;
const dx = x1 - x2;
const dy = y1 - y2;
const m12 = dy / dx;
const m2 = h2 / w2;
let x2a;
let y2a;
if ( Math.abs(m12) > Math.abs(m2) ) {
// if slope of line is greater than aspect ratio of box
// line exits out the bottom
x2a = x2 + dy/Math.abs(dy) * h2 / m12;
y2a = y2 + dy/Math.abs(dy) * h2;
} else {
// line exits out the side
x2a = x2 + dx/Math.abs(dx) * w2;
y2a = y2 + dx/Math.abs(dx) * w2 * m12;
}
d3.select(path)
.attr('d', `
M ${x1} ${y1}
L ${x2a} ${y2a}
`);
}
function newVertexAtMouse() {
var x = d3.mouse(world.node())[0];
var y = d3.mouse(world.node())[1];
newVertex(x, y);
}
function newVertex(x = 0, y = 0) {
var lastVertexId = 0;
vertices.forEach(function(vertex){
if (vertex.id > lastVertexId) {
lastVertexId = vertex.id;
}
});
var vertex = {id: ++lastVertexId, title: 'New', type: '', x: x, y: y};
vertices.push(vertex);
selectObj(vertex);
inspector.focus();
update();
simulation.alpha(0.3).restart();
return vertex;
}
function deleteObj(obj) {
if (vertices.indexOf(obj) !== -1) {
vertices.splice(vertices.indexOf(obj), 1);
edges = edges.filter(function (edge) {
return (edge.source !== obj && edge.target !== obj);
});
}
if (edges.indexOf(obj) !== -1) {
edges.splice(edges.indexOf(obj), 1);
}
update();
simulation.alpha(0.3).restart();
}
function bldgDragStart(d) {
source = d;
if (!ctrlPressed) {
dragging = true; // dragging the bldg
} else {
dragging = false; // drawing new edge
}
}
function bldgDragProgress(d) {
var tx, ty;
if (dragging) {
source.fx = d3.event.x;
source.fy = d3.event.y;
} else {
if (target && target !== source) {
drawPath.call(linePreview, {source, target});
} else {
tx = d3.mouse(world.node())[0];
ty = d3.mouse(world.node())[1];
linePreview
.style('display', 'inline')
.style('marker-end', 'url(#end-arrow)')
.attr('d', function(d) {
return `M ${source.x} ${source.y} L ${tx} ${ty}`
});
}
}
simulation.alpha(0.3).restart();
}
function bldgDragEnd(d) {
linePreview
.style('display', 'none');
if (dragging) {
if (!d.fixed) {
source.fx = null;
source.fy = null;
}
} else {
if (target) {
if (source !== target && !edgeExists(source, target)) {
edges.push({source: source, target: target});
updateInspector(selected);
}
}
}
update();
}
function bldgHover(d) {
target = d;
d3.select(this).classed('hover', true);
}
function bldgUnHover(d) {
target = null;
d3.select(this).classed('hover', false);
}
function lineHover(d) {
line = d;
}
function lineUnHover(d) {
line = null;
}
function selectObj(subject) {
if (d3.event) {
d3.event.stopPropagation();
}
if (subject === selected) {
// TODO: re-implement for multi-select
// do not interfere with dblclick
// subject = null;
}
selected = subject;
edges.forEach(function(edge) {
edge.selected = false;
});
vertices.forEach(function(vertex) {
vertex.selected = false;
});
updateInspector(selected);
update();
}
function updateInspector(subject) {
inspector.title.node().value = '';
inspector.type.node().value = '';
inspector.to.node().innerText = '';
inspector.from.node().innerText = '';
if (subject && subject.title !== '') {
subject.selected = true;
inspector.title.node().value = subject.title;
inspector.type.node().value = subject.type;
var from = inspector.from
.append('ul');
edges.forEach(function(edge) {
if (edge.target === subject) {
let li = from.append('li');
li.append('a')
.text(edge.source.title)
.on('click', function() {
selectObj(edge.source);
});
li.append('a')
.text('×')
.on('click', function() {
deleteObj(edge);
updateInspector(subject);
});
}
});
from.append('li')
.text('+')
.on('click', function() {
var vertex = newVertex();
edges.push({source: vertex, target: subject});
updateInspector(vertex);
update();
});
var to = inspector.to
.append('ul');
edges.forEach(function(edge) {
if (edge.source === subject) {
let li = to.append('li');
li.append('a')
.text(edge.target.title)
.on('click', function() {
selectObj(edge.target);
});
li.append('a')
.text('×')
.on('click', function() {
deleteObj(edge);
updateInspector(subject);
});
}
});
to.append('li')
.text('+')
.on('click', function() {
var vertex = newVertex();
edges.push({source: subject, target: vertex});
updateInspector(vertex);
update();
});
} else {
if (subject && subject.title === '') {
deleteObj(subject);
}
}
}
function fixBldg(subject) {
if (subject && subject.fixed) {
d3.select(this).classed('fixed', false);
subject.fixed = false;
subject.fx = null;
subject.fy = null;
simulation.alpha(0.3).restart();
} else if (subject) {
d3.select(this).classed('fixed', true);
subject.fixed = true;
subject.fx = subject.x;
subject.fy = subject.y;
}
}
function edgeExists(source, target) {
for (var i = 0; i < edges.length; i++) {
if (source === edges[i].source && target === edges[i].target) {
return true;
}
}
}
function exportGraph() {
var cleanEdges = edges.map(function(edge) {
var cleanEdge = Object.assign({}, edge);
cleanEdge.source = cleanEdge.source.id;
cleanEdge.target = cleanEdge.target.id;
return cleanEdge;
});
var cleanGraph = {vertices: vertices, edges: cleanEdges};
return JSON.stringify(cleanGraph);
}
function importGraph(dirtyGraph) {
// TODO: check for duplicate IDs
var graph = JSON.parse(dirtyGraph);
vertices = [];
edges = [];
vertices = graph.vertices;
edges = graph.edges;
update();
simulation.alpha(1).restart();
}
function windowKeydown(d) {
if (d3.event.target.type !== "text") {
switch(d3.event.keyCode) {
case 16: // shift
case 17: // ctrl
case 18: // alt
case 91: // cmd
ctrlPressed = true;
svg.attr('style', 'cursor: pointer');
break;
case 8: // backspace
case 46: // delete
case 68: // d
d3.event.preventDefault();
world.selectAll('.selected').each(deleteObj);
target = null;
break;
case 80: // p
world.selectAll('.selected').each(fixBldg);
break;
case 27: // esc
selectObj(null);
break;
default:
break;
}
}
}
function windowKeyup() {
switch(d3.event.keyCode) {
case 16: // shift
case 17: // ctrl
case 18: // alt
case 91: // cmd
ctrlPressed = false;
svg.attr('style', 'cursor: default');
break;
default:
break;
}
}
function resize() {
var windowWidth = window.innerWidth,
windowHeight = window.innerHeight;
width = windowWidth - 258,
height = windowHeight - 10;
svg
.attr('width', width)
.attr('height', height);
simulation
.alpha(0.3).restart();
}
function zoomed() {
world.attr('transform', d3.event.transform);
}
html {
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin: 5px;
width: 100%;
height: 100%;
}
#inspector {
border: 2px solid black;
width: 250px;
height: calc(100% - 10px);
position: absolute;
top: 5px;
right: 5px;
overflow: scroll;
background: rgba(255,255,255,0.8);
padding: 1em;
}
#inspector input {
font-size: 0.85em;
width: 100%;
height: 2em;
margin-bottom: 0.5em;
padding-left: 0.25em;
}
#inspector ul {
list-style: none;
text-decoration: none;
margin: 0;
padding: 0;
}
#inspector li {
display: inline-block;
border: 1px solid white;
border-radius: 0.5em;
margin: 1px 2px;
font-size: 0.75em;
cursor: default;
}
#inspector li:hover {
border: 1px solid black;
}
#inspector li:last-child {
width: 2em;
text-align: center;
}
.from li {
background-color: #dcd;
}
.from a:first-child:before {
content: '← ';
padding-left: 0.5em;
}
.to li {
background-color: #ddc;
}
.to a:first-child:before {
content: '→ ';
padding-left: 0.5em;
}
#inspector a:last-child {
display: inline-block;
background-color: rgba(255, 0, 0, 0.25);
width: 1.25em;
margin-left: 0.5em;
margin-right: 0;
border-radius: 100%;
text-align: center;
font-size: 1em;
}
#inspector a:last-child:hover {
background-color: rgba(255, 0, 0, 0.5);
}
svg {
border: 2px solid black;
}
rect {
stroke-width: 2;
fill: #fff;
}
.hover rect {
stroke-width: 3.5;
}
.fixed rect {
fill: #eee;
}
path.selected, .selected rect {
stroke-width: 3;
}
path {
stroke: #000;
stroke-width: 2;
fill: none;
}
path:hover {
stroke-width: 3;
}
line {
stroke: #000;
stroke-width: 2;
}
text {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
pointer-events: none;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment