From cd76ae22a44d0a0a83d1ea52122619b5d1bd129d Mon Sep 17 00:00:00 2001 From: Uwe Oestermeier Date: Thu, 11 Jul 2019 15:41:03 +0200 Subject: [PATCH] Added LowPassFilter and support for smooth scatter rotation and zoom. --- dist/iwmlib.js | 2600 ++++++++++++++++++++++--------------------- dist/iwmlib.pixi.js | 129 ++- lib/bundle.js | 3 +- lib/scatter.js | 34 +- lib/utils.html | 21 + lib/utils.js | 91 +- 6 files changed, 1602 insertions(+), 1276 deletions(-) diff --git a/dist/iwmlib.js b/dist/iwmlib.js index eba57b3..3ba4f4b 100644 --- a/dist/iwmlib.js +++ b/dist/iwmlib.js @@ -989,8 +989,8 @@ // Distance == 0.0 indicates an inside relation. static distanceToRect(p, r) { - var cx = Math.max(Math.min(p.x, r.x + r.width), r.x); - var cy = Math.max(Math.min(p.y, r.y + r.height), r.y); + 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)) } @@ -1552,6 +1552,92 @@ } + + 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 */ @@ -2886,1246 +2972,8 @@ window.Capabilities = Capabilities; window.CapabilitiesTests = CapabilitiesTests; - /** 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(); - - /** A Popup that shows text labels, images, or html - */ - class Popup 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) { - 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(); - } - 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() { - //console.log("Popup.close", this.closeCommand) - this.unregister(this.context); - if (this.closeCommand) { - this.closeCommand(this, () => this.remove()); - } - 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({ - 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.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 { - /** - * 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); - } - } - } - /* eslint-disable no-unused-vars */ + /** * A base class for scatter specific events. * @@ -4390,7 +3238,8 @@ scaleAutoClose = false, scaleCloseThreshold = 0.10, scaleCloseBuffer = 0.05, - maxRotation = Angle.degree2radian(5) + maxRotation = Angle.degree2radian(5), + useLowPassFilter = true } = {}) { if (rotationDegrees != null && rotation != null) { throw new Error('Use rotationDegrees or rotation but not both') @@ -4434,7 +3283,12 @@ this.resizable = resizable; this.mouseZoomFactor = mouseZoomFactor; this.autoBringToFront = autoBringToFront; - + this.useLowPassFilter = useLowPassFilter; + 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; @@ -4458,6 +3312,11 @@ 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 } @@ -4471,14 +3330,19 @@ let delta = interaction.delta(); if (delta != null) { this.addVelocity(delta); - let alpha = delta.rotate; + let rotate = delta.rotate; + let zoom = delta.zoom; if (this.maxRotation != null) { - if (Math.abs(alpha) > this.maxRotation) { - alpha = 0; + if (Math.abs(rotate) > this.maxRotation) { + rotate = 0; } } - this.transform(delta, delta.zoom, alpha, delta.about); - if (delta.zoom != 1) this.interactionAnchor = delta.about; + if (this.useLowPassFilter) { + rotate = this.rotateLPF.next(rotate); + zoom = this.zoomLPF.next(zoom); + } + this.transform(delta, zoom, rotate, delta.about); + if (zoom != 1) this.interactionAnchor = delta.about; } } @@ -6388,6 +5252,1245 @@ } } + /** 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(); + + /** A Popup that shows text labels, images, or html + */ + class Popup 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) { + 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(); + } + 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() { + //console.log("Popup.close", this.closeCommand) + this.unregister(this.context); + if (this.closeCommand) { + this.closeCommand(this, () => this.remove()); + } + 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({ + 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.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 { + /** + * 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) { @@ -7569,6 +7672,7 @@ window.InteractionPoints = InteractionPoints; window.Interface = Interface; window.Logging = Logging; + window.LowPassFilter = LowPassFilter; window.PointMap = PointMap; window.Rect = Rect; window.Points = Points; diff --git a/dist/iwmlib.pixi.js b/dist/iwmlib.pixi.js index 95a8876..37526a9 100644 --- a/dist/iwmlib.pixi.js +++ b/dist/iwmlib.pixi.js @@ -3265,8 +3265,8 @@ // Distance == 0.0 indicates an inside relation. static distanceToRect(p, r) { - var cx = Math.max(Math.min(p.x, r.x + r.width), r.x); - var cy = Math.max(Math.min(p.y, r.y + r.height), r.y); + 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)) } @@ -3702,6 +3702,92 @@ } } + + 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 + } + } + /* global apollo, subscriptions, gql */ /** @@ -6053,11 +6139,8 @@ window.Capabilities = Capabilities; window.CapabilitiesTests = CapabilitiesTests; - /** Basic class for poppable elements that need to be closed as soon as one poppable is - * shown. - */ - /* eslint-disable no-unused-vars */ + /** * A base class for scatter specific events. * @@ -6322,7 +6405,8 @@ scaleAutoClose = false, scaleCloseThreshold = 0.10, scaleCloseBuffer = 0.05, - maxRotation = Angle.degree2radian(5) + maxRotation = Angle.degree2radian(5), + useLowPassFilter = true } = {}) { if (rotationDegrees != null && rotation != null) { throw new Error('Use rotationDegrees or rotation but not both') @@ -6366,7 +6450,12 @@ this.resizable = resizable; this.mouseZoomFactor = mouseZoomFactor; this.autoBringToFront = autoBringToFront; - + this.useLowPassFilter = useLowPassFilter; + 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; @@ -6390,6 +6479,11 @@ 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 } @@ -6403,14 +6497,19 @@ let delta = interaction.delta(); if (delta != null) { this.addVelocity(delta); - let alpha = delta.rotate; + let rotate = delta.rotate; + let zoom = delta.zoom; if (this.maxRotation != null) { - if (Math.abs(alpha) > this.maxRotation) { - alpha = 0; + if (Math.abs(rotate) > this.maxRotation) { + rotate = 0; } } - this.transform(delta, delta.zoom, alpha, delta.about); - if (delta.zoom != 1) this.interactionAnchor = delta.about; + if (this.useLowPassFilter) { + rotate = this.rotateLPF.next(rotate); + zoom = this.zoomLPF.next(zoom); + } + this.transform(delta, zoom, rotate, delta.about); + if (zoom != 1) this.interactionAnchor = delta.about; } } @@ -14870,7 +14969,7 @@ * @extends Popup * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/popupmenu.html|DocTest} */ - class PopupMenu$1 extends Popup { + class PopupMenu extends Popup { /** * Creates an instance of a PopupMenu. @@ -15519,7 +15618,7 @@ window.Stylus = Stylus; window.Switch = Switch; window.Popup = Popup; - window.PopupMenu = PopupMenu$1; + window.PopupMenu = PopupMenu; window.Modal = Modal; window.Volatile = Volatile; window.Message = Message; diff --git a/lib/bundle.js b/lib/bundle.js index 67a3ea2..da94acf 100755 --- a/lib/bundle.js +++ b/lib/bundle.js @@ -16,7 +16,7 @@ import {FrameContainer, FrameTarget} from './frames.js' import {Inspect} from './inspect.js' import {PointMap, InteractionPoints, Interaction, IInteractionTarget, InteractionDelta, InteractionMapper, InteractionDelegate, IInteractionMapperTarget} from './interaction.js' import {ResizeEvent, DOMScatterContainer, AbstractScatter, DOMScatter, ScatterEvent, BaseEvent} from './scatter.js' -import {Cycle, Colors, Elements, Angle, Dates, Points, Polygon, Rect, Sets, Strings, isEmpty, getId, lerp, debounce, randomInt, randomFloat} from './utils.js' +import {Cycle, Colors, Elements, Angle, Dates, Points, Polygon, Rect, Sets, Strings, isEmpty, getId, lerp, debounce, randomInt, randomFloat, LowPassFilter} from './utils.js' import UITest from './uitest.js' /* Needed to ensure that rollup.js includes class definitions and the classes @@ -62,6 +62,7 @@ window.InteractionMapper = InteractionMapper window.InteractionPoints = InteractionPoints window.Interface = Interface window.Logging = Logging +window.LowPassFilter = LowPassFilter window.PointMap = PointMap window.Rect = Rect window.Points = Points diff --git a/lib/scatter.js b/lib/scatter.js index 8dd3ebc..32a104c 100644 --- a/lib/scatter.js +++ b/lib/scatter.js @@ -1,11 +1,11 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-console */ /* globals TweenLite debugCanvas */ -import { Points, Polygon, Angle, Elements } from './utils.js' +import { Points, Polygon, Angle, Elements, LowPassFilter } from './utils.js' import Events from './events.js' import { InteractionMapper } from './interaction.js' import { Capabilities } from './capabilities.js' -import PopupMenu from './popupmenu.js' + /** * A base class for scatter specific events. * @@ -270,7 +270,8 @@ export class AbstractScatter extends Throwable { scaleAutoClose = false, scaleCloseThreshold = 0.10, scaleCloseBuffer = 0.05, - maxRotation = Angle.degree2radian(5) + maxRotation = Angle.degree2radian(5), + useLowPassFilter = true } = {}) { if (rotationDegrees != null && rotation != null) { throw new Error('Use rotationDegrees or rotation but not both') @@ -314,7 +315,12 @@ export class AbstractScatter extends Throwable { this.resizable = resizable this.mouseZoomFactor = mouseZoomFactor this.autoBringToFront = autoBringToFront - + this.useLowPassFilter = useLowPassFilter + 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 @@ -338,6 +344,11 @@ export class AbstractScatter extends Throwable { 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 } @@ -351,14 +362,19 @@ export class AbstractScatter extends Throwable { let delta = interaction.delta() if (delta != null) { this.addVelocity(delta) - let alpha = delta.rotate + let rotate = delta.rotate + let zoom = delta.zoom if (this.maxRotation != null) { - if (Math.abs(alpha) > this.maxRotation) { - alpha = 0 + if (Math.abs(rotate) > this.maxRotation) { + rotate = 0 } } - this.transform(delta, delta.zoom, alpha, delta.about) - if (delta.zoom != 1) this.interactionAnchor = delta.about + if (this.useLowPassFilter) { + rotate = this.rotateLPF.next(rotate) + zoom = this.zoomLPF.next(zoom) + } + this.transform(delta, zoom, rotate, delta.about) + if (zoom != 1) this.interactionAnchor = delta.about } } diff --git a/lib/utils.html b/lib/utils.html index 116f0eb..9b6c472 100644 --- a/lib/utils.html +++ b/lib/utils.html @@ -132,6 +132,27 @@ is compatible with arrays of absolute points. context.stroke() +

+ Low Pass Filter +

+

+ Low Pass Filter muffles fast (high-frequency) changes to the signal. + For more information visit the wikipedia article. +

+

References

diff --git a/lib/utils.js b/lib/utils.js index 5b550a1..27b2a8c 100755 --- a/lib/utils.js +++ b/lib/utils.js @@ -75,7 +75,7 @@ export function sample(population, k) { if (n <= setsize) { // An n-length list is smaller than a k-length set let pool = population.slice() - for (let i = 0; i < k; i++) { // invariant: non-selected at [0,n-i) + for (let i = 0; i < k; i++) { // inletiant: non-selected at [0,n-i) let j = Math.random() * (n - i) | 0 result[i] = pool[j] pool[j] = pool[n - i - 1] // move non-selected item into vacancy @@ -404,8 +404,8 @@ export class Points { // Distance == 0.0 indicates an inside relation. static distanceToRect(p, r) { - var cx = Math.max(Math.min(p.x, r.x + r.width), r.x) - var cy = Math.max(Math.min(p.y, r.y + r.height), r.y) + 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)) } @@ -968,4 +968,89 @@ export class Strings { } +export 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 + } +} +