iwmlib/lib/card/highlight.js

414 lines
14 KiB
JavaScript

/* eslint-disable no-console */
/* global TweenLite */
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 represnted in percent.
*
* @class Highlight
* @extends {Object}
*/
export default class Highlight extends Object {
static disableAnimations() {
_HighlightEnabled = false
let expanded = document.querySelectorAll('.expanded')
for (let obj of expanded) {
this.shrink(obj)
}
}
static enableAnimations() {
_HighlightEnabled = true
}
static removeAnimations(svgRoot) {
let expanded = svgRoot.querySelectorAll('.expanded')
for (let obj of expanded) {
TweenLite.set(obj, { scale: 1 })
obj.classList.remove('zooming')
obj.classList.remove('expanded')
}
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 = 2, duration = 3, stroke = 2, onComplete = null } = {}
) {
if (obj == null) return
//console.log("expand")
obj.classList.add('zooming')
TweenLite.to(obj, duration, {
scale: 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('expanded')
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('expanded')
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('expanded')) {
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 })
}
Highlight._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 = 2, onExpanded = null } = {}
) {
if (Highlight._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] = Highlight._getSVGMask(target, {
svgRoot,
image
})
let center = Highlight._calculateCenterRelativeTo(target, image)
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('expanded')
}
}
static toggleHighlight(node) {
if (Highlight._isExpanded(node)) {
Highlight.closeHighlight(node)
} else {
Highlight.openHighlight(node)
}
}
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] = Highlight._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')
return { x: (cx / width) * 100, y: (cy / height) * 100 }
}
static _isExpanded(target) {
return target.classList.contains(Highlight.expandedClass)
}
static _setExpanded(target) {
target.classList.add(Highlight.expandedClass)
}
static _notExpanded(target) {
target.classList.remove(Highlight.expandedClass)
}
static closeHighlight(target, { animation = 0.5 } = {}) {
console.log('Close Highlight')
Highlight._notExpanded(target)
// eslint-disable-next-line no-unused-vars
let [mask, maskImage] = Highlight._getSVGMask(target)
TweenLite.to([target, maskImage], animation, {
scale: 1
})
}
static animate(event) {
if (!_HighlightEnabled) return
event.stopPropagation()
Highlight.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'