UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering.

520 lines (456 loc) 14.7 kB
import { Point, Line, Rectangle, Polyline, Ellipse, Path } from '../../geometry' import { attr } from './attr' import { sample, toPath, getPointsFromSvgElement } from './path' import { ensureId, isSVGGraphicsElement, createSvgElement, isHTMLElement, } from './elem' import { getComputedStyle } from './style' import { createSVGPoint, createSVGMatrix, decomposeMatrix, transformRectangle, transformStringToMatrix, } from './matrix' /** * Returns the bounding box of the element after transformations are * applied. If `withoutTransformations` is `true`, transformations of * the element will not be considered when computing the bounding box. * If `target` is specified, bounding box will be computed relatively * to the `target` element. */ export function bbox( elem: SVGElement, withoutTransformations?: boolean, target?: SVGElement, ): Rectangle { let box const ownerSVGElement = elem.ownerSVGElement // If the element is not in the live DOM, it does not have a bounding // box defined and so fall back to 'zero' dimension element. if (!ownerSVGElement) { return new Rectangle(0, 0, 0, 0) } try { box = (elem as SVGGraphicsElement).getBBox() } catch (e) { // Fallback for IE. box = { x: elem.clientLeft, y: elem.clientTop, width: elem.clientWidth, height: elem.clientHeight, } } if (withoutTransformations) { return Rectangle.create(box) } const matrix = getTransformToElement(elem, target || ownerSVGElement) return transformRectangle(box, matrix) } /** * Returns the bounding box of the element after transformations are * applied. Unlike `bbox()`, this function fixes a browser implementation * bug to return the correct bounding box if this elemenent is a group of * svg elements (if `options.recursive` is specified). */ export function getBBox( elem: SVGElement, options: { target?: SVGElement | null recursive?: boolean } = {}, ): Rectangle { let outputBBox const ownerSVGElement = elem.ownerSVGElement // If the element is not in the live DOM, it does not have a bounding box // defined and so fall back to 'zero' dimension element. // If the element is not an SVGGraphicsElement, we could not measure the // bounding box either if (!ownerSVGElement || !isSVGGraphicsElement(elem)) { if (isHTMLElement(elem)) { // If the element is a HTMLElement, return the position relative to the body const { left, top, width, height } = getBoundingOffsetRect(elem as any) return new Rectangle(left, top, width, height) } return new Rectangle(0, 0, 0, 0) } let target = options.target const recursive = options.recursive if (!recursive) { try { outputBBox = elem.getBBox() } catch (e) { outputBBox = { x: elem.clientLeft, y: elem.clientTop, width: elem.clientWidth, height: elem.clientHeight, } } if (!target) { return Rectangle.create(outputBBox) } // transform like target const matrix = getTransformToElement(elem, target) return transformRectangle(outputBBox, matrix) } // recursive { const children = elem.childNodes const n = children.length if (n === 0) { return getBBox(elem, { target }) } if (!target) { target = elem // eslint-disable-line } for (let i = 0; i < n; i += 1) { const child = children[i] as SVGElement let childBBox if (child.childNodes.length === 0) { childBBox = getBBox(child, { target }) } else { // if child is a group element, enter it with a recursive call childBBox = getBBox(child, { target, recursive: true }) } if (!outputBBox) { outputBBox = childBBox } else { outputBBox = outputBBox.union(childBBox) } } return outputBBox as Rectangle } } // BBox is calculated by the attribute on the node export function getBBoxByElementAttr(elem: SVGElement) { let node = elem let tagName = node ? node.tagName.toLowerCase() : '' // find shape node while (tagName === 'g') { node = node.firstElementChild as SVGElement tagName = node ? node.tagName.toLowerCase() : '' } const attr = (name: string) => { const s = node.getAttribute(name) const v = s ? parseFloat(s) : 0 return Number.isNaN(v) ? 0 : v } let r let bbox switch (tagName) { case 'rect': bbox = new Rectangle(attr('x'), attr('y'), attr('width'), attr('height')) break case 'circle': r = attr('r') bbox = new Rectangle(attr('cx') - r, attr('cy') - r, 2 * r, 2 * r) break default: break } return bbox } // Matrix is calculated by the transform attribute on the node export function getMatrixByElementAttr(elem: SVGElement, target: SVGElement) { let matrix = createSVGMatrix() if (isSVGGraphicsElement(target) && isSVGGraphicsElement(elem)) { let node = elem const matrixList = [] while (node && node !== target) { const transform = node.getAttribute('transform') || null const nodeMatrix = transformStringToMatrix(transform) matrixList.push(nodeMatrix) node = node.parentNode as SVGGraphicsElement } matrixList.reverse().forEach((m) => { matrix = matrix.multiply(m) }) } return matrix } /** * Returns an DOMMatrix that specifies the transformation necessary * to convert `elem` coordinate system into `target` coordinate system. */ export function getTransformToElement(elem: SVGElement, target: SVGElement) { if (isSVGGraphicsElement(target) && isSVGGraphicsElement(elem)) { const targetCTM = target.getScreenCTM() const nodeCTM = elem.getScreenCTM() if (targetCTM && nodeCTM) { return targetCTM.inverse().multiply(nodeCTM) } } // Could not get actual transformation matrix return createSVGMatrix() } /** * Converts a global point with coordinates `x` and `y` into the * coordinate space of the element. */ export function toLocalPoint( elem: SVGElement | SVGSVGElement, x: number, y: number, ) { const svg = elem instanceof SVGSVGElement ? elem : (elem.ownerSVGElement as SVGSVGElement) const p = svg.createSVGPoint() p.x = x p.y = y try { const ctm = svg.getScreenCTM()! const globalPoint = p.matrixTransform(ctm.inverse()) const globalToLocalMatrix = getTransformToElement(elem, svg).inverse() return globalPoint.matrixTransform(globalToLocalMatrix) } catch (e) { return p } } /** * Convert the SVGElement to an equivalent geometric shape. The element's * transformations are not taken into account. * * SVGRectElement => Rectangle * * SVGLineElement => Line * * SVGCircleElement => Ellipse * * SVGEllipseElement => Ellipse * * SVGPolygonElement => Polyline * * SVGPolylineElement => Polyline * * SVGPathElement => Path * * others => Rectangle */ export function toGeometryShape(elem: SVGElement) { const attr = (name: string) => { const s = elem.getAttribute(name) const v = s ? parseFloat(s) : 0 return Number.isNaN(v) ? 0 : v } switch (elem instanceof SVGElement && elem.nodeName.toLowerCase()) { case 'rect': return new Rectangle(attr('x'), attr('y'), attr('width'), attr('height')) case 'circle': return new Ellipse(attr('cx'), attr('cy'), attr('r'), attr('r')) case 'ellipse': return new Ellipse(attr('cx'), attr('cy'), attr('rx'), attr('ry')) case 'polyline': { const points = getPointsFromSvgElement(elem as SVGPolylineElement) return new Polyline(points) } case 'polygon': { const points = getPointsFromSvgElement(elem as SVGPolygonElement) if (points.length > 1) { points.push(points[0]) } return new Polyline(points) } case 'path': { let d = elem.getAttribute('d') as string if (!Path.isValid(d)) { d = Path.normalize(d) } return Path.parse(d) } case 'line': { return new Line(attr('x1'), attr('y1'), attr('x2'), attr('y2')) } default: break } // Anything else is a rectangle return getBBox(elem) } export function getIntersection( elem: SVGElement | SVGSVGElement, ref: Point | Point.PointLike | Point.PointData, target?: SVGElement, ) { const svg = elem instanceof SVGSVGElement ? elem : elem.ownerSVGElement! target = target || svg // eslint-disable-line const bbox = getBBox(target) const center = bbox.getCenter() if (!bbox.intersectsWithLineFromCenterToPoint(ref)) { return null } let spot: Point | null = null const tagName = elem.tagName.toLowerCase() // Little speed up optimization for `<rect>` element. We do not do convert // to path element and sampling but directly calculate the intersection // through a transformed geometrical rectangle. if (tagName === 'rect') { const gRect = new Rectangle( parseFloat(elem.getAttribute('x') || '0'), parseFloat(elem.getAttribute('y') || '0'), parseFloat(elem.getAttribute('width') || '0'), parseFloat(elem.getAttribute('height') || '0'), ) // Get the rect transformation matrix with regards to the SVG document. const rectMatrix = getTransformToElement(elem, target) const rectMatrixComponents = decomposeMatrix(rectMatrix) // Rotate the rectangle back so that we can use // `intersectsWithLineFromCenterToPoint()`. const reseted = svg.createSVGTransform() reseted.setRotate(-rectMatrixComponents.rotation, center.x, center.y) const rect = transformRectangle(gRect, reseted.matrix.multiply(rectMatrix)) spot = Rectangle.create(rect).intersectsWithLineFromCenterToPoint( ref, rectMatrixComponents.rotation, ) } else if ( tagName === 'path' || tagName === 'polygon' || tagName === 'polyline' || tagName === 'circle' || tagName === 'ellipse' ) { const pathNode = tagName === 'path' ? elem : toPath(elem as any) const samples = sample(pathNode as SVGPathElement) let minDistance = Infinity let closestSamples: any[] = [] for (let i = 0, ii = samples.length; i < ii; i += 1) { const sample = samples[i] // Convert the sample point in the local coordinate system // to the global coordinate system. let gp = createSVGPoint(sample.x, sample.y) gp = gp.matrixTransform(getTransformToElement(elem, target)) const ggp = Point.create(gp) const centerDistance = ggp.distance(center) // Penalize a higher distance to the reference point by 10%. // This gives better results. This is due to // inaccuracies introduced by rounding errors and getPointAtLength() returns. const refDistance = ggp.distance(ref) * 1.1 const distance = centerDistance + refDistance if (distance < minDistance) { minDistance = distance closestSamples = [{ sample, refDistance }] } else if (distance < minDistance + 1) { closestSamples.push({ sample, refDistance }) } } closestSamples.sort((a, b) => a.refDistance - b.refDistance) if (closestSamples[0]) { spot = Point.create(closestSamples[0].sample) } } return spot } export interface AnimateCallbacks { start?: (e: Event) => void repeat?: (e: Event) => void complete?: (e: Event) => void } export type AnimationOptions = AnimateCallbacks & { [name: string]: string | number | undefined } export function animate(elem: SVGElement, options: AnimationOptions) { return createAnimation(elem, options, 'animate') } export function animateTransform(elem: SVGElement, options: AnimationOptions) { return createAnimation(elem, options, 'animateTransform') } function createAnimation( elem: SVGElement, options: AnimationOptions, type: 'animate' | 'animateTransform', ) { // @see // https://www.w3.org/TR/SVG11/animate.html#AnimateElement // https://developer.mozilla.org/en-US/docs/Web/API/SVGAnimateElement // https://developer.mozilla.org/en-US/docs/Web/API/SVGAnimateTransformElement const animate = createSvgElement<SVGAnimationElement>(type) elem.appendChild(animate) try { return setupAnimation(animate, options) } catch (error) { // pass } return () => {} } function setupAnimation( animate: SVGAnimationElement, options: AnimationOptions, ) { const { start, complete, repeat, ...attrs } = options attr(animate, attrs) start && animate.addEventListener('beginEvent', start) complete && animate.addEventListener('endEvent', complete) repeat && animate.addEventListener('repeatEvent', repeat) const ani = animate as any ani.beginElement() return () => ani.endElement() } /** * Animate the element along the path SVG element (or Vector object). * `attrs` contain Animation Timing attributes describing the animation. */ export function animateAlongPath( elem: SVGElement, options: AnimationOptions, path: SVGPathElement, ): () => void { const id = ensureId(path) // https://developer.mozilla.org/en-US/docs/Web/API/SVGAnimationElement const animate = createSvgElement<SVGAnimateMotionElement>('animateMotion') const mpath = createSvgElement('mpath') attr(mpath, { 'xlink:href': `#${id}` }) animate.appendChild(mpath) elem.appendChild(animate) try { return setupAnimation(animate, options) } catch (e) { // Fallback for IE 9. if (document.documentElement.getAttribute('smiling') === 'fake') { // Register the animation. (See `https://answers.launchpad.net/smil/+question/203333`) const ani = animate as any ani.animators = [] const win = window as any const animationID = ani.getAttribute('id') if (animationID) { win.id2anim[animationID] = ani } const targets = win.getTargets(ani) for (let i = 0, ii = targets.length; i < ii; i += 1) { const target = targets[i] const animator = new win.Animator(ani, target, i) win.animators.push(animator) ani.animators[i] = animator animator.register() } } } return () => {} } export function getBoundingOffsetRect(elem: HTMLElement) { let left = 0 let top = 0 let width = 0 let height = 0 if (elem) { let current = elem as any while (current) { left += current.offsetLeft top += current.offsetTop current = current.offsetParent if (current) { left += parseInt(getComputedStyle(current, 'borderLeft'), 10) top += parseInt(getComputedStyle(current, 'borderTop'), 10) } } width = elem.offsetWidth height = elem.offsetHeight } return { left, top, width, height } }