public/navigator/js-mindmap.js
/*
js-mindmap
Copyright (c) 2008/09/10 Kenneth Kufluk http://kenneth.kufluk.com/
MIT (X11) license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/*
Things to do:
- remove Lines - NO - they seem harmless enough!
- add better "make active" methods
- remove the "root node" concept. Tie nodes to elements better, so we can check if a parent element is root
- allow progressive exploration
- allow easy supplying of an ajax param for loading new kids and a loader anim
- allow easy exploration of a ul or ol to find nodes
- limit to an area
- allow more content (div instead of an a)
- test multiple canvases
- Hidden children should not be bounded
- Layout children in circles
- Add/Edit nodes
- Resize event
- incorporate widths into the forces, so left boundaries push on right boundaries
Make demos:
- amazon explore
- directgov explore
- thesaurus
- themes
*/
(function ($) {
'use strict';
var TIMEOUT = 4, // movement timeout in seconds
CENTRE_FORCE = 3, // strength of attraction to the centre by the active node
Node,
Line;
// Define all Node related functions.
Node = function (obj, name, parent, opts) {
this.obj = obj;
this.options = obj.options;
this.name = name;
this.href = opts.href;
if (opts.url) {
this.url = opts.url;
}
// create the element for display
this.el = $('<a href="' + this.href + '">' + this.name + '</a>').addClass('node');
$('body').prepend(this.el);
if (!parent) {
obj.activeNode = this;
this.el.addClass('active root');
} else {
obj.lines[obj.lines.length] = new Line(obj, this, parent);
}
this.parent = parent;
this.children = [];
if (this.parent) {
this.parent.children.push(this);
}
// animation handling
this.moving = false;
this.moveTimer = 0;
this.obj.movementStopped = false;
this.visible = true;
this.x = 1;
this.y = 1;
this.dx = 0;
this.dy = 0;
this.hasPosition = false;
this.content = []; // array of content elements to display onclick;
this.el.css('position', 'absolute');
var thisnode = this;
this.el.draggable({
drag: function () {
obj.root.animateToStatic();
}
});
this.el.click(function () {
if (obj.activeNode) {
obj.activeNode.el.removeClass('active');
if (obj.activeNode.parent) {
obj.activeNode.parent.el.removeClass('activeparent');
}
}
if (typeof opts.onclick === 'function') {
opts.onclick(thisnode);
}
obj.activeNode = thisnode;
obj.activeNode.el.addClass('active');
if (obj.activeNode.parent) {
obj.activeNode.parent.el.addClass('activeparent');
}
obj.root.animateToStatic();
return false;
});
};
// ROOT NODE ONLY: control animation loop
Node.prototype.animateToStatic = function () {
clearTimeout(this.moveTimer);
// stop the movement after a certain time
var thisnode = this;
this.moveTimer = setTimeout(function () {
//stop the movement
thisnode.obj.movementStopped = true;
}, TIMEOUT * 1000);
if (this.moving) {
return;
}
this.moving = true;
this.obj.movementStopped = false;
this.animateLoop();
};
// ROOT NODE ONLY: animate all nodes (calls itself recursively)
Node.prototype.animateLoop = function () {
var i, len, mynode = this;
this.obj.canvas.clear();
for (i = 0, len = this.obj.lines.length; i < len; i++) {
this.obj.lines[i].updatePosition();
}
if (this.findEquilibrium() || this.obj.movementStopped) {
this.moving = false;
return;
}
setTimeout(function () {
mynode.animateLoop();
}, 10);
};
// find the right position for this node
Node.prototype.findEquilibrium = function () {
var i, len, stable = true;
stable = this.display() && stable;
for (i = 0, len = this.children.length; i < len; i++) {
stable = this.children[i].findEquilibrium() && stable;
}
return stable;
};
//Display this node, and its children
Node.prototype.display = function (depth) {
var parent = this,
stepAngle,
angle;
depth = depth || 0;
if (this.visible) {
// if: I'm not active AND my parent's not active AND my children aren't active ...
if (this.obj.activeNode !== this && this.obj.activeNode !== this.parent && this.obj.activeNode.parent !== this) {
// TODO hide me!
this.el.hide();
this.visible = false;
}
} else {
if (this.obj.activeNode === this || this.obj.activeNode === this.parent || this.obj.activeNode.parent === this) {
this.el.show();
this.visible = true;
}
}
this.drawn = true;
// am I positioned? If not, position me.
if (!this.hasPosition) {
this.x = this.options.mapArea.x / 2;
this.y = this.options.mapArea.y / 2;
this.el.css({'left': this.x + "px", 'top': this.y + "px"});
this.hasPosition = true;
}
// are my children positioned? if not, lay out my children around me
stepAngle = Math.PI * 2 / this.children.length;
$.each(this.children, function (index) {
if (!this.hasPosition) {
if (!this.options.showProgressive || depth <= 1) {
angle = index * stepAngle;
this.x = (50 * Math.cos(angle)) + parent.x;
this.y = (50 * Math.sin(angle)) + parent.y;
this.hasPosition = true;
this.el.css({'left': this.x + "px", 'top': this.y + "px"});
}
}
});
// update my position
return this.updatePosition();
};
// updatePosition returns a boolean stating whether it's been static
Node.prototype.updatePosition = function () {
var forces, showx, showy;
if (this.el.hasClass("ui-draggable-dragging")) {
this.x = parseInt(this.el.css('left'), 10) + (this.el.width() / 2);
this.y = parseInt(this.el.css('top'), 10) + (this.el.height() / 2);
this.dx = 0;
this.dy = 0;
return false;
}
//apply accelerations
forces = this.getForceVector();
this.dx += forces.x * this.options.timeperiod;
this.dy += forces.y * this.options.timeperiod;
// damp the forces
this.dx = this.dx * this.options.damping;
this.dy = this.dy * this.options.damping;
//ADD MINIMUM SPEEDS
if (Math.abs(this.dx) < this.options.minSpeed) {
this.dx = 0;
}
if (Math.abs(this.dy) < this.options.minSpeed) {
this.dy = 0;
}
if (Math.abs(this.dx) + Math.abs(this.dy) === 0) {
return true;
}
//apply velocity vector
this.x += this.dx * this.options.timeperiod;
this.y += this.dy * this.options.timeperiod;
this.x = Math.min(this.options.mapArea.x, Math.max(1, this.x));
this.y = Math.min(this.options.mapArea.y, Math.max(1, this.y));
// display
showx = this.x - (this.el.width() / 2);
showy = this.y - (this.el.height() / 2) - 10;
this.el.css({'left': showx + "px", 'top': showy + "px"});
return false;
};
Node.prototype.getForceVector = function () {
var i, x1, y1, xsign, dist, theta, f,
xdist, rightdist, bottomdist, otherend,
fx = 0,
fy = 0,
nodes = this.obj.nodes,
lines = this.obj.lines;
// Calculate the repulsive force from every other node
for (i = 0; i < nodes.length; i++) {
if (nodes[i] === this) {
continue;
}
if (!nodes[i].visible) {
continue;
}
// Repulsive force (coulomb's law)
x1 = (nodes[i].x - this.x);
y1 = (nodes[i].y - this.y);
//adjust for variable node size
// var nodewidths = (($(nodes[i]).width() + this.el.width())/2);
dist = Math.sqrt((x1 * x1) + (y1 * y1));
// var myrepulse = this.options.repulse;
// if (this.parent==nodes[i]) myrepulse=myrepulse*10; //parents stand further away
if (Math.abs(dist) < 500) {
if (x1 === 0) {
theta = Math.PI / 2;
xsign = 0;
} else {
theta = Math.atan(y1 / x1);
xsign = x1 / Math.abs(x1);
}
// force is based on radial distance
f = (this.options.repulse * 500) / (dist * dist);
fx += -f * Math.cos(theta) * xsign;
fy += -f * Math.sin(theta) * xsign;
}
}
// add repulsive force of the "walls"
//left wall
xdist = this.x + this.el.width();
f = (this.options.wallrepulse * 500) / (xdist * xdist);
fx += Math.min(2, f);
//right wall
rightdist = (this.options.mapArea.x - xdist);
f = -(this.options.wallrepulse * 500) / (rightdist * rightdist);
fx += Math.max(-2, f);
//top wall
f = (this.options.wallrepulse * 500) / (this.y * this.y);
fy += Math.min(2, f);
//bottom wall
bottomdist = (this.options.mapArea.y - this.y);
f = -(this.options.wallrepulse * 500) / (bottomdist * bottomdist);
fy += Math.max(-2, f);
// for each line, of which I'm a part, add an attractive force.
for (i = 0; i < lines.length; i++) {
otherend = null;
if (lines[i].start === this) {
otherend = lines[i].end;
} else if (lines[i].end === this) {
otherend = lines[i].start;
} else {
continue;
}
// Ignore the pull of hidden nodes
if (!otherend.visible) {
continue;
}
// Attractive force (hooke's law)
x1 = (otherend.x - this.x);
y1 = (otherend.y - this.y);
dist = Math.sqrt((x1 * x1) + (y1 * y1));
if (Math.abs(dist) > 0) {
if (x1 === 0) {
theta = Math.PI / 2;
xsign = 0;
}
else {
theta = Math.atan(y1 / x1);
xsign = x1 / Math.abs(x1);
}
// force is based on radial distance
f = (this.options.attract * dist) / 10000;
fx += f * Math.cos(theta) * xsign;
fy += f * Math.sin(theta) * xsign;
}
}
// if I'm active, attract me to the centre of the area
if (this.obj.activeNode === this) {
// Attractive force (hooke's law)
otherend = this.options.mapArea;
x1 = ((otherend.x / 2) - this.options.centreOffset - this.x);
y1 = ((otherend.y / 2) - this.y);
dist = Math.sqrt((x1 * x1) + (y1 * y1));
if (Math.abs(dist) > 0) {
if (x1 === 0) {
theta = Math.PI / 2;
xsign = 0;
} else {
xsign = x1 / Math.abs(x1);
theta = Math.atan(y1 / x1);
}
// force is based on radial distance
f = (0.1 * this.options.attract * dist * CENTRE_FORCE) / 1000;
fx += f * Math.cos(theta) * xsign;
fy += f * Math.sin(theta) * xsign;
}
}
if (Math.abs(fx) > this.options.maxForce) {
fx = this.options.maxForce * (fx / Math.abs(fx));
}
if (Math.abs(fy) > this.options.maxForce) {
fy = this.options.maxForce * (fy / Math.abs(fy));
}
return {
x: fx,
y: fy
};
};
Node.prototype.removeNode = function () {
var i,
oldnodes = this.obj.nodes,
oldlines = this.obj.lines;
for (i = 0; i < this.children.length; i++) {
this.children[i].removeNode();
}
this.obj.nodes = [];
for (i = 0; i < oldnodes.length; i++) {
if (oldnodes[i] === this) {
continue;
}
this.obj.nodes.push(oldnodes[i]);
}
this.obj.lines = [];
for (i = 0; i < oldlines.length; i++) {
if (oldlines[i].start === this) {
continue;
} else if (oldlines[i].end === this) {
continue;
}
this.obj.lines.push(oldlines[i]);
}
this.el.remove();
};
// Define all Line related functions.
Line = function (obj, startNode, endNode) {
this.obj = obj;
this.options = obj.options;
this.start = startNode;
this.colour = "blue";
this.size = "thick";
this.end = endNode;
};
Line.prototype.updatePosition = function () {
if (!this.options.showSublines && (!this.start.visible || !this.end.visible)) {
return;
}
this.size = (this.start.visible && this.end.visible) ? "thick" : "thin";
this.color = (this.obj.activeNode.parent === this.start || this.obj.activeNode.parent === this.end) ? "red" : "blue";
this.strokeStyle = "#FFF";
this.obj.canvas.path("M" + this.start.x + ' ' + this.start.y + "L" + this.end.x + ' ' + this.end.y).attr({'stroke': this.strokeStyle, 'opacity': 0.2, 'stroke-width': '5px'});
};
$.fn.addNode = function (parent, name, options) {
var obj = this[0],
node = obj.nodes[obj.nodes.length] = new Node(obj, name, parent, options);
console.log(obj.root);
obj.root.animateToStatic();
return node;
};
$.fn.addRootNode = function (name, opts) {
var node = this[0].nodes[0] = new Node(this[0], name, null, opts);
this[0].root = node;
return node;
};
$.fn.removeNode = function (name) {
return this.each(function () {
// if (!!this.mindmapInit) return false;
//remove a node matching the anme
// alert(name+' removed');
});
};
$.fn.mindmap = function (options) {
// Define default settings.
options = $.extend({
attract: 15,
repulse: 6,
damping: 0.55,
timeperiod: 10,
wallrepulse: 0.4,
mapArea: {
x: -1,
y: -1
},
canvasError: 'alert',
minSpeed: 0.05,
maxForce: 0.1,
showSublines: false,
updateIterationCount: 20,
showProgressive: true,
centreOffset: 100,
timer: 0
}, options);
var $window = $(window);
return this.each(function () {
var mindmap = this;
this.mindmapInit = true;
this.nodes = [];
this.lines = [];
this.activeNode = null;
this.options = options;
this.animateToStatic = function () {
this.root.animateToStatic();
};
$window.resize(function () {
mindmap.animateToStatic();
});
//canvas
if (options.mapArea.x === -1) {
options.mapArea.x = $window.width();
}
if (options.mapArea.y === -1) {
options.mapArea.y = $window.height();
}
//create drawing area
this.canvas = Raphael(0, 0, options.mapArea.x, options.mapArea.y);
// Add a class to the object, so that styles can be applied
$(this).addClass('js-mindmap-active');
// Add keyboard support (thanks to wadefs)
$(this).keyup(function (event) {
var newNode, i, activeParent = mindmap.activeNode.parent;
switch (event.which) {
case 33: // PgUp
case 38: // Up, move to parent
if (activeParent) {
activeParent.el.click();
}
break;
case 13: // Enter (change to insert a sibling)
case 34: // PgDn
case 40: // Down, move to first child
if (mindmap.activeNode.children.length) {
mindmap.activeNode.children[0].el.click();
}
break;
case 37: // Left, move to previous sibling
if (activeParent) {
newNode = null;
if (activeParent.children[0] === mindmap.activeNode) {
newNode = activeParent.children[activeParent.children.length - 1];
} else {
for (i = 1; i < activeParent.children.length; i++) {
if (activeParent.children[i] === mindmap.activeNode) {
newNode = activeParent.children[i - 1];
}
}
}
if (newNode) {
newNode.el.click();
}
}
break;
case 39: // Right, move to next sibling
if (activeParent) {
newNode = null;
if (activeParent.children[activeParent.children.length - 1] === mindmap.activeNode) {
newNode = activeParent.children[0];
} else {
for (i = activeParent.children.length - 2; i >= 0; i--) {
if (activeParent.children[i] === mindmap.activeNode) {
newNode = activeParent.children[i + 1];
}
}
}
if (newNode) {
newNode.el.click();
}
}
break;
case 45: // Ins, insert a child
break;
case 46: // Del, delete this node
break;
case 27: // Esc, cancel insert
break;
case 83: // 'S', save
break;
}
return false;
});
});
};
}(jQuery));
/*jslint devel: true, browser: true, continue: true, plusplus: true, indent: 2 */