Published
Edited
Aug 9, 2019
Insert cell
Insert cell
Insert cell
params = html`<form>
<div><input type=range name=SPRING_K min=100 max=10000 step=100 value=1000> <i>spring factor</i></div>
<div><input type=range name=REPULSE_K min=100000 max=1000000 step=100000 value=200000> <i>repulse factor</i></div>
<div><input type=range name=CENTRIPETAL_K min=10 max=200 step=10 value=100> <i>centripetal factor</i></div>
</form>`
Insert cell
SPRING_K = Generators.input(params.SPRING_K); // spring stiffness factor
Insert cell
REPULSE_K = Generators.input(params.REPULSE_K); // repulse force factor
Insert cell
CENTRIPETAL_K = Generators.input(params.CENTRIPETAL_K); // centripetal force factor
Insert cell
INIT_RADIUS = 100;
Insert cell
TIME_INTERVAL = 1; // time interval of tick
Insert cell
DEFALUT_EDGE_LEN = 0; // rest length of spring
Insert cell
D_T = 0.001; // delta time
Insert cell
MASS = 10; // mass value of nodes
Insert cell
VELOCITY_DECAY = 0.99; // decay of velocity of each iteration
Insert cell
class ForceSimulation {
constructor(nodes, links, enableWeightLink) {
this.nodes = nodes;
this.links = links;
this.tickFunc = () => { };
this.hasWeightLink = enableWeightLink;

this._init();
}

onTick(callback) {
this.tickFunc = callback;
}

start() {
this.timer = setInterval(() => {
this.step();
}, TIME_INTERVAL);
}

stop() {
// TODO: to be implemented
clearInterval(this.timer);
}

step() {
this._computeVelocities();
this._computePositions();
this.tickFunc();
}

_init() {
const id2Node = new Map();
this.nodes.forEach(node => {
node.x = this._randomRange(-INIT_RADIUS, INIT_RADIUS);
node.y = this._randomRange(-INIT_RADIUS, INIT_RADIUS);
node.vx = 0;
node.vy = 0;
id2Node[node.id] = node;
});

this.links.forEach(link => {
link.source = id2Node[link.source];
link.target = id2Node[link.target];
});
}

_randomRange(x, y) {
return Math.random() * (y - x) + x;
}

_lenTwoNodes(source, target) {
return Math.sqrt((source.x - target.x) ** 2 + (source.y - target.y) ** 2);
}

_normalTwoNodes(source, target) {
const len = this._lenTwoNodes(source, target);
return {
x: (target.x - source.x) / len,
y: (target.y - source.y) / len,
};
}

_lenLink(link) {
return this._lenTwoNodes(link.source, link.target);
}

_normalLink(link) {
return this._normalTwoNodes(link.source, link.target);
}

_computeSpring(link, hasWeight = false) {
const len = this._lenLink(link);
const normal = this._normalLink(link);
const f = (len - DEFALUT_EDGE_LEN) * SPRING_K;

// NOTE: weight method, linear or log
// const weightFactor = hasWeight ? 1 + Math.log(link.value) : 1;
const weightFactor = hasWeight ? link.value : 1;
return {
x: f * normal.x * weightFactor,
y: f * normal.y * weightFactor,
}
}

_computeRepulse(center) {
const f = { x: 0, y: 0 };

this.nodes.forEach(node => {
if (node !== center) {
const fv = REPULSE_K * MASS * MASS / this._lenTwoNodes(center, node) ** 2;
const normal = this._normalTwoNodes(node, center);
f.x += fv * normal.x;
f.y += fv * normal.y;
}
})

return f;
}

_computeCentripetal(node) {
const f = { x: 0, y: 0 };
const center = { x: 0, y: 0 }; // TODO: default center is zero
const fv = CENTRIPETAL_K * this._lenTwoNodes(node, center);
const normal = this._normalTwoNodes(node, center);
f.x += fv * normal.x;
f.y += fv * normal.y;
return f;
}

_computeVelocities() {
const repulseForceList = {}
this.nodes.forEach( node => {
repulseForceList[node.id] = this._computeRepulse(node)
});

const centripetalForceList = {}
this.nodes.forEach( node => {
centripetalForceList[node.id] = this._computeCentripetal(node)
});
this.links.forEach(link => {
const springForce = this._computeSpring(link, this.hasWeightLink);
const repulseForceSource = repulseForceList[link.source.id];
const repulseForceTarget = repulseForceList[link.target.id];
const centripetalForceSource = centripetalForceList[link.source.id];
const centripetalForceTarget = centripetalForceList[link.target.id];

const fSource = {
x: springForce.x + repulseForceSource.x + centripetalForceSource.x,
y: springForce.y + repulseForceSource.y + centripetalForceSource.y,
};
const fTarget = {
x: -springForce.x + repulseForceTarget.x + centripetalForceTarget.x,
y: -springForce.y + repulseForceTarget.y + centripetalForceTarget.y,
};

// force to velocity, explicit Euler method
link.source.vx += fSource.x / MASS * D_T;
link.source.vy += fSource.y / MASS * D_T;
link.target.vx += fTarget.x / MASS * D_T;
link.target.vy += fTarget.y / MASS * D_T;

// NOTE: speed decay, maybe not proper
link.source.vx *= VELOCITY_DECAY;
link.source.vy *= VELOCITY_DECAY;
link.target.vx *= VELOCITY_DECAY;
link.target.vy *= VELOCITY_DECAY;
});
}

_computePositions() {
this.nodes.forEach(node => {
node.x += node.vx * D_T;
node.y += node.vy * D_T;
});
}
}
Insert cell
data = d3.json("https://gist.githubusercontent.com/mbostock/4062045/raw/5916d145c8c048a6e3086915a6be464467391c62/miserables.json")
Insert cell
height = 600
Insert cell
color = {
const scale = d3.scaleOrdinal(d3.schemeCategory10);
return d => scale(d.group);
}
Insert cell
d3 = require("d3@5")
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more