@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
520 lines (456 loc) • 14.7 kB
text/typescript
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 }
}