386 lines
13 KiB
JavaScript
386 lines
13 KiB
JavaScript
|
/* eslint-disable no-console */
|
||
|
|
||
|
let _HighlightEnabled = true
|
||
|
let _CircleIds = 0
|
||
|
|
||
|
/** Helper method to round values with one digit precision */
|
||
|
function round(value) {
|
||
|
return Math.round(parseFloat(value) * 10) / 10
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* A namespace with static functions to expand and shrink highlighted image regions.
|
||
|
* Assumes an SVG image with the following structure:
|
||
|
*
|
||
|
* <svg viewbox="0 0 100 100">
|
||
|
* <!-- The defs section must be defined and cannot be generated in JavaScript -->
|
||
|
* <defs>
|
||
|
* </defs>
|
||
|
* <image width="100" height="100" xlink:href="../assets/chess.jpg"/>
|
||
|
* <circle onclick="Highlight.animateZoom(event)" cx="47" cy="18" r="8" stroke-width="0.5" />
|
||
|
* <circle onclick="Highlight.animateZoom(event)" cx="60" cy="67" r="8" stroke-width="0.5" />
|
||
|
* </svg>
|
||
|
*
|
||
|
* The SVG root element should use a viewbox with 0 0 100 100 to ensure that the positions and size of the
|
||
|
* circles can be represented in percent.
|
||
|
*
|
||
|
* @class Highlight
|
||
|
* @extends {Object}
|
||
|
*/
|
||
|
|
||
|
const SCALE = 1.5
|
||
|
|
||
|
export default class Highlight extends Object {
|
||
|
static disableAnimations() {
|
||
|
_HighlightEnabled = false
|
||
|
let expanded = document.querySelectorAll('.' + this.expandedClass)
|
||
|
for (let obj of expanded) {
|
||
|
this.shrink(obj)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
static enableAnimations() {
|
||
|
_HighlightEnabled = true
|
||
|
}
|
||
|
|
||
|
static removeAnimations(svgRoot) {
|
||
|
let expanded = svgRoot.querySelectorAll('.' + this.expandedClass)
|
||
|
for (let obj of expanded) {
|
||
|
TweenLite.set(obj, { scale: 1 })
|
||
|
obj.classList.remove('zooming')
|
||
|
obj.classList.remove(this.expandedClass)
|
||
|
}
|
||
|
let defs = svgRoot.querySelector('defs')
|
||
|
while (defs.firstChild) {
|
||
|
defs.firstChild.remove()
|
||
|
}
|
||
|
let maskImages = svgRoot.querySelectorAll('.addedImage')
|
||
|
for (let m of maskImages) {
|
||
|
m.remove()
|
||
|
}
|
||
|
let circles = svgRoot.querySelectorAll('circle')
|
||
|
for (let circle of circles) {
|
||
|
if (circle.classList.length == 0) {
|
||
|
circle.removeAttribute('class')
|
||
|
}
|
||
|
if (circle.hasAttribute('id') && circle.getAttribute('id').startsWith('@@')) {
|
||
|
circle.removeAttribute('id')
|
||
|
}
|
||
|
circle.removeAttribute('data-svg-origin')
|
||
|
circle.removeAttribute('transform')
|
||
|
circle.removeAttribute('style')
|
||
|
let cx = circle.getAttribute('cx')
|
||
|
let cy = circle.getAttribute('cy')
|
||
|
let r = circle.getAttribute('r')
|
||
|
circle.setAttribute('cx', round(cx))
|
||
|
circle.setAttribute('cy', round(cy))
|
||
|
circle.setAttribute('r', round(r))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
static expand(obj, { scale = SCALE, duration = 3, stroke = 2, onComplete = null } = {}) {
|
||
|
if (obj == null) return
|
||
|
//console.log("expand")
|
||
|
obj.classList.add('zooming')
|
||
|
TweenLite.to(obj, duration, {
|
||
|
scale,
|
||
|
onUpdate: () => {
|
||
|
let scale = obj._gsTransform.scaleX
|
||
|
obj.setAttribute('stroke-width', stroke / scale)
|
||
|
},
|
||
|
onComplete: () => {
|
||
|
console.log('expand complete')
|
||
|
obj.classList.remove('zooming')
|
||
|
obj.classList.add(this.expandedClass)
|
||
|
obj.setAttribute('stroke-width', stroke / scale)
|
||
|
if (onComplete) onComplete()
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
static shrink(obj, { duration = 0.5, stroke = 2 } = {}) {
|
||
|
//console.log("shrink")
|
||
|
if (obj == null) return
|
||
|
obj.classList.add('zooming')
|
||
|
TweenLite.to(obj, duration, {
|
||
|
scale: 1,
|
||
|
onUpdate: () => {
|
||
|
let scale = obj._gsTransform.scaleX
|
||
|
obj.setAttribute('stroke-width', stroke / scale)
|
||
|
},
|
||
|
onComplete: () => {
|
||
|
//console.log("shrink complete")
|
||
|
obj.classList.remove('zooming')
|
||
|
obj.classList.remove(this.expandedClass)
|
||
|
obj.setAttribute('stroke-width', stroke)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
static animateCircle(target, callback) {
|
||
|
console.log('ANIMATE CIRCLE', this)
|
||
|
// ** DEBUG OUTPUTS **
|
||
|
|
||
|
let circle = target
|
||
|
// We need a unique id to ensure correspondence between circle, mask, and maskImage
|
||
|
if (!circle.hasAttribute('id')) {
|
||
|
_CircleIds += 1
|
||
|
circle.setAttribute('id', '@@' + _CircleIds)
|
||
|
}
|
||
|
let id = circle.getAttribute('id')
|
||
|
TweenLite.set(circle, { transformOrigin: '50% 50%' })
|
||
|
/*if (circle.classList.contains('zooming')) {
|
||
|
console.log("already zooming")
|
||
|
return
|
||
|
}*/
|
||
|
|
||
|
let svgRoot = circle.closest('svg')
|
||
|
let circleGroup = circle.parentNode
|
||
|
let image = svgRoot.querySelector('image')
|
||
|
|
||
|
let stroke = parseFloat(circleGroup.getAttribute('stroke-width') || 6)
|
||
|
|
||
|
let defs = svgRoot.querySelector('defs')
|
||
|
if (defs == null) {
|
||
|
defs = document.createElementNS(svgRoot, 'defs')
|
||
|
svgRoot.insertBefore(defs, image)
|
||
|
}
|
||
|
|
||
|
// // We need direct children, therefore we cannot use querySelectorAll
|
||
|
let maskImageId = 'maskImage' + id
|
||
|
let maskImage = svgRoot.getElementById(maskImageId)
|
||
|
|
||
|
if (circle.classList.contains(this.expandedClass)) {
|
||
|
if (!circle.classList.contains('zooming')) {
|
||
|
this.shrink(circle, { stroke })
|
||
|
this.shrink(maskImage, { stroke })
|
||
|
return
|
||
|
}
|
||
|
//console.log("animate called while zooming out -> expand")
|
||
|
} else if (circle.classList.contains('zooming')) {
|
||
|
//console.log("animate called while zooming in -> shrink")
|
||
|
this.shrink(circle, { stroke })
|
||
|
this.shrink(maskImage, { stroke })
|
||
|
return
|
||
|
}
|
||
|
let circles = Array.from(circleGroup.children).filter(e => e.tagName == 'circle')
|
||
|
for (let c of circles) {
|
||
|
//console.log("shrinking all circles")
|
||
|
this.shrink(c, { stroke })
|
||
|
}
|
||
|
let maskImages = circleGroup.querySelectorAll('.addedImage')
|
||
|
for (let m of maskImages) {
|
||
|
this.shrink(m, { stroke })
|
||
|
}
|
||
|
|
||
|
this._createSVGMask(svgRoot, image, id)
|
||
|
|
||
|
// TweenLite.set(maskImage, { transformOrigin: `${tx}% ${ty}%` })
|
||
|
|
||
|
this.expand(circle, { stroke, onComplete: callback })
|
||
|
this.expand(maskImage)
|
||
|
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
static openHighlight(target, { animation = 0.5, scale = SCALE, onExpanded = null } = {}) {
|
||
|
if (this._isExpanded(target)) {
|
||
|
console.log('Target is already expanded!')
|
||
|
return
|
||
|
} else {
|
||
|
let targetId = target.getAttribute('id')
|
||
|
if (targetId && targetId.startsWith('@@')) {
|
||
|
let id = targetId.slice(2)
|
||
|
const imageId = '#maskImage' + id
|
||
|
const parent = target.parentNode
|
||
|
if (parent != null) {
|
||
|
let image = parent.querySelector(imageId)
|
||
|
if (image) {
|
||
|
this._bringToFront(image)
|
||
|
} else console.error('Could not find corresponding image element.')
|
||
|
} else console.log('Element was no parent:', target)
|
||
|
}
|
||
|
this._bringToFront(target)
|
||
|
let svgRoot = target.closest('svg')
|
||
|
if (svgRoot == null) {
|
||
|
return
|
||
|
}
|
||
|
let image = svgRoot.querySelector('image')
|
||
|
|
||
|
// eslint-disable-next-line no-unused-vars
|
||
|
let [mask, maskImage] = this._getSVGMask(target, {
|
||
|
svgRoot,
|
||
|
image
|
||
|
})
|
||
|
let center = this._calculateCenterRelativeTo(target, image)
|
||
|
console.log('_calculateCenterRelativeTo', center)
|
||
|
TweenLite.set(maskImage, {
|
||
|
transformOrigin: `${center.x} ${center.y}`
|
||
|
})
|
||
|
TweenLite.set(target, { transformOrigin: '50% 50%' })
|
||
|
|
||
|
TweenLite.to([target, maskImage], animation, {
|
||
|
scale,
|
||
|
onComplete: onExpanded
|
||
|
})
|
||
|
|
||
|
target.classList.add(this.expandedClass)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
static toggleHighlight(node, options = {}) {
|
||
|
console.log('toggleHighlight', this._isExpanded(node))
|
||
|
if (this._isExpanded(node)) {
|
||
|
this.closeHighlight(node, options)
|
||
|
} else {
|
||
|
this.openHighlight(node, options)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
static _bringToFront(target) {
|
||
|
const parent = target.parentNode
|
||
|
if (target && parent) {
|
||
|
parent.removeChild(target)
|
||
|
parent.appendChild(target)
|
||
|
} else console.error('Could not bring to front. Either no target or no parent.', target, parent)
|
||
|
}
|
||
|
|
||
|
static _getSVGMask(circle, { svgRoot = null, image = null } = {}) {
|
||
|
const id = this._retrieveId(circle)
|
||
|
const maskId = 'mask' + id
|
||
|
const maskImageId = 'maskImage' + id
|
||
|
|
||
|
if (!svgRoot) svgRoot = circle.closest('svg')
|
||
|
|
||
|
let mask = svgRoot.getElementById(maskId)
|
||
|
let maskImage = svgRoot.getElementById(maskImageId)
|
||
|
|
||
|
if (!mask || !maskImage)
|
||
|
[mask, maskImage] = this._createSVGMask(circle, {
|
||
|
svgRoot,
|
||
|
image,
|
||
|
id
|
||
|
})
|
||
|
|
||
|
return [mask, maskImage]
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Creates an SVG mask for a provided svgElement.
|
||
|
*
|
||
|
* @static
|
||
|
* @param {SVGElement} element - Element that should be masked.
|
||
|
* @param {object} opts - Optional parameters to avoid unnecessary fetching of elements.
|
||
|
* @param {SVGElement} opts.svgRoot - The root <svg> element of the element.
|
||
|
* @param {SVGImageElement} opts.image - The image that is used in the mask.
|
||
|
* @param {number} opts.id - The id of the mask.
|
||
|
* @returns
|
||
|
* @memberof Highlight
|
||
|
*/
|
||
|
static _createSVGMask(element, { svgRoot = null, image = null, id = null } = {}) {
|
||
|
// We can fetch these values here, but it's more efficient to
|
||
|
// simply pass them in, as it's likely they were already retrieved beforehand.
|
||
|
if (svgRoot == null) svgRoot = element.closest('svg')
|
||
|
if (image == null) image = svgRoot.querySelector('image')
|
||
|
if (id == null) id = this._retrieveId(element)
|
||
|
|
||
|
let svg = 'http://www.w3.org/2000/svg'
|
||
|
let xlink = 'http://www.w3.org/1999/xlink'
|
||
|
|
||
|
let svgGroup = element.parentNode
|
||
|
|
||
|
let src = image.getAttributeNS(xlink, 'href')
|
||
|
|
||
|
let maskId = 'mask' + id
|
||
|
let maskImageId = 'maskImage' + id
|
||
|
let mask = svgRoot.getElementById(maskId)
|
||
|
let maskImage = svgRoot.getElementById(maskImageId)
|
||
|
|
||
|
let defs = svgRoot.querySelector('defs')
|
||
|
if (defs == null) {
|
||
|
defs = document.createElementNS(svgRoot, 'defs')
|
||
|
svgRoot.insertBefore(defs, image)
|
||
|
}
|
||
|
|
||
|
if (mask == null) {
|
||
|
mask = document.createElementNS(svg, 'mask')
|
||
|
mask.setAttribute('id', maskId)
|
||
|
let maskCircle = element.cloneNode(true)
|
||
|
mask.appendChild(maskCircle)
|
||
|
defs.appendChild(mask)
|
||
|
}
|
||
|
|
||
|
let bbox = svgRoot.getElementsByTagName('image')[0].getBBox()
|
||
|
let width = bbox.width
|
||
|
let height = bbox.height
|
||
|
|
||
|
if (maskImage == null) {
|
||
|
maskImage = document.createElementNS(svg, 'image')
|
||
|
maskImage.style.pointerEvents = 'none'
|
||
|
maskImage.setAttribute('id', maskImageId)
|
||
|
maskImage.setAttributeNS(xlink, 'href', src)
|
||
|
maskImage.setAttribute('width', width)
|
||
|
maskImage.setAttribute('height', height)
|
||
|
maskImage.setAttribute('class', 'addedImage')
|
||
|
svgGroup.insertBefore(maskImage, element) // image.nextSibling)
|
||
|
TweenLite.set(maskImage, { scale: 1 })
|
||
|
maskImage.style.mask = 'url(#' + maskId + ')'
|
||
|
}
|
||
|
|
||
|
svgGroup.appendChild(maskImage)
|
||
|
// svgGroup.appendChild(element)
|
||
|
|
||
|
return [mask, maskImage]
|
||
|
}
|
||
|
|
||
|
static _calculateCenterRelativeTo(target, image) {
|
||
|
let bbox = image.getBBox()
|
||
|
let width = bbox.width
|
||
|
let height = bbox.height
|
||
|
let cx = target.getAttribute('cx')
|
||
|
let cy = target.getAttribute('cy')
|
||
|
let x = cx.endsWith('%') ? cx : round((cx / width) * 100) + '%'
|
||
|
let y = cy.endsWith('%') ? cy : round((cy / height) * 100) + '%'
|
||
|
return { x, y }
|
||
|
}
|
||
|
|
||
|
static _isExpanded(target) {
|
||
|
return target.classList.contains(this.expandedClass)
|
||
|
}
|
||
|
|
||
|
static closeHighlight(target, { animation = 0.5 } = {}) {
|
||
|
target.classList.remove(this.expandedClass)
|
||
|
// eslint-disable-next-line no-unused-vars
|
||
|
let [mask, maskImage] = this._getSVGMask(target)
|
||
|
// console.log('Close Highlight', maskImage)
|
||
|
TweenLite.to([target, maskImage], animation, {
|
||
|
scale: 1
|
||
|
})
|
||
|
}
|
||
|
|
||
|
static animate(event) {
|
||
|
if (!_HighlightEnabled) return
|
||
|
|
||
|
event.stopPropagation()
|
||
|
this.animateCircle(event.target)
|
||
|
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
static _retrieveId(target) {
|
||
|
let id = target.getAttribute('id')
|
||
|
// We need a unique id to ensure correspondence between circle, mask, and maskImage
|
||
|
if (!id) {
|
||
|
_CircleIds += 1
|
||
|
target.setAttribute('id', '@@' + _CircleIds)
|
||
|
id = _CircleIds
|
||
|
} else {
|
||
|
id = parseInt(id.substring(2))
|
||
|
}
|
||
|
|
||
|
return id
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Highlight.expandedClass = 'expanded'
|