UNPKG

vis-network

Version:

A dynamic, browser-based visualization library.

914 lines (845 loc) 26.5 kB
import { overrideOpacity } from "vis-util"; import { EndPoints } from "./end-points"; import { ArrowData, ArrowDataWithCore, ArrowType, EdgeFormattingValues, EdgeType, Id, Label, EdgeOptions, Point, PointT, SelectiveRequired, VBody, VNode } from "./types"; export interface FindBorderPositionOptions<Via> { via: Via; } export interface FindBorderPositionCircleOptions { x: number; y: number; low: number; high: number; direction: number; } /** * The Base Class for all edges. */ export abstract class EdgeBase<Via = undefined> implements EdgeType { public from!: VNode; // Initialized in setOptions public fromPoint: Point; public to!: VNode; // Initialized in setOptions public toPoint: Point; public via?: VNode; public color: unknown = {}; public colorDirty: boolean = true; public id!: Id; // Initialized in setOptions public options!: EdgeOptions; // Initialized in setOptions public hoverWidth: number = 1.5; public selectionWidth: number = 2; /** * Create a new instance. * * @param options - The options object of given edge. * @param _body - The body of the network. * @param _labelModule - Label module. */ public constructor( options: EdgeOptions, protected _body: VBody, protected _labelModule: Label ) { this.setOptions(options); this.fromPoint = this.from; this.toPoint = this.to; } /** * Find the intersection between the border of the node and the edge. * * @param node - The node (either from or to node of the edge). * @param ctx - The context that will be used for rendering. * @param options - Additional options. * * @returns Cartesian coordinates of the intersection between the border of the node and the edge. */ protected abstract _findBorderPosition( node: VNode, ctx: CanvasRenderingContext2D, options?: FindBorderPositionOptions<Via> ): PointT; /** * Return additional point(s) the edge passes through. * * @returns Cartesian coordinates of the point(s) the edge passes through. */ public abstract getViaNode(): Via; /** @inheritdoc */ public abstract getPoint(position: number, viaNode?: Via): Point; /** @inheritdoc */ public connect(): void { this.from = this._body.nodes[this.options.from]; this.to = this._body.nodes[this.options.to]; } /** @inheritdoc */ public cleanup(): boolean { return false; } /** * Set new edge options. * * @param options - The new edge options object. */ public setOptions(options: EdgeOptions): void { this.options = options; this.from = this._body.nodes[this.options.from]; this.to = this._body.nodes[this.options.to]; this.id = this.options.id; } /** @inheritdoc */ public drawLine( ctx: CanvasRenderingContext2D, values: SelectiveRequired< EdgeFormattingValues, | "color" | "opacity" | "shadowColor" | "shadowSize" | "shadowX" | "shadowY" | "width" >, _selected?: boolean, _hover?: boolean, viaNode: Via = this.getViaNode() ): void { // set style ctx.strokeStyle = this.getColor(ctx, values); ctx.lineWidth = values.width; if (values.dashes !== false) { this._drawDashedLine(ctx, values, viaNode); } else { this._drawLine(ctx, values, viaNode); } } /** * Draw a line with given style between two nodes through supplied node(s). * * @param ctx - The context that will be used for rendering. * @param values - Formatting values like color, opacity or shadow. * @param viaNode - Additional control point(s) for the edge. * @param fromPoint - TODO: Seems ignored, remove? * @param toPoint - TODO: Seems ignored, remove? */ private _drawLine( ctx: CanvasRenderingContext2D, values: SelectiveRequired< EdgeFormattingValues, "shadowColor" | "shadowSize" | "shadowX" | "shadowY" >, viaNode: Via, fromPoint?: Point, toPoint?: Point ): void { if (this.from != this.to) { // draw line this._line(ctx, values, viaNode, fromPoint, toPoint); } else { const [x, y, radius] = this._getCircleData(ctx); this._circle(ctx, values, x, y, radius); } } /** * Draw a dashed line with given style between two nodes through supplied node(s). * * @param ctx - The context that will be used for rendering. * @param values - Formatting values like color, opacity or shadow. * @param viaNode - Additional control point(s) for the edge. * @param _fromPoint - Ignored (TODO: remove in the future). * @param _toPoint - Ignored (TODO: remove in the future). */ private _drawDashedLine( ctx: CanvasRenderingContext2D, values: SelectiveRequired< EdgeFormattingValues, "shadowColor" | "shadowSize" | "shadowX" | "shadowY" >, viaNode: Via, _fromPoint?: Point, _toPoint?: Point ): void { ctx.lineCap = "round"; const pattern = Array.isArray(values.dashes) ? values.dashes : [5, 5]; // only firefox and chrome support this method, else we use the legacy one. if (ctx.setLineDash !== undefined) { ctx.save(); // set dash settings for chrome or firefox ctx.setLineDash(pattern); ctx.lineDashOffset = 0; // draw the line if (this.from != this.to) { // draw line this._line(ctx, values, viaNode); } else { const [x, y, radius] = this._getCircleData(ctx); this._circle(ctx, values, x, y, radius); } // restore the dash settings. ctx.setLineDash([0]); ctx.lineDashOffset = 0; ctx.restore(); } else { // unsupporting smooth lines if (this.from != this.to) { // draw line ctx.dashedLine(this.from.x, this.from.y, this.to.x, this.to.y, pattern); } else { const [x, y, radius] = this._getCircleData(ctx); this._circle(ctx, values, x, y, radius); } // draw shadow if enabled this.enableShadow(ctx, values); ctx.stroke(); // disable shadows for other elements. this.disableShadow(ctx, values); } } /** * Draw a line with given style between two nodes through supplied node(s). * * @param ctx - The context that will be used for rendering. * @param values - Formatting values like color, opacity or shadow. * @param viaNode - Additional control point(s) for the edge. * @param fromPoint - TODO: Seems ignored, remove? * @param toPoint - TODO: Seems ignored, remove? */ protected abstract _line( ctx: CanvasRenderingContext2D, values: EdgeFormattingValues, viaNode: Via, fromPoint?: Point, toPoint?: Point ): void; /** * Find the intersection between the border of the node and the edge. * * @param node - The node (either from or to node of the edge). * @param ctx - The context that will be used for rendering. * @param options - Additional options. * * @returns Cartesian coordinates of the intersection between the border of the node and the edge. */ public findBorderPosition( node: VNode, ctx: CanvasRenderingContext2D, options?: FindBorderPositionOptions<Via> | FindBorderPositionCircleOptions ): PointT { if (this.from != this.to) { return this._findBorderPosition(node, ctx, options as any); } else { return this._findBorderPositionCircle(node, ctx, options as any); } } /** @inheritdoc */ public findBorderPositions( ctx: CanvasRenderingContext2D ): { from: Point; to: Point; } { if (this.from != this.to) { return { from: this._findBorderPosition(this.from, ctx), to: this._findBorderPosition(this.to, ctx) }; } else { const [x, y] = this._getCircleData(ctx).slice(0, 2); return { from: this._findBorderPositionCircle(this.from, ctx, { x, y, low: 0.25, high: 0.6, direction: -1 }), to: this._findBorderPositionCircle(this.from, ctx, { x, y, low: 0.6, high: 0.8, direction: 1 }) }; } } /** * Compute the center point and radius of an edge connected to the same node at both ends. * * @param ctx - The context that will be used for rendering. * * @returns `[x, y, radius]` */ protected _getCircleData( ctx?: CanvasRenderingContext2D ): [number, number, number] { let x: number; let y: number; const node = this.from; const radius = this.options.selfReferenceSize; if (ctx !== undefined) { if (node.shape.width === undefined) { node.shape.resize(ctx); } } // get circle coordinates if (node.shape.width > node.shape.height) { x = node.x + node.shape.width * 0.5; y = node.y - radius; } else { x = node.x + radius; y = node.y - node.shape.height * 0.5; } return [x, y, radius]; } /** * Get a point on a circle. * * @param x - Center of the circle on the x axis. * @param y - Center of the circle on the y axis. * @param radius - Radius of the circle. * @param position - Value between 0 (line start) and 1 (line end). * * @returns Cartesian coordinates of requested point on the circle. */ private _pointOnCircle( x: number, y: number, radius: number, position: number ): Point { const angle = position * 2 * Math.PI; return { x: x + radius * Math.cos(angle), y: y - radius * Math.sin(angle) }; } /** * Find the intersection between the border of the node and the edge. * * @remarks * This function uses binary search to look for the point where the circle crosses the border of the node. * * @param nearNode - The node (either from or to node of the edge). * @param ctx - The context that will be used for rendering. * @param options - Additional options. * * @returns Cartesian coordinates of the intersection between the border of the node and the edge. */ private _findBorderPositionCircle( nearNode: VNode, ctx: CanvasRenderingContext2D, options: FindBorderPositionCircleOptions ): PointT { const x = options.x; const y = options.y; let low = options.low; let high = options.high; const direction = options.direction; const maxIterations = 10; const radius = this.options.selfReferenceSize; const threshold = 0.05; let pos: Point; let middle = (low + high) * 0.5; let iteration = 0; do { middle = (low + high) * 0.5; pos = this._pointOnCircle(x, y, radius, middle); const angle = Math.atan2(nearNode.y - pos.y, nearNode.x - pos.x); const distanceToBorder = nearNode.distanceToBorder(ctx, angle); const distanceToPoint = Math.sqrt( Math.pow(pos.x - nearNode.x, 2) + Math.pow(pos.y - nearNode.y, 2) ); const difference = distanceToBorder - distanceToPoint; if (Math.abs(difference) < threshold) { break; // found } else if (difference > 0) { // distance to nodes is larger than distance to border --> t needs to be bigger if we're looking at the to node. if (direction > 0) { low = middle; } else { high = middle; } } else { if (direction > 0) { high = middle; } else { low = middle; } } ++iteration; } while (low <= high && iteration < maxIterations); return { ...pos, t: middle }; } /** * Get the line width of the edge. Depends on width and whether one of the connected nodes is selected. * * @param selected - Determines wheter the line is selected. * @param hover - Determines wheter the line is being hovered, only applies if selected is false. * * @returns The width of the line. */ public getLineWidth(selected: boolean, hover: boolean): number { if (selected === true) { return Math.max(this.selectionWidth, 0.3 / this._body.view.scale); } else if (hover === true) { return Math.max(this.hoverWidth, 0.3 / this._body.view.scale); } else { return Math.max(this.options.width, 0.3 / this._body.view.scale); } } /** * Compute the color or gradient for given edge. * * @param ctx - The context that will be used for rendering. * @param values - Formatting values like color, opacity or shadow. * @param _selected - Ignored (TODO: remove in the future). * @param _hover - Ignored (TODO: remove in the future). * * @returns Color string if single color is inherited or gradient if two. */ public getColor( ctx: CanvasRenderingContext2D, values: SelectiveRequired<EdgeFormattingValues, "color" | "opacity"> ): string | CanvasGradient { if (values.inheritsColor !== false) { // when this is a loop edge, just use the 'from' method if (values.inheritsColor === "both" && this.from.id !== this.to.id) { const grd = ctx.createLinearGradient( this.from.x, this.from.y, this.to.x, this.to.y ); let fromColor = this.from.options.color.highlight.border; let toColor = this.to.options.color.highlight.border; if (this.from.selected === false && this.to.selected === false) { fromColor = overrideOpacity( this.from.options.color.border, values.opacity ); toColor = overrideOpacity( this.to.options.color.border, values.opacity ); } else if (this.from.selected === true && this.to.selected === false) { toColor = this.to.options.color.border; } else if (this.from.selected === false && this.to.selected === true) { fromColor = this.from.options.color.border; } grd.addColorStop(0, fromColor); grd.addColorStop(1, toColor); // -------------------- this returns -------------------- // return grd; } if (values.inheritsColor === "to") { return overrideOpacity(this.to.options.color.border, values.opacity); } else { // "from" return overrideOpacity(this.from.options.color.border, values.opacity); } } else { return overrideOpacity(values.color, values.opacity); } } /** * Draw a line from a node to itself, a circle. * * @param ctx - The context that will be used for rendering. * @param values - Formatting values like color, opacity or shadow. * @param x - Center of the circle on the x axis. * @param y - Center of the circle on the y axis. * @param radius - Radius of the circle. */ private _circle( ctx: CanvasRenderingContext2D, values: SelectiveRequired< EdgeFormattingValues, "shadowColor" | "shadowSize" | "shadowX" | "shadowY" >, x: number, y: number, radius: number ): void { // draw shadow if enabled this.enableShadow(ctx, values); // draw a circle ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI, false); ctx.stroke(); // disable shadows for other elements. this.disableShadow(ctx, values); } /** * @inheritdoc * * @remarks * http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment */ public getDistanceToEdge( x1: number, y1: number, x2: number, y2: number, x3: number, y3: number ): number { if (this.from != this.to) { return this._getDistanceToEdge(x1, y1, x2, y2, x3, y3); } else { const [x, y, radius] = this._getCircleData(undefined); const dx = x - x3; const dy = y - y3; return Math.abs(Math.sqrt(dx * dx + dy * dy) - radius); } } /** * Calculate the distance between a point (x3, y3) and a line segment from (x1, y1) to (x2, y2). * * @remarks * http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment * * @param x1 - First end of the line segment on the x axis. * @param y1 - First end of the line segment on the y axis. * @param x2 - Second end of the line segment on the x axis. * @param y2 - Second end of the line segment on the y axis. * @param x3 - Position of the point on the x axis. * @param y3 - Position of the point on the y axis. * @param via - Additional control point(s) for the edge. * * @returns The distance between the line segment and the point. */ protected abstract _getDistanceToEdge( x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, via?: Via ): number; /** * Calculate the distance between a point (x3, y3) and a line segment from (x1, y1) to (x2, y2). * * @param x1 - First end of the line segment on the x axis. * @param y1 - First end of the line segment on the y axis. * @param x2 - Second end of the line segment on the x axis. * @param y2 - Second end of the line segment on the y axis. * @param x3 - Position of the point on the x axis. * @param y3 - Position of the point on the y axis. * * @returns The distance between the line segment and the point. */ protected _getDistanceToLine( x1: number, y1: number, x2: number, y2: number, x3: number, y3: number ): number { const px = x2 - x1; const py = y2 - y1; const something = px * px + py * py; let u = ((x3 - x1) * px + (y3 - y1) * py) / something; if (u > 1) { u = 1; } else if (u < 0) { u = 0; } const x = x1 + u * px; const y = y1 + u * py; const dx = x - x3; const dy = y - y3; //# Note: If the actual distance does not matter, //# if you only want to compare what this function //# returns to other results of this function, you //# can just return the squared distance instead //# (i.e. remove the sqrt) to gain a little performance return Math.sqrt(dx * dx + dy * dy); } /** @inheritdoc */ public getArrowData( ctx: CanvasRenderingContext2D, position: "middle", viaNode: VNode, selected: boolean, hover: boolean, values: SelectiveRequired< EdgeFormattingValues, "middleArrowType" | "middleArrowScale" | "width" > ): ArrowDataWithCore; /** @inheritdoc */ public getArrowData( ctx: CanvasRenderingContext2D, position: "to", viaNode: VNode, selected: boolean, hover: boolean, values: SelectiveRequired< EdgeFormattingValues, "toArrowType" | "toArrowScale" | "width" > ): ArrowDataWithCore; /** @inheritdoc */ public getArrowData( ctx: CanvasRenderingContext2D, position: "from", viaNode: VNode, selected: boolean, hover: boolean, values: SelectiveRequired< EdgeFormattingValues, "fromArrowType" | "fromArrowScale" | "width" > ): ArrowDataWithCore; /** @inheritdoc */ public getArrowData( ctx: CanvasRenderingContext2D, position: "from" | "to" | "middle", viaNode: VNode, _selected: boolean, _hover: boolean, values: SelectiveRequired<EdgeFormattingValues, "width"> ): ArrowDataWithCore { // set lets let angle: number; let arrowPoint: Point; let node1: VNode; let node2: VNode; let reversed: boolean; let scaleFactor: number; let type: ArrowType; let lineWidth: number = values.width; if (position === "from") { node1 = this.from; node2 = this.to; reversed = values.fromArrowScale! < 0; scaleFactor = Math.abs(values.fromArrowScale!); type = values.fromArrowType!; } else if (position === "to") { node1 = this.to; node2 = this.from; reversed = values.toArrowScale! < 0; scaleFactor = Math.abs(values.toArrowScale!); type = values.toArrowType!; } else { node1 = this.to; node2 = this.from; reversed = values.middleArrowScale! < 0; scaleFactor = Math.abs(values.middleArrowScale!); type = values.middleArrowType!; } const length = 15 * scaleFactor + 3 * lineWidth; // 3* lineWidth is the width of the edge. // if not connected to itself if (node1 != node2) { const approximateEdgeLength = Math.hypot( node1.x - node2.x, node1.y - node2.y ); const relativeLength = length / approximateEdgeLength; if (position !== "middle") { // draw arrow head if (this.options.smooth.enabled === true) { const pointT = this._findBorderPosition(node1, ctx, { via: viaNode }); const guidePos = this.getPoint( pointT.t + relativeLength * (position === "from" ? 1 : -1), viaNode ); angle = Math.atan2(pointT.y - guidePos.y, pointT.x - guidePos.x); arrowPoint = pointT; } else { angle = Math.atan2(node1.y - node2.y, node1.x - node2.x); arrowPoint = this._findBorderPosition(node1, ctx); } } else { // Negative half length reverses arrow direction. const halfLength = (reversed ? -relativeLength : relativeLength) / 2; const guidePos1 = this.getPoint(0.5 + halfLength, viaNode); const guidePos2 = this.getPoint(0.5 - halfLength, viaNode); angle = Math.atan2( guidePos1.y - guidePos2.y, guidePos1.x - guidePos2.x ); arrowPoint = this.getPoint(0.5, viaNode); } } else { // draw circle const [x, y, radius] = this._getCircleData(ctx); if (position === "from") { const pointT = this._findBorderPositionCircle(this.from, ctx, { x, y, low: 0.25, high: 0.6, direction: -1 }); angle = pointT.t * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI; arrowPoint = pointT; } else if (position === "to") { const pointT = this._findBorderPositionCircle(this.from, ctx, { x, y, low: 0.6, high: 1.0, direction: 1 }); angle = pointT.t * -2 * Math.PI + 1.5 * Math.PI - 1.1 * Math.PI; arrowPoint = pointT; } else { arrowPoint = this._pointOnCircle(x, y, radius, 0.175); angle = 3.9269908169872414; // === 0.175 * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI; } } const xi = arrowPoint.x - length * 0.9 * Math.cos(angle); const yi = arrowPoint.y - length * 0.9 * Math.sin(angle); const arrowCore = { x: xi, y: yi }; return { point: arrowPoint, core: arrowCore, angle: angle, length: length, type: type }; } /** @inheritdoc */ public drawArrowHead( ctx: CanvasRenderingContext2D, values: SelectiveRequired< EdgeFormattingValues, | "color" | "opacity" | "shadowColor" | "shadowSize" | "shadowX" | "shadowY" | "width" >, _selected: boolean, _hover: boolean, arrowData: ArrowData ): void { // set style ctx.strokeStyle = this.getColor(ctx, values); ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = values.width; EndPoints.draw(ctx, arrowData); // draw shadow if enabled this.enableShadow(ctx, values); ctx.fill(); // disable shadows for other elements. this.disableShadow(ctx, values); } /** * Set the shadow formatting values in the context if enabled, do nothing otherwise. * * @param ctx - The context that will be used for rendering. * @param values - Formatting values for the shadow. */ public enableShadow( ctx: CanvasRenderingContext2D, values: SelectiveRequired< EdgeFormattingValues, "shadowColor" | "shadowSize" | "shadowX" | "shadowY" > ): void { if (values.shadow === true) { ctx.shadowColor = values.shadowColor; ctx.shadowBlur = values.shadowSize; ctx.shadowOffsetX = values.shadowX; ctx.shadowOffsetY = values.shadowY; } } /** * Reset the shadow formatting values in the context if enabled, do nothing otherwise. * * @param ctx - The context that will be used for rendering. * @param values - Formatting values for the shadow. */ public disableShadow( ctx: CanvasRenderingContext2D, values: EdgeFormattingValues ): void { if (values.shadow === true) { ctx.shadowColor = "rgba(0,0,0,0)"; ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; } } /** * Render the background according to the formatting values. * * @param ctx - The context that will be used for rendering. * @param values - Formatting values for the background. */ public drawBackground( ctx: CanvasRenderingContext2D, values: SelectiveRequired< EdgeFormattingValues, "backgroundColor" | "backgroundSize" > ): void { if (values.background !== false) { // save original line attrs const origCtxAttr = { strokeStyle: ctx.strokeStyle, lineWidth: ctx.lineWidth, dashes: (ctx as any).dashes }; ctx.strokeStyle = values.backgroundColor; ctx.lineWidth = values.backgroundSize; this.setStrokeDashed(ctx, values.backgroundDashes); ctx.stroke(); // restore original line attrs ctx.strokeStyle = origCtxAttr.strokeStyle; ctx.lineWidth = origCtxAttr.lineWidth; (ctx as any).dashes = origCtxAttr.dashes; this.setStrokeDashed(ctx, values.dashes); } } /** * Set the line dash pattern if supported. Logs a warning to the console if it isn't supported. * * @param ctx - The context that will be used for rendering. * @param dashes - The pattern [line, space, line…], true for default dashed line or false for normal line. */ public setStrokeDashed( ctx: CanvasRenderingContext2D, dashes?: boolean | number[] ): void { if (dashes !== false) { if (ctx.setLineDash !== undefined) { const pattern = Array.isArray(dashes) ? dashes : [5, 5]; ctx.setLineDash(pattern); } else { console.warn( "setLineDash is not supported in this browser. The dashed stroke cannot be used." ); } } else { if (ctx.setLineDash !== undefined) { ctx.setLineDash([]); } else { console.warn( "setLineDash is not supported in this browser. The dashed stroke cannot be used." ); } } } }