iwmlib/lib/scatter.js

1503 lines
44 KiB
JavaScript

/* eslint-disable no-unused-vars */
/* eslint-disable no-console */
/* globals 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),
minInteractionDistance = 0,
useLowPassFilter = false
} = {}) {
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.minInteractionDistance = minInteractionDistance
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() {
console.log('SCATTER WAS CLOSED!')
this._callCloseCallbacks()
this._removeCallbacks()
this._removeSelfFromScatterContainer()
}
_callCloseCallbacks() {
if (this.onClose) {
this.onClose.forEach(callback => callback(this))
}
}
_removeCallbacks() {
this.onClose = []
this.onTransform = []
}
_removeSelfFromScatterContainer() {
// Removes self from container when it's closed.
if (this.container) {
this.container.remove(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.minInteractionDistance) {
let ratio = delta.distance / this.minInteractionDistance
rotate *= ratio
let zoomDelta = zoom - 1
zoomDelta *= ratio
zoom = 1 + zoomDelta
}
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)) {
console.log("Scatter.isTap")
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) {}
onTap(event, interaction, point) {
console.log("AbstractScatter.onTap", this.tapDelegate, interaction)
if (this.tapDelegate) {
Events.stop(event)
this.tapDelegate.tap(event, 'scatter')
}
}
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,
capturePointerEvents = 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,
capturePointerEvents,
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()
}
}
/**
* Removes an element from the scatter.
*
* @param {Scatter} scatter - Element to remove.
* @memberof DOMScatterContainer
*/
remove(scatter) {
this.scatter.delete(scatter.element)
}
/**
* Adds an element to the ScatterContainer.
*
* @param {Scatter} scatter - Element to add to the ScatterContainer.
* @memberof DOMScatterContainer
*/
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 = false,
maxRotation = Angle.degree2radian(15),
minInteractionDistance = 200
} = {}
) {
super({
minScale,
maxScale,
startScale,
overdoScaling,
autoBringToFront,
translatable,
scalable,
rotatable,
movableX,
movableY,
resizable,
rotationDegrees,
rotation,
onTransform,
throwVisibility,
throwDamping,
autoThrow,
scaleAutoClose,
scaleCloseThreshold,
scaleCloseBuffer,
onClose,
useLowPassFilter,
maxRotation,
minInteractionDistance
})
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++ })
}
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