Skip to content

Instantly share code, notes, and snippets.

@lgersman
Forked from anonymous/README.md
Last active February 2, 2020 00:18
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save lgersman/5370827 to your computer and use it in GitHub Desktop.
Save lgersman/5370827 to your computer and use it in GitHub Desktop.
improved state diagram editor based on https://gist.github.com/lgersman/5311202 featuring curvy connections with helper points

new features

  • css improved
  • css transitions added

nodes

  • double click on a node to remove it

connection lines are splines !!

  • drawn as cardinal splines (https://github.com/mbostock/d3/wiki/SVG-Shapes)
  • can be selected by single click it
  • will be highlighted while hovering
  • can be made curvy by adding connection points :
  • connection points can be added by pressing CTRL + double click somewhere on a connection line
  • remove a connection point by pressing CTRL + double click on a existing connection point
  • move a connection point by dragging the connection point
  • remove a connection be double click on it

drag selection

  • if both end nodes of a connection selected, the connection including its connection points will also be dragged

See it live : http://bl.ocks.org/lgersman/5370827

rect.selection {
stroke : gray;
stroke-dasharray: 2px;
stroke-opacity : 0.5;
fill : transparent;
}
g.state circle {
stroke : gray;
}
g.state circle.inner {
fill : white;
transition : fill 0.5s;
cursor : move;
}
g.state circle.inner.hover,
g.state circle.outer.hover {
fill : aliceblue;
}
g.state circle.outer.hover {
stroke-width : 1px;
}
g.state circle.outer {
stroke-width : 0px;
stroke-dasharray: 2px;
stroke-color : gray;
fill : transparent;
transition : all 0.5s;
cursor : pointer;
}
g.state.selected circle.outer {
stroke-width : 1px;
}
g.state text {
font : 12px sans-serif;
font-weight : bold;
pointer-events : none;
}
g.transition path,
path.dragline {
fill : none;
stroke : gray;
stroke-width: 1px;
}
g.transition path.foreground {
marker-end : url(#end-arrow);
}
g.transition.hover path.background {
stroke-dasharray: none;
stroke : aliceblue;
stroke-opacity : 1.0;
transition : all 0.5s;
}
g.transition path.background {
stroke-dasharray: none;
stroke-width: 8px;
stroke : transparent;
}
g.transition.selected path.foreground {
stroke-dasharray: 2px;
stroke-color : gray;
}
g.transition path {
cursor : default;
}
.end-arrow {
fill : gray;
stroke-width : 1px;
}
g.transition circle.endpoint {
display : none;
fill : none;
cursor : pointer;
stroke : gray;
stroke-dasharray: 2px;
}
g.transition circle.point {
display : none;
fill : aliceblue;
cursor : move;
stroke : gray;
}
g.transition.selected circle.endpoint,
g.transition.selected circle.point {
display : inline;
transition : all 0.5s;
}
g.transition:not( .selected).hover *,
path.dragline {
display : inline;
}
g.transition:not( .selected).hover {
transition : all 0.5s;
}
path.dragline {
pointer-events: none;
stroke-opacity : 0.5;
stroke-dasharray: 2px;
}
path.dragline.hidden {
stroke-width: 0;
}
/* disable text selection */
svg *::selection {
background : transparent;
}
svg *::-moz-selection {
background:transparent;
}
svg *::-webkit-selection {
background:transparent;
}
var radius = 40;
window.states = [
{ x : 43, y : 67, label : "first", transitions : [] },
{ x : 340, y : 150, label : "second", transitions : [] },
{ x : 200, y : 250, label : "third", transitions : [] },
{ x : 300, y : 320, label : "fourth", transitions : [] },
{ x : 50, y : 250, label : "fifth", transitions : [] },
{ x : 90, y : 170, label : "last", transitions : [] }
];
window.states[0].transitions.push( { label : 'whooo', points : [ { x : 150, y : 50}, { x : 200, y : 30}], target : window.states[ 1]})
window.states[1].transitions.push( { label : 'waaa!', points : [ { x : 250, y : 30}], target : window.states[ 2]})
window.svg = d3.select( 'body')
.append("svg")
.attr("width", "960px")
.attr("height", "500px");
// define arrow markers for graph links
svg.append('svg:defs').append('svg:marker')
.attr('id', 'end-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 4)
.attr('markerWidth', 8)
.attr('markerHeight', 8)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('class', 'end-arrow')
;
// line displayed when dragging new nodes
var drag_line = svg.append('svg:path')
.attr({
'class' : 'dragline hidden',
'd' : 'M0,0L0,0'
})
;
var gTransitions = svg.append( 'g').selectAll( "path.transition");
var gStates = svg.append("g").selectAll( "g.state");
var transitions = function() {
return states.reduce( function( initial, state) {
return initial.concat(
state.transitions.map( function( transition) {
return { source : state, transition : transition};
})
);
}, []);
};
var transformTransitionEndpoints = function( d, i) {
var endPoints = d.endPoints();
var point = [
d.type=='start' ? endPoints[0].x : endPoints[1].x,
d.type=='start' ? endPoints[0].y : endPoints[1].y
];
return "translate("+ point + ")";
}
var transformTransitionPoints = function( d, i) {
return "translate("+ [d.x,d.y] + ")";
}
var computeTransitionPath = (function() {
var line = d3.svg.line()
.x( function( d, i){
return d.x;
})
.y( function(d, i){
return d.y;
})
.interpolate("cardinal");
return function( d) {
var source = d.source,
target = d.transition.points.length && d.transition.points[0] || d.transition.target,
deltaX = target.x - source.x,
deltaY = target.y - source.y,
dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY),
normX = deltaX / dist,
normY = deltaY / dist,
sourcePadding = radius + 4,//d.left ? 17 : 12,
sourceX = source.x + (sourcePadding * normX),
sourceY = source.y + (sourcePadding * normY);
source = d.transition.points.length && d.transition.points[ d.transition.points.length-1] || d.source;
target = d.transition.target;
deltaX = target.x - source.x;
deltaY = target.y - source.y;
dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
normX = deltaX / dist;
normY = deltaY / dist;
targetPadding = radius + 8;//d.right ? 17 : 12,
targetX = target.x - (targetPadding * normX);
targetY = target.y - (targetPadding * normY);
var points =
[ { x : sourceX, y : sourceY}].concat(
d.transition.points,
[{ x : targetX, y : targetY}]
)
;
var l = line( points);
return l;
};
})();
var dragPoint = d3.behavior.drag()
.on("drag", function( d, i) {
console.log( "transitionmidpoint drag");
var gTransitionPoint = d3.select( this);
gTransitionPoint.attr( "transform", function( d, i) {
d.x += d3.event.dx;
d.y += d3.event.dy;
return "translate(" + [ d.x,d.y ] + ")"
});
// refresh transition path
gTransitions.selectAll( "path").attr( 'd', computeTransitionPath);
// refresh transition endpoints
gTransitions.selectAll( "circle.endpoint").attr({
transform : transformTransitionEndpoints
});
// refresh transition points
gTransitions.selectAll( "circle.point").attr({
transform : transformTransitionPoints
});
d3.event.sourceEvent.stopPropagation();
});
var renderTransitionMidPoints = function( gTransition) {
gTransition.each( function( transition) {
var transitionPoints = d3.select( this).selectAll('circle.point').data( transition.transition.points, function( d) {
return transition.transition.points.indexOf( d);
});
transitionPoints.enter().append( "circle")
.attr({
'class' : 'point',
r : 4,
transform : transformTransitionPoints
})
.on({
dblclick : function( d) {
console.log( "transitionmidpoint dblclick");
var gTransition = d3.select( d3.event.target.parentElement),
transition = gTransition.datum(),
index = transition.transition.points.indexOf( d);
if( gTransition.classed( "selected")) {
transition.transition.points.splice( index, 1);
gTransition.selectAll( 'path').attr({
d : computeTransitionPath
});
renderTransitionMidPoints( gTransition);
//renderTransitionPoints( gTransition);
gTransition.selectAll( "circle.endpoint").attr({
transform : transformTransitionEndpoints
});
}
d3.event.stopPropagation();
}
})
.call( dragPoint)
;
transitionPoints.exit().remove();
});
};
var renderTransitionPoints = function( gTransition) {
gTransition.each( function( d) {
var endPoints = function() {
var source = d.source,
target = d.transition.points.length && d.transition.points[0] || d.transition.target,
deltaX = target.x - source.x,
deltaY = target.y - source.y,
dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY),
normX = deltaX / dist,
normY = deltaY / dist,
sourceX = source.x + (radius * normX),
sourceY = source.y + (radius * normY);
source = d.transition.points.length && d.transition.points[ d.transition.points.length-1] || d.source;
target = d.transition.target;
deltaX = target.x - source.x;
deltaY = target.y - source.y;
dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
normX = deltaX / dist;
normY = deltaY / dist;
targetPadding = radius + 8;//d.right ? 17 : 12,
targetX = target.x - (radius * normX);
targetY = target.y - (radius * normY);
return [ { x : sourceX, y : sourceY}, { x : targetX, y : targetY}];
};
var transitionEndpoints = d3.select( this).selectAll('circle.endpoint').data( [
{ endPoints : endPoints, type : 'start' },
{ endPoints : endPoints, type : 'end' }
]);
transitionEndpoints.enter().append( "circle")
.attr({
'class' : function( d) {
return 'endpoint ' + d.type;
},
r : 4,
transform : transformTransitionEndpoints
})
;
transitionEndpoints.exit().remove();
});
};
var renderTransitions = function() {
gTransition = gTransitions.enter().append( 'g')
.attr({
'class' : 'transition'
})
.on({
click : function() {
console.log( "transition click");
d3.selectAll( 'g.state.selection').classed( "selection", false);
d3.selectAll( 'g.selected').classed( "selected", false);
d3.select( this).classed( "selected", true);
d3.event.stopPropagation();
},
mouseover : function() {
svg.select( "rect.selection").empty() && d3.select( this).classed( "hover", true);
},
mouseout : function() {
svg.select( "rect.selection").empty() && d3.select( this).classed( "hover", false);
}
});
;
gTransition.append( 'path')
.attr({
d : computeTransitionPath,
class : 'background'
})
.on({
dblclick : function( d, i) {
gTransition = d3.select( d3.event.target.parentElement);
if( d3.event.ctrlKey) {
var p = d3.mouse( this);
gTransition.classed( 'selected', true);
d.transition.points.push( { x : p[0], y : p[1]});
renderTransitionMidPoints( gTransition, d);
gTransition.selectAll( 'path').attr({
d : computeTransitionPath
});
} else {
var gTransition = d3.select( d3.event.target.parentElement),
transition = gTransition.datum(),
index = transition.source.transitions.indexOf( transition.transition);
transition.source.transitions.splice( index, 1)
gTransition.remove();
d3.event.stopPropagation();
}
}
})
;
gTransition.append( 'path')
.attr({
d : computeTransitionPath,
class : 'foreground'
})
;
renderTransitionPoints( gTransition);
renderTransitionMidPoints( gTransition);
gTransitions.exit().remove();
};
var renderStates = function() {
var gState = gStates.enter()
.append( "g")
.attr({
"transform" : function( d) {
return "translate("+ [d.x,d.y] + ")";
},
'class' : 'state'
})
.call( drag);
gState.append( "circle")
.attr({
r : radius + 4,
class : 'outer'
})
.on({
mousedown : function( d) {
console.log( "state circle outer mousedown");
startState = d, endState = undefined;
// reposition drag line
drag_line
.style('marker-end', 'url(#end-arrow)')
.classed('hidden', false)
.attr('d', 'M' + d.x + ',' + d.y + 'L' + d.x + ',' + d.y)
;
// force element to be an top
this.parentNode.parentNode.appendChild( this.parentNode);
//d3.event.stopPropagation();
},
mouseover : function() {
svg.select( "rect.selection").empty() && d3.select( this).classed( "hover", true);
// http://stackoverflow.com/questions/9956958/changing-the-position-of-bootstrap-popovers-based-on-the-popovers-x-position-in
// http://bl.ocks.org/zmaril/3012212
// $( this).popover( "show");
},
mouseout : function() {
svg.select( "rect.selection").empty() && d3.select( this).classed( "hover", false);
//$( this).popover( "hide");
}
});
;
gState.append( "circle")
.attr({
r : radius,
class : 'inner'
})
.on({
click : function( d, i) {
console.log( "state circle inner mousedown");
var e = d3.event,
g = this.parentNode,
isSelected = d3.select( g).classed( "selected");
if( !e.ctrlKey) {
d3.selectAll( 'g.selected').classed( "selected", false);
}
d3.select( g).classed( "selected", !isSelected);
// reappend dragged element as last
// so that its stays on top
g.parentNode.appendChild( g);
//d3.event.stopPropagation();
},
mouseover : function() {
svg.select( "rect.selection").empty() && d3.select( this).classed( "hover", true);
},
mouseout : function() {
svg.select( "rect.selection").empty() && d3.select( this).classed( "hover", false);
},
dblclick : function() {
console.log( "state circle outer dblclick");
var d = d3.select( this.parentNode).datum();
var index = states.indexOf( d);
states.splice( index, 1);
// remove transitions targeting the removed state
states.forEach( function( state) {
state.transitions.forEach( function( transition, index) {
if( transition.target===d) {
state.transitions.splice( index, 1);
}
});
});
//console.log( "removed state " + d.label);
//d3.select( this.parentNode).remove();
update();
}
});
;
gState.append( "text")
.attr({
'text-anchor' : 'middle',
y : 4
})
.text( function( d) {
return d.label;
})
;
gState.append( "title")
.text( function( d) {
return d.label;
})
;
gStates.exit().remove();
};
var startState, endState;
var drag = d3.behavior.drag()
.on("drag", function( d, i) {
console.log( "drag");
if( startState) {
return;
}
var selection = d3.selectAll( '.selected');
// if dragged state is not in current selection
// mark it selected and deselect all others
if( selection[0].indexOf( this)==-1) {
selection.classed( "selected", false);
selection = d3.select( this);
selection.classed( "selected", true);
}
// move states
selection.attr("transform", function( d, i) {
d.x += d3.event.dx;
d.y += d3.event.dy;
return "translate(" + [ d.x,d.y ] + ")"
});
// move transistion points of each transition
// where transition target is also in selection
var selectedStates = d3.selectAll( 'g.state.selected').data();
var affectedTransitions = selectedStates.reduce( function( array, state) {
return array.concat( state.transitions);
}, [])
.filter( function( transition) {
return selectedStates.indexOf( transition.target)!=-1;
});
affectedTransitions.forEach( function( transition) {
for( var i = transition.points.length - 1; i >= 0; i--) {
var point = transition.points[i];
point.x += d3.event.dx;
point.y += d3.event.dy;
}
});
// reappend dragged element as last
// so that its stays on top
selection.each( function() {
this.parentNode.appendChild( this);
});
// refresh transition path
gTransitions.selectAll( "path").attr( 'd', computeTransitionPath);
// refresh transition endpoints
gTransitions.selectAll( "circle.endpoint").attr({
transform : transformTransitionEndpoints
});
// refresh transition points
gTransitions.selectAll( "circle.point").attr({
transform : transformTransitionPoints
});
d3.event.sourceEvent.stopPropagation();
})
.on( "dragend", function( d) {
console.log( "dragend");
// TODO : http://stackoverflow.com/questions/14667401/click-event-not-firing-after-drag-sometimes-in-d3-js
// needed by FF
drag_line
.classed('hidden', true)
.style('marker-end', '')
;
if( startState && endState) {
startState.transitions.push( { label : "transition label 1", points : [], target : endState});
update();
}
startState = undefined;
d3.event.sourceEvent.stopPropagation();
});
svg.on({
mousedown : function() {
console.log( "mousedown", d3.event.target);
if( d3.event.target.tagName=='svg') {
if( !d3.event.ctrlKey) {
d3.selectAll( 'g.selected').classed( "selected", false);
}
var p = d3.mouse( this);
svg.append( "rect")
.attr({
rx : 6,
ry : 6,
class : "selection",
x : p[0],
y : p[1],
width : 0,
height : 0
});
}
},
mousemove : function() {
//console.log( "mousemove");
var p = d3.mouse( this),
s = svg.select( "rect.selection");
if( !s.empty()) {
var d = {
x : parseInt( s.attr( "x"), 10),
y : parseInt( s.attr( "y"), 10),
width : parseInt( s.attr( "width"), 10),
height : parseInt( s.attr( "height"), 10)
},
move = {
x : p[0] - d.x,
y : p[1] - d.y
}
;
if( move.x < 1 || (move.x*2<d.width)) {
d.x = p[0];
d.width -= move.x;
} else {
d.width = move.x;
}
if( move.y < 1 || (move.y*2<d.height)) {
d.y = p[1];
d.height -= move.y;
} else {
d.height = move.y;
}
s.attr( d);
// deselect all temporary selected state objects
d3.selectAll( 'g.state.selection.selected').classed( "selected", false);
d3.selectAll( 'g.state >circle.inner').each( function( state_data, i) {
if(
!d3.select( this).classed( "selected") &&
// inner circle inside selection frame
state_data.x-radius>=d.x && state_data.x+radius<=d.x+d.width &&
state_data.y-radius>=d.y && state_data.y+radius<=d.y+d.height
) {
d3.select( this.parentNode)
.classed( "selection", true)
.classed( "selected", true);
}
});
} else if( startState) {
// update drag line
drag_line.attr('d', 'M' + startState.x + ',' + startState.y + 'L' + p[0] + ',' + p[1]);
var state = d3.select( 'g.state .inner.hover');
endState = (!state.empty() && state.data()[0]) || undefined;
}
},
mouseup : function() {
console.log( "mouseup");
// remove selection frame
svg.selectAll( "rect.selection").remove();
// remove temporary selection marker class
d3.selectAll( 'g.state.selection').classed( "selection", false);
},
mouseout : function() {
if( !d3.event.relatedTarget || d3.event.relatedTarget.tagName=='HTML') {
// remove selection frame
svg.selectAll( "rect.selection").remove();
// remove temporary selection marker class
d3.selectAll( 'g.state.selection').classed( "selection", false);
}
},
dblclick : function() {
console.log( "dblclick");
var p = d3.mouse( this);
if( d3.event.target.tagName=='svg') {
states.push( { x : p[0], y : p[1], label : "tst", transitions : [] });
update();
}
}
});
update();
function update() {
gStates = gStates.data( states, function( d) {
return states.indexOf( d);
});
renderStates();
var _transitions = transitions();
gTransitions = gTransitions.data( _transitions, function( d) {
return _transitions.indexOf( d);
});
renderTransitions();
};
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>d3.js selection frame example</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
</body>
<script src="http://d3js.org/d3.v3.min.js"></script>
<script src="app.js"></script>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment