(function () {
'use strict';
// In order to test this interface implementation run jsc interface.js
class Interface {
// Abstract interface that should be extended in interface subclasses.
// By convention all interfaces should start with an upper 'I'
static implementationError(klass) {
let interfaceKeys = Reflect.ownKeys(this.prototype);
let classKeys = Reflect.ownKeys(klass.prototype);
for (let key of interfaceKeys) {
let interfaceDesc = this.prototype[key];
let classDesc = klass.prototype[key];
if (typeof classDesc == 'undefined') return 'Missing ' + key
}
return null
}
static implementedBy(klass) {
// In the first step only checks whether the methods of this
// interface are all implemented by the given class
let error = this.implementationError(klass);
return error == null
}
// TODO: Specify optional methods
// static optionalMethods() {
// return [this.onMouseWheel]
// }
}
/** Basic Application object to be used as a singleton.
Provides an interface for automatic testing and common device specific
feature detection.
*/
class IApp extends Interface {
/** Build the app by registering event handlers,
* adding DOM elements, instanciating templates, etc...
*/
setup() {
return this
}
/** Run the application by starting a main loop, ...
*/
run() {
return this
}
}
class App extends Object {
/** Override this method to build your app.
*/
setup() {
return this
}
/** Start and run the application. Override this method with everything
that is needed to maintain your App, main loops, etc.
*/
run() {
return this
}
/** Defines all test suites. Overwrite this method to ensure that
all testable aspects of your App are evaluated.
*/
allTests() {
console.log('Overwrite App.allTests()');
}
/** Run all tests. Should return 'ok' and the amount of time needed to
run App.allTests() or a failure message with diagnostic error decription.
@return {array} - array with 'ok' as first element and needed time as
second element or "Tests failed" and an error string
*/
runTests() {
var start = performance.now();
try {
this.allTests();
var end = performance.now();
return ['ok', end - start]
} catch (e) {
console.trace();
return ['Tests failed', e.message]
}
}
}
IApp.implementedBy(App);
// Allows browsers to perform doctests.
// Uses the code highlight package from http://highlightjs.readthedocs.io
// if available
var docTestLogMessages = [];
Array.prototype.equals = function(array) {
return (
this.length == array.length &&
this.every(function(this_i, i) {
return this_i == array[i]
})
)
};
class Doctest {
static assert(value) {
if (!value) {
throw new Error('Assertion violated')
}
}
static pprint(obj) {
if (obj === null) return 'null'
let stringified = obj.toString();
if (stringified == '[object Object]') return JSON.stringify(obj)
return stringified
}
/**
* When dealing with floating point numbers,
* floating point imprecision may occur. Therefore a simple
* equality check is not sufficient.
*
* The expectPointPrecision method compares two points by calculating their difference
* and accepting the result as far as it is lower than the defined acceptable error.
*
* @static
* @param {number} a - Number a to test.
* @param {number} b - Number b to test against.
* @param {number} accetableError - Defines the value of how much the two numbers may differ, that the test still passes.
* @memberof Doctest
*/
static expectPointPrecision(pointA, pointB, accetableError = 0.000001) {
if (Math.abs(pointA.x - pointB.x) > accetableError || Math.abs(pointA.y - pointB.y) > accetableError) {
throw new Error(
`Testing difference of Points ${this.pprint(pointA)} and ${this.pprint(
pointB
)} exceeded the acceptable error of ${accetableError} (x:${Math.abs(
pointB.x - pointA.x
)}, y: ${Math.abs(pointB.y - pointA.y)}).`
)
}
}
/**
* When dealing with floating point numbers,
* floating point imprecision may occur. Therefore a simple
* equality check is not sufficient.
*
* The expectPrecise method compares two numbers by calculating their difference
* and accepting the result as far as it is lower than the defined acceptable error.
*
* @static
* @param {number} a - Number a to test.
* @param {number} b - Number b to test against.
* @param {number} accetableError - Defines the value of how much the two numbers may differ, that the test still passes.
* @memberof Doctest
*/
static expectPrecision(a, b, accetableError = 0.000001) {
let aFloat = parseFloat(a);
let bFloat = parseFloat(b);
console.log(aFloat);
if (isNaN(aFloat) || isNaN(bFloat) || Math.abs(bFloat - aFloat) > accetableError) {
throw new Error(
`Testing difference of ${this.pprint(a)} and ${this.pprint(
b
)} exceeded the acceptable error of ${accetableError} (${Math.abs(bFloat - aFloat)}).`
)
}
}
static expect(expr, value) {
if (this.pprint(expr) != this.pprint(value)) {
//throw new Error("got `" + expr + "` but expected `" + value + "`.")
throw new Error('got `' + this.pprint(expr) + '` but expected `' + this.pprint(value) + '`.')
}
}
static expectError(error, message) {
let index = error.toString().indexOf(message);
if (index < 0) {
throw new Error('got `' + message + '` but expected `' + error + '`.')
}
}
static expectLog(...messages) {
// if (!docTestLogMessages.equals(messages)) {
docTestLogMessages.forEach((msg, i) => {
if (msg != messages[i]) throw new Error('Unexpected log message: `' + messages[i] + '`.')
});
// throw new Error('Uups')
//}
}
static log(message) {
docTestLogMessages.push(message);
}
static highlight(code) {
if (typeof hljs == 'undefined') return code
return hljs.highlight('javascript', code)
}
static stripLeadingLines(code) {
let result = [];
let informative = false;
for (let line of code.split('\n')) {
if (line.trim().length > 0) {
informative = true;
}
if (informative) result.push(line);
}
return result.join('\n')
}
static event(type = 'mouse', { clientX = 0, clientY = 0 } = {}) {
if (type.startsWith('mouse')) {
return new MouseEvent(type, { clientX, clientY })
}
return { type, clientX, clientY }
}
static run(replaceExpect = false) {
if (typeof hljs != 'undefined') {
hljs.initHighlighting();
}
let doctests = document.querySelectorAll('.doctest');
for (let i = 0; i < doctests.length; i++) {
let doctest = doctests[i];
let code = this.stripLeadingLines(doctest.innerHTML);
let text = this.highlight(code);
let container = document.createElement('div');
container.className = 'doctest-wrapper';
if (doctest.hasAttribute('data-title') || doctest.hasAttribute('data-collapsible')) {
let titlebar = document.createElement('div');
titlebar.className = 'doctest-titlebar';
titlebar.style = 'min-height: 10px;';
container.appendChild(titlebar);
if (doctest.hasAttribute('data-title')) {
let title = document.createElement('h6');
title.innerText = doctest.getAttribute('data-title');
title.className = 'doctest-section-title';
titlebar.appendChild(title);
}
if (doctest.hasAttribute('data-collapsible')) {
let icon = document.createElement('i');
icon.className = 'material-icons';
titlebar.classList.add('doctest-collapsible-toggle');
if (titlebar.childNodes.length > 0) {
titlebar.insertBefore(icon, titlebar.childNodes[0]);
} else {
titlebar.appendChild(icon);
}
const collapsedClass = 'collapsed';
function setToggleMode(collapse) {
if (collapse) {
container.classList.add(collapsedClass);
icon.innerText = 'arrow_drop_down';
} else {
container.classList.remove(collapsedClass);
icon.innerText = 'arrow_drop_up';
}
}
function determineToggleMode() {
setToggleMode(!container.classList.contains(collapsedClass));
}
setToggleMode(doctest.hasAttribute('data-collapsed'));
titlebar.addEventListener('click', determineToggleMode);
}
}
let pre = document.createElement('pre');
pre.className = 'hljs doctest';
// See http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work
// let re = /Doctest\.expect\(([\s\S]*)[\,\s\S]*([\s\S]*)\)/g
let lines = text.value ? text.value.split('\n') : text.split('\n');
let better = [];
for (let line of lines) {
if (replaceExpect && line.trim().startsWith('Doctest.expect(')) {
line = line.replace(/Doctest\.expect\(/, '>>> ').trim();
if (line.endsWith(')') || line.endsWith(',')) {
line = line.slice(0, -1);
}
}
better.push(line);
}
pre.innerHTML = better.join('\n'); // text.value.replace(re, ">>> $1\n$2")
container.appendChild(pre);
doctest.parentNode.replaceChild(container, doctest);
}
}
}
// Needed to make Doctest visible in modules
//window.Doctest = Doctest
var recordedErrors = new Map();
class Errors {
static countErrors() {
let total = 0;
for (let error of recordedErrors.keys()) {
total += recordedErrors.get(error).size;
}
return total
}
static setStyle(element, styles) {
for (let key in styles) {
element.style[key] = styles[key];
}
}
static appendError(error, source) {
if (recordedErrors.has(error)) {
let sources = recordedErrors.get(error);
sources.add(source);
} else {
recordedErrors.set(error, new Set([source]));
}
}
static showErrors() {
if (this.countErrors() == 0) {
return
}
let errors = document.getElementById('runtime-errors');
if (errors == null) {
errors = document.createElement('div');
errors.setAttribute('id', 'runtime-errors');
this.setStyle(document.body, {
border: '2px solid red'
});
this.setStyle(errors, {
position: 'absolute',
top: '0px',
padding: '8px',
width: '100%',
background: 'red',
color: 'white'
});
document.body.appendChild(errors);
let counter = document.createElement('div');
counter.setAttribute('id', 'runtime-errors-counter');
this.setStyle(counter, {
borderRadius: '50%',
width: '32px',
height: '32px',
background: 'white',
color: 'red',
fontSize: '18px',
textAlign: 'center',
lineHeight: '32px',
verticalAlign: 'middle'
});
counter.innerHTML = '1';
errors.appendChild(counter);
let header = document.createElement('div');
this.setStyle(header, {
position: 'absolute',
top: '6px',
left: '48px',
height: '44px',
fontSize: '32px'
});
header.innerHTML = 'Runtime Errors';
errors.appendChild(header);
errors.addEventListener('click', this.toggleErrors.bind(this));
}
let counter = document.getElementById('runtime-errors-counter');
counter.innerHTML = this.countErrors();
}
static expandErrors() {
let errors = document.getElementById('runtime-errors');
for (let error of recordedErrors.keys()) {
for (var source of recordedErrors.get(error)) {
if (typeof source == 'undefined') {
source = 'See console for details';
return
}
let info = document.createElement('div');
info.className = 'info';
info.style.wordWrap = 'break-word';
info.innerHTML = error + `
${source}`;
errors.appendChild(info);
}
}
}
static toggleErrors() {
let errors = document.getElementById('runtime-errors');
let infos = errors.querySelectorAll('.info');
if (infos.length > 0) {
infos.forEach(info => errors.removeChild(info));
} else {
this.expandErrors();
}
}
static removeError(event) {
console.log('removeError', event);
if (recordedErrors.has(event.error)) {
let sources = recordedErrors.get(event.error);
sources.delete(event.source);
console.log('sources', sources);
}
}
static registerGlobalErrorHandler() {
// Register more informative error handler
window.addEventListener(
'error',
event => {
// if (typeof(event.error) == 'undefined') {
// console.info("Catched undefined error", event)
// }
this.appendError(event.error, event.filename);
},
true
);
document.addEventListener('DOMContentLoaded', event => {
this.showErrors();
});
}
static registerFrameAwaitErrors() {
let iframes = document.getElementsByTagName('iframe');
for (let i = 0; i < iframes.length; i++) {
let target = iframes[i];
target.iframeTimeout = setTimeout(() => {
this.appendError('Cannot load iframe', target.src);
}, frameErrorTimeout);
target.onload = () => {
clearTimeout(target.iframeTimeout);
};
}
}
}
Errors.registerGlobalErrorHandler();
class Events {
static stop(event) {
event.preventDefault();
event.stopPropagation();
}
static extractPoint(event) {
switch (event.constructor.name) {
case 'TouchEvent':
for (let i = 0; i < event.targetTouches.length; i++) {
let t = event.targetTouches[i];
return { x: t.clientX, y: t.clientY }
}
break
default:
return { x: event.clientX, y: event.clientY }
}
}
static isCaptured(event) {
if (event.__capturedBy) return true
return false
}
static capturedBy(event, obj) {
event.__capturedBy = obj;
}
static isPointerDown(event) {
// According to
// https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
// pointer events use the buttons feature to represent pressed buttons
return event.buttons
}
static isMouseDown(event) {
// Attempts to clone the which attribute of events failed in WebKit. May
// be this is a bug or a security feature. Workaround: we introduce
// a mouseDownSubstitute attribute that can be assigned to cloned
// events after instantiation.
if (Reflect.has(event, 'mouseDownSubstitute')) return event.mouseDownSubstitute
return event.buttons || event.which
}
static isSimulatedEvent(event) {
return Reflect.has(event, 'mouseDownSubstitute')
}
static isMouseRightClick(event) {
return event.buttons || event.which
}
static extractTouches(targets) {
let touches = [];
for (let i = 0; i < targets.length; i++) {
let t = targets[i];
touches.push({
targetSelector: this.selector(t.target),
identifier: t.identifier,
screenX: t.screenX,
screenY: t.screenY,
clientX: t.clientX,
clientY: t.clientY,
pageX: t.pageX,
pageY: t.pageY
});
}
return touches
}
static createTouchList(targets) {
let touches = [];
for (let i = 0; i < targets.length; i++) {
let t = targets[i];
let touchTarget = document.elementFromPoint(t.pageX, t.pageY);
let touch = new Touch(undefined, touchTarget, t.identifier, t.pageX, t.pageY, t.screenX, t.screenY);
touches.push(touch);
}
return new TouchList(...touches)
}
static extractEvent(timestamp, event) {
let targetSelector = this.selector(event.target);
let infos = {
type: event.type,
time: timestamp,
constructor: event.constructor,
data: {
targetSelector: targetSelector,
view: event.view,
mouseDownSubstitute: event.buttons || event.which, // which cannot be cloned directly
bubbles: event.bubbles,
cancelable: event.cancelable,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
layerX: event.layerX,
layerY: event.layerY,
pageX: event.pageX,
pageY: event.pageY,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: event.shiftKey,
metaKey: event.metaKey
}
};
if (event.type.startsWith('touch')) {
// On Safari-WebKit the TouchEvent has layerX, layerY coordinates
let data = infos.data;
data.targetTouches = this.extractTouches(event.targetTouches);
data.changedTouches = this.extractTouches(event.changedTouches);
data.touches = this.extractTouches(event.touches);
}
if (event.type.startsWith('pointer')) {
let data = infos.data;
data.pointerId = event.pointerId;
data.pointerType = event.pointerType;
}
if (Events.debug) {
Events.extracted.push(this.toLine(event));
}
return infos
}
static cloneEvent(type, constructor, data) {
if (type.startsWith('touch')) {
// We need to find target from layerX, layerY
//var target = document.querySelector(data.targetSelector)
// elementFromPoint(data.layerX, data.layerY)
//data.target = target
data.targetTouches = this.createTouchList(data.targetTouches);
data.changedTouches = this.createTouchList(data.changedTouches);
data.touches = this.createTouchList(data.touches);
}
// We need to find target from pageX, pageY which are only
// available after construction. They seem to getter items.
let clone = Reflect.construct(constructor, [type, data]);
clone.mouseDownSubstitute = data.mouseDownSubstitute;
return clone
}
static simulateEvent(type, constructor, data) {
data.target = document.querySelector(data.targetSelector);
let clone = this.cloneEvent(type, constructor, data);
if (data.target != null) {
data.target.dispatchEvent(clone);
}
if (Events.debug) {
Events.simulated.push(this.toLine(clone));
}
}
static toLine(event) {
return `${event.type} #${event.target.id} ${event.clientX} ${event.clientY}`
}
static compareExtractedWithSimulated() {
if (this.extracted.length != this.simulated.length) {
alert(
'Unequal length of extracted [' +
this.extracted.length +
'] and simulated events [' +
this.simulated.length +
'].'
);
} else {
for (let i = 0; i < this.extracted.length; i++) {
var extracted = this.extracted[i];
var simulated = this.simulated[i];
if (extracted != simulated) {
console.log('Events differ:' + extracted + '|' + simulated);
}
}
}
}
static selector(context) {
return OptimalSelect.select(context)
}
static reset() {
this.extracted = [];
this.simulated = [];
}
static resetSimulated() {
this.simulated = [];
}
static showExtractedEvents(event) {
if (!event.shiftKey) {
return
}
if (this.popup == null) {
let element = document.createElement('div');
Elements.setStyle(element, {
position: 'absolute',
width: '480px',
height: '640px',
overflow: 'auto',
backgroundColor: 'lightgray'
});
document.body.appendChild(element);
this.popup = element;
}
this.popup.innerHTML = '';
for (let line of this.extracted) {
let div = document.createElement('div');
div.innerHTML = line;
this.popup.appendChild(div);
}
let div = document.createElement('div');
div.innerHTML = '------------ Simulated -----------';
this.popup.appendChild(div);
for (let line of this.simulated) {
let div = document.createElement('div');
div.innerHTML = line;
this.popup.appendChild(div);
}
Elements.setStyle(this.popup, {
left: event.clientX + 'px',
top: event.clientY + 'px'
});
}
}
Events.popup = null;
Events.debug = true;
Events.extracted = [];
Events.simulated = [];
Events.simulationRunning = false;
class EventRecorder {
constructor() {
this.recording = [];
this.recorded = [];
this.step = 0;
}
record(event) {
let length = this.recording.length;
if (length == 0) {
this.startTime = event.timeStamp;
Events.reset();
} else {
let last = this.recording[length - 1];
if (event.timeStamp < last.time) {
console.log('warning: wrong temporal order');
}
}
let t = event.timeStamp - this.startTime;
this.recording.push(Events.extractEvent(t, event));
}
stopRecording() {
this.recorded = this.recording;
this.recording = [];
console.log('Recorded ' + this.recorded.length + ' events');
}
startReplay(whileCondition = null, onComplete = null) {
this.step = 0;
Events.resetSimulated();
console.log('Start replay');
Events.simulationRunning = true;
this.replay(whileCondition, onComplete);
}
replay(whileCondition = null, onComplete = null) {
if (this.step < this.recorded.length) {
let { type, time, constructor, data } = this.recorded[this.step];
Events.simulateEvent(type, constructor, data);
this.step += 1;
let dt = 0;
if (this.step < this.recorded.length) {
var next = this.recorded[this.step];
dt = next.time - time;
if (dt < 0) {
console.log('warning: wrong temporal order');
}
}
if (whileCondition == null || whileCondition()) {
let delta = Math.round(dt);
setTimeout(() => this.replay(whileCondition, onComplete), delta);
}
} else {
console.log('Played ' + this.step + ' events' + onComplete);
Events.simulationRunning = false;
if (onComplete != null) {
onComplete();
}
//Events.compareExtractedWithSimulated()
}
}
}
/* globals */
/** Tests whether an object is empty
* @param {Object} obj - the object to be tested
* @return {boolean}
*/
function isEmpty(obj) {
// > isEmpty({})
// true
for (let i in obj) {
return false
}
return true
}
function lerp(start, stop, amt) {
return amt * (stop - start) + start
}
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
// Taken from: https://davidwalsh.name/essential-javascript-functions
function debounce(func, wait, immediate) {
let timeout;
return function() {
let context = this,
args = arguments;
let later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
let callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
}
}
/** Returns an id that is guaranteed to be unique within the livetime of the
* application
* @return {string}
*/
let _idGenerator = 0;
function getId$1() {
return 'id' + _idGenerator++
}
function randomInt(min = 0, max = 100) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
function randomFloat(min = 0.0, max = 1.0) {
return Math.random() * (max - min) + min
}
class Dates {
static create(fullYear, month, day) {
return new Date(Date.UTC(fullYear, month, day))
}
static daysInMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
}
static startYearRange(date) {
return new Date(Date.UTC(date.getFullYear() - 1, 11, 31, 23, 59, 59, 999))
}
static endYearRange(date) {
return new Date(Date.UTC(date.getFullYear() + 1, 0, 1))
}
static prevYear(date, offset = 1) {
return this.create(date.getFullYear() - offset, 0, 1)
}
static nextYear(date, offset = 1) {
return this.create(date.getFullYear() + offset, 0, 1)
}
static nextMonth(date) {
return this.create(date.getFullYear(), date.getMonth() + 1, 1)
}
static nextDay(date) {
return this.create(date.getFullYear(), date.getMonth(), date.getDate() + 1)
}
static nextHour(date) {
// See http://stackoverflow.com/questions/1050720/adding-hours-to-javascript-date-object
return new Date(date.getTime() + 60 * 60 * 1000)
}
static nextMinute(date) {
// See above
return new Date(date.getTime() + 60 * 1000)
}
static nextSecond(date) {
// See above
return new Date(date.getTime() + 1000)
}
static nextMillisecond(date) {
// See above
return new Date(date.getTime() + 1)
}
static *iterYears(start, end) {
let date = this.create(start.getFullYear(), 0, 1);
while (date <= end) {
yield date;
date = this.nextYear(date);
}
yield date;
}
static *iterMonths(year, limit = 12) {
let month = 0;
while (month < limit) {
let date = this.create(year.getFullYear(), month, 1);
yield date;
month += 1;
}
}
static *iterMonthsOfYears(years) {
for (let year of years) {
for (let month of this.iterMonths(year)) {
yield month;
}
}
}
static *iterDays(month) {
let day = 1;
let limit = Dates.daysInMonth(month);
while (day <= limit) {
let date = this.create(month.getFullYear(), month.getMonth(), day);
yield date;
day += 1;
}
}
static *iterDaysOfMonths(months) {
for (let month of months) {
for (let day of this.iterDays(month)) {
yield day;
}
}
}
}
/* Color conversion functions */
class Colors {
// http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
static rgb2num(red, green, blue) {
let rgb = blue | (green << 8) | (red << 16);
return 0x000000 + rgb
}
static rgb2hex(red, green, blue) {
let rgb = blue | (green << 8) | (red << 16);
return '#' + (0x1000000 + rgb).toString(16).slice(1)
}
static hex2rgb(hex) {
// long version
let r = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (r) {
return r.slice(1, 4).map(x => {
return parseInt(x, 16)
})
}
// short version
r = hex.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (r) {
return r.slice(1, 4).map(x => {
return 0x11 * parseInt(x, 16)
})
}
return null
}
static rgb(r, g, b) {
return { r, g, b }
}
static string2hex(str) {
return parseInt('0x' + str.slice(1))
}
static lerp(rgb1, rgb2, amount) {
return {
r: Math.round(lerp(rgb1.r, rgb2.r, amount)),
g: Math.round(lerp(rgb1.g, rgb2.g, amount)),
b: Math.round(lerp(rgb1.b, rgb2.b, amount))
}
}
static get violet() {
return Colors.rgb2num(89, 34, 131)
}
static get steelblue() {
return Colors.rgb2num(0, 130, 164)
}
static get ochre() {
return Colors.rgb2num(181, 157, 0)
}
static get turquoise() {
return Colors.rgb2num(34, 164, 131)
}
static get eminence() {
return Colors.rgb2num(150, 60, 134)
}
static random() {
let r = Math.round(Math.random() * 255);
let g = Math.round(Math.random() * 255);
let b = Math.round(Math.random() * 255);
return Colors.rgb2num(r, g, b)
}
}
class Cycle extends Array {
constructor(...items) {
super();
for (let item of items) {
this.push(item);
}
this.index = 0;
}
next() {
if (this.index == this.length) {
this.index = 0;
}
return this[this.index++]
}
current() {
if (this.index === this.length) {
this.index = 0;
}
return this[this.index]
}
}
/** Static methods to compute 2D points with x and y coordinates.
*/
class Points$1 {
static length(a) {
return Math.sqrt(a.x * a.x + a.y * a.y)
}
static normalize(p) {
let len = this.length(p);
return this.multiplyScalar(p, 1 / len)
}
static mean(a, b) {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
}
static subtract(a, b) {
return { x: a.x - b.x, y: a.y - b.y }
}
static multiply(a, b) {
return { x: a.x * b.x, y: a.y * b.y }
}
static divide(a, b) {
return { x: a.x / b.x, y: a.y / b.y }
}
static multiplyScalar(a, b) {
return { x: a.x * b, y: a.y * b }
}
static add(a, b) {
return { x: a.x + b.x, y: a.y + b.y }
}
static negate(p) {
return { x: -p.x, y: -p.y }
}
static angle(p1, p2) {
return Math.atan2(p1.y - p2.y, p1.x - p2.x)
}
static normalizedAngle(p1, p2) {
return Angle.normalize(this.angle(p1, p2))
}
static normalized2Angle(p1, p2) {
return Angle.normalize2(this.angle(p1, p2))
}
static arc(p, alpha, radius) {
return {
x: p.x + radius * Math.cos(alpha),
y: p.y + radius * Math.sin(alpha)
}
}
static distance(a, b) {
let dx = a.x - b.x;
let dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy)
}
// Distance == 0.0 indicates an inside relation.
static distanceToRect(p, r) {
let cx = Math.max(Math.min(p.x, r.x + r.width), r.x);
let cy = Math.max(Math.min(p.y, r.y + r.height), r.y);
return Math.sqrt((p.x - cx) * (p.x - cx) + (p.y - cy) * (p.y - cy))
}
static fromPageToNode(element, p) {
// if (window.webkitConvertPointFromPageToNode) {
// return window.webkitConvertPointFromPageToNode(element,
// new WebKitPoint(p.x, p.y))
// }
return window.convertPointFromPageToNode(element, p.x, p.y)
}
static fromNodeToPage(element, p) {
// if (window.webkitConvertPointFromNodeToPage) {
// return window.webkitConvertPointFromNodeToPage(element,
// new WebKitPoint(p.x, p.y))
// }
return window.convertPointFromNodeToPage(element, p.x, p.y)
}
}
/**
* A helper class for common set operations.
*
* @export
* @class Sets
*/
class Sets {
/**
* Returns the intersection of all sets
* https://stackoverflow.com/questions/31930894/javascript-set-data-structure-intersect
* @static
* @param {*} sets
* @returns
* @memberof Sets
*/
static intersect(...sets) {
if (!sets.length) return new Set()
const i = sets.reduce((m, s, i) => (s.size < sets[m].size ? i : m), 0);
const [smallest] = sets.splice(i, 1);
const res = new Set();
for (let val of smallest) if (sets.every(s => s.has(val))) res.add(val);
return res
}
/**
* Returns the union of all sets
*
* @static
* @param {*} sets
* @returns
* @memberof Sets
*/
static union(...sets) {
let result = new Set();
for (let set of sets) {
for (let m of set) {
result.add(m);
}
}
return result
}
/**
* Returns the difference of the given sets. Starts with the first set and removing all elements of the following sets.
*
* @static
* @param {*} set
* @param {*} sets
* @returns
* @memberof Sets
*/
static difference(set, ...sets) {
let result = new Set();
for (let m of set) {
result.add(m);
}
for (let s of sets) {
for (let m of s) {
result.delete(m);
}
}
return result
}
}
/** Static methods to compute angles.
*/
class Angle {
static normalize(angle) {
let TAU = Math.PI * 2.0;
while (angle > Math.PI) {
angle -= TAU;
}
while (angle < -Math.PI) {
angle += TAU;
}
return angle
}
static normalize2(angle) {
let TAU = Math.PI * 2.0;
while (angle > TAU) {
angle -= TAU;
}
while (angle < 0) {
angle += TAU;
}
return angle
}
static normalizeDegree(angle) {
let full = 360.0;
while (angle > 180.0) {
angle -= full;
}
while (angle < -180.0) {
angle += full;
}
return angle
}
static normalizedDiff(a, b) {
return this.normalize(this.diff(a, b))
}
static normalized2Diff(a, b) {
return this.normalize2(this.diff(a, b))
}
static diff(a, b) {
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
}
static degree2radian(degree) {
return (Math.PI * degree) / 180.0
}
static radian2degree(rad) {
return (180.0 / Math.PI) * rad
}
}
class Elements$1 {
static setStyle(element, styles) {
for (let key in styles) {
element.style[key] = styles[key];
}
}
static addClass(element, cssClass) {
element.classList.add(cssClass);
}
static removeClass(element, cssClass) {
element.classList.remove(cssClass);
}
static toggleClass(element, cssClass) {
element.classList.toggle(cssClass);
}
static hasClass(element, cssClass) {
return element.classList.contains(cssClass)
}
}
class MapProxy {
/* This class is needed if we want to use the interaction classes
in Firefox 45.8 and modern Browsers.
A workaround for https://github.com/babel/babel/issues/2334
*/
constructor() {
this.map = new Map();
}
get size() {
return this.map.size
}
get(key) {
return this.map.get(key)
}
set(key, value) {
return this.map.set(key, value)
}
delete(key) {
return this.map.delete(key)
}
clear() {
return this.map.clear()
}
has(key) {
return this.map.has(key)
}
keys() {
return this.map.keys()
}
values() {
return this.map.values()
}
entries() {
return this.map.entries()
}
forEach(func) {
this.map.forEach(func);
}
}
/* Based om https://gist.github.com/cwleonard/e124d63238bda7a3cbfa */
class Polygon {
/*
* This is the Polygon constructor. All points are center-relative.
*/
constructor(center) {
this.points = new Array();
this.center = center;
}
/*
* Point x and y values should be relative to the center.
*/
addPoint(p) {
this.points.push(p);
}
/*
* Point x and y values should be absolute coordinates.
*/
addAbsolutePoint(p) {
this.points.push({ x: p.x - this.center.x, y: p.y - this.center.y });
}
/*
* Returns the number of sides. Equal to the number of vertices.
*/
getNumberOfSides() {
return this.points.length
}
/*
* rotate the polygon by a number of radians
*/
rotate(rads) {
for (let i = 0; i < this.points.length; i++) {
let x = this.points[i].x;
let y = this.points[i].y;
this.points[i].x = Math.cos(rads) * x - Math.sin(rads) * y;
this.points[i].y = Math.sin(rads) * x + Math.cos(rads) * y;
}
}
/*
* The draw function takes as a parameter a Context object from
* a Canvas element and draws the polygon on it.
*/
draw(context, { lineWidth = 2, stroke = '#000000', fill = null } = {}) {
context.beginPath();
context.moveTo(this.points[0].x + this.center.x, this.points[0].y + this.center.y);
for (let i = 1; i < this.points.length; i++) {
context.lineTo(this.points[i].x + this.center.x, this.points[i].y + this.center.y);
}
context.closePath();
context.lineWidth = lineWidth;
if (stroke) {
context.strokeStyle = stroke;
context.stroke();
}
if (fill) {
context.fillStyle = fill;
context.fill();
}
}
absolutePoints() {
let result = new Array();
for (let p of this.points) {
result.push(Points$1.add(p, this.center));
}
return result
}
flatAbsolutePoints() {
let result = new Array();
for (let p of this.points) {
let a = Points$1.add(p, this.center);
result.push(a.x);
result.push(a.y);
}
return result
}
/*
* This function returns true if the given point is inside the polygon,
* and false otherwise.
*/
containsPoint(pnt) {
let nvert = this.points.length;
let testx = pnt.x;
let testy = pnt.y;
let vertx = new Array();
for (let q = 0; q < this.points.length; q++) {
vertx.push(this.points[q].x + this.center.x);
}
let verty = new Array();
for (let w = 0; w < this.points.length; w++) {
verty.push(this.points[w].y + this.center.y);
}
let i,
j = 0;
let c = false;
for (i = 0, j = nvert - 1; i < nvert; j = i++) {
if (
verty[i] > testy != verty[j] > testy &&
testx < ((vertx[j] - vertx[i]) * (testy - verty[i])) / (verty[j] - verty[i]) + vertx[i]
)
c = !c;
}
return c
}
multiplyScalar(scale) {
let center = Points$1.multiplyScalar(this.center, scale);
let clone = new Polygon(center);
for (let p of this.points) {
clone.addPoint(Points$1.multiplyScalar(p, scale));
}
return clone
}
/*
* To detect intersection with another Polygon object, this
* function uses the Separating Axis Theorem. It returns false
* if there is no intersection, or an object if there is. The object
* contains 2 fields, overlap and axis. Moving the polygon by overlap
* on axis will get the polygons out of intersection.
*/
intersectsWith(other) {
let axis = { x: 0, y: 0 };
let tmp, minA, maxA, minB, maxB;
let side, i;
let smallest = null;
let overlap = 99999999;
/* test polygon A's sides */
for (side = 0; side < this.getNumberOfSides(); side++) {
/* get the axis that we will project onto */
if (side == 0) {
axis.x = this.points[this.getNumberOfSides() - 1].y - this.points[0].y;
axis.y = this.points[0].x - this.points[this.getNumberOfSides() - 1].x;
} else {
axis.x = this.points[side - 1].y - this.points[side].y;
axis.y = this.points[side].x - this.points[side - 1].x;
}
/* normalize the axis */
tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
axis.x /= tmp;
axis.y /= tmp;
/* project polygon A onto axis to determine the min/max */
minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y;
for (i = 1; i < this.getNumberOfSides(); i++) {
tmp = this.points[i].x * axis.x + this.points[i].y * axis.y;
if (tmp > maxA) maxA = tmp;
else if (tmp < minA) minA = tmp;
}
/* correct for offset */
tmp = this.center.x * axis.x + this.center.y * axis.y;
minA += tmp;
maxA += tmp;
/* project polygon B onto axis to determine the min/max */
minB = maxB = other.points[0].x * axis.x + other.points[0].y * axis.y;
for (i = 1; i < other.getNumberOfSides(); i++) {
tmp = other.points[i].x * axis.x + other.points[i].y * axis.y;
if (tmp > maxB) maxB = tmp;
else if (tmp < minB) minB = tmp;
}
/* correct for offset */
tmp = other.center.x * axis.x + other.center.y * axis.y;
minB += tmp;
maxB += tmp;
/* test if lines intersect, if not, return false */
if (maxA < minB || minA > maxB) {
return false
} else {
let o = maxA > maxB ? maxB - minA : maxA - minB;
if (o < overlap) {
overlap = o;
smallest = { x: axis.x, y: axis.y };
}
}
}
/* test polygon B's sides */
for (side = 0; side < other.getNumberOfSides(); side++) {
/* get the axis that we will project onto */
if (side == 0) {
axis.x = other.points[other.getNumberOfSides() - 1].y - other.points[0].y;
axis.y = other.points[0].x - other.points[other.getNumberOfSides() - 1].x;
} else {
axis.x = other.points[side - 1].y - other.points[side].y;
axis.y = other.points[side].x - other.points[side - 1].x;
}
/* normalize the axis */
tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
axis.x /= tmp;
axis.y /= tmp;
/* project polygon A onto axis to determine the min/max */
minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y;
for (i = 1; i < this.getNumberOfSides(); i++) {
tmp = this.points[i].x * axis.x + this.points[i].y * axis.y;
if (tmp > maxA) maxA = tmp;
else if (tmp < minA) minA = tmp;
}
/* correct for offset */
tmp = this.center.x * axis.x + this.center.y * axis.y;
minA += tmp;
maxA += tmp;
/* project polygon B onto axis to determine the min/max */
minB = maxB = other.points[0].x * axis.x + other.points[0].y * axis.y;
for (i = 1; i < other.getNumberOfSides(); i++) {
tmp = other.points[i].x * axis.x + other.points[i].y * axis.y;
if (tmp > maxB) maxB = tmp;
else if (tmp < minB) minB = tmp;
}
/* correct for offset */
tmp = other.center.x * axis.x + other.center.y * axis.y;
minB += tmp;
maxB += tmp;
/* test if lines intersect, if not, return false */
if (maxA < minB || minA > maxB) {
return false
} else {
let o = maxA > maxB ? maxB - minA : maxA - minB;
if (o < overlap) {
overlap = o;
smallest = { x: axis.x, y: axis.y };
}
}
}
return { overlap: overlap + 0.001, axis: smallest }
}
static fromPoints(points) {
let min = { x: Number.MAX_VALUE, y: Number.MAX_VALUE };
let max = { x: Number.MIN_VALUE, y: Number.MIN_VALUE };
for (let p of points) {
min.x = Math.min(p.x, min.x);
max.x = Math.max(p.x, max.x);
min.y = Math.min(p.y, min.y);
max.y = Math.max(p.y, max.y);
}
let center = Points$1.mean(min, max);
let polygon = new Polygon(center);
for (let p of points) {
polygon.addAbsolutePoint(p);
}
return polygon
}
}
/**
* Util functions to deal with DOMRects.
*/
class Rect$1 {
/**
* Test if a given point is contained by the provided Rect.
*
* @static
* @param {DOMRect} rect - Rectangle to check the collision with.
* @param {Point} point - Point that should be tested.
* @returns {boolean} - True if point is inside of rect, otherwise false.
* @memberof Rect
*/
static contains(rect, point) {
return point.x > rect.left && point.x < rect.x + rect.right && point.y > rect.top && point.y < rect.bottom
}
/**
*Returns the position of an rect as point object.
*
* @static
* @param {Rect} rect - The rectangle we want to get the position from.
* @returns {Point} - Returns the position as Point.
* @memberof Rect
*/
static getPosition(rect) {
return { x: rect.x, y: rect.y }
}
}
/** String utility functions */
class Strings {
static toUpperCaseFirstChar(str) {
return str.substr(0, 1).toUpperCase() + str.substr(1)
}
static toLowerCaseFirstChar(str) {
return str.substr(0, 1).toLowerCase() + str.substr(1)
}
static toUpperCaseEachWord(str, delim = ' ') {
return str
.split(delim)
.map(v => v.toUpperCaseFirstChar())
.join(delim)
}
static toLowerCaseEachWord(str, delim = ' ') {
return str
.split(delim)
.map(v => v.toLowerCaseFirstChar())
.join(delim)
}
}
class LowPassFilter {
constructor(smoothing = 0.5, bufferMaxSize = 10) {
this.smoothing = smoothing; // must be smaller than 1
this.buffer = []; // FIFO queue
this.bufferMaxSize = bufferMaxSize;
}
/**
* Setup buffer with array of values
*
* @param {array} values
* @returns {array}
* @access public
*/
setup(values) {
for (let i = 0; i < values.length; i++) {
this.__push(values[i]);
}
return this.buffer
}
/**
* Clear buffer to prepare for new values.
*
* @access public
*/
clear() {
this.buffer = [];
}
/**
* Add new value to buffer (FIFO queue)
*
* @param {integer|float} value
* @returns {integer|float}
* @access private
*/
__push(value) {
let removed = this.buffer.length === this.bufferMaxSize ? this.buffer.shift() : 0;
this.buffer.push(value);
return removed
}
/**
* Smooth value from stream
*
* @param {integer|float} nextValue
* @returns {integer|float}
* @access public
*/
next(nextValue) {
// push new value to the end, and remove oldest one
let removed = this.__push(nextValue);
// smooth value using all values from buffer
let result = this.buffer.reduce((last, current) => {
return this.smoothing * current + (1 - this.smoothing) * last
}, removed);
// replace smoothed value
this.buffer[this.buffer.length - 1] = result;
return result
}
/**
* Smooth array of values
*
* @param {array} values
* @returns {undefined}
* @access public
*/
smoothArray(values) {
let value = values[0];
for (let i = 1; i < values.length; i++) {
let currentValue = values[i];
value += (currentValue - value) * this.smoothing;
values[i] = Math.round(value);
}
return values
}
}
/* eslint-disable no-undef */
/* eslint-disable no-console */
/* eslint-disable no-unused-vars */
let ipc = null;
let logMessages = new Set();
let logHandlers = {
log: console.log,
warn: console.warn,
error: console.error
};
try {
ipc = require('electron').ipcRenderer;
logHandlers.log = message => ipc.send('log', message);
logHandlers.warn = message => ipc.send('warn', message);
logHandlers.error = message => ipc.send('error', message);
} catch (e) {
console.log('Cannot use electron logging.');
}
/** Basic class for app specific logging requirements.
* Can be used to implement persistent logging in electron apps.
* Uses a logMessage cache to prevent error overflows. This is
* needed since errors may occur very frequently
* (e.g. display update loops at 60fps, programmatic loops, ...).
*
* The logging handlers can be overwritten by calling the static
* setup method.
*/
class Logging$1 {
/** Static log function.
* @param {*} message
*/
static log(message) {
logHandlers.log(message);
}
/**
* Static warn function.
* Emits each warning only once per session.
* @param {*} message
*/
static warn(message) {
if (!logMessages.has(message)) {
logMessages.add(message);
logHandlers.warn(message);
}
}
/**
* Static error function.
* Emits each error message only once per session.
* @param {*} message
*/
static error(message) {
if (!logMessages.has(message)) {
logMessages.add(message);
logHandlers.error(message);
}
}
static setup({ log = console.log, warn = console.warn, error = console.error } = {}) {
logHandlers.log = log;
logHandlers.warn = warn;
logHandlers.error = error;
}
}
/* eslint-disable no-unused-vars */
/** Interaction patterns
See interaction.html for explanation
*/
class IInteractionTarget extends Interface {
capture(event) {
return typeof true
}
onStart(event, interaction) {}
onMove(event, interaction) {}
onEnd(event, interaction) {}
onMouseWheel(event) {}
}
class IInteractionMapperTarget extends Interface {
capture(event) {
return typeof true
}
findTarget(event, local, global) {
return IInteractionTarget
}
}
class PointMap extends MapProxy {
// Collects touch points, mouse coordinates, etc. as key value pairs.
// Keys are pointer and touch ids, the special "mouse" key.
// Values are points, i.e. all objects with numeric x and y properties.
constructor(points = {}) {
super();
for (let key in points) {
this.set(key, points[key]);
}
}
toString() {
let points = [];
for (let key of this.keys()) {
let value = this.get(key);
points.push(`${key}:{x:${value.x}, y:${value.y}}`);
}
let attrs = points.join(', ');
return `[PointMap ${attrs}]`
}
clone() {
let result = new PointMap();
for (let key of this.keys()) {
let value = this.get(key);
result.set(key, { x: value.x, y: value.y });
}
return result
}
keyOf(value) {
for (let key of this.keys()) {
let p = this.get(key);
if (p.x == value.x && p.y == value.y) {
return key
}
}
return null
}
firstKey() {
for (let key of this.keys()) {
return key
}
return null
}
first() {
for (let key of this.keys()) {
return this.get(key)
}
return null
}
farthests() {
if (this.size == 0) {
return null
}
let pairs = [];
for (let key of this.keys()) {
let p = this.get(key);
p.key = key;
for (let k of this.keys()) {
let q = this.get(k);
q.key = k;
pairs.push([p, q]);
}
}
let sorted = pairs.sort((a, b) => {
return Points$1.distance(b[0], b[1]) - Points$1.distance(a[0], a[1])
});
return sorted[0]
}
mean() {
if (this.size == 0) {
return null
}
let x = 0.0,
y = 0.0;
for (let p of this.values()) {
x += p.x;
y += p.y;
}
return { x: x / this.size, y: y / this.size }
}
}
class InteractionDelta {
/**
*Creates an instance of InteractionDelta.
* @param {*} x
* @param {*} y
* @param {*} zoom
* @param {*} rotate
* @param {*} about
* @param {*} number - number of involved pointer
* @param {*} distance - distance of farthests touch points
* @memberof InteractionDelta
*/
constructor(x, y, zoom, rotate, about, number, distance) {
this.x = x;
this.y = y;
this.zoom = zoom;
this.rotate = rotate;
this.about = about;
this.number = number;
this.distance = distance;
}
toString() {
let values = [];
for (let key of Object.keys(this)) {
let value = this[key];
if (key == 'about') {
values.push(`${key}:{x:${value.x}, y:${value.y}}`);
} else {
values.push(`${key}:${value}`);
}
}
let attrs = values.join(', ');
return `[InteractionDelta ${attrs}]`
}
}
class InteractionPoints {
constructor(parent = null) {
this.parent = parent;
this.current = new PointMap();
this.previous = new PointMap();
this.start = new PointMap();
this.ended = new PointMap();
this.timestamps = new Map();
}
moved(key) {
let current = this.current.get(key);
let previous = this.previous.get(key);
return Points$1.subtract(current, previous)
}
move() {
let current = this.current.mean();
let previous = this.previous.mean();
return Points$1.subtract(current, previous)
}
/**
* Computes the delta between previous and current angles. Corrects
* value that are larger than 45°
* @param {*} a
* @param {*} b
* @returns delta
*/
diffAngle(a, b) {
let alpha = Math.atan2(Math.sin(a - b), Math.cos(a - b));
if (Math.abs(alpha) > Math.PI / 4) {
alpha -= Math.PI;
}
return alpha
}
/**
* Computes the delta between interaction points at t and t+1.
*
* @returns InteractionDelta
* @memberof InteractionPoints
*/
delta() {
let prev = [];
let curr = [];
let cm = { x: 0, y: 0 };
let pm = { x: 0, y: 0 };
let count = 0;
for (let key of this.current.keys()) {
if (this.previous.has(key)) {
let p = this.previous.get(key);
let c = this.current.get(key);
pm = Points$1.add(pm, p);
cm = Points$1.add(cm, c);
prev.push(p);
curr.push(c);
count += 1;
}
}
if (count > 0) {
pm = Points$1.multiplyScalar(pm, 1 / count);
cm = Points$1.multiplyScalar(cm, 1 / count);
let delta = Points$1.subtract(cm, pm);
let scale = 0;
let scaled = 0;
let alpha = 0;
let zoom = 1;
for (let i = 0; i < count; i++) {
let p = prev[i];
let c = curr[i];
let previousAngle = Points$1.angle(p, pm);
let currentAngle = Points$1.angle(c, cm);
let diff = this.diffAngle(currentAngle, previousAngle);
alpha += diff;
let distance1 = Points$1.distance(p, pm);
let distance2 = Points$1.distance(c, cm);
if (distance1 != 0 && distance2 != 0) {
scale += distance2 / distance1;
scaled += 1;
}
}
if (scaled > 0) {
zoom = scale / scaled;
}
alpha /= count;
let current = this.current.farthests();
let c1 = current[0];
let c2 = current[1];
let distance2 = Points$1.distance(c1, c2);
return new InteractionDelta(delta.x, delta.y, zoom, alpha, cm, count, distance2)
} else {
return null
}
}
/**
* Computes the delta between interaction points at t and t+1.
*
* @returns InteractionDelta
* @memberof InteractionPoints
*/
deltaByTwoFarthestsPoints() {
let csize = this.current.size;
let psize = this.previous.size;
if (csize >= 2 && csize == psize) {
// Reduce to the two farthests points
let current = this.current.farthests();
let c1 = current[0];
let c2 = current[1];
let p1 = this.previous.get(c1.key);
let p2 = this.previous.get(c2.key);
let d1 = Points$1.subtract(c1, p1);
let d2 = Points$1.subtract(c2, p2);
let cm = Points$1.mean(c1, c2);
// Using the mean leads to jumps between time slices with 3 and 2 fingers
// We use the mean of deltas instead
let delta = Points$1.mean(d1, d2);
let zoom = 1.0;
let distance1 = Points$1.distance(p1, p2);
let distance2 = Points$1.distance(c1, c2);
if (distance1 != 0 && distance2 != 0) {
zoom = distance2 / distance1;
}
let currentAngle = Points$1.angle(c1, c2);
let previousAngle = Points$1.angle(p1, p2);
let alpha = this.diffAngle(currentAngle, previousAngle);
return new InteractionDelta(delta.x, delta.y, zoom, alpha, cm, csize, distance2)
} else if (csize == 1 && psize == 1 && this.current.firstKey() == this.previous.firstKey()) {
// We need to ensure that the keys are the same, since single points with different keys
// can jump
let current = this.current.first();
let previous = this.previous.first();
let delta = Points$1.subtract(current, previous);
return new InteractionDelta(delta.x, delta.y, 1.0, 0.0, current, csize)
}
return null
}
started(key, point) {
this.current.set(key, point);
this.start.set(key, point);
this.previous.set(key, point);
this.timestamps.set(key, performance.now());
}
update(key, point) {
// Returns true iff the key is new
this.current.set(key, point);
if (!this.start.has(key)) {
this.start.set(key, point);
this.previous.set(key, point);
this.timestamps.set(key, performance.now());
return true
}
return false
}
updatePrevious() {
for (let key of this.current.keys()) {
this.previous.set(key, this.current.get(key));
}
}
stop(key, point) {
if (this.current.has(key)) {
this.current.delete(key);
this.previous.delete(key);
this.ended.set(key, point);
}
}
finish(key, point) {
this.current.delete(key);
this.previous.delete(key);
this.start.delete(key);
this.timestamps.delete(key);
this.ended.delete(key);
}
isFinished() {
return this.current.size == 0
}
isNoLongerTwoFinger() {
return this.previous.size > 1 && this.current.size < 2
}
isTap(key) {
return this.parent.isTap(key)
}
isDoubleTap(key) {
return this.parent.isDoubleTap(key)
}
isLongPress(key) {
return this.parent.isLongPress(key)
}
}
class Interaction extends InteractionPoints {
constructor(tapDistance = 10, tapDuration = 250.0, longPressTime = 500.0) {
super();
this.tapDistance = tapDistance;
this.tapCounts = new Map();
this.tapPositions = new Map();
this.tapTimestamps = new Map();
this.tapDuration = tapDuration;
this.longPressTime = longPressTime;
this.targets = new Map();
this.subInteractions = new Map(); // target:Object : InteractionPoints
}
stop(key, point) {
super.stop(key, point);
for (let points of this.subInteractions.values()) {
points.stop(key, point);
}
}
addTarget(key, target) {
this.targets.set(key, target);
this.subInteractions.set(target, new InteractionPoints(this));
}
removeTarget(key) {
let target = this.targets.get(key);
this.targets.delete(key);
// Only remove target if no keys are refering to the target
let remove = true;
for (let t of this.targets.values()) {
if (target === t) {
remove = false;
}
}
if (remove) {
this.subInteractions.delete(target);
}
}
finish(key, point) {
super.finish(key, point);
this.removeTarget(key);
}
mapInteraction(points, aspects, mappingFunc) {
// Map centrally registered points to target interactions
// Returns an array of [target, updated subInteraction] pairs
let result = new Map();
for (let key in points) {
if (this.targets.has(key)) {
let target = this.targets.get(key);
if (this.subInteractions.has(target)) {
let interaction = this.subInteractions.get(target);
for (let aspect of aspects) {
let pointMap = this[aspect];
let point = pointMap.get(key);
let mapped = mappingFunc(point, target);
interaction[aspect].set(key, mapped);
}
result.set(target, interaction);
}
}
}
return result
}
registerTap(key, point) {
if (this.tapCounts.has(key)) {
let count = this.tapCounts.get(key);
this.tapCounts.set(key, count + 1);
} else {
this.tapCounts.set(key, 1);
}
this.tapPositions.set(key, point);
this.tapTimestamps.set(key, performance.now());
}
unregisterTap(key) {
this.tapCounts.delete(key);
this.tapPositions.delete(key);
this.tapTimestamps.delete(key);
}
isTap(key) {
let ended = this.ended.get(key);
let start = this.start.get(key);
if (start && ended && Points$1.distance(ended, start) < this.tapDistance) {
let t1 = this.timestamps.get(key);
let tookLong = performance.now() > t1 + this.longPressTime;
if (tookLong) {
return false
}
return true
}
return false
}
isDoubleTap(key) {
let ended = this.ended.get(key);
if (this.tapCounts.has(key) && this.tapCounts.get(key) > 2) {
this.unregisterTap(key);
}
if (this.tapPositions.has(key)) {
let pos = this.tapPositions.get(key);
if (Points$1.distance(ended, pos) > this.tapDistance) {
this.unregisterTap(key);
}
}
if (this.tapTimestamps.has(key) && performance.now() > this.tapTimestamps.get(key) + this.tapDuration) {
//console.log("tap too long")
this.unregisterTap(key);
}
let result = false;
if (this.isTap(key)) {
this.registerTap(key, ended);
result = this.tapCounts.get(key) == 2;
} else {
this.unregisterTap(key);
}
//console.log("isDoubleTap", this.tapCounts.get(key), result)
return result
}
isAnyTap() {
for (let key of this.ended.keys()) {
if (this.isTap(key)) return true
}
return false
}
isLongPress(key) {
let ended = this.ended.get(key);
let start = this.start.get(key);
if (start && ended && Points$1.distance(ended, start) < this.tapDistance) {
let t1 = this.timestamps.get(key);
let tookLong = performance.now() > t1 + this.longPressTime;
if (tookLong) {
return true
}
return false
}
return false
}
isAnyLongPress() {
for (let key of this.ended.keys()) {
if (this.isLongPress(key)) return true
}
return false
}
isStylus(key) {
return key === 'stylus'
}
}
/**
* This class implements the main delegate functionality: All necessary event handlers are registered for the
* given element. Uses PointerEvents if available or TouchEvents on iOS. The fallback is on mouse events.
* Collects the events if the interaction target captures the start event (i.e. declares that
* the target wants the start event as well as all following move and end evcents.)
*
* @export
* @class InteractionDelegate
*/
class InteractionDelegate {
// Long press: http://stackoverflow.com/questions/1930895/how-long-is-the-event-onlongpress-in-the-android
// Stylus support: https://w3c.github.io/touch-events/
/**
* Creates an instance of InteractionDelegate.
* @param {any} element
* @param {any} target
* @param {any} [{ mouseWheelElement = null, useCapture = true, capturePointerEvents = true, debug = false }={}]
* @memberof InteractionDelegate
*/
constructor(
element,
target,
{
mouseWheelElement = null,
useCapture = true,
capturePointerEvents = true,
cancelOnWindowOut = true,
debug = false
} = {}
) {
this.debug = debug;
this.interaction = new Interaction();
this.element = element;
this.mouseWheelElement = mouseWheelElement || element;
this.target = target;
this.useCapture = useCapture;
this.capturePointerEvents = capturePointerEvents;
this.cancelOnWindowOut = cancelOnWindowOut;
this.setupInteraction();
}
setupInteraction() {
if (this.debug) {
let error = this.targetInterface.implementationError(this.target.constructor);
if (error != null) {
throw new Error('Expected IInteractionTarget: ' + error)
}
}
this.setupTouchInteraction();
this.setupMouseWheelInteraction();
}
get targetInterface() {
return IInteractionTarget
}
setupTouchInteraction() {
let element = this.element;
let useCapture = this.useCapture;
if (window.PointerEvent) {
if (this.debug) console.log('Pointer API' + window.PointerEvent);
element.addEventListener(
'pointerdown',
e => {
if (this.debug) console.log('pointerdown', e.pointerId);
if (this.capture(e)) {
if (this.capturePointerEvents) {
try {
element.setPointerCapture(e.pointerId);
} catch (e) {
console.warn('Cannot setPointerCapture');
}
}
this.onStart(e);
}
},
useCapture
);
element.addEventListener(
'pointermove',
e => {
if (this.debug) console.log('pointermove', e.pointerId, e.pointerType);
if (e.pointerType == 'touch' || (e.pointerType == 'mouse' && Events.isPointerDown(e))) {
// this.capture(e) &&
if (this.debug) console.log('pointermove captured', e.pointerId);
this.onMove(e);
}
},
useCapture
);
element.addEventListener(
'pointerup',
e => {
if (this.debug) console.log('pointerup', e.pointerId, e.pointerType);
this.onEnd(e);
if (this.capturePointerEvents) {
try {
element.releasePointerCapture(e.pointerId);
} catch (e) {
console.warn('Cannot release pointer');
}
}
},
useCapture
);
element.addEventListener(
'pointercancel',
e => {
if (this.debug) console.log('pointercancel', e.pointerId, e.pointerType);
this.onEnd(e);
if (this.capturePointerEvents) element.releasePointerCapture(e.pointerId);
},
useCapture
);
if (!this.capturePointerEvents) {
element.addEventListener(
'pointerleave',
e => {
if (this.debug) console.log('pointerleave', e.pointerId, e.pointerType);
if (e.target == element) this.onEnd(e);
},
useCapture
);
}
if (!this.capturePointerEvents) {
element.addEventListener(
'pointerout',
e => {
if (this.debug) console.log('pointerout', e.pointerId, e.pointerType);
if (e.target == element) this.onEnd(e);
},
useCapture
);
}
if (this.cancelOnWindowOut) {
window.addEventListener(
'pointerout',
e => {
if (this.debug) console.log('pointerout', e.pointerId, e.pointerType, e.target);
if (e.target == element) {
this.onEnd(e);
}
},
useCapture
);
}
} else if (window.TouchEvent) {
if (this.debug) console.log('Touch API');
element.addEventListener(
'touchstart',
e => {
if (this.debug) console.log('touchstart', this.touchPoints(e));
if (this.capture(e)) {
for (let touch of e.changedTouches) {
this.onStart(touch);
}
}
},
useCapture
);
element.addEventListener(
'touchmove',
e => {
if (this.debug) console.log('touchmove', this.touchPoints(e), e);
for (let touch of e.changedTouches) {
this.onMove(touch);
}
for (let touch of e.targetTouches) {
this.onMove(touch);
}
},
useCapture
);
element.addEventListener(
'touchend',
e => {
if (this.debug) console.log('touchend', this.touchPoints(e));
for (let touch of e.changedTouches) {
this.onEnd(touch);
}
},
useCapture
);
element.addEventListener(
'touchcancel',
e => {
if (this.debug) console.log('touchcancel', e.targetTouches.length, e.changedTouches.length);
for (let touch of e.changedTouches) {
this.onEnd(touch);
}
},
useCapture
);
} else {
if (this.debug) console.log('Mouse API');
element.addEventListener(
'mousedown',
e => {
if (this.debug) console.log('mousedown', e);
if (this.capture(e)) {
this.onStart(e);
}
},
useCapture
);
element.addEventListener(
'mousemove',
e => {
// Dow we only use move events if the mouse is down?
// HOver effects have to be implemented by other means
// && Events.isMouseDown(e))
if (Events.isMouseDown(e)) {
if (this.debug) console.log('mousemove', e);
this.onMove(e);
}
},
useCapture
);
element.addEventListener(
'mouseup',
e => {
if (this.debug) console.log('mouseup', e);
this.onEnd(e);
},
true
);
if (!this.capturePointerEvents) {
element.addEventListener(
'mouseout',
e => {
if (e.target == element) {
this.onEnd(e);
console.warn("Shouldn't happen: mouseout ends interaction");
}
},
useCapture
);
}
if (this.cancelOnWindowOut) {
window.addEventListener(
'mouseout',
e => {
if (e.target == element) {
this.onEnd(e);
}
},
useCapture
);
}
}
}
isDescendant(parent, child) {
if (parent == child) return true
let node = child.parentNode;
while (node != null) {
if (node == parent) {
return true
}
node = node.parentNode;
}
return false
}
touchPoints(event) {
let result = [];
for (let touch of event.changedTouches) {
result.push(this.extractPoint(touch));
}
return result
}
setupMouseWheelInteraction() {
this.mouseWheelElement.addEventListener('mousewheel', this.onMouseWheel.bind(this), true);
this.mouseWheelElement.addEventListener('DOMMouseScroll', this.onMouseWheel.bind(this), true);
}
onMouseWheel(event) {
if (this.capture(event) && this.target.onMouseWheel) {
this.target.onMouseWheel(event);
}
}
onStart(event) {
let extracted = this.extractPoint(event);
this.startInteraction(event, extracted);
this.target.onStart(event, this.interaction);
}
onMove(event) {
let extracted = this.extractPoint(event, 'all');
this.updateInteraction(event, extracted);
this.target.onMove(event, this.interaction);
this.interaction.updatePrevious();
}
onEnd(event) {
let extracted = this.extractPoint(event, 'changedTouches');
this.endInteraction(event, extracted);
this.target.onEnd(event, this.interaction);
this.finishInteraction(event, extracted);
}
/**
* Asks the target whether the event should be captured
*
* @param {any} event
* @returns {bool}
* @memberof InteractionDelegate
*/
capture(event) {
if (Events.isCaptured(event)) {
return false
}
let captured = this.target.capture(event);
return captured
}
getPosition(event) {
return { x: event.clientX, y: event.clientY }
}
extractPoint(event, touchEventKey = 'all') {
// 'targetTouches'
let result = {};
switch (event.constructor.name) {
case 'MouseEvent': {
let buttons = event.buttons || event.which;
if (buttons) result['mouse'] = this.getPosition(event);
break
}
case 'PointerEvent': {
result[event.pointerId.toString()] = this.getPosition(event);
break
}
case 'Touch': {
let id = event.touchType === 'stylus' ? 'stylus' : event.identifier.toString();
result[id] = this.getPosition(event);
break
}
// case 'TouchEvent':
// // Needs to be observed: Perhaps changedTouches are all we need. If so
// // we can remove the touchEventKey default parameter
// if (touchEventKey == 'all') {
// for(let t of event.targetTouches) {
// result[t.identifier.toString()] = this.getPosition(t)
// }
// for(let t of event.changedTouches) {
// result[t.identifier.toString()] = this.getPosition(t)
// }
// }
// else {
// for(let t of event.changedTouches) {
// result[t.identifier.toString()] = this.getPosition(t)
// }
// }
// break
default:
break
}
return result
}
interactionStarted(event, key, point) {
// Callback: can be overwritten
}
interactionEnded(event, key, point) {
// Callback: can be overwritten
}
interactionFinished(event, key, point) {}
startInteraction(event, extracted) {
for (let key in extracted) {
let point = extracted[key];
this.interaction.started(key, point);
this.interactionStarted(event, key, point);
}
}
updateInteraction(event, extracted) {
for (let key in extracted) {
let point = extracted[key];
let updated = this.interaction.update(key, point);
if (updated) {
console.warn("new pointer in updateInteraction shouldn't happen", key);
this.interactionStarted(event, key, point);
}
}
}
endInteraction(event, ended) {
for (let key in ended) {
let point = ended[key];
this.interaction.stop(key, point);
this.interactionEnded(event, key, point);
}
}
finishInteraction(event, ended) {
for (let key in ended) {
let point = ended[key];
this.interaction.finish(key, point);
this.interactionFinished(event, key, point);
}
}
}
/**
* A special InteractionDelegate that maps events to specific parts of
* the interaction target. The InteractionTarget must implement a findTarget
* method that returns an object implementing the IInteractionTarget interface.
*
* If the InteractionTarget also implements a mapPositionToPoint method this
* is used to map the points to the local coordinate space of the the target.
*
* This makes it easier to lookup elements and relate events to local
* positions.
*
* @export
* @class InteractionMapper
* @extends {InteractionDelegate}
*/
class InteractionMapper$1 extends InteractionDelegate {
constructor(
element,
target,
{
tapDistance = 10,
longPressTime = 500.0,
useCapture = true,
capturePointerEvents = true,
mouseWheelElement = null,
logInteractionsAbove = 12
} = {}
) {
super(element, target, {
tapDistance,
useCapture,
capturePointerEvents,
longPressTime,
mouseWheelElement
});
this.logInteractionsAbove = logInteractionsAbove;
}
get targetInterface() {
return IInteractionMapperTarget
}
mapPositionToPoint(point, element = null) {
if (this.target.mapPositionToPoint) {
return this.target.mapPositionToPoint(point, element)
}
return point
}
interactionStarted(event, key, point) {
if (this.target.findTarget) {
let local = this.mapPositionToPoint(point);
let found = this.target.findTarget(event, local, point);
if (found != null) {
this.interaction.addTarget(key, found);
}
}
let size = this.interaction.current.size;
let limit = this.logInteractionsAbove;
if (size > limit) {
Logging$1.log(`Number of interactions ${size} exceeds ${limit}`);
}
}
onMouseWheel(event) {
if (this.capture(event)) {
if (this.target.findTarget) {
let point = this.getPosition(event);
let local = this.mapPositionToPoint(point);
let found = this.target.findTarget(event, local, point);
if (found != null && found.onMouseWheel) {
found.onMouseWheel(event);
return
}
}
if (this.target.onMouseWheel) {
this.target.onMouseWheel(event);
}
}
}
onStart(event) {
let extracted = this.extractPoint(event);
this.startInteraction(event, extracted);
let mapped = this.interaction.mapInteraction(
extracted,
['current', 'start'],
this.mapPositionToPoint.bind(this)
);
for (let [target, interaction] of mapped.entries()) {
target.onStart(event, interaction);
}
}
onMove(event) {
let extracted = this.extractPoint(event, 'all');
this.updateInteraction(event, extracted);
let mapped = this.interaction.mapInteraction(
extracted,
['current', 'previous'],
this.mapPositionToPoint.bind(this)
);
for (let [target, interaction] of mapped.entries()) {
target.onMove(event, interaction);
interaction.updatePrevious();
}
this.interaction.updatePrevious();
}
onEnd(event) {
let extracted = this.extractPoint(event, 'changedTouches');
this.endInteraction(event, extracted);
let mapped = this.interaction.mapInteraction(extracted, ['ended'], this.mapPositionToPoint.bind(this));
for (let [target, interaction] of mapped.entries()) {
target.onEnd(event, interaction);
}
this.finishInteraction(event, extracted);
}
/**
*
*
* @static
* @param {string|array} types - An event type, an array of event types or event types seperated by a space sign. The following
* events are possible:
* pan, panstart, panmove, panend, pancancel, panleft, panright, panup, pandown
* pinch, pinchstart, pinchmove, pinchend, pinchcancel, pinchin, pinchout
* press, pressup
* rotate, rotatestart, rotatemove, rotateend, rotatecancel
* swipe, swipeleft, swiperight, swipeup, swipedown
* tap
* @param {HTMLElement|HTMLElement[]} elements - An HTML element or an array of HTML elements.
* @param {function} [cb] - The callback. A function which is executed after the event occurs. Receives the event object as the
* first paramter
* @param {object} [opts] - An options object. See the hammer documentation for more details.
*/
static on(types, elements, cb, opts = {}) {
opts = Object.assign({}, {}, opts);
if (typeof Hammer === 'undefined') {
console.error('Hammer.js not found!');
return this
}
if (typeof Hammer.__hammers === 'undefined') {
Hammer.__hammers = new Map();
}
// convert to array
types = Array.isArray(types) ? types : types.split(/\s/);
if (elements instanceof NodeList || elements instanceof HTMLCollection) {
elements = Array.from(elements);
}
elements = Array.isArray(elements) ? elements : [elements];
for (let i = 0; i < types.length; i++) {
const type = types[i].toLowerCase();
// list of hammer events
const useHammer = /^(tap|doubletap|press|pan|swipe|pinch|rotate).*$/.test(type);
// if it is a hammer event
if (useHammer) {
for (let j = 0; j < elements.length; j++) {
// if(elements[j].tagName == "svg") return false;
let hammer = new Hammer(elements[j], opts);
if (window.propagating !== 'undefined') {
hammer = propagating(hammer);
}
// recognizers
if (type.startsWith('pan')) {
hammer.get('pan').set(Object.assign({ direction: Hammer.DIRECTION_ALL }, opts));
} else if (type.startsWith('pinch')) {
hammer.get('pinch').set(Object.assign({ enable: true }, opts));
} else if (type.startsWith('press')) {
hammer.get('press').set(opts);
} else if (type.startsWith('rotate')) {
hammer.get('rotate').set(Object.assign({ enable: true }, opts));
} else if (type.startsWith('swipe')) {
hammer.get('swipe').set(Object.assign({ direction: Hammer.DIRECTION_ALL }, opts));
} else if (type.startsWith('tap')) {
hammer.get('tap').set(opts);
}
hammer.on(type, event => {
cb(event);
});
if (Hammer.__hammers.has(elements[j])) {
const elementHammers = Hammer.__hammers.get(elements[j]);
elementHammers.push(hammer);
Hammer.__hammers.set(elements[j], elementHammers);
} else {
Hammer.__hammers.set(elements[j], [hammer]);
}
}
} else {
for (let j = 0; j < elements.length; j++) {
Hammer.on(elements[j], type, event => {
cb(event);
});
}
}
}
}
/**
*
*
* @static
* @param {HTMLElement|HTMLElement[]} elements - An HTML element or an array of HTML elements.
*/
static off(elements) {
if (typeof Hammer === 'undefined') {
console.error('Hammer.js not found!');
return this
}
// convert to array
if (elements instanceof NodeList || elements instanceof HTMLCollection) {
elements = Array.from(elements);
}
elements = Array.isArray(elements) ? elements : [elements];
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (Hammer.__hammers.has(element)) {
const elementHammers = Hammer.__hammers.get(element);
elementHammers.forEach(it => it.destroy());
Hammer.__hammers.delete(element);
}
}
}
}
window.InteractionMapper = InteractionMapper$1;
/** Report capabilities with guaranteed values.
*/
class Capabilities {
/** Returns the browser userAgent.
@return {string}
*/
static get userAgent() {
return navigator.userAgent || 'Unknown Agent'
}
/** Tests whether the app is running on a mobile device.
Implemented as a readonly attribute.
@return {boolean}
*/
static get isMobile() {
return /Mobi/.test(navigator.userAgent)
}
/** Tests whether the app is running on a iOS device.
Implemented as a readonly attribute.
@return {boolean}
*/
static get isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
}
/** Tests whether the app is running in a Safari environment.
See https://stackoverflow.com/questions/7944460/detect-safari-browser
Implemented as a readonly attribute.
@return {boolean}
*/
static get isSafari() {
return (
navigator.vendor &&
navigator.vendor.indexOf('Apple') > -1 &&
navigator.userAgent &&
!navigator.userAgent.match('CriOS')
)
}
/**
* Distincts if the app is running inside electron or not.
*
* source: https://github.com/cheton/is-electron
*/
static get isElectron() {
// Renderer process
if (typeof window !== 'undefined' && typeof window.process === 'object' && window.process.type === 'renderer') {
return true
}
// Main process
if (typeof process !== 'undefined' && typeof process.versions === 'object' && !!process.versions.electron) {
return true
}
// Detect the user agent when the `nodeIntegration` option is set to true
if (
typeof navigator === 'object' &&
typeof navigator.userAgent === 'string' &&
navigator.userAgent.indexOf('Electron') >= 0
) {
return true
}
return false
}
/** Returns the display resolution. Necessary for retina displays.
@return {number}
*/
static get devicePixelRatio() {
return window.devicePixelRatio || 1
}
/** Returns true if the device is a multi-touch table. This method is currently not universal usable and not sure!
@return {boolean}
*/
static get isMultiTouchTable() {
return (
Capabilities.devicePixelRatio > 2 &&
Capabilities.isMobile === false &&
/Windows/i.test(Capabilities.userAgent)
)
}
/** Returns true if mouse events are supported
@return {boolean}
*/
static supportsMouseEvents() {
return typeof window.MouseEvent != 'undefined'
}
/** Returns true if touch events are supported
@return {boolean}
*/
static supportsTouchEvents() {
return typeof window.TouchEvent != 'undefined'
}
/** Returns true if pointer events are supported
@return {boolean}
*/
static supportsPointerEvents() {
return typeof window.PointerEvent != 'undefined'
}
/** Returns true if DOM templates are supported
@return {boolean}
*/
static supportsTemplate() {
return 'content' in document.createElement('template')
}
}
/** Basic tests for Capabilities.
*/
class CapabilitiesTests {
static testConfirm() {
let bool = confirm('Please confirm');
document.getElementById('demo').innerHTML = bool ? 'Confirmed' : 'Not confirmed';
}
static testPrompt() {
let person = prompt('Please enter your name', 'Harry Potter');
if (person != null) {
demo.innerHTML = 'Hello ' + person + '! How are you today?';
}
}
static testUserAgent() {
let agent = 'User-agent: ' + Capabilities.userAgent;
user_agent.innerHTML = agent;
}
static testDevicePixelRatio() {
let value = 'Device Pixel Ratio: ' + Capabilities.devicePixelRatio;
device_pixel_ratio.innerHTML = value;
}
static testMultiTouchTable() {
let value = 'Is the device a multi-touch table? ' + Capabilities.isMultiTouchTable;
multi_touch_table.innerHTML = value;
}
static testSupportedEvents() {
let events = [];
if (Capabilities.supportsMouseEvents()) {
events.push('MouseEvents');
}
if (Capabilities.supportsTouchEvents()) {
events.push('TouchEvents');
}
if (Capabilities.supportsPointerEvents()) {
events.push('PointerEvents');
}
supported_events.innerHTML = 'Supported Events: ' + events.join(', ');
}
static testAll() {
this.testUserAgent();
this.testDevicePixelRatio();
this.testMultiTouchTable();
this.testSupportedEvents();
}
}
/* Optional global variables, needed in DocTests. */
window.Capabilities = Capabilities;
window.CapabilitiesTests = CapabilitiesTests;
/* eslint-disable no-unused-vars */
/** This interface allows scatters to delegate tap events to other objects. */
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
*/
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
*/
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
*/
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$1.add(sum, nv);
count += 1;
if (t > milliseconds) {
break
}
}
if (count === 0) return sum // empty vector
return Points$1.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$1.length(this.velocity);
let nextLength = Points$1.length(next);
if (nextLength > prevLength) {
let factor = nextLength / prevLength;
next = Points$1.multiplyScalar(next, 1 / factor);
console.log('Prevent acceleration', factor, this.velocity, next);
}
this.velocity = next;
let d = Points$1.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$1.length(this.velocity) > 0.01
}
nextVelocity(velocity) {
// Must be overwritten: computes the changed velocity. Implement
// damping, collison detection, etc. here
let next = Points$1.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
}
}
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() {
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$1.subtract(target, center);
return Points$1.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$1.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$1.subtract(p, c);
this.move(delta, { animate: animate });
}
centerAt(p, { animate = 0 } = {}) {
let c = this.center;
let delta = Points$1.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$1.angle(origin, anchor);
let distance = Points$1.distance(origin, anchor);
let { scale: newScale, zoom: thresholdedZoom } = this.calculateScale(zoom);
let newOrigin = Points$1.arc(anchor, beta + rotate, distance * thresholdedZoom);
let extra = Points$1.subtract(newOrigin, origin);
let offset = Points$1.subtract(anchor, origin);
this._move(offset);
this.scale = newScale;
this.rotation += rotate;
offset = Points$1.negate(offset);
offset = Points$1.add(offset, extra);
offset = Points$1.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.
*/
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$1.setStyle(element, { touchAction });
}
this.scatter = new Map();
this.delegate = new InteractionMapper$1(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$1.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
}
}
class DOMScatter$1 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$1.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$1.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$1.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$1.fromPageToNode(this.element, { x, y })
}
fromNodeToPage(x, y) {
return Points$1.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$1.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$1.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$1.zIndex = 1000;
/* eslint-disable no-console */
class CardWrapper extends Object {
constructor(domNode, { triggerSVGClicks = true, allowClickDistance = 44 } = {}) {
super();
this.domNode = domNode;
this.triggerSVGClicks = triggerSVGClicks;
this.allowClickDistance = allowClickDistance;
this.tapNodes = new Map();
this.tapHandler = new Map();
}
handleClicks() {
this.domNode.addEventListener(
'click',
event => {
if (event.isTrusted) {
Events.stop(event);
if (this.triggerSVGClicks && this.isSVGNode(event.target)) {
this.tap(event, 'triggerSVGClicks');
}
}
},
true
);
}
handleClicksAsTaps() {
this.domNode.addEventListener(
'click',
event => {
if (event.isTrusted) {
Events.stop(event);
}
this.tap(event);
},
true
);
}
isClickPrevented(node) {
if (node == null) {
return false
}
if (node.style && node.style.pointerEvents == 'none') {
return true
}
return this.isClickPrevented(node.parentNode)
}
isClickable(node) {
if (node == null) return false
// console.log("isClickable", node, this.isClickPrevented(node))
if (this.isClickPrevented(node)) {
return false
}
if (node.tagName == 'A' && node.hasAttribute('href')) return true
if (node.hasAttribute('onclick')) return true
return false
}
hasClickHandler(node) {
if (node == null) return false
if (this.tapNodes.has(node)) return true
for (let [selector, handler] of this.tapHandler.entries()) {
for (let obj of this.domNode.querySelectorAll(selector)) {
if (node == obj) {
return true
}
}
}
return false
}
/**
* Returns an array of all active nodes.
* Unfortunately we cannot search for all nodes with an attached 'click' event listener
* See https://stackoverflow.com/questions/11455515/how-to-check-whether-dynamically-attached-event-listener-exists-or-not
* Therefore we can only detect the following standard cases:
* I. All clickable objects like activeNodes
* II. Objects that have been attached a click handler by the scatter itself via
*/
activeNodes() {
let result = [];
for (let node of this.domNode.querySelectorAll('*')) {
if (this.isClickable(node)) result.push(node);
if (this.hasClickHandler(node)) result.push(node);
}
return result
}
nearestActive(event) {
let element = this.domNode;
let activeNodes = this.activeNodes();
let globalClick = event.center ? event.center : { x: event.x, y: event.y };
let localClick = Points$1.fromPageToNode(element, globalClick);
let clickRects = activeNodes.map(link => {
let rect = link.getBoundingClientRect();
let topLeft = Points$1.fromPageToNode(element, rect);
let center = Points$1.fromPageToNode(element, {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2
});
return {
x: topLeft.x,
y: topLeft.y,
width: rect.width,
height: rect.height,
center,
link
}
});
let distances = [];
clickRects.forEach(rect => {
let distance = Points$1.distanceToRect(localClick, rect);
distances.push(parseInt(distance));
});
let closestClickIndex = distances.indexOf(Math.min(...distances));
let closestClickable = activeNodes[closestClickIndex];
if (distances[closestClickIndex] < this.allowClickDistance) {
return closestClickable
}
return null
}
isSVGNode(node) {
return node.ownerSVGElement || node.tagName == 'svg'
}
simulateClick(node, event) {
/* https://stackoverflow.com/questions/49564905/is-it-possible-to-use-click-function-on-svg-tags-i-tried-element-click-on-a
proposes the dispatchEvent solution. But this leads to problems in flippable.html hiding the back page.
Therefore we use the original click event (see constructor). */
if (this.isSVGNode(node)) {
if (this.triggerSVGClicks) {
let click = new Event('click');
node.dispatchEvent(click);
}
return
}
node.click();
}
nodeTapped(node, event) {
console.log('nodeTapped', node, this.isClickable(node));
if (this.isClickable(node)) {
this.simulateClick(node, event);
return true
}
if (this.tapNodes.has(node)) {
let handler = this.tapNodes.get(node);
handler(event, node);
return true
}
for (let [selector, handler] of this.tapHandler.entries()) {
console.log('nodeTapped', selector);
for (let obj of this.domNode.querySelectorAll(selector)) {
if (node == obj) {
handler(event, node);
return true
}
}
}
return false
}
tap(event, calledBy = 'unknown') {
if (event.isTrusted) {
let node = this.nearestActive(event);
console.log('tap', node);
this.nodeTapped(node, event);
/* let node = document.elementFromPoint(event.clientX, event.clientY)
if (!this.nodeTapped(node, event)) {
node = this.nearestActive(event)
this.nodeTapped(node, event)
} */
}
}
onTap(objOrSelector, handler) {
if (typeof objOrSelector == 'string') {
this.tapHandler.set(objOrSelector, handler);
} else {
this.tapNodes.set(objOrSelector, handler);
}
}
}
/* eslint-disable no-unused-vars */
class CardLoader {
constructor(
src,
{
x = 0,
y = 0,
width = 1000,
height = 800,
maxWidth = null,
maxHeight = null,
scale = 1,
minScale = 0.5,
maxScale = 1.5,
rotation = 0
} = {}
) {
this.src = src;
this.x = x;
this.y = y;
this.scale = scale;
this.rotation = rotation;
this.maxScale = maxScale;
this.minScale = minScale;
this.wantedWidth = width;
this.wantedHeight = height;
this.maxWidth = maxWidth != null ? maxWidth : window.innerWidth;
this.maxHeight = maxHeight != null ? maxHeight : window.innerHeight;
this.addedNode = null;
}
unload() {
if (this.addedNode) {
this.addedNode.remove();
this.addedNode = null;
}
}
}
class PDFLoader extends CardLoader {
constructor(src, { width = 1640, height = 800, scale = 1 } = {}) {
super(src, { width, height, scale });
if (typeof PDFJS == 'undefined') {
alert('PDF.js needed');
}
}
load(domNode) {
return new Promise((resolve, reject) => {
PDFJS.getDocument(this.src).then(pdf => {
pdf.getPage(1).then(page => {
let scale = this.scale * app.renderer.resolution;
let invScale = 1 / scale;
let viewport = page.getViewport(scale);
// Prepare canvas using PDF page dimensions.
let canvas = document.createElement('canvas');
let context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
// Render PDF page into canvas context.
let renderContext = {
canvasContext: context,
viewport: viewport
};
page.render(renderContext);
domNode.appendChild(canvas);
this.wantedWidth = canvas.width;
this.wantedHeight = canvas.height;
this.scale = invScale;
this.addedNode = canvas;
resolve(this);
});
});
})
}
}
class ImageLoader extends CardLoader {
load(domNode) {
return new Promise((resolve, reject) => {
let isImage = domNode instanceof HTMLImageElement;
let image = isImage ? domNode : document.createElement('img');
image.onload = e => {
if (!isImage) {
domNode.appendChild(image);
this.addedNode = image;
}
this.wantedWidth = image.naturalWidth;
this.wantedHeight = image.naturalHeight;
let scaleW = this.maxWidth / image.naturalWidth;
let scaleH = this.maxHeight / image.naturalHeight;
this.scale = Math.min(this.maxScale, Math.min(scaleW, scaleH));
image.setAttribute('draggable', false);
image.width = image.naturalWidth;
image.height = image.naturalHeight;
resolve(this);
};
image.onerror = e => {
reject(this);
};
image.src = this.src;
})
}
}
class FrameLoader extends CardLoader {
load(domNode) {
return new Promise((resolve, reject) => {
let isFrame = domNode instanceof HTMLIFrameElement;
let iframe = isFrame ? domNode : document.createElement('iframe');
iframe.frameBorder = 0;
iframe.style.scrolling = false;
iframe.width = this.wantedWidth;
iframe.height = this.wantedHeight;
if (!isFrame) {
// Unlike img onload is only triggered if the iframe is part of the DOM tree
domNode.appendChild(iframe);
this.addedNode = iframe;
}
iframe.onload = e => {
resolve(this);
};
iframe.onerror = e => {
reject(this);
};
iframe.src = this.src;
})
}
}
class HTMLLoader extends CardLoader {
load(domNode) {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open('GET', this.src, false);
xhr.onload = e => {
domNode.innerHTML = this.prepare(xhr.response);
this.addedNode = domNode.firstElementChild;
let { width, height } = this.size(this.addedNode);
if (width) this.wantedWidth = width || this.wantedWidth;
if (height) this.wantedHeight = height || this.wantedHeight;
resolve(this);
};
xhr.onerror = e => {
reject(this);
};
xhr.send();
})
}
/**
* Preoares the html before it is assigned with innerHTML.
* Can be overwritten in subclasses.
*
* @param {*} html
* @returns
* @memberof HTMLLoader
*/
prepare(html) {
return html
}
/**
* Tries to determine the size of the addedNode.
* Checks for explicit width and height style attributes.
*
* Overwrite this method if you want to extract values from other infos.
*
* @returns { width: int, height: int }
* @memberof HTMLLoader
*/
size(node) {
let width = parseInt(node.style.width) || null;
let height = parseInt(node.style.height) || null;
return { width, height }
}
}
class DOMFlip {
constructor(
domScatterContainer,
flipTemplate,
frontLoader,
backLoader,
{
closeOnMinScale = false,
flipDuration = 1,
fadeDuration = 0.2,
overdoScaling = 1,
autoLoad = false,
center = null,
preloadBack = false,
translatable = true,
scalable = true,
rotatable = true,
tapDelegateFactory = null,
onFront = null,
onBack = null,
onClose = null,
onUpdate = null,
onRemoved = null,
onLoaded = null
} = {}
) {
this.domScatterContainer = domScatterContainer;
this.id = getId$1();
this.flipDuration = flipDuration;
this.fadeDuration = fadeDuration;
this.closeOnMinScale = closeOnMinScale;
this.flipTemplate = flipTemplate;
this.frontLoader = frontLoader;
this.backLoader = backLoader;
this.translatable = translatable;
this.scalable = scalable;
this.rotatable = rotatable;
this.tapDelegateFactory = tapDelegateFactory;
this.onFrontFlipped = onFront;
this.onBackFlipped = onBack;
this.onClose = onClose;
this.onRemoved = onRemoved;
this.onUpdate = onUpdate;
this.onLoaded = onLoaded;
this.center = center;
this.preloadBack = preloadBack;
this.overdoScaling = overdoScaling;
if (autoLoad) {
this.load();
}
}
load() {
return new Promise((resolve, reject) => {
let t = this.flipTemplate;
let dom = this.domScatterContainer.element;
let wrapper = t.content.querySelector('.flipWrapper');
wrapper.id = this.id;
let clone = document.importNode(t.content, true);
dom.appendChild(clone);
// We cannot use the document fragment itself because it
// is not part of the main dom tree. After the appendChild
// call we can access the new dom element by id
this.cardWrapper = dom.querySelector('#' + this.id);
let front = this.cardWrapper.querySelector('.front');
this.frontLoader.load(front).then(loader => {
this.frontLoaded(loader).then(obj => {
if (this.onLoaded) this.onLoaded();
resolve(this);
});
});
})
}
frontLoaded(loader) {
return new Promise((resolve, reject) => {
let scatter = new DOMScatter$1(this.cardWrapper, this.domScatterContainer, {
x: loader.x,
y: loader.y,
startScale: loader.scale,
scale: loader.scale,
maxScale: loader.maxScale,
minScale: loader.minScale,
width: loader.wantedWidth,
height: loader.wantedHeight,
rotation: loader.rotation,
translatable: this.translatable,
scalable: this.scalable,
rotatable: this.rotatable,
overdoScaling: this.overdoScaling,
tapDelegate: this.tapDelegateFactory ? new this.tapDelegateFactory(this.cardWrapper) : null
});
if (this.center) {
scatter.centerAt(this.center);
}
if (this.closeOnMinScale) {
const removeOnMinScale = function() {
if (scatter.scale <= scatter.minScale) {
this.flippable.close();
// 'Disable' overdoscaling to avoid weird jumps on close.
scatter.minScale /= scatter.overdoScaling;
scatter.overdoScaling = 1;
//Remove callback
if (scatter.onTransform) {
let callbackIdx = scatter.onTransform.indexOf(removeOnMinScale);
scatter.onTransform.splice(callbackIdx, 1);
}
}
}.bind(this);
scatter.addTransformEventCallback(removeOnMinScale);
}
let flippable = new DOMFlippable(this.cardWrapper, scatter, this);
let back = this.cardWrapper.querySelector('.back');
if (this.preloadBack) {
this.backLoader.load(back).then(loader => {
this.setupFlippable(flippable, loader);
});
}
this.flippable = flippable;
resolve(this);
})
}
centerAt(p) {
this.center = p;
this.flippable.centerAt(p);
}
zoom(scale) {
this.flippable.zoom(scale);
}
setupFlippable(flippable, loader) {
flippable.wantedWidth = loader.wantedWidth;
flippable.wantedHeight = loader.wantedHeight;
flippable.wantedScale = loader.scale;
flippable.minScale = loader.minScale;
flippable.maxScale = loader.maxScale;
flippable.scaleButtons();
}
start({ targetCenter = null } = {}) {
this.flippable.showFront();
if (this.preloadBack) {
this.flippable.start({ duration: this.flipDuration, targetCenter });
} else {
let back = this.cardWrapper.querySelector('.back');
let flippable = this.flippable;
this.backLoader.load(back).then(loader => {
this.setupFlippable(flippable, loader);
flippable.start({ duration: this.flipDuration, targetCenter });
});
}
}
fadeOutAndRemove() {
TweenLite.to(this.cardWrapper, this.fadeDuration, {
opacity: 0,
onComplete: () => {
this.cardWrapper.remove();
}
});
}
closed() {
this.unload();
}
unload() {
if (!this.preloadBack) {
this.backLoader.unload();
}
}
}
class DOMFlippable {
constructor(element, scatter, flip) {
// Set log to console.log or a custom log function
// define data structures to store our touchpoints in
this.element = element;
this.flip = flip;
this.card = element.querySelector('.flipCard');
this.front = element.querySelector('.front');
this.back = element.querySelector('.back');
this.flipped = false;
this.scatter = scatter;
this.onFrontFlipped = flip.onFrontFlipped;
this.onBackFlipped = flip.onBackFlipped;
this.onClose = flip.onClose;
this.onRemoved = flip.onRemoved;
this.onUpdate = flip.onUpdate;
this.wantedWidth = scatter.width;
this.wantedHeight = scatter.height;
this.wantedScale = scatter.scale;
this.minScale = scatter.minScale;
this.maxScale = scatter.maxScale;
this.flipDuration = flip.flipDuration;
this.fadeDuration = flip.fadeDuration;
scatter.addTransformEventCallback(this.scatterTransformed.bind(this));
TweenLite.set(this.element, { perspective: 5000 });
TweenLite.set(this.card, { transformStyle: 'preserve-3d' });
TweenLite.set(this.back, { rotationY: -180 });
TweenLite.set([this.back, this.front], {
backfaceVisibility: 'hidden',
perspective: 5000
});
TweenLite.set(this.front, { visibility: 'visible' });
this.infoBtn = element.querySelector('.infoBtn');
this.backBtn = element.querySelector('.backBtn');
this.closeBtn = element.querySelector('.closeBtn');
/* Buttons are not guaranteed to exist. */
if (scatter.tapDelegate == null) {
let tapDelegate = new CardWrapper(element);
scatter.tapDelegate = tapDelegate;
}
if (this.infoBtn) {
scatter.tapDelegate.onTap(this.infoBtn, event => {
this.flip.start();
});
this.enable(this.infoBtn);
}
if (this.backBtn) {
scatter.tapDelegate.onTap(this.backBtn, event => {
this.start();
});
}
if (this.closeBtn) {
scatter.tapDelegate.onTap(this.closeBtn, event => {
this.close();
});
this.enable(this.closeBtn);
}
this.scaleButtons();
this.bringToFront();
}
close() {
this.disable(this.infoBtn);
this.disable(this.closeBtn);
if (this.onClose) {
this.onClose(this);
this.flip.closed();
} else {
this.scatter.zoom(0.1, {
animate: this.fadeDuration,
onComplete: () => {
this.element.remove();
this.flip.closed();
if (this.onRemoved) {
this.onRemoved.call(this);
}
}
});
}
}
showFront() {
TweenLite.set(this.front, { visibility: 'visible' });
}
centerAt(p) {
this.scatter.centerAt(p);
}
zoom(scale) {
this.scatter.zoom(scale);
}
get buttonScale() {
let iscale = 1.0;
if (this.scatter != null) {
let scale = this.scatter.scale || 1;
iscale = 1.0 / scale;
}
return iscale
}
scaleButtons() {
TweenLite.set([this.infoBtn, this.backBtn, this.closeBtn], {
scale: this.buttonScale
});
}
bringToFront() {
this.scatter.bringToFront();
TweenLite.set(this.element, { zIndex: DOMScatter$1.zIndex++ });
}
clickInfo() {
this.bringToFront();
this.infoBtn.click();
}
scatterTransformed(event) {
this.scaleButtons();
}
targetRotation(alpha) {
let ortho = 90;
let rest = alpha % ortho;
let delta = 0.0;
if (rest > ortho / 2.0) {
delta = ortho - rest;
} else {
delta = -rest;
}
return delta
}
infoValues(info) {
let startX = this.element._gsTransform.x;
let startY = this.element._gsTransform.y;
let startAngle = this.element._gsTransform.rotation;
let startScale = this.element._gsTransform.scaleX;
let w = this.element.style.width;
let h = this.element.style.height;
// eslint-disable-next-line no-console
console.log(info, startX, startY, startAngle, startScale, w, h);
}
show(element, duration = 0, alpha = 1) {
if (element) {
TweenLite.to(element, duration, { autoAlpha: alpha }); // visibility: 'visible', display: 'initial'})
}
}
hide(element, duration = 0, alpha = 0) {
if (element) {
TweenLite.to(element, duration, { autoAlpha: alpha }); // {visibility: 'hidden', display: 'none'})
}
}
enable(button) {
this.show(button, this.fadeDuration);
if (button) {
TweenLite.set(button, { pointerEvents: 'auto' });
}
}
disable(button) {
this.hide(button, this.fadeDuration);
if (button) {
TweenLite.set(button, { pointerEvents: 'none' });
}
}
start({ targetCenter = null } = {}) {
this.bringToFront();
console.log('flippable start', this.flipped);
if (!this.flipped) {
this.startX = this.element._gsTransform.x;
this.startY = this.element._gsTransform.y;
this.startAngle = this.element._gsTransform.rotation;
this.startScale = this.element._gsTransform.scaleX;
this.startWidth = this.element.style.width;
this.startHeight = this.element.style.height;
this.scatterStartWidth = this.scatter.width;
this.scatterStartHeight = this.scatter.height;
this.show(this.back);
this.disable(this.infoBtn);
this.disable(this.closeBtn);
} else {
this.show(this.front, this.fadeDuration);
this.disable(this.backBtn);
}
let { scalable, translatable, rotatable } = this.scatter;
this.saved = { scalable, translatable, rotatable };
this.scatter.scalable = false;
this.scatter.translatable = false;
this.scatter.rotatable = false;
this.scatter.killAnimation();
this.flipped = !this.flipped;
let targetY = this.flipped ? 180 : 0;
let targetZ = this.flipped ? this.startAngle + this.targetRotation(this.startAngle) : this.startAngle;
let targetScale = this.flipped ? this.wantedScale : this.startScale;
let w = this.flipped ? this.wantedWidth : this.startWidth;
let h = this.flipped ? this.wantedHeight : this.startHeight;
let dw = this.wantedWidth - this.scatter.width;
let dh = this.wantedHeight - this.scatter.height;
let tc = targetCenter;
let xx = tc != null ? tc.x - w / 2 : this.startX - dw / 2;
let yy = tc != null ? tc.y - h / 2 : this.startY - dh / 2;
let x = this.flipped ? xx : this.startX;
let y = this.flipped ? yy : this.startY;
let onUpdate = this.onUpdate !== null ? () => this.onUpdate(this) : null;
TweenLite.to(this.card, this.flipDuration, {
rotationY: targetY,
ease: Power1.easeOut,
transformOrigin: '50% 50%',
onUpdate,
onComplete: e => {
if (this.flipped) {
//this.hide(this.front)
this.enable(this.backBtn);
this.show(this.backBtn);
if (this.onFrontFlipped) {
this.onFrontFlipped(this);
}
} else {
if (this.onBackFlipped == null) {
this.enable(this.infoBtn, this.fadeDuration);
this.enable(this.closeBtn, this.fadeDuration);
} else {
this.onBackFlipped(this);
}
this.flip.unload();
}
this.scatter.scale = targetScale;
this.scaleButtons();
this.scatter.rotationDegrees = targetZ;
this.scatter.width = this.flipped ? w : this.scatterStartWidth;
this.scatter.height = this.flipped ? h : this.scatterStartHeight;
let { scalable, translatable, rotatable } = this.saved;
this.scatter.scalable = scalable;
this.scatter.translatable = translatable;
this.scatter.rotatable = rotatable;
},
force3D: true
});
// See https://greensock.com/forums/topic/7997-rotate-the-shortest-way/
TweenLite.to(this.element, this.flipDuration / 2, {
scale: targetScale,
ease: Power1.easeOut,
rotationZ: targetZ + '_short',
transformOrigin: '50% 50%',
width: w,
height: h,
x: x,
y: y,
onComplete: e => {
if (this.flipped) {
this.hide(this.front);
// this.hide(this.infoBtn)
} else {
this.hide(this.back);
// this.show(this.infoBtn)
}
}
});
}
}
class Index {
constructor(template, pages, notfound = 'thumbnails/notfound.png') {
this.template = template;
this.pages = pages;
this.notfound = notfound;
}
setup() {
for (let pair of this.pages) {
let [title, src] = pair;
let id = getId();
pair.push(id);
let t = this.template;
let wrapper = t.content.querySelector('.wrapper');
wrapper.id = id;
let clone = document.importNode(t.content, true);
container.appendChild(clone);
wrapper = container.querySelector('#' + id);
let icon = wrapper.querySelector('.icon');
icon.onerror = e => {
if (this.notfound) icon.src = this.notfound;
};
let iconSrc = src.replace('.html', '.png');
//console.log("iconSrc", iconSrc)
if (iconSrc.endsWith('index.png')) {
icon.src = iconSrc.replace('index.png', 'thumbnail.png');
} else {
icon.src = 'thumbnails/' + iconSrc;
}
// icon.src = 'thumbnails/' + iconSrc
// console.log(iconSrc)
wrapper.href = src;
let titleDiv = wrapper.querySelector('.title');
titleDiv.innerText = title;
}
}
frames() {
if (this.pages.length == 0) return
let [title, src, id] = this.pages.shift();
let iframe = document.createElement('iframe');
iframe.frameborder = 0;
let wrapper = document.getElementById(id);
let icon = wrapper.querySelector('.icon');
icon.parentNode.replaceChild(iframe, icon);
iframe.onload = e => {
this.frames();
};
iframe.src = src + window.location.search;
}
load() {
this.setup();
if (window.location.search.startsWith('?test')) this.frames();
}
loadAndTest() {
this.setup();
if (!Capabilities.isMobile) this.frames();
}
}
/** Basic class for poppable elements that need to be closed as soon as one poppable is
* shown.
*/
class Poppable {
/** Register the poppable element in a context. Closes previously registered ones.
* @param {*} context
*/
register(context) {
let registered = Poppable.get(context);
if (registered != null) {
registered.close();
}
Poppable.set(context, this);
}
/**
* Unregister object from context
* @param {*} context
*/
unregister(context) {
Poppable.delete(context);
}
/**
* Returns the given poppable in a context
* @static
* @param {*} context
* @returns
* @memberof Poppable
*/
static get(context) {
return Poppable.registrations.get(context)
}
/** Sets the poppable in the given context
* @static
* @param {*} context
* @param {*} poppable
* @returns
* @memberof Poppable
*/
static set(context, poppable) {
return Poppable.registrations.set(context, poppable)
}
/** Test whether a poppable exists in the given context
*
* @param {*} context
*/
static has(context) {
return Poppable.registrations.has(context)
}
/**
* Removes the poppable from the given context.
*
* @static
* @param {*} context
* @memberof Poppable
*/
static delete(context) {
Poppable.registrations.delete(context);
}
/** All poppable must implement a close method. */
close() {
console.error('Must be implemented');
}
}
Poppable.registrations = new Map();
/* eslint-disable no-console */
/** A Popup that shows text labels, images, or html
*/
class Popup$1 extends Poppable {
/**
* Creates an instance of Popup.
* @param {any} [{
* parent = null, - The DOM parent element.
* content = null, - A dict object with type strings (text, img, html) as keys
* and corresponding values.
* context = window, - A context object for poppable elements
* fontSize = "1em", - Describes the font size as CSS value
* fontFamily = "Arial", - Describes the font family as CSS value
* padding = 16, - {number || string} padding - Describes the padding as CSS value
* notchSize = 10, - {number || string} notchSize - Describes the size of the notch (callout) as CSS value
* switchPos = false,
* minWidth = null,
* maxWidth = 800,
* backgroundColor = "#EEE", - The color of the background as CSS value
* normalColor = "#444", - normalColor - The color of textitems as CSS value
* notchPosition = "bottomLeft",
* zIndex = 0,
* keepWithin = null, - Ensure that popup is visible within the bounds of the given container
* autoClose = true, - Autoclose the Popup on tap
* closeIcon = null,
* resizeIcon = null,
* closeCommand = null,
* draggable = false
* noStyle = false - When true, prevents the popup from doing any aesthetic manipulations to the DOM leaving the styling completely to the style sheets.
* }={}]
* @memberof Popup
*/
constructor({
parent = null,
content = null,
context = window,
fontSize = '1em',
fontFamily = 'Arial',
padding = 16,
notchSize = 10,
switchPos = false,
minWidth = null,
maxWidth = 800,
backgroundColor = '#EEE',
normalColor = '#444',
notchPosition = 'bottomCenter',
zIndex = 0,
keepWithin = null,
autoClose = true,
closeIcon = null,
resizeIcon = null,
closeCommand = null,
draggable = false,
posOffset = 0,
targetBoundingBox = null,
useEventPosWithBoundingBox = false,
interactive = false,
onResize = null,
onMove = null,
noStyle = false,
hideOnUp = true
} = {}) {
super();
this.context = context;
this.noStyle = noStyle;
this.hideOnUp = hideOnUp;
this.padding = padding;
this.notchPosition = notchPosition;
this.notchSize = notchSize;
this.switchPos = switchPos;
this.fontSize = fontSize;
this.fontFamily = fontFamily;
this.minWidth = minWidth;
this.maxWidth = maxWidth;
this.normalColor = normalColor;
this.backgroundColor = backgroundColor;
this.keepWithin = keepWithin;
this.autoClose = autoClose;
this.resizeIcon = resizeIcon;
this.closeIcon = closeIcon;
this.closeCommand = closeCommand;
this.zIndex = zIndex;
this.parent = parent || document.body;
this.draggable = draggable;
this.posOffset = posOffset;
this.targetBoundingBox = targetBoundingBox;
this.useEventPosWithBoundingBox = useEventPosWithBoundingBox;
this.currentPos = null;
this.insertedNode = null;
this.loaded = false;
this.interactive = interactive;
this.onload = null;
this.onResize = onResize;
this.onMove = onMove;
if (content) {
this.show(content);
}
}
/** Setup popup with a dictionary of content types and contents.
* @param {Object} content - A dict object with type strings (text, img, html) as keys
* and corresponding values.
* @return {Popup} this
*/
setup(content) {
//console.log("Popup.setup", this.draggable)
this.content = content;
this.items = {};
this.element = document.createElement('div');
this.element.classList.add('popup');
this.setAlpha(this.element, 0);
// this.element.style.opacity = 0
Elements$1.addClass(this.element, 'unselectable');
this.notch = document.createElement('div');
Elements$1.setStyle(this.notch, this.notchStyle());
this.notch.className = 'notch';
this.setupDraggable();
if (this.closeIcon) {
let img = document.createElement('img');
img.setAttribute('draggable', false);
img.src = this.closeIcon;
img.style.position = 'absolute';
img.style.right = '0px';
img.style.top = '0px';
img.style.width = '16px';
img.style.height = '16px';
img.onclick = e => {
this.close();
};
this.element.appendChild(img);
}
if (this.resizeIcon) {
let img = document.createElement('img');
img.style.position = 'absolute';
img.style.right = '0px';
img.style.bottom = '0px';
img.style.width = '16px';
img.style.height = '16px';
img.src = this.resizeIcon;
img.setAttribute('draggable', true);
img.ondragstart = e => {
this.currentPos = { x: e.clientX, y: e.clientY };
return true
};
img.ondrag = e => {
e.preventDefault();
let target = this.element.querySelector('iframe') || this.element;
let delta = {
x: e.clientX - this.currentPos.x,
y: e.clientY - this.currentPos.y
};
this.currentPos = { x: e.clientX, y: e.clientY };
if (delta.x == 0 && delta.y == 0) return
let rect = target.getBoundingClientRect();
let width = rect.width + delta.x;
let height = rect.height + delta.y;
target.style.width = width + 'px';
target.style.height = height + 'px';
switch (this.notchPosition) {
case 'bottomLeft':
case 'bottomCenter':
let bottom = parseFloat(this.element.style.bottom);
this.element.style.bottom = bottom - delta.y + 'px';
break
default:
break
}
//console.log("onResize", this.onResize)
if (this.onResize) {
this.onResize({ target, delta, width, height });
}
};
img.ondragend = e => {};
this.element.appendChild(img);
}
for (let key in content) {
console.log('using', key, this.loaded);
switch (key) {
case 'selector':
break
case 'text':
let text = document.createElement('span');
this.element.appendChild(text);
text.innerHTML = content[key];
Elements$1.setStyle(text, { color: this.normalColor });
Elements$1.addClass(text, 'unselectable');
Elements$1.addClass(text, 'PopupContent');
this.insertedNode = text;
this.loaded = true;
break
case 'img':
alert('img to be implemented');
break
case 'iframe':
let iframe = document.createElement('iframe');
iframe.setAttribute('frameBorder', 0);
iframe.src = content[key];
iframe.onload = e => {
let body = iframe.contentWindow.document.body;
let observer = new MutationObserver(() => {
this.iframeChanged(iframe);
});
observer.observe(iframe.contentWindow.document, {
attributes: true,
subtree: true,
childList: true,
characterData: true
});
let w = Math.max(body.scrollWidth, body.offsetWidth);
let h = Math.max(body.scrollHeight, body.offsetHeight);
iframe.style.width = w + 'px';
iframe.style.height = h + 'px';
this.layoutAfterInsert();
if (this.onload != null) {
this.onload();
}
this.loaded = true;
};
this.element.appendChild(iframe);
Elements$1.addClass(iframe, 'PopupContent');
this.insertIntoDOM();
return
case 'html':
this.loaded = false;
let div = document.createElement('div');
Elements$1.addClass(div, 'PopupContent');
this.element.appendChild(div);
div.innerHTML = content.html;
//console.log("insert", content)
let selector = content.selector;
if (selector) {
this.insertedNode = div.querySelector(selector);
if (this.insertedNode == null) {
div.innerHTML = `
Popup content not found. Missing ${selector}
`; this.insertedNode = div.firstElementChild; } } else { this.insertedNode = div.firstElementChild || div; } this.setAlpha(this.insertedNode, 0); let images = this.element.querySelectorAll('img'); let total = 0; if (images.length > 0) { let count = 0; for (let image of images) { if (!image.complete && !image.src.startsWith('data:')) { total += 1; console.log('image not complete', image.src); image.onload = e => { count += 1; if (count == total) { this.loaded = true; if (this.onload != null) { this.onload(); } } }; } } } if (total == 0) { this.loaded = true; } break case 'node': this.loaded = true; Elements$1.addClass(content.node, 'PopupContent'); this.element.appendChild(content.node); this.insertedNode = content.node; this.setAlpha(this.insertedNode, 0); break default: alert('Unexpected content type: ' + key); break } } this.insertIntoDOM(); this.layoutAfterInsert(); this.setupEventHandler(); return this } handleClose(e) { let closing = this.closingEvent(e); if (closing) { this.close(e); } else { this.setupCloseHandler(); } } setupCloseHandler() { let close = this.handleClose; if (this.hideOnUp) { if (window.PointerEvent) this.parent.addEventListener('pointerup', close.bind(this), { capture: true, once: true }); else if (window.TouchEvent) this.parent.addEventListener('touchend', close.bind(this), { capture: true, once: true }); else this.parent.addEventListener('mouseup', close.bind(this), { capture: true, once: true }); } else { if (window.PointerEvent) this.parent.addEventListener('pointerdown', close.bind(this), { capture: true, once: true }); else if (window.TouchEvent) this.parent.addEventListener('touchstart', close.bind(this), { capture: true, once: true }); else this.parent.addEventListener('mousedown', close.bind(this), { capture: true, once: true }); } } setupEventHandler() { if (this.autoClose) { this.setupCloseHandler(); } } closingEvent(e) { if (this.interactive) { let node = e.target.closest('.PopupContent'); return node == null } return true } iframeChanged(iframe) { let body = iframe.contentWindow.document.body; let w = Math.max(body.scrollWidth, body.offsetWidth); let h = Math.max(body.scrollHeight, body.offsetHeight); iframe.style.width = w + 'px'; iframe.style.height = h + 'px'; } setupDraggable() { if (this.draggable) { let target = this.element; target.setAttribute('draggable', true); target.ondragstart = e => { this.currentPos = { x: e.clientX, y: e.clientY }; var img = document.createElement('img'); img.src = 'data:image/gifbase64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; e.dataTransfer.setDragImage(img, 0, 0); }; target.ondrag = e => { e.preventDefault(); let delta = { x: e.clientX - this.currentPos.x, y: e.clientY - this.currentPos.y }; this.currentPos = { x: e.clientX, y: e.clientY }; let left = parseFloat(target.style.left); let top = parseFloat(target.style.top); target.style.left = left + delta.x + 'px'; target.style.top = top + delta.y + 'px'; //console.log("Popup.ondrag", target, event.target) if (this.onMove) { this.onMove({ target, delta }); } this.lastDrag = { left, top }; }; target.ondragend = e => { target.style.left = this.lastDrag.left + 'px'; target.style.top = this.lastDrag.top + 'px'; this.currentPos = null; }; } } moveDragged(target) {} insertIntoDOM(layout = true) { this.setAlpha(this.insertedNode, 0); this.element.appendChild(this.notch); this.parent.appendChild(this.element); } layoutAfterInsert() { Elements$1.setStyle(this.element, this.defaultStyle()); this.layout(); //this.element.style.opacity = 1 } /** Layout the menu items. Needed only in the subclass. */ layout() {} remove() { if (this.parent.contains(this.element)) this.parent.removeChild(this.element); this.unregister(this.context); } /** Close and remove the Popup from the DOM tree. */ close(event) { //console.log("Popup.close", this.closeCommand) this.unregister(this.context); if (this.closeCommand) { this.closeCommand(this, () => this.remove(), event); } else { this.remove(); } } /** * Set the alpha value to show or hide the popup. Uses CSS transitions. * (A former dependency on TweenLite has beeen removed.) * * @param {*} targets * @param {*} value * @memberof Popup */ setAlpha(targets, value) { let objs = targets instanceof Array ? targets : [targets]; for (let obj of objs) { if (value) { obj.style.transition = 'opacity 0.2s ease-in'; } obj.style.opacity = value; } // if (value) { // TweenLite.to(targets, 0.2, { autoAlpha: value }) // } // else { // TweenLite.set(targets, { autoAlpha: 0 }) // } } /** * Starts a fade in animation. * * @memberof Popup */ fadeIn() { this.setAlpha([this.element, this.insertedNode], 1); } /** Shows the Popup with the given commands at the specified point. * @param {Object} content - A dict object with type strings (text, img, html) as keys * and corresponding values. * @param {Point} point - The position as x, y coordinates {px}. * @return {Popup} this */ showAt(content, point) { this.setup(content); console.log('showAt', this.loaded); if (this.loaded) { this.placeAt(point); this.fadeIn(); } else { this.setAlpha([this.element, this.insertedNode], 0); this.onload = () => { this.layoutAfterInsert(); this.placeAt(point); this.fadeIn(); }; } return this } /** * Place the origin, i.e. the upper left corner at the given position using CSS styles. * * @param {any} x * @param {any} y * @memberof Popup */ placeOrigin(x, y) { Elements$1.setStyle(this.element, { left: x + 'px', top: y + 'px' }); } /** * Calculate the local coordinates within the keepWithin container. * * @param {any} x * @param {any} y * @returns * @memberof Popup */ localPointWithin(x, y, width, height) { let pt = { x, y }; return pt } withinDimensions() { return { width: this.keepWithin.offsetWidth, height: this.keepWithin.offsetHeight } } localDimensions() { return { width: this.element.offsetWidth, height: this.element.offsetHeight } } /** * Returns the notch position depending on the local coordinates within the keepWithin container * Divides the space vertically into top, center, bottom and horizontally into left, center, right * * @param {any} x * @param {any} y * @returns * @memberof Popup */ notchPositionWithin(x, y) { let horizontal = 'Center'; let vertical = 'center'; let { width, height } = this.withinDimensions(); let local = this.localPointWithin(x, y, width, height); if (local.y < height * 0.33) { vertical = 'top'; } if (local.y > height * 0.66) { vertical = 'bottom'; } if (local.x < width * 0.33) { horizontal = 'Left'; } if (local.x > width * 0.66) { horizontal = 'Right'; } let result = vertical + horizontal; if (result == 'centerCenter') return this.notchPosition return result } placeAt(point) { let x = point.x; let y = point.y; let notchPosition = this.notchPosition; if (this.keepWithin != null) { notchPosition = this.notchPositionWithin(x, y); } Elements$1.setStyle(this.notch, this.notchStyle(notchPosition)); this.notch.className = 'notch ' + notchPosition; let { width, height } = this.localDimensions(); //if targetBoundingBox is set, popup is placed next to the rectangle if (this.targetBoundingBox) { let bbTop = this.targetBoundingBox.y; let bbBottom = this.targetBoundingBox.y + this.targetBoundingBox.height; let bbLeft = this.targetBoundingBox.x; let bbRight = this.targetBoundingBox.x + this.targetBoundingBox.width; //console.log("place popup with bb set:", x, y, bbTop, bbBottom, bbLeft, bbRight) switch (notchPosition) { case 'bottomLeft': case 'bottomRight': case 'bottomCenter': y = bbTop; if (!this.useEventPosWithBoundingBox) x = (bbLeft + bbRight) / 2; break case 'topLeft': case 'topRight': case 'topCenter': y = bbBottom; if (!this.useEventPosWithBoundingBox) x = (bbLeft + bbRight) / 2; break case 'centerRight': x = bbLeft; if (!this.useEventPosWithBoundingBox) y = (bbTop + bbBottom) / 2; break case 'centerLeft': x = bbRight; if (!this.useEventPosWithBoundingBox) y = (bbTop + bbBottom) / 2; break default: break } } //calculate position depending on several (optional) parameters switch (notchPosition) { case 'bottomLeft': x -= this.padding; x -= this.notchSize; y -= height; y -= this.notchSize * 2; y -= this.posOffset; break case 'bottomRight': x -= width; x += this.padding; x += this.notchSize; y -= height; y -= this.notchSize * 2; y -= this.posOffset; break case 'bottomCenter': x -= width / 2; y -= height; y -= this.notchSize * 2; y -= this.posOffset; break case 'topLeft': x -= this.padding; x -= this.notchSize; y += this.notchSize * 2; y += this.posOffset; break case 'topRight': x -= width; x += this.padding; x += this.notchSize; y += this.notchSize * 2; y += this.posOffset; break case 'topCenter': x -= width / 2; y += this.notchSize * 2; y += this.posOffset; break case 'centerRight': x -= width + this.notchSize * 2; x -= this.posOffset; y -= height / 2; break case 'centerLeft': //console.log("height", height) y -= height / 2; x += this.notchSize * 2; x += this.posOffset; break default: break } this.placeOrigin(x, y); } /** Shows the Popup with the given commands at the current position. * @param {Object} content - A dict object with type strings (text, img, html) as keys * and corresponding values. * @return {Popup} this */ show(content) { this.setup(content); this.fadeIn(); return this } /** Configuration object. Return default styles as CSS values. */ defaultStyle() { let padding = this.padding; let style = { maxWidth: this.maxWidth + 'px', zIndex: this.zIndex, position: 'absolute' }; if (this.minWidth) { style.minWidth = this.minWidth + 'px'; } if (!this.noStyle) { Object.assign(style, { borderRadius: Math.round(this.padding / 2) + 'px', backgroundColor: this.backgroundColor, padding: this.padding + 'px', boxShadow: '0 10px 15px rgba(0, 0, 0, 0.3)', fontFamily: this.fontFamily, fontSize: this.fontSize, stroke: 'black', fill: 'white' }); } return style } /** Configuration object. Return notch styles as CSS values. */ notchStyle(notchPosition = null) { if (notchPosition == null) { notchPosition = this.notchPosition; } let width = 0; let height = 0; let left = this.padding; let size = this.localDimensions(); if (notchPosition.endsWith('Right')) { left = size.width - this.padding - this.notchSize * 2; } if (notchPosition.endsWith('Center')) { left = size.width / 2 - this.notchSize; } left = Math.round(left) + 'px'; if (notchPosition.startsWith('bottom')) { if (this.noStyle) { return { width, height, left, bottom: -this.notchSize + 'px', position: 'absolute', borderStyle: 'solid', borderTopWidth: this.notchSize + 'px', borderRight: this.notchSize + 'px solid transparent', borderLeft: this.notchSize + 'px solid transparent', borderBottom: 0 } } else { return { width, height, left, boxShadow: '0 12px 15px rgba(0, 0, 0, 0.1)', bottom: -this.notchSize + 'px', position: 'absolute', borderTop: this.notchSize + 'px solid ' + this.backgroundColor, borderRight: this.notchSize + 'px solid transparent', borderLeft: this.notchSize + 'px solid transparent', borderBottom: 0 } } } if (notchPosition.startsWith('top')) { if (this.noStyle) { return { width, height, left, top: -this.notchSize + 'px', position: 'absolute', borderStyle: 'solid', borderBottomWidth: this.notchSize + 'px', borderRight: this.notchSize + 'px solid transparent', borderLeft: this.notchSize + 'px solid transparent', borderTop: 0 } } else { return { width, height, left, top: -this.notchSize + 'px', position: 'absolute', borderBottom: this.notchSize + 'px solid ' + this.backgroundColor, borderRight: this.notchSize + 'px solid transparent', borderLeft: this.notchSize + 'px solid transparent', borderTop: 0 } } } if (this.noStyle) { if (notchPosition.endsWith('Left')) { left = -this.notchSize * 2 + 'px'; } if (notchPosition.endsWith('Right')) { left = size.width + 'px'; } let top = size.height / 2 - this.notchSize; top = Math.round(top) + 'px'; return { width, height, left, top, borderRightWidth: this.notchSize, borderLeftWidth: this.notchSize, position: 'absolute', borderTop: this.notchSize + 'px solid transparent', borderBottom: this.notchSize + 'px solid transparent' } } else { let borderRight = this.notchSize + 'px solid transparent'; let borderLeft = this.notchSize + 'px solid transparent'; let top = size.height / 2 - this.notchSize; if (notchPosition.endsWith('Left')) { left = -this.notchSize * 2 + 'px'; borderRight = this.notchSize + 'px solid ' + this.backgroundColor; this.element.style.boxShadow = '15px 10px 15px rgba(0, 0, 0, 0.3)'; } if (notchPosition.endsWith('Right')) { left = size.width + 'px'; borderLeft = this.notchSize + 'px solid ' + this.backgroundColor; this.element.style.boxShadow = '15px 5px 15px rgba(0, 0, 0, 0.3)'; } top = Math.round(top) + 'px'; return { width, height, left, top, borderRight, borderLeft, // boxShadow, position: 'absolute', borderTop: this.notchSize + 'px solid transparent', borderBottom: this.notchSize + 'px solid transparent' } } } /** Convenient static methods to show and reuse a Popup implemented * as a class variable. * @param {Object} content - A dict object with type strings (text, img, html) as keys * and corresponding values. * @param {Point} point - The position as x, y coordinates {px}. * @param {boolean} autoClose - Autoclose the menu after selecting an item. */ static open( content, point, { parent = null, context = window, fontSize = '1em', fontFamily = 'Arial', padding = 16, notchSize = 10, switchPos = false, minWidth = null, maxWidth = 800, backgroundColor = '#EEE', zIndex = 0, normalColor = '#444', closeIcon = null, resizeIcon = null, closeCommand = null, autoClose = true, keepWithin = null, draggable = false, posOffset = 0, targetBoundingBox = null, useEventPosWithBoundingBox = false, interactive = false, onResize = null, onMove = null } = {} ) { let notchPosition = switchPos && point.y < 50 ? 'topCenter' : 'bottomCenter'; let popup = new Popup$1({ parent, context, fontFamily, fontSize, padding, notchSize, switchPos, minWidth, maxWidth, backgroundColor, normalColor, notchPosition, zIndex, autoClose, keepWithin, closeCommand, closeIcon, resizeIcon, draggable, posOffset, targetBoundingBox, useEventPosWithBoundingBox, interactive, onResize, onMove }); popup.register(context); popup.showAt(content, point); return popup } /** Convenient static method to close the Popup implemented * as a class variable. Calls the close command. */ static closePopup(context = window) { let popup = Poppable.registrations.get(context); if (popup != null) { popup.close(); } } /** Convenient static methods to remove the Popup implemented * as a class variable. Removes the popup without performing the close command. */ static remove(context = window) { let popup = Poppable.registrations.get(context); if (popup != null) { popup.remove(); } } /** * Convenient static method to compute the clicked rect of objects that have multiple clients rects. * Needed to position popups correctly above objects with line breaks, e.g. spans * * @static * @param {*} event * @returns {*} DOMRect * @memberof Popup */ static targetRect(event) { let target = event.target; let x = event.pageX; let y = event.pageY; for (let rect of target.getClientRects()) { let withinX = x >= rect.left && x <= rect.left + rect.width; let withinY = y >= rect.top && y <= rect.top + rect.height; if (withinX && withinY) { return rect } } return null } /** * Convenient static method to compute the center of objects that have multiple clients rects. * Needed to position popups correctly above objects with line breaks, e.g. spans * * @static * @param {*} event * @returns {*} Point * @memberof Popup */ static targetCenter(event) { let target = event.target; let x = event.pageX; let y = event.pageY; let rect = Popup$1.targetRect(event); if (rect != null) { x = rect.left + rect.width / 2; y = rect.top + rect.height / 2; } return { x, y } } } /** A Popup Menu that shows text labels in a vertical row. */ class PopupMenu extends Popup$1 { /** * The constructor. * @constructor * @param {DOM Element} parent - The DOM parent element. * @param {Object} commands - A dict object with command label strings as keys * and command functions as values. * @param {string} fontSize - Describes the font size as CSS value * @param {number || string} padding - Describes the padding as CSS value * @param {number || string} notchSize - Describes the size of the notch (callout) as CSS value * @param {string} highlightColor - The color of highlighted menu items as CSS value * @param {string} backgroundColor - The color of the background as CSS value * @param {string} normalColor - The color of normal menu items as CSS value * @param {DOM Element} keepWithin - The container to stay within * @param {boolean} autoClose - Autoclose the menu after selecting an item */ constructor({ parent = null, commands = null, fontSize = '1em', fontFamily = 'Arial', padding = 16, zIndex = 1, spacing = '0px', switchPos = false, notchSize = 10, maxWidth = 800, backgroundColor = '#EEE', normalColor = '#444', highlightColor = 'black', notchPosition = 'bottomLeft', keepWithin = null, autoClose = true } = {}) { super({ parent, fontSize, fontFamily, padding, notchSize, notchPosition, backgroundColor, keepWithin, normalColor, autoClose }); this.commands = commands; this.zIndex = zIndex; this.switchPos = switchPos; this.spacing = spacing; this.highlightColor = highlightColor; } /** Setup menu with a dictionary of command labels and command functions. * @param {Object} commands - A dict object with command label strings as keys * and command functions as values. * @return {PopupMenu} this */ setup(commands) { this.commands = commands; this.items = {}; this.element = document.createElement('div'); this.element.style.zIndex = this.zIndex; Elements$1.addClass(this.element, 'unselectable'); this.notch = document.createElement('div'); Elements$1.setStyle(this.notch, this.notchStyle()); for (let key in commands) { let item = document.createElement('div'); this.element.appendChild(item); item.innerHTML = key; item.style.paddingBottom = item.style.paddingTop = this.spacing; Elements$1.setStyle(item, { color: this.normalColor, cursor: 'default' }); Elements$1.addClass(item, 'unselectable'); Elements$1.addClass(item, 'popupMenuItem'); this.items[key] = item; item.onclick = event => { this.perform(key); }; item.ontap = event => { this.perform(key); }; item.onmouseover = event => { this.over(event, key); }; item.onmouseout = event => { this.out(event, key); }; } this.element.appendChild(this.notch); this.parent.appendChild(this.element); this.insertedNode = this.element; Elements$1.setStyle(this.element, this.defaultStyle()); this.layout(); return this } /** Execute a menu command. * @param {string} key - The selected key. */ perform(key) { let func = this.commands[key]; if (this.autoClose) { this.close(); } setTimeout(() => { func.call(); }, 20); } /** Update the menu item denoted by key. * @param {string} key - The selected key. * @param {boolean} highlight - Show the item highlighted. */ update(key, highlight = false) { let text = this.items[key]; text.style.color = highlight ? this.highlightColor : this.normalColor; } /** Mouse over handöer. * @param {Event} event - The mouse event. * @param {boolean} key - The selected key. */ over(event, key) { for (let k in this.items) { this.update(k, k == key); } } /** Mouse out handöer. * @param {Event} event - The mouse event. * @param {boolean} key - The selected key. */ out(event, key) { this.update(key); } /** Shows the PopupMenu with the given commands at the specified point. * @param {Object} commands - A dict object with command label strings as keys * and command functions as values. * @param {Point} point - The position as x, y coordinates {px}. * @return {PopupMenu} this */ showAt(commands, point) { this.show(commands); this.placeAt(point); return this } /** Convenient static methods to show and reuse a PopupMenu implemented * as a class variable. * @param {Object} commands - A dict object with command label strings as keys * and command functions as values. * @param {Point} point - The position as x, y coordinates {px}. * @param {string} fontSize - Describes the font size as CSS value * @param {number || string} padding - Describes the padding as CSS value * @param {number || string} notchSize - Describes the size of the notch (callout) as CSS value * @param {string} highlightColor - The color of highlighted menu items as CSS value * @param {string} backgroundColor - The color of the background as CSS value * @param {string} normalColor - The color of normal menu items as CSS value * @param {boolean} autoClose - Autoclose the menu after selecting an item */ static open( commands, point, { parent = null, context = window, fontSize = '1em', fontFamily = 'Arial', padding = 16, zIndex = 1, spacing = '0px', switchPos = false, notchSize = 10, maxWidth = 800, keepWithin = null, backgroundColor = '#EEE', normalColor = '#444', autoClose = true } = {} ) { let registered = Poppable.get(context); if (registered) { this.closePopup(); return } console.log('open', point); let notchPosition = point.y < 50 && switchPos ? 'topCenter' : 'bottomCenter'; let popup = new PopupMenu({ parent, fontSize, padding, zIndex, spacing, switchPos, notchSize, notchPosition, maxWidth, backgroundColor, normalColor, notchPosition, keepWithin, autoClose }); popup.showAt(commands, point); popup.register(context); popup.closeEventListener = e => { if (this.eventOutside(e)) this.closePopup(context); }; if (autoClose) { context.addEventListener('mousedown', popup.closeEventListener, true); context.addEventListener('touchstart', popup.closeEventListener, true); context.addEventListener('pointerdown', popup.closeEventListener, true); } } static eventOutside(e) { return !Elements$1.hasClass(e.target, 'popupMenuItem') } /** Convenient static methods to close the PopupMenu implemented * as a class variable. */ static closePopup(context = window) { let registered = Poppable.get(context); if (registered) { registered.close(); context.removeEventListener('mousedown', registered.closeEventListener); context.removeEventListener('touchstart', registered.closeEventListener); context.removeEventListener('pointerdown', registered.closeEventListener); } } } class FrameContainer { constructor(element) { this.element = element; this.delegate = new InteractionMapper(element, this, { mouseWheelElement: window }); } capture(event) { return true } findTarget(event, local, global) { let found = document.elementFromPoint(global.x, global.y); let iframe = found.querySelector('iframe'); if (iframe) { let p = Points$1.fromPageToNode(found, global); let doc = iframe.contentWindow.document; let target = doc.elementFromPoint(p.x, p.y); if (target != null) { console.log('iframe element', target); return new FrameTarget(iframe, target) } } return null } } class FrameTarget { constructor(frame, target, debug = false) { this.frame = frame; this.target = target; this.debug = debug; } capture(event) { return true } simulateMouseEvent(type, point) { let p = Points$1.fromPageToNode(this.frame, point); let event = new MouseEvent(type, { view: this.frame.contentWindow, bubbles: true, cancelable: true, clientX: p.x, clientY: p.y }); this.target.dispatchEvent(event); } createTouchList(pointMap) { let touches = []; let doc = this.frame.contentWindow.document; for (let key of pointMap.keys()) { let point = pointMap.get(key); let p = Points$1.fromPageToNode(this.frame, point); let touchTarget = doc.elementFromPoint(p.x, p.y); let touch = new Touch(undefined, touchTarget, key, p.x, p.y, p.x, p.y); touches.push(touch); } return new TouchList(...touches) } simulateTouchEventChrome(type, point, pointMap) { let doc = this.frame.contentWindow.document; let p = Points$1.fromPageToNode(this.frame, point); let touchTarget = doc.elementFromPoint(p.x, p.y); const touchObj = new Touch({ identifier: Date.now(), target: touchTarget, clientX: p.x, clientY: p.y, pageX: p.x, pageY: p.y, radiusX: 2.5, radiusY: 2.5, rotationAngle: 10, force: 0.5 }); const touchEvent = new TouchEvent(type, { cancelable: true, bubbles: true, touches: [touchObj], targetTouches: [touchObj], changedTouches: [touchObj], shiftKey: false }); if (this.debug) console.log('simulateTouchEventChrome', touchEvent); this.target.dispatchEvent(touchEvent); } simulateTouchEventSafari(type, point, pointMap, touchEventKey = 'targetTouches') { let p = Points$1.fromPageToNode(this.frame, point); let data = { view: this.frame.contentWindow, bubbles: true, cancelable: true, clientX: p.x, clientY: p.y }; data[touchEventKey] = this.createTouchList(pointMap); let event = new TouchEvent(type, data); if (this.debug) console.log('simulateTouchEventChrome', touchEvent); this.target.dispatchEvent(event); } simulateTouchEvent(type, point, pointMap, touchEventKey = 'targetTouches') { if (Capabilities.isSafari) { this.simulateTouchEventSafari(type, point, pointMap, touchEventKey); } else { this.simulateTouchEventChrome(type, point, pointMap); } } isMouseLikeEvent(event) { return event.type.startsWith('mouse') || event.type.startsWith('pointer') } onStart(event, interaction) { if (this.debug) console.log('onStart', this.frame.parentNode); for (let [key, point] of interaction.current.entries()) { if (this.isMouseLikeEvent(event)) { this.simulateMouseEvent('mousedown', point); } else { this.simulateTouchEvent('touchstart', point, interaction.current); return } } } onMove(event, interaction) { if (this.debug) console.log('onMove'); for (let [key, point] of interaction.current.entries()) { if (this.isMouseLikeEvent(event)) { this.simulateMouseEvent('mousemove', point); } else { this.simulateTouchEvent('touchmove', point, interaction.current); return } } } onEnd(event, interaction) { if (this.debug) console.log('onEnd'); for (let [key, point] of interaction.current.entries()) { if (this.isMouseLikeEvent(event)) { this.simulateMouseEvent('mouseend', point); } else { this.simulateTouchEvent('touchend', point, interaction.ended, 'changedTouches'); return } } } } class Inspect { // Code inspection functions static allScriptSources() { let sources = []; let scripts = document.getElementsByTagName('script'); for (let i = 0; i < scripts.length; i++) { console.dir(scripts[i]); sources.push(scripts[i]); } return sources } } /* globals Power0 */ /* eslint no-console: ["error", { allow: ["log", "info", "warn", "error"] }] */ /** * Imports */ /** * A class that can be used to perform automated user interface tests. * * @example * // Create the UITest object * const test = new UITest({ * timeScale: 2 * }) * * // Add an action to the test case * test.tap(button, {eventType: 'click'}) * * // Start the test case * test.start() * * @class * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/uitest.html|DocTest} */ class UITest { /** * Creates an instance of an UITest. * * In the background, the class UITest uses the Greensock TimelineMax class. The opts object is passed directly to the TimelineMax class, so it can use any key that uses the TimelineMax class. * * @constructor * @param {object} [opts] - An options object to specify the behaviour of the test case. * @param {number} [opts.timeScale=1] - The speed at which the test should run, see https://greensock.com/docs/TimelineMax/timeScale(). * @param {string} [opts.eventType=auto] - The type of events which should be used. Possible values: pointer, touch, mouse, auto. If set to auto, the eventType is set depending on the support of the browser used. * @param {boolean} [opts.debug=false] - If set to true, multiple informations will be print to the console. * @param {number} [opts.defaultInterval] - The interval used when no action is specified for an action. */ constructor(opts = {}) { this.opts = Object.assign( {}, { timeScale: 1, eventType: 'auto', debug: false, defaultInterval: null }, opts ); // timeline //-------------------- this._timeline = new TimelineMax( Object.assign( {}, { paused: true }, this.opts ) ); this._timeline.timeScale(this.opts.timeScale); // eventType //-------------------- if (this.opts.eventType === 'auto') { if (window.PointerEvent) { this.opts.eventType = 'pointer'; } else if ('ontouchstart' in window) { this.opts.eventType = 'touch'; } else { this.opts.eventType = 'mouse'; } } if (this.opts.debug) { console.log(`Event type: ${this.opts.eventType}`); } this._timelinePositions = [0]; this._actions = 0; // setup //----------------- this.setup(); } /** * Generates the required structure. * * @private * @return {UITest} A reference to the UITest for chaining. */ setup() { return this } /** * Gets the Greensock TimelineMax object, used in the background of UITest. * * @member {TimelineMax} */ get timeline() { return this._timeline } /** * Starts the test case and executes the corresponding statements in the specified order. * * @return {UITest} A reference to the UITest for chaining. */ start() { this._timeline.play(); return this } /** * Stops the test case and stops executing any further instructions. * * @return {UITest} A reference to the UITest for chaining. */ stop() { this._timeline.pause(); return this } /** * Clears all instructions of the test case. * * @return {UITest} A reference to the UITest for chaining. */ clear() { this._timeline.clear(); return this } /** * Restarts the test case. * * @return {UITest} A reference to the UITest for chaining. */ restart() { this._timeline.restart(); return this } /** * Executes a tap event (pointerdown, pointerup) on a specific element. * * @param {HTMLElement|string} element - The HTML element on which the event is to be executed, e.g. button, document, h2, canvas, etc. or an selector string. If a selector has been specified, it is evaluated immediately before the event is called using the querySelector method. * @param {number[]|object|PIXI.DisplayObject} [position=The center of the element.] - The local position of the event in the context of the specified HTML element. If no position is specified, the center of the HTML element is used. The position can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object. * @param {number} [timelinePosition=One second after the last action.] - The position in seconds when the event should be triggered, see shttps://greensock.com/docs/TimelineMax/addCallback(). * @param {object} [opts] - An options object to specify the behaviour of the action. * @param {function} [opts.onStart] - A function that runs after the first event is fired. Will not be fired if only one event is running (for example, a click event). Receives the fired event object as the first parameter. The test case (UITest) is bound to this. * @param {function} [opts.onComplete] - A function that runs after the second event is fired. Always fired, even if only one event is running (for example, a click event). Receives the fired event object as the first parameter. The test case (UITest) is bound to this. * @param {string[]} [opts.eventTypes=['pointerdown', 'pointerup']] - The event types to use. If no types are specified, the event types specified in the UITest constructor are used (or auto if not specified). * @param {string} [opts.eventType] - If you want the tap method to fire only one event (for example, a click event), you can specify the opts.eventType parameter. If eventType is not null, the parameter opts.eventTypes is ignored. * @param {Window|Frame} [opts.context=window] - The context within which the optionally specified element selector should be executed. * @param {boolean} [opts.bubbles=true] - The Event property bubbles indicates whether the event bubbles up through the DOM or not. * @param {boolean} [opts.cancelable=true] - Events' cancelable property indicates if the event can be canceled, and therefore prevented as if the event never happened. If the event is not cancelable, then its cancelable property will be false and the event listener cannot stop the event from occurring. */ tap(element, position, timelinePosition, opts = {}) { [position, timelinePosition, opts] = this.reorderArguments(arguments); this._timelinePositions.push(timelinePosition); // debug //-------------------- if (this.opts.debug) console.log('tap params', { element, position, timelinePosition, opts }); // opts //-------------------- opts = Object.assign( {}, { onStart: null, onComplete: null, eventTypes: this.resolveEvents(['down', 'up']), eventType: null, context: window, bubbles: true, cancelable: true }, opts ); if (opts.eventType) { opts.eventTypes = opts.eventType; } opts.eventTypes = Array.isArray(opts.eventTypes) ? opts.eventTypes : [opts.eventTypes]; // timeline //-------------------- this._timeline.addCallback( position => { // element //-------------------- const elem = Util.extractElement(opts.context, element); // position //-------------------- if (position === null) { const rect = elem.getBoundingClientRect(); position = [rect.width / 2, rect.height / 2]; } // coords //-------------------- const coords = Util.extractPosition(position); if (this.opts.debug) console.log('local coords', coords); // eventTypes //-------------------- if (opts.eventTypes.length === 1) { opts.eventTypes.unshift(null); } // event opts //-------------------- const eventOpts = { bubbles: opts.bubbles, cancelable: opts.cancelable }; if (opts.eventTypes[0]) { // create and dispatch event //-------------------- const eventStart = Event$1.create(elem, coords, opts.eventTypes[0], eventOpts); if (this.opts.debug) console.log('dispatch event', eventStart); elem.dispatchEvent(eventStart); // onStart //-------------------- if (opts.onStart) { opts.onStart.call(this, eventStart); } } // create and dispatch event //-------------------- const eventComplete = Event$1.create(elem, coords, opts.eventTypes[1], eventOpts); if (this.opts.debug) console.log('dispatch event', eventComplete); elem.dispatchEvent(eventComplete); // onComplete //-------------------- if (opts.onComplete) { opts.onComplete.call(this, eventComplete); } }, timelinePosition, [position] ); this._actions++; return this } /** * Executes a pan event (pointerdown, pointermove, pointerup) on a specific element. * * @param {HTMLElement|string} element - The HTML element on which the event is to be executed, e.g. button, document, h2, canvas, etc. or an selector string. If a selector has been specified, it is evaluated immediately before the event is called using the querySelector method. * @param {number[]|object|PIXI.DisplayObject} [position=The center of the element.] - The local position of the event in the context of the specified HTML element. If no position is specified, the center of the HTML element is used. The position can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object. * @param {number} [timelinePosition=One second after the last action.] - The position in seconds when the event should be triggered, see shttps://greensock.com/docs/TimelineMax/addCallback(). * @param {object} [opts] - An options object to specify the behaviour of the action. * @param {function} [opts.onStart] - A function that runs after the first event is fired. Receives the fired event object as the first parameter. The test case (UITest) is bound to this. * @param {function} [opts.onUpdate] - A function that runs after each execution of the second event. Receives the fired event object as the first parameter. The test case (UITest) is bound to this. * @param {function} [opts.onComplete] - A function that runs after the third event is fired. Receives the fired event object as the first parameter. The test case (UITest) is bound to this. * @param {number[]|object|PIXI.DisplayObject} [opts.to={x: 0, y: 0}] - The target of the pan process. The position can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object. * @param {number} [opts.duration=1] - The duration of the pan animation in seconds, see https://greensock.com/docs/TweenLite/duration(). * @param {Ease} [opts.ease=Power0.easeNone] - The easing of the pan animation, see https://greensock.com/docs/Easing. * @param {string[]} [opts.eventTypes=['pointerdown', 'pointermove', 'pointerup']] - The event types to use. If no types are specified, the event types specified in the UITest constructor are used (or auto if not specified). * @param {Window|Frame} [opts.context=window] - The context within which the optionally specified element selector should be executed. * @param {boolean} [opts.bubbles=true] - The Event property bubbles indicates whether the event bubbles up through the DOM or not. * @param {boolean} [opts.cancelable=true] - Events' cancelable property indicates if the event can be canceled, and therefore prevented as if the event never happened. If the event is not cancelable, then its cancelable property will be false and the event listener cannot stop the event from occurring. */ pan(element, position, timelinePosition, opts = {}) { [position, timelinePosition, opts] = this.reorderArguments(arguments); this._timelinePositions.push(timelinePosition); // debug //-------------------- if (this.opts.debug) console.log('tap params', { element, position, timelinePosition, opts }); // opts //-------------------- opts = Object.assign( {}, { onStart: null, onUpdate: null, onComplete: null, to: { x: 0, y: 0 }, duration: 1, ease: Power0.easeNone, eventTypes: this.resolveEvents(['down', 'move', 'up']), context: window, bubbles: true, cancelable: true }, opts ); // timeline //-------------------- this._timeline.addCallback( position => { // element //-------------------- const elem = Util.extractElement(opts.context, element); // coords //-------------------- const from = Util.extractPosition(position); // event opts //-------------------- const eventOpts = { bubbles: opts.bubbles, cancelable: opts.cancelable }; const gsOpts = { ease: opts.ease, onStart: () => { // create and dispatch event //-------------------- const event = Event$1.create(elem, from, opts.eventTypes[0], eventOpts); if (this.opts.debug) console.log('dispatch event', event); elem.dispatchEvent(event); // onStart //-------------------- if (opts.onStart) { opts.onStart.call(this, event); } }, onUpdate: () => { // create and dispatch event //-------------------- const event = Event$1.create(elem, from, opts.eventTypes[1], eventOpts); if (this.opts.debug) console.log('dispatch event', event); elem.dispatchEvent(event); // onUpdate //-------------------- if (opts.onUpdate) { opts.onUpdate.call(this, event); } }, onComplete: () => { // create and dispatch event //-------------------- const event = Event$1.create(elem, from, opts.eventTypes[2], eventOpts); if (this.opts.debug) console.log('dispatch event', event); elem.dispatchEvent(event); // onComplete //-------------------- if (opts.onComplete) { opts.onComplete.call(this, event); } } }; // to //-------------------- const object = Util.extractTo(opts); Object.assign(gsOpts, object); // drag animation //-------------------- TweenLite.to(from, opts.duration, gsOpts); }, timelinePosition, [position] ); this._actions++; return this } /** * Executes a pinch event (pointerdown, pointermove, pointerup) on a specific element with two "fingers" simultaneously. * * @param {HTMLElement|string} element - The HTML element on which the event is to be executed, e.g. button, document, h2, canvas, etc. or an selector string. If a selector has been specified, it is evaluated immediately before the event is called using the querySelector method. * @param {number[]|object|PIXI.DisplayObject} [position=The center of the element.] - The local position of the event in the context of the specified HTML element. If no position is specified, the center of the HTML element is used. The position can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object. * @param {number} [timelinePosition=One second after the last action.] - The position in seconds when the event should be triggered, see shttps://greensock.com/docs/TimelineMax/addCallback(). * @param {object} [opts] - An options object to specify the behaviour of the action. * @param {function} [opts.onStart] - A function that runs after the first events are fired. Receives the fired event object as the first parameter. The test case (UITest) is bound to this. * @param {function} [opts.onUpdate] - A function that runs after each execution of the second events. Receives the fired event object as the first parameter. The test case (UITest) is bound to this. * @param {function} [opts.onComplete] - A function that runs after the third events are fired. Receives the fired event object as the first parameter. The test case (UITest) is bound to this. * @param {boolean} [opts.doubleCallbacks=false] - The callbacks onStart, onUpdate and onComplete will be fired only for one finger. If set to true, the events will be fired for both fingers. * @param {number} [opts.distance=100] - The distance in pixels, how far the two "fingers" should move apart. If to or bezier specified, distance is ignored. * @param {number[][]|object[]|PIXI.DisplayObject[]} [opts.to] - The targets of the pinch process. The position must be an array with two entries. An entry can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object. If bezier is specified, to is ignored. * @param {number[][]|object[]|PIXI.DisplayObject[]} [opts.bezier] - The targets of the pinch process. The position must be an array with two entries. An entry may be an array of positions or a bezier object (https://greensock.com/docs/Plugins/BezierPlugin). A position in the array or the values array of the bezier object can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object. If bezier is specified, to is ignored. * @param {number} [opts.duration=1] - The duration of the pan animation in seconds, see https://greensock.com/docs/TweenLite/duration(). * @param {Ease} [opts.ease=Power0.easeNone] - The easing of the pan animation, see https://greensock.com/docs/Easing. * @param {string[]} [opts.eventTypes=['pointerdown', 'pointermove', 'pointerup']] - The event types to use. If no types are specified, the event types specified in the UITest constructor are used (or auto if not specified). * @param {Window|Frame} [opts.context=window] - The context within which the optionally specified element selector should be executed. * @param {boolean} [opts.bubbles=true] - The Event property bubbles indicates whether the event bubbles up through the DOM or not. * @param {boolean} [opts.cancelable=true] - Events' cancelable property indicates if the event can be canceled, and therefore prevented as if the event never happened. If the event is not cancelable, then its cancelable property will be false and the event listener cannot stop the event from occurring. */ pinch(element, position, timelinePosition, opts = {}) { [position, timelinePosition, opts] = this.reorderArguments(arguments); this._timelinePositions.push(timelinePosition); // debug //-------------------- if (this.opts.debug) console.log('tap params', { element, position, timelinePosition, opts }); // opts //-------------------- opts = Object.assign( {}, { onStart: null, onUpdate: null, onComplete: null, doubleCallbacks: false, duration: 1, distance: 100, to: null, bezier: null, ease: Power0.easeNone, eventTypes: this.resolveEvents(['down', 'move', 'up']), context: window, bubbles: true, cancelable: true }, opts ); // timeline //-------------------- this._timeline.addCallback( position => { // element //-------------------- const elem = Util.extractElement(opts.context, element); // from //-------------------- let from1 = null; let from2 = null; if (Array.isArray(position) && !Util.isNumber(position[0])) { from1 = Util.extractPosition(position[0]); from2 = Util.extractPosition(position[1]); } else { from1 = Util.extractPosition(position); from2 = { x: from1.x, y: from1.y }; } // to //-------------------- let gsOpts1 = {}; let gsOpts2 = {}; if (opts.to || opts.bezier) { [gsOpts1, gsOpts2] = Util.extractMultiTo(opts); } else { const distance = opts.distance != null ? opts.distance : 100; gsOpts1.x = from1.x - distance / 2; gsOpts1.y = from1.y; gsOpts2.x = from2.x + distance / 2; gsOpts2.y = from2.y; } // pointers //-------------------- const pointers = new Map(); pointers.set(0, { element: from1, gsOpts: gsOpts1 }); pointers.set(1, { element: from2, gsOpts: gsOpts2 }); // loop //-------------------- pointers.forEach((value, key) => { // from //-------------------- const from = value.element; // event opts //-------------------- const eventOpts = { bubbles: opts.bubbles, cancelable: opts.cancelable, pointerId: key, isPrimary: key === 0 }; const gsOpts = { ease: opts.ease, onStart: () => { // create and dispatch event //-------------------- const event = Event$1.create(elem, from, opts.eventTypes[0], eventOpts); if (this.opts.debug) console.log('dispatch event', event); elem.dispatchEvent(event); // onStart //-------------------- if (opts.onStart && (opts.doubleCallbacks || key === 0)) { opts.onStart.call(this, event); } }, onUpdate: () => { // create and dispatch event //-------------------- const event = Event$1.create(elem, from, opts.eventTypes[1], eventOpts); if (this.opts.debug) console.log('dispatch event', event); elem.dispatchEvent(event); // onUpdate //-------------------- if (opts.onUpdate && (opts.doubleCallbacks || key === 0)) { opts.onUpdate.call(this, event); } }, onComplete: () => { // create and dispatch event //-------------------- const event = Event$1.create(elem, from, opts.eventTypes[2], eventOpts); if (this.opts.debug) console.log('dispatch event', event); elem.dispatchEvent(event); // onComplete //-------------------- if (opts.onComplete && (opts.doubleCallbacks || key === 0)) { opts.onComplete.call(this, event); } } }; // to //-------------------- Object.assign(gsOpts, value.gsOpts); // drag animation //-------------------- TweenLite.to(from, opts.duration, gsOpts); }); }, timelinePosition, [position] ); this._actions++; return this } // /** // * Adds a tap event to the timeline. // * // * @return {UITest} A reference to the uitest for chaining. // */ // rotate() { // return this // } // /** // * Adds a tap event to the timeline. // * // * @return {UITest} A reference to the uitest for chaining. // */ // swipe() { // return this // } // /** // * Adds a tap event to the timeline. // * // * @return {UITest} A reference to the uitest for chaining. // */ // press() { // return this // } // /** // * Adds a tap event to the timeline. // * // * @return {UITest} A reference to the uitest for chaining. // */ // event() { // return this // } /** * Sorts the parameters so that the second, third, and fourth parameters can be optional (and possibly slip forward). * * @private * @param {arguments} params - The arguments which were passed to the function. * @returns {array} - Returns an array of the position, the timelinePosition and the opts object. */ reorderArguments(params) { // first parameter //-------------------- const element = params[0]; // other parameter //-------------------- let position = null; let timelinePosition = null; let opts = null; // second parameter //-------------------- if (Util.isNumber(params[1])) { timelinePosition = params[1]; } else if ( Util.isObject(params[1]) && !Util.isPixiDisplayObject(params[1]) && (params[1].x == null || params[1].y == null) ) { opts = params[1]; } else if (params[1] != null) { position = params[1]; } // third parameter //-------------------- if (Util.isNumber(params[2])) { timelinePosition = params[2]; } else if (Util.isObject(params[2])) { opts = params[2]; } // fourth parameter //-------------------- if (Util.isObject(params[3])) { opts = params[3]; } if (timelinePosition === null) { if (this.opts.defaultInterval === null && this._actions > 1) { throw new Error( 'No execution time was specified for this action, and a default interval was not set in the class constructor!' ) } timelinePosition = Math.max(...this._timelinePositions) + (this.opts.defaultInterval || 1); } if (opts === null) { opts = {}; } return [position, timelinePosition, opts] } /** * Converts event type shortcuts to real event names. * * @private * @param {string[]} events - An array of event types. */ resolveEvents(events) { const data = []; if (this.opts.eventType === 'pointer') { events.forEach(it => { if (it === 'down') { data.push('pointerdown'); } else if (it === 'move') { data.push('pointermove'); } else if (it === 'up') { data.push('pointerup'); } }); } else if (this.opts.eventType === 'touch') { events.forEach(it => { if (it === 'down') { data.push('touchstart'); } else if (it === 'move') { data.push('touchmove'); } else if (it === 'up') { data.push('touchend'); } }); } else { events.forEach(it => { if (it === 'down') { data.push('mousedown'); } else if (it === 'move') { data.push('mousemove'); } else if (it === 'up') { data.push('mouseup'); } }); } return data } } /** * Helper class. * * @example * // Checks if a thing is a number. * const num = Util.isNumber(20) * * @private * @ignore * @class */ class Util { /** * Resolves the element from a specific context. * * @static * @param {Window|Frame} context - The context within which the optionally specified element selector should be executed. * @return {HTMLElement|string} element - The HTML element on which the event is to be executed, e.g. button, document, h2, canvas, etc. or an selector string. If a selector has been specified, it is evaluated immediately before the event is called using the querySelector method. */ static extractElement(context, element) { const cont = Util.isFrame(context) ? context.contentDocument : context.document; const elem = Util.isString(element) ? cont.querySelector(element) : element; return elem } /** * Extracts the position of the second parameter. * * @static * @param {object} object - Something were the coords should be extracted. * @return {object} - Returns an object with the keys x and y. */ static extractPosition(object) { // event coords //-------------------- const position = { x: 0, y: 0 }; // get the position //-------------------- if (!object) { position.x = 0; position.y = 0; } else if (typeof object.getBounds === 'function') { const bounds = object.getBounds(); position.x = bounds.x + bounds.width / 2; position.y = bounds.y + bounds.height / 2; } else if (Array.isArray(object)) { position.x = object[0]; position.y = object[1]; } else if (object.x != null && object.y != null) { position.x = object.x; position.y = object.y; } return position } /** * Extracts the to or bezier key. * * @static * @param {object} opts - An options object where to or bezier should be extracted. * @return {object} - Returns an object with the to or bezier keys. */ static extractTo(opts) { const object = {}; if (opts.bezier) { let bezier = null; if (Array.isArray(opts.bezier)) { bezier = { values: opts.bezier.map(it => Util.extractPosition(it)), type: 'thru' }; } else { opts.bezier.values = opts.bezier.values.map(it => Util.extractPosition(it)); bezier = opts.bezier; } object.bezier = bezier; } else { const to = Util.extractPosition(opts.to); object.x = to.x; object.y = to.y; } return object } /** * Extracts multiple to or bezier keys. * * @static * @param {object} opts - An options object where to or bezier should be extracted. * @return {object[]} - Returns an array of objects with the keys x and y. */ static extractMultiTo(opts) { const objects = []; if (opts.bezier) { opts.bezier.forEach(it => { let bezier = null; if (Array.isArray(it)) { bezier = { values: it.map(it => Util.extractPosition(it)), type: 'thru' }; } else { it.values = it.values.map(it => Util.extractPosition(it)); bezier = it; } objects.push({ bezier }); }); } else { opts.to.forEach(it => { const to = Util.extractPosition(it); objects.push({ x: to.x, y: to.y }); }); } return objects } /** * Checks if a thing is a string. * * @static * @param {object} object - The object to test for. * @return {boolean} - true if the thing is a string, otherwise false. */ static isString(object) { return typeof object === 'string' } /** * Checks if a thing is a number. * * @static * @param {object} object - The object to test for. * @return {boolean} - true if the thing is a number, otherwise false. */ static isNumber(object) { return typeof object === 'number' } /** * Checks if a thing is an object. * * @static * @param {object} object - The object to test for. * @return {boolean} - true if the thing is an object, otherwise false. */ static isObject(object) { return typeof object === 'object' && !Array.isArray(object) } /** * Checks if a thing is an PIXI.DisplayObject. * * @static * @param {object} object - The object to test for. * @return {boolean} - true if the thing is a PIXI.DisplayObject, otherwise false. */ static isPixiDisplayObject(object) { return ( typeof object.getBounds === 'function' && typeof object.renderWebGL === 'function' && typeof object.setTransform === 'function' ) } /** * Checks if a thing is a frame. * * @static * @param {object} object - The object to test for. * @return {boolean} - true if the thing is a frame, otherwise false. */ static isFrame(object) { return object.contentDocument != null } } /** * Event helper class. * * @example * // Creates an event object. * const event = Event.create(h2, {x: 5, y: 10}, 'touchstart') * * @private * @ignore * @class */ class Event$1 { /** * Creates an event object. * * @static * @param {HTMLElement} target - The element on which the event should be executed. * @param {object} position - The local position of the event in relation to the target. The object must have the keys x and y. * @param {string} type - The type of the event, see https://developer.mozilla.org/de/docs/Web/Events * @param {object} opts - An options object. Every paramter of the event object can be overridden, see e.g. https://developer.mozilla.org/de/docs/Web/API/MouseEvent for all the properties. */ static create(target, position = { x: 0, y: 0 }, type = 'pointerup', opts = {}) { const rect = typeof target.getBoundingClientRect === 'function' ? target.getBoundingClientRect() : { x: 0, y: 0 }; // EventInit const eventOpts = { bubbles: true, cancelable: true, composed: false }; // UIEventInit const uiEventOpts = { detail: 0, view: window }; // MouseEvent const mouseEventOpts = { screenX: window.screenX + (target.offsetLeft || 0) + position.x, screenY: window.screenY + (target.offsetTop || 0) + position.y, clientX: rect.x + position.x, clientY: rect.y + position.y, ctrlKey: false, shiftKey: false, altKey: false, metaKey: false, button: 0, buttons: 1, relatedTarget: null, region: null }; // TouchEvent const touchEventOpts = { touches: [], targetTouches: [], changedTouches: [], ctrlKey: false, shiftKey: false, altKey: false, metaKey: false }; // PointerEvent const pointerEventOpts = { pointerId: 0, width: 1, height: 1, pressure: 0, tangentialPressure: 0, tiltX: 0, tiltY: 0, twist: 0, pointerType: 'touch', isPrimary: true }; if (type.startsWith('pointer')) { return new PointerEvent( type, Object.assign({}, eventOpts, uiEventOpts, mouseEventOpts, pointerEventOpts, opts) ) } else if (type.startsWith('touch')) { return new TouchEvent(type, Object.assign({}, eventOpts, uiEventOpts, touchEventOpts, opts)) } else { return new MouseEvent(type, Object.assign({}, eventOpts, uiEventOpts, mouseEventOpts, opts)) } } } /* 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: * * * * 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} */ class Highlight extends Object { static disableAnimations() { _HighlightEnabled = false; let expanded = document.querySelectorAll('.' + Highlight.expandedClass); for (let obj of expanded) { this.shrink(obj); } } static enableAnimations() { _HighlightEnabled = true; } static removeAnimations(svgRoot) { let expanded = svgRoot.querySelectorAll('.' + Highlight.expandedClass); for (let obj of expanded) { TweenLite.set(obj, { scale: 1 }); obj.classList.remove('zooming'); obj.classList.remove(Highlight.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 = 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(Highlight.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(Highlight.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(Highlight.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 }); } 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); 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(Highlight.expandedClass); } } static toggleHighlight(node) { console.log('toggleHighlight', Highlight._isExpanded(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