UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering.

267 lines (213 loc) 6.2 kB
import { Point } from '../geometry' import { Events } from '../common' import { KeyValue } from '../types' import { NumberExt } from '../util' import { Cell } from '../model/cell' import { Node } from '../model/node' import { Edge } from '../model/edge' import { Model } from '../model/model' export class ForceDirected extends Events { t: number energy: number progress: number public readonly options: ForceDirected.Options protected edges: Edge[] protected nodes: Node[] protected nodeData: KeyValue<ForceDirected.NodeData> protected edgeData: KeyValue<ForceDirected.EdgeData> get model() { return this.options.model } constructor(options: ForceDirected.Options) { super() this.options = { charge: 10, edgeDistance: 10, edgeStrength: 1, ...options, } this.nodes = this.model.getNodes() this.edges = this.model.getEdges() this.t = 1 this.energy = Infinity this.progress = 0 } start() { const x = this.options.x const y = this.options.y const width = this.options.width const height = this.options.height this.nodeData = {} this.edgeData = {} this.nodes.forEach((node) => { const posX = NumberExt.random(x, x + width) const posY = NumberExt.random(y, y + height) node.position(posX, posY, { forceDirected: true }) this.nodeData[node.id] = { charge: node.prop('charge') || this.options.charge, weight: node.prop('weight') || 1, x: posX, y: posY, px: posX, py: posY, fx: 0, fy: 0, } }) this.edges.forEach((edge) => { this.edgeData[edge.id] = { source: edge.getSourceCell()!, target: edge.getTargetCell()!, strength: edge.prop('strength') || this.options.edgeStrength, distance: edge.prop('distance') || this.options.edgeDistance, } }) } step() { if (0.99 * this.t < 0.005) { return this.notifyEnd() } this.energy = 0 let xBefore = 0 let yBefore = 0 let xAfter = 0 let yAfter = 0 const nodeCount = this.nodes.length const edgeCount = this.edges.length for (let i = 0; i < nodeCount - 1; i += 1) { const v = this.nodeData[this.nodes[i].id] xBefore += v.x yBefore += v.y for (let j = i + 1; j < nodeCount; j += 1) { const u = this.nodeData[this.nodes[j].id] const dx = u.x - v.x const dy = u.y - v.y const distanceSquared = dx * dx + dy * dy // const distance = Math.sqrt(distanceSquared) const fr = (this.t * v.charge) / distanceSquared const fx = fr * dx const fy = fr * dy v.fx -= fx v.fy -= fy u.fx += fx u.fy += fy this.energy += fx * fx + fy * fy } } // Add the last node positions as it couldn't be done in the loops above. const last = this.nodeData[this.nodes[nodeCount - 1].id] xBefore += last.x yBefore += last.y // Calculate attractive forces. for (let i = 0; i < edgeCount; i += 1) { const a = this.edgeData[this.edges[i].id] const v = this.nodeData[a.source.id] const u = this.nodeData[a.target.id] const dx = u.x - v.x const dy = u.y - v.y const distanceSquared = dx * dx + dy * dy const distance = Math.sqrt(distanceSquared) const fa = (this.t * a.strength * (distance - a.distance)) / distance const fx = fa * dx const fy = fa * dy const k = v.weight / (v.weight + u.weight) v.x += fx * (1 - k) v.y += fy * (1 - k) u.x -= fx * k u.y -= fy * k this.energy += fx * fx + fy * fy } const x = this.options.x const y = this.options.y const w = this.options.width const h = this.options.height const gravityCenter = this.options.gravityCenter const gravity = 0.1 const energyBefore = this.energy // Set positions on nodes. for (let i = 0; i < nodeCount; i += 1) { const node = this.nodes[i] const data = this.nodeData[node.id] const pos = { x: data.x, y: data.y, } if (gravityCenter) { pos.x += (gravityCenter.x - pos.x) * this.t * gravity pos.y += (gravityCenter.y - pos.y) * this.t * gravity } pos.x += data.fx pos.y += data.fy // Make sure positions don't go out of the graph area. pos.x = Math.max(x, Math.min(x + w, pos.x)) pos.y = Math.max(y, Math.min(x + h, pos.y)) // Position Verlet integration. const friction = 0.9 pos.x += friction * (data.px - pos.x) pos.y += friction * (data.py - pos.y) data.px = pos.x data.py = pos.y data.fx = 0 data.fy = 0 data.x = pos.x data.y = pos.y xAfter += data.x yAfter += data.y node.setPosition(pos, { forceDirected: true }) } this.t = this.cool(this.t, this.energy, energyBefore) // If the global distance hasn't change much, the layout converged // and therefore trigger the `end` event. const gdx = xBefore - xAfter const gdy = yBefore - yAfter const gd = Math.sqrt(gdx * gdx + gdy * gdy) if (gd < 1) { this.notifyEnd() } } protected cool(t: number, energy: number, energyBefore: number) { if (energy < energyBefore) { this.progress += 1 if (this.progress >= 5) { this.progress = 0 return t / 0.99 // Warm up. } } else { this.progress = 0 return t * 0.99 // Cool down. } return t // Keep the same temperature. } protected notifyEnd() { this.trigger('end') } } export namespace ForceDirected { export interface Options { model: Model x: number y: number width: number height: number charge?: number edgeDistance?: number edgeStrength?: number gravityCenter?: Point.PointLike } export interface NodeData { x: number y: number px: number py: number fx: number fy: number charge: number weight: number } export interface EdgeData { source: Cell target: Cell strength: number distance: number } }