/* eslint-disable no-unused-vars */ /* eslint-disable no-console */ /* globals TweenLite debugCanvas */ import Interface from './interface.js' import { Points, Polygon, Angle, Elements, LowPassFilter } from './utils.js' import Events from './events.js' import { InteractionMapper } from './interaction.js' import { Capabilities } from './capabilities.js' /** This interface allows scatters to delegate tap events to other objects. */ export class ITapDelegate extends Interface { /** This method must be defined by the delegate. It handles the tap event. */ tap(event) {} /** Tells the delegate that it should handle standard click events. */ handleClicks() {} } /** * A base class for scatter specific events. * * @constructor * @param {name} String - The name of the event * @param {target} Object - The target of the event */ export class BaseEvent { constructor(name, target) { this.name = name this.target = target } } // Event types const START = 'onStart' const UPDATE = 'onUpdate' const END = 'onEnd' /** * A scatter event that describes how the scatter has changed. * * @constructor * @param {target} Object - The target scatter of the event * @param {optional} Object - Optional parameter */ export class ScatterEvent extends BaseEvent { constructor( target, { translate = { x: 0, y: 0 }, scale = null, rotate = 0, about = null, fast = false, type = null } = {} ) { super('scatterTransformed', { target: target }) this.translate = translate this.scale = scale this.rotate = rotate this.about = about this.fast = fast this.type = type } toString() { return ( "Event('scatterTransformed', scale: " + this.scale + ' about: ' + this.about.x + ', ' + this.about.y + ')' ) } } /** * A scatter resize event that describes how the scatter has changed. * * @constructor * @param {target} Object - The target scatter of the event * @param {optional} Object - Optional parameter */ export class ResizeEvent extends BaseEvent { constructor(target, { width = 0, height = 0 } = {}) { super('scatterResized', { width: width, height: height }) this.width = width this.height = height } toString() { return ( 'Event(scatterResized width: ' + this.width + 'height: ' + this.height + ')' ) } } /** * A abstract base class that implements the throwable behavior of a scatter * object. * * @constructor */ class Throwable { constructor({ movableX = true, movableY = true, throwVisibility = 44, throwDamping = 0.95, autoThrow = true, onThrowFinished = null } = {}) { this.movableX = movableX this.movableY = movableY this.throwVisibility = throwVisibility this.throwDamping = throwDamping this.autoThrow = autoThrow this.velocities = [] this.velocity = null this.timestamp = null this.onThrowFinished = onThrowFinished //console.log("onThrowFinished", onThrowFinished) } observeVelocity() { this.lastframe = performance.now() } addVelocity(delta, buffer = 5) { let t = performance.now() let dt = t - this.lastframe this.lastframe = t if (dt > 0) { // Avoid division by zero errors later on // and consider the number of involved pointers sind addVelocity will be called by the // onMove events let velocity = { t: t, dt: dt, dx: delta.x / delta.number, dy: delta.y / delta.number } this.velocities.push(velocity) while (this.velocities.length > buffer) { this.velocities.shift() } } } meanVelocity(milliseconds = 30) { this.addVelocity({ x: 0, y: 0, number: 1 }) let sum = { x: 0, y: 0 } let count = 0 let t = 0 for (let i = this.velocities.length - 1; i > 0; i--) { let v = this.velocities[i] t += v.dt let nv = { x: v.dx / v.dt, y: v.dy / v.dt } sum = Points.add(sum, nv) count += 1 if (t > milliseconds) { break } } if (count === 0) return sum // empty vector return Points.multiplyScalar(sum, 1 / count) } killAnimation() { this.velocity = null this.velocities = [] } startThrow() { this.velocity = this.meanVelocity() if (this.velocity != null) { // Call next velocity to ansure that specializations // that use keepOnStage are called this.velocity = this.nextVelocity(this.velocity) if (this.autoThrow) this.animateThrow(performance.now()) } else { this.onDragComplete() } } _throwDeltaTime() { let t = performance.now() let dt = t - this.lastframe this.lastframe = t return dt } animateThrow(time) { if (this.velocity != null) { let dt = this._throwDeltaTime() // console.log("animateThrow", dt) let next = this.nextVelocity(this.velocity) let prevLength = Points.length(this.velocity) let nextLength = Points.length(next) if (nextLength > prevLength) { let factor = nextLength / prevLength next = Points.multiplyScalar(next, 1 / factor) console.log('Prevent acceleration', factor, this.velocity, next) } this.velocity = next let d = Points.multiplyScalar(this.velocity, dt) this._move(d) this.onDragUpdate(d) if (dt == 0 || this.needsAnimation()) { requestAnimationFrame(this.animateThrow.bind(this)) return } else { if (this.isOutside()) { requestAnimationFrame(this.animateThrow.bind(this)) return } } } this.onDragComplete() if (this.onThrowFinished != null) { this.onThrowFinished() } } needsAnimation() { if (this.velocity == null) { return false } return Points.length(this.velocity) > 0.01 } nextVelocity(velocity) { // Must be overwritten: computes the changed velocity. Implement // damping, collison detection, etc. here let next = Points.multiplyScalar(velocity, this.throwDamping) return { x: this.movableX ? next.x : 0, y: this.movableY ? next.y : 0 } } _move(delta) { // Overwrite if necessary } onDragComplete() { // Overwrite if necessary } onDragUpdate(delta) { // Overwrite if necessary } } export class AbstractScatter extends Throwable { constructor({ minScale = 0.1, maxScale = 1.0, startScale = 1.0, autoBringToFront = true, autoThrow = true, translatable = true, scalable = true, rotatable = true, resizable = false, movableX = true, movableY = true, throwVisibility = 44, throwDamping = 0.95, overdoScaling = 1, mouseZoomFactor = 1.1, rotationDegrees = null, rotation = null, onTransform = null, interactive = true, onClose = null, onThrowFinished = null, scaleAutoClose = false, scaleCloseThreshold = 0.1, scaleCloseBuffer = 0.05, maxRotation = Angle.degree2radian(5), minRotationDistance = 200, useLowPassFilter = true } = {}) { if (rotationDegrees != null && rotation != null) { throw new Error('Use rotationDegrees or rotation but not both') } else if (rotation != null) { rotationDegrees = Angle.radian2degree(rotation) } else if (rotationDegrees == null) { rotationDegrees = 0 } super({ movableX, movableY, throwVisibility, throwDamping, autoThrow, onThrowFinished }) /** * Closes the card when the minScale is reached and the * card is released. Card can be saved by scaling it up again. */ this.scaleAutoClose = scaleAutoClose this.scaleCloseThreshold = scaleCloseThreshold this.scaleCloseBuffer = scaleCloseBuffer this.scaleAutoCloseTimeout = null this.interactive = interactive this.startRotationDegrees = rotationDegrees this.startScale = startScale // Needed to reset object this.minScale = minScale this.maxScale = maxScale this.maxRotation = maxRotation this.overdoScaling = overdoScaling this.translatable = translatable if (!translatable) { this.movableX = false this.movableY = false } this.scalable = scalable this.rotatable = rotatable this.resizable = resizable this.mouseZoomFactor = mouseZoomFactor this.autoBringToFront = autoBringToFront this.useLowPassFilter = useLowPassFilter this.minRotationDistance = minRotationDistance if (useLowPassFilter) { this.rotateLPF = new LowPassFilter() this.zoomLPF = new LowPassFilter() this.zoomLPF.setup([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) } this.dragging = false this.onTransform = onTransform != null ? [onTransform] : null this.onClose = onClose != null ? [onClose] : null } addCloseEventCallback(callback) { if (this.onClose == null) { this.onClose = [] } this.onClose.push(callback) } addTransformEventCallback(callback) { if (this.onTransform == null) { this.onTransform = [] } this.onTransform.push(callback) } startGesture(interaction) { this.bringToFront() this.killAnimation() this.observeVelocity() if (this.useLowPassFilter) { this.rotateLPF.clear() this.zoomLPF.clear() this.zoomLPF.setup([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) } return true } close() { if (this.onClose) { this.onClose.forEach(callback => callback(this)) } } gesture(interaction) { let delta = interaction.delta() if (delta != null) { this.addVelocity(delta) let rotate = delta.rotate let zoom = delta.zoom if (this.maxRotation != null) { if (Math.abs(rotate) > this.maxRotation) { rotate = 0 } } if (this.useLowPassFilter) { rotate = this.rotateLPF.next(rotate) zoom = this.zoomLPF.next(zoom) if (delta.distance < this.minRotationDistance) { rotate = 0 } } this.transform(delta, zoom, rotate, delta.about) if (zoom != 1) this.interactionAnchor = delta.about } } get polygon() { let w2 = (this.width * this.scale) / 2 let h2 = (this.height * this.scale) / 2 let center = this.center let polygon = new Polygon(center) polygon.addPoint({ x: -w2, y: -h2 }) polygon.addPoint({ x: w2, y: -h2 }) polygon.addPoint({ x: w2, y: h2 }) polygon.addPoint({ x: -w2, y: h2 }) polygon.rotate(this.rotation) return polygon } isOutside() { let stagePolygon = this.containerPolygon if (stagePolygon == null) return false let polygon = this.polygon if (polygon == null) return false let result = stagePolygon.intersectsWith(polygon) return result === false || result.overlap < this.throwVisibility } recenter() { // Return a small vector that guarantees that the scatter is moving // towards the center of the stage let center = this.center let target = this.container.center let delta = Points.subtract(target, center) return Points.normalize(delta) } nextVelocity(velocity) { return this.keepOnStage(velocity) } bouncing() { // Implements the bouncing behavior of the scatter. Moves the scatter // to the center of the stage if the scatter is outside the stage or // not within the limits of the throwVisibility. let stagePolygon = this.containerPolygon let polygon = this.polygon let result = stagePolygon.intersectsWith(polygon) if (result === false || result.overlap < this.throwVisibility) { let cv = this.recenter() let recentered = false while (result === false || result.overlap < this.throwVisibility) { polygon.center.x += cv.x polygon.center.y += cv.y this._move(cv) result = stagePolygon.intersectsWith(polygon) recentered = true } return recentered } return false } keepOnStage(velocity, collision = 0.5) { let stagePolygon = this.containerPolygon // UO: since keepOnStage is called in nextVelocity we need to // ensure a return value if (!stagePolygon) return { x: 0, y: 0 } let polygon = this.polygon let bounced = this.bouncing() if (bounced) { let stage = this.containerBounds let x = this.center.x let y = this.center.y let dx = this.movableX ? velocity.x : 0 let dy = this.movableY ? velocity.y : 0 let factor = this.throwDamping // if (recentered) { if (x < 0) { dx = -dx factor = collision } if (x > stage.width) { dx = -dx factor = collision } if (y < 0) { dy = -dy factor = collision } if (y > stage.height) { dy = -dy factor = collision } // } return Points.multiplyScalar({ x: dx, y: dy }, factor) } return super.nextVelocity(velocity) } endGesture(interaction) { this.startThrow() this._checkAutoClose() } _checkAutoClose() { if (this.scaleAutoClose) if ( this.scale < this.minScale + this.scaleCloseThreshold - this.scaleCloseBuffer ) { this.zoom(this.minScale, { animate: 0.2, onComplete: this.close.bind(this) }) } else if (this.scale < this.minScale + this.scaleCloseThreshold) { this.zoom(this.minScale + this.scaleCloseThreshold, { animate: 0.4 }) } } rotateDegrees(degrees, anchor) { let rad = Angle.degree2radian(degrees) this.rotate(rad, anchor) } rotate(rad, anchor) { this.transform({ x: 0, y: 0 }, 1.0, rad, anchor) } move(d, { animate = 0 } = {}) { if (this.translatable) { if (animate > 0) { let startPos = this.position TweenLite.to(this, animate, { x: '+=' + d.x, y: '+=' + d.y, /* scale: scale, uo: not defined, why was this here? */ onUpdate: e => { let p = this.position let dx = p.x - startPos.x let dy = p.x - startPos.y this.onMoved(dx, dy) } }) } else { this._move(d) this.onMoved(d.x, d.y) } } } moveTo(p, { animate = 0 } = {}) { let c = this.origin let delta = Points.subtract(p, c) this.move(delta, { animate: animate }) } centerAt(p, { animate = 0 } = {}) { let c = this.center let delta = Points.subtract(p, c) this.move(delta, { animate: animate }) } zoom( scale, { animate = 0, about = null, delay = 0, x = null, y = null, onComplete = null } = {} ) { let anchor = about || this.center if (scale != this.scale) { if (animate > 0) { TweenLite.to(this, animate, { scale: scale, delay: delay, onComplete: onComplete, onUpdate: this.onZoomed.bind(this) }) } else { this.scale = scale this.onZoomed(anchor) } } } _move(delta) { this.x += this.movableX ? delta.x : 0 this.y += this.movableX ? delta.y : 0 } transform(translate, zoom, rotate, anchor) { let delta = { x: this.movableX ? translate.x : 0, y: this.movableY ? translate.y : 0 } if (this.resizable) var vzoom = zoom if (!this.translatable) delta = { x: 0, y: 0 } if (!this.rotatable) rotate = 0 if (!this.scalable) zoom = 1.0 if (zoom == 1.0 && rotate == 0) { this._move(delta) if (this.onTransform != null) { let event = new ScatterEvent(this, { translate: delta, scale: this.scale, rotate: 0, about: anchor, fast: false, type: UPDATE }) this.onTransform.forEach(function(f) { f(event) }) } return } let origin = this.rotationOrigin let beta = Points.angle(origin, anchor) let distance = Points.distance(origin, anchor) let { scale: newScale, zoom: thresholdedZoom } = this.calculateScale( zoom ) let newOrigin = Points.arc( anchor, beta + rotate, distance * thresholdedZoom ) let extra = Points.subtract(newOrigin, origin) let offset = Points.subtract(anchor, origin) this._move(offset) this.scale = newScale this.rotation += rotate offset = Points.negate(offset) offset = Points.add(offset, extra) offset = Points.add(offset, translate) this._move(offset) delta.x += extra.x delta.y += extra.y if (this.onTransform != null) { let event = new ScatterEvent(this, { translate: delta, scale: newScale, rotate: rotate, about: anchor }) this.onTransform.forEach(function(f) { f(event) }) } if (this.resizable) { this.resizeAfterTransform(vzoom) } } /** * For a given zoom, a new scale is calculated, taking * min and max scale into account. * * @param {number} zoom - The zoom factor, to scale the object with. * @returns {object} - Returns an object containing the a value for a valid scale and the corrected zoom factor. */ calculateScale(zoom) { let scale = this.scale * zoom let minScale = this.minScale / this.overdoScaling let maxScale = this.maxScale * this.overdoScaling if (scale < minScale) { scale = minScale zoom = scale / this.scale } if (scale > maxScale) { scale = maxScale zoom = scale / this.scale } if (this.scaleAutoClose) this._updateTransparency() return { zoom, scale } } _updateTransparency() { if (this.scale < this.minScale + this.scaleCloseThreshold) { let transparency = this.calculateScaleTransparency() this.element.style.opacity = transparency } else this.element.style.opacity = 1 } calculateScaleTransparency() { let transparency = (this.scale - this.minScale) / this.scaleCloseThreshold transparency = transparency > 1 ? 1 : transparency < 0 ? 0 : transparency return transparency } resizeAfterTransform(zoom) { // Overwrite this in subclasses. } validScale(scale) { scale = Math.max(scale, this.minScale) scale = Math.min(scale, this.maxScale) return scale } animateZoomBounce(dt = 1) { if (this.zoomAnchor != null) { let zoom = 1 let amount = Math.min(0.01, (0.3 * dt) / 100000.0) if (this.scale < this.minScale) zoom = 1 + amount if (this.scale > this.maxScale) zoom = 1 - amount if (zoom != 1) { this.transform({ x: 0, y: 0 }, zoom, 0, this.zoomAnchor) requestAnimationFrame(dt => { this.animateZoomBounce(dt) }) return } this.zoomAnchor = null } } checkScaling(about, delay = 0) { this.zoomAnchor = about clearTimeout(this.animateZoomBounce.bind(this)) setTimeout(this.animateZoomBounce.bind(this), delay) } onMouseWheel(event) { if (event.claimedByScatter) { if (event.claimedByScatter != this) return } this.killAnimation() this.targetScale = null let direction = event.detail < 0 || event.wheelDelta > 0 let globalPoint = { x: event.clientX, y: event.clientY } let centerPoint = this.mapPositionToContainerPoint(globalPoint) if (event.shiftKey) { let degrees = direction ? 5 : -5 let rad = Angle.degree2radian(degrees) return this.transform({ x: 0, y: 0 }, 1.0, rad, centerPoint) } const zoomFactor = this.mouseZoomFactor let zoom = direction ? zoomFactor : 1 / zoomFactor this.transform({ x: 0, y: 0 }, zoom, 0, centerPoint) this.checkScaling(centerPoint, 200) if (this.scaleAutoClose) { if (this.scale <= this.minScale + this.scaleCloseThreshold) { if (this.scaleAutoCloseTimeout) clearTimeout(this.scaleAutoCloseTimeout) this.scaleAutoCloseTimeout = setTimeout(() => { this._checkAutoClose() }, 600) } this._updateTransparency() } } onStart(event, interaction) { if (this.startGesture(interaction)) { this.dragging = true this.interactionAnchor = null } if (this.onTransform != null) { let event = new ScatterEvent(this, { translate: { x: 0, y: 0 }, scale: this.scale, rotate: 0, about: null, fast: false, type: START }) this.onTransform.forEach(function(f) { f(event) }) } } onMove(event, interaction) { /** As long as mouseout && mouseleave interrupt we cannot be sure that * dragging remains correct. */ if (this.dragging) { this.gesture(interaction) } } onEnd(event, interaction) { //console.log("Scatter.onEnd", this.dragging) if (interaction.isFinished()) { this.endGesture(interaction) this.dragging = false for (let key of interaction.ended.keys()) { if (interaction.isTap(key)) { let point = interaction.ended.get(key) this.onTap(event, interaction, point) } } if (this.onTransform != null) { let event = new ScatterEvent(this, { translate: { x: 0, y: 0 }, scale: this.scale, rotate: 0, about: null, fast: false, type: END }) this.onTransform.forEach(function(f) { f(event) }) } } let about = this.interactionAnchor if (about != null) { this.checkScaling(about, 100) } } onTap(event, interaction, point) {} onDragUpdate(delta) { if (this.onTransform != null) { let event = new ScatterEvent(this, { fast: true, translate: delta, scale: this.scale, about: this.currentAbout, type: null }) this.onTransform.forEach(function(f) { f(event) }) } } onDragComplete() { if (this.onTransform) { let event = new ScatterEvent(this, { scale: this.scale, about: this.currentAbout, fast: false, type: null }) this.onTransform.forEach(function(f) { f(event) }) } } onMoved(dx, dy, about) { if (this.onTransform != null) { let event = new ScatterEvent(this, { translate: { x: dx, y: dy }, about: about, fast: true, type: null }) this.onTransform.forEach(function(f) { f(event) }) } } onResizing() { if (this.onTransform != null) { let event = new ScatterEvent(this, { scale: this.scale, fast: false, type: null }) this.onTransform.forEach(function(f) { f(event) }) } } onZoomed(about) { if (this.scaleAutoClose) this._updateTransparency() if (this.onTransform != null) { let event = new ScatterEvent(this, { scale: this.scale, about: about, fast: false, type: null }) this.onTransform.forEach(function(f) { f(event) }) } } } /** A container for scatter objects, which uses a single InteractionMapper * for all children. This reduces the number of registered event handlers * and covers the common use case that multiple objects are scattered * on the same level. */ export class DOMScatterContainer { /** * @constructor * @param {DOM node} element - DOM element that receives events * @param {Bool} stopEvents - Whether events should be stopped or propagated * @param {Bool} claimEvents - Whether events should be marked as claimed * if findTarget return as non-null value. * @param {String} [touchAction=none] - CSS to set touch action style, needed to prevent * pointer cancel events. Use null if the * the touch action should not be set. * @param {DOM node} debugCanvas - Shows debug infos about touches if not null */ constructor( element, { stopEvents = 'auto', claimEvents = true, useCapture = true, touchAction = 'none', debugCanvas = null } = {} ) { this.onCapture = null this.element = element if (stopEvents === 'auto') { /* The events have to be stopped in Safari, otherwise the whole page will be zoomed with a pinch gesture (preventDefault in method preventPinch). In order to enable the movement of scatter objects, the touchmove event has to be bound again. */ if (Capabilities.isSafari) { document.addEventListener( 'touchmove', event => this.preventPinch(event), false ) stopEvents = false } else { stopEvents = true } } this.stopEvents = stopEvents this.claimEvents = claimEvents if (touchAction !== null) { Elements.setStyle(element, { touchAction }) } this.scatter = new Map() this.delegate = new InteractionMapper(element, this, { useCapture, mouseWheelElement: window }) if (debugCanvas !== null) { requestAnimationFrame(dt => { this.showTouches(dt, debugCanvas) }) } } showTouches(dt, canvas) { let resolution = window.devicePixelRatio let current = this.delegate.interaction.current let context = canvas.getContext('2d') let radius = 20 * resolution context.clearRect(0, 0, canvas.width, canvas.height) context.fillStyle = 'rgba(0, 0, 0, 0.3)' context.lineWidth = 2 context.strokeStyle = '#003300' for (let [key, point] of current.entries()) { let local = point context.beginPath() context.arc( local.x * resolution, local.y * resolution, radius, 0, 2 * Math.PI, false ) context.fill() context.stroke() } requestAnimationFrame(dt => { this.showTouches(dt, canvas) }) } preventPinch(event) { event = event.originalEvent || event if (event.scale !== 1) { event.preventDefault() } } add(scatter) { this.scatter.set(scatter.element, scatter) } capture(event) { if (this.onCapture) { return this.onCapture(event) } if (event.target == this.element && this.stopEvents) { Events.stop(event) } return true } mapPositionToPoint(point) { return Points.fromPageToNode(this.element, point) } isDescendant(parent, child, clickable = false) { if (parent == child) return true let node = child.parentNode while (node != null) { if (!clickable && node.onclick) { return false } if (node == parent) { return true } node = node.parentNode } return false } findTarget(event, local, global) { /*** Note that elementFromPoint works with clientX, clientY, not pageX, pageY The important point is that event should not be used, since the TouchEvent points are hidden in sub objects. ***/ let found = document.elementFromPoint(global.x, global.y) for (let target of this.scatter.values()) { if ( target.interactive && this.isDescendant(target.element, found) ) { if (this.stopEvents) Events.stop(event) if (this.claimEvents) event.claimedByScatter = target return target } } return null } get center() { let r = this.bounds let w2 = r.width / 2 let h2 = r.height / 2 return { x: w2, y: h2 } } get bounds() { return this.element.getBoundingClientRect() } get polygon() { let r = this.bounds let w2 = r.width / 2 let h2 = r.height / 2 let center = { x: w2, y: h2 } let polygon = new Polygon(center) polygon.addPoint({ x: -w2, y: -h2 }) polygon.addPoint({ x: w2, y: -h2 }) polygon.addPoint({ x: w2, y: h2 }) polygon.addPoint({ x: -w2, y: h2 }) return polygon } } export class DOMScatter extends AbstractScatter { constructor( element, container, { startScale = 1.0, minScale = 0.1, maxScale = 1.0, overdoScaling = 1.5, autoBringToFront = true, translatable = true, scalable = true, rotatable = true, movableX = true, movableY = true, rotationDegrees = null, rotation = null, onTransform = null, transformOrigin = 'center center', // extras which are in part needed x = 0, y = 0, width = null, // required height = null, // required resizable = false, tapDelegate = null, triggerSVGClicks = false, allowClickDistance = 44, verbose = true, onResize = null, touchAction = 'none', throwVisibility = 44, throwDamping = 0.95, autoThrow = true, scaleAutoClose = false, onClose = null, scaleCloseThreshold = 0.1, scaleCloseBuffer = 0.05, useLowPassFilter = true, maxRotation = Angle.degree2radian(15) } = {} ) { super({ minScale, maxScale, startScale, overdoScaling, autoBringToFront, translatable, scalable, rotatable, movableX, movableY, resizable, rotationDegrees, rotation, onTransform, throwVisibility, throwDamping, autoThrow, scaleAutoClose, scaleCloseThreshold, scaleCloseBuffer, onClose, useLowPassFilter, maxRotation }) if (container == null || width == null || height == null) { throw new Error('Invalid value: null') } element.scatter = this this.element = element this.x = x this.y = y this.oldX = 0 this.oldY = 0 this.meanX = x this.meanY = y this.width = width this.height = height this.throwVisibility = Math.min(width, height, throwVisibility) this.container = container this.tapDelegate = tapDelegate this.scale = startScale this.rotationDegrees = this.startRotationDegrees this.transformOrigin = transformOrigin this.initialValues = { x: x, y: y, width: width, height: height, scale: startScale, rotation: this.startRotationDegrees, transformOrigin: transformOrigin } this.tapNodes = new Map() // For tweenlite we need initial values in _gsTransform TweenLite.set(element, this.initialValues) this.onResize = onResize this.verbose = verbose if (touchAction !== null) { Elements.setStyle(element, { touchAction }) } this.resizeButton = null if (resizable) { let button = document.createElement('div') button.style.position = 'absolute' button.style.right = '0px' button.style.bottom = '0px' button.style.width = '50px' button.style.height = '50px' button.className = 'interactiveElement' this.element.appendChild(button) button.addEventListener('pointerdown', e => { this.startResize(e) }) button.addEventListener('pointermove', e => { this.resize(e) }) button.addEventListener('pointerup', e => { this.stopResize(e) }) this.resizeButton = button } if (tapDelegate) { tapDelegate.handleClicks() } container.add(this) } /** Returns geometry data as object. **/ getState() { return { scale: this.scale, x: this.x, y: this.y, rotation: this.rotation } } close() { super.close() let parent = this.element.parentNode if (parent) parent.removeChild(this.element) } get rotationOrigin() { return this.center } get x() { return this._x } get y() { return this._y } set x(value) { this._x = value TweenLite.set(this.element, { x: value }) } set y(value) { this._y = value TweenLite.set(this.element, { y: value }) } get position() { let transform = this.element._gsTransform let x = transform.x let y = transform.y return { x, y } } get origin() { let p = this.fromNodeToPage(0, 0) return Points.fromPageToNode(this.container.element, p) } get bounds() { let stage = this.container.element.getBoundingClientRect() let rect = this.element.getBoundingClientRect() return { top: rect.top - stage.top, left: rect.left - stage.left, width: rect.width, height: rect.height } } get center() { let r = this.bounds let w2 = r.width / 2 let h2 = r.height / 2 // if (this.resizable) { // w2 *= this.scale // h2 *= this.scale // } var x = r.left + w2 var y = r.top + h2 return { x, y } } set rotation(radians) { let rad = radians // Angle.normalize(radians) let degrees = Angle.radian2degree(rad) TweenLite.set(this.element, { rotation: degrees }) this._rotation = rad } set rotationDegrees(degrees) { let deg = degrees // Angle.normalizeDegree(degrees) TweenLite.set(this.element, { rotation: deg }) this._rotation = Angle.degree2radian(deg) } get rotation() { return this._rotation } get rotationDegrees() { return this._rotation } set scale(scale) { TweenLite.set(this.element, { scale: scale, transformOrigin: this.transformOrigin }) this._scale = scale } get scale() { return this._scale } get containerBounds() { return this.container.bounds } get containerPolygon() { return this.container.polygon } mapPositionToContainerPoint(point) { return this.container.mapPositionToPoint(point) } capture(event) { return true } reset() { TweenLite.set(this.element, this.initialValues) } hide() { TweenLite.to(this.element, 0.1, { display: 'none', onComplete: e => { this.element.parentNode.removeChild(this.element) } }) } show() { TweenLite.set(this.element, { display: 'block' }) } showAt(p, rotationDegrees) { TweenLite.set(this.element, { display: 'block', x: p.x, y: p.y, rotation: rotationDegrees, transformOrigin: this.transformOrigin }) } bringToFront() { // this.element.parentNode.appendChild(this.element) // uo: On Chome and Electon appendChild leads to flicker TweenLite.set(this.element, { zIndex: DOMScatter.zIndex++ }) } onTap(event, interaction, point) { if (this.tapDelegate) { Events.stop(event) this.tapDelegate.tap(event, 'scatter') } } isDescendant(parent, child) { let node = child.parentNode while (node != null) { if (node == parent) { return true } node = node.parentNode } return false } fromPageToNode(x, y) { return Points.fromPageToNode(this.element, { x, y }) } fromNodeToPage(x, y) { return Points.fromNodeToPage(this.element, { x, y }) } _move(delta) { // UO: We need to keep TweenLite's _gsTransform and the private // _x and _y attributes aligned let x = this.element._gsTransform.x let y = this.element._gsTransform.y if (this.movableX) { x += delta.x } if (this.movableY) { y += delta.y } this._x = x this._y = y TweenLite.set(this.element, { x: x, y: y }) } resizeAfterTransform(zoom) { if (this.onResize) { let w = this.width * this.scale let h = this.height * this.scale let event = new ResizeEvent(this, { width: w, height: h }) this.onResize(event) } } startResize(e) { e.preventDefault() let event = new CustomEvent('resizeStarted') let oldPostition = { x: this.element.getBoundingClientRect().left, y: this.element.getBoundingClientRect().top } this.bringToFront() this.element.style.transformOrigin = '0% 0%' let newPostition = { x: this.element.getBoundingClientRect().left, y: this.element.getBoundingClientRect().top } let offset = Points.subtract(oldPostition, newPostition) this.oldX = e.clientX this.oldY = e.clientY e.target.setAttribute('resizing', 'true') e.target.setPointerCapture(e.pointerId) TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } }) TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } }) this.element.dispatchEvent(event) } resize(e) { e.preventDefault() let rotation = Angle.radian2degree(this.rotation) rotation = (rotation + 360) % 360 let event = new CustomEvent('resized') if (e.target.getAttribute('resizing') == 'true') { let deltaX = e.clientX - this.oldX let deltaY = e.clientY - this.oldY let r = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) let phi = Angle.radian2degree(Math.atan2(deltaX, deltaY)) phi = (phi + 630) % 360 let rot = (rotation + 90 + 630) % 360 let diffAngle = (0 + rot + 360) % 360 let phiCorrected = (phi + diffAngle + 360) % 360 let resizeW = r * Math.cos(Angle.degree2radian(phiCorrected)) let resizeH = -r * Math.sin(Angle.degree2radian(phiCorrected)) if ( (this.element.offsetWidth + resizeW) / this.scale > (this.width * 0.5) / this.scale && (this.element.offsetHeight + resizeH) / this.scale > (this.height * 0.3) / this.scale ) TweenLite.to(this.element, 0, { width: this.element.offsetWidth + resizeW / this.scale, height: this.element.offsetHeight + resizeH / this.scale }) this.oldX = e.clientX this.oldY = e.clientY this.onResizing() this.element.dispatchEvent(event) } } stopResize(e) { e.preventDefault() let event = new CustomEvent('resizeEnded') let oldPostition = { x: this.element.getBoundingClientRect().left, y: this.element.getBoundingClientRect().top } this.element.style.transformOrigin = '50% 50%' let newPostition = { x: this.element.getBoundingClientRect().left, y: this.element.getBoundingClientRect().top } let offset = Points.subtract(oldPostition, newPostition) TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } }) TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } }) e.target.setAttribute('resizing', 'false') this.element.dispatchEvent(event) } } DOMScatter.zIndex = 1000