diff --git a/dist/iwmlib.js b/dist/iwmlib.js index 4d2024d..e107bce 100644 --- a/dist/iwmlib.js +++ b/dist/iwmlib.js @@ -184,7 +184,7 @@ let pre = document.createElement('pre'); // 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.split('\n'); + 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(')) { @@ -508,6 +508,19 @@ static toLine(event) { return `${event.type} #${event.target.id} ${event.clientX} ${event.clientY}` + let result = event.type; + let selector = this.selector(event.target); + result += ' selector: ' + selector; + if (event.target != document.querySelector(selector)) console.log('Cannot resolve', selector); + let keys = ['layerX', 'layerY', 'pageX', 'pageY', 'clientX', 'clientY']; + for (let key of keys) { + try { + result += ' ' + key + ':' + event[key]; + } catch (e) { + console.log('Invalid key: ' + key); + } + } + return result } static compareExtractedWithSimulated() { @@ -2651,6 +2664,7 @@ if (found != null) { this.interaction.addTarget(key, found); } + console.log(this.target); } let size = this.interaction.current.size; let limit = this.logInteractionsAbove; @@ -10980,6 +10994,21 @@ } speak() { + /** + * This is a little bit ugly, but imho the most elegant of all dirty solutions. + * + 5ht * Within the plugins we have no knowledge of other cards and such. But must differentiate the + * clicks by their corresponding owner. The SpeechUtterance just takes a text and has no knowledge + * about the node that is currently read to the user. + * + * This means, that we can identify same text, but not differentiate same text on different nodes. + * To account for that, we add the node to the speechSynthesis object (#benefitsOfJavaScript) and + * have access to the node, by - let's say - expanding the functionality of the SpeechSynthesis object. + * + * SO -17.07.19 + */ + + let activeNode = window.speechSynthesis['speechPluginNode']; this._updateText(); } diff --git a/dist/iwmlib.pixi.js b/dist/iwmlib.pixi.js index f255d3c..5343fb5 100644 --- a/dist/iwmlib.pixi.js +++ b/dist/iwmlib.pixi.js @@ -1335,6 +1335,19 @@ static toLine(event) { return `${event.type} #${event.target.id} ${event.clientX} ${event.clientY}` + let result = event.type; + let selector = this.selector(event.target); + result += ' selector: ' + selector; + if (event.target != document.querySelector(selector)) console.log('Cannot resolve', selector); + let keys = ['layerX', 'layerY', 'pageX', 'pageY', 'clientX', 'clientY']; + for (let key of keys) { + try { + result += ' ' + key + ':' + event[key]; + } catch (e) { + console.log('Invalid key: ' + key); + } + } + return result } static compareExtractedWithSimulated() { @@ -6296,6 +6309,7 @@ if (found != null) { this.interaction.addTarget(key, found); } + console.log(this.target); } let size = this.interaction.current.size; let limit = this.logInteractionsAbove; @@ -11525,6 +11539,7 @@ get bounds() { let x = 0; let y = 0; + // @container: We need to call the constant values, as the container // gets resized, when a child moves outside the original boundaries. let w = this.container ? this.containerDimensions.x : this.backgroundWidth || this.app.width; @@ -11608,9 +11623,6 @@ } findHitScatter(data, displayObject, hit) { - // if (hit) { - // console.log("findHitScatter", displayObject) - // } if (hit && this.hitScatter === null && typeof displayObject != undefined) { this.hitScatter = displayObject.scatter ? displayObject.scatter : null; } @@ -11650,11 +11662,13 @@ if (event.claimedByScatter) { return null } + this.hitScatter = null; let interactionManager = this.renderer.plugins.interaction; let displayObject = interactionManager.hitTest(local, this); if (displayObject != null && displayObject.scatter != null) this.hitScatter = displayObject.scatter; if (this.claimEvents) event.claimedByScatter = this.hitScatter; + return this.hitScatter } @@ -16807,6 +16821,5222 @@ } } + /** + * The EventHandler class is used to take care of a event based design + * pattern. Callbacks can subscribe to an event and these unknown sources + * get notified whenever the event changes. + * + * @export + * @class EventHandler + */ + class EventHandler { + /** + * Creates an instance of EventHandler. + * @param {any} name + * @param {any} [{ + * listeners = [] - With the listnerers parameter the user can specify a function, array of functions or null (no function - useful when used in constructor with optional parameter). + * }={}] + * @memberof EventHandler + */ + constructor(name, { listeners = [] } = {}) { + this.name = name; + this.listeners = []; + this.onces = []; + + /** + * One may initialize the eventListener using a parameter + * that is either passed or null. + */ + + if (listeners == null) ; else if (Array.isArray(listeners)) this.listeners = listeners; + else if (typeof listeners == 'function') { + this.listeners = []; + this.add(listeners); + } else { + console.warn( + "The provided 'listeners' is neither an Array of functions, nor a function. No eventcallback was added!", + listeners, + this + ); + } + } + + addMultiple(...callbacks) { + for (let callback of callbacks) { + this.listeners.push(callback); + } + } + + add(callback) { + this.listeners.push(callback); + return callback + } + + once(callback) { + this.onces.push(callback); + } + + remove(func) { + for (const [idx, listener] of this.listeners.entries()) { + if (listener === func) { + this.listeners.splice(idx, 1); + return true + } + } + return false + } + + empty() { + this.listeners = []; + } + + call(context, ...args) { + this.listeners.forEach(listener => listener.call(context, ...args)); + this.onces.forEach(listener => listener.call(context, ...args)); + this.onces = []; + } + + get length() { + return this.listeners.length + this.onces.length + } + } + + /* ES Lint */ + + /** + * The AdvancedScatterContainer extends the ScatterContainer, but + * uses the findTargetNew instead of the findTarget method, which takes interactive elements + * into account, when calculating the target. + */ + class AdvancedScatterContainer extends ScatterContainer { + applyToChildScattersRecursively(parent, root, applyFunc) { + for (let child of parent.children) { + if (child.scatter) { + applyFunc(child, root); + this.applyToChildScattersRecursively(child, root, applyFunc); + } + } + } + + /** + * The update method was adjusted to draw bounds and polygpns + */ + update(dt) { + this.clear(); + this.lineStyle(1, 0x0000ff); + if (this.showBounds) { + this.applyToChildScattersRecursively(this, this, (child, root) => { + let position = child.worldTransform.apply(new PIXI.Point(0, 0)); + + this.drawShape(child.scatter.bounds); + + let center = { + x: child.scatter.width / 2 / child.scatter.scale, + y: child.scatter.height / 2 / child.scatter.scale + }; + center = child.worldTransform.apply(center); + + this.drawCircle(center.x, center.y, 4); + this.drawCircle(position.x, position.y, 4); + }); + + this.lineStyle(2, 0x0000ff); + this.drawShape(this.bounds); + } + if (this.showPolygon) { + this.lineStyle(2, 0xff0000); + for (let child of this.children) { + if (child.scatter) { + let polygon = child.scatter.polygon; + let shape = new PIXI.Polygon(polygon.flatAbsolutePoints()); + if ( + shape.points[0] !== shape.points[shape.points.length - 2] || + shape.points[1] !== shape.points[shape.points.length - 1] + ) { + shape.points.push(shape.points[0], shape.points[1]); + } + this.drawShape(shape); + } + } + } + if (this.showTouches) { + let current = this.delegate.interaction.current; + for (let [key, point] of current.entries()) { + let local = this.mapPositionToPoint(point); + this.drawCircle(local.x, local.y, 12); + } + } + } + + get width() { + return this.bounds.width + } + + get height() { + return this.bounds.width + } + + findTarget(event, local, global) { + // UO: still problematic. Does not find non interactive elements + // which are needed for some stylus applications + if (event.claimedByScatter) { + return null + } + + this.hitScatter = null; + let interactionManager = this.renderer.plugins.interaction; + + let displayObject = interactionManager.hitTest(local, this); + console.log(displayObject.dontBlockScatter, displayObject.parent); + if (displayObject.dontBlockScatter && displayObject.parent != null) { + displayObject = interactionManager.hitTest(local, displayObject.parent); + } + + if (displayObject != null && displayObject.scatter != null) this.hitScatter = displayObject.scatter; + if (this.claimEvents) event.claimedByScatter = this.hitScatter; + + console.log(displayObject); + return this.hitScatter + } + } + + class RigidScatterContainer extends AdvancedScatterContainer { + constructor(width, height, renderer, opts) { + super(renderer, opts); + this._width = width; + this._height = height; + } + + resize(width, height) { + this._width = width; + this._height = height; + } + + get width() { + return this._width + } + + get height() { + return this._height + } + + _calculateBounds() { + this._bounds.clear(); + let bounds = new PIXI.Bounds(); + bounds.minX = this.parent.position.x; + bounds.minY = this.parent.position.y; + bounds.maxX = this.parent.position.x + this.width; + bounds.maxY = this.parent.position.y + this.height; + this._bounds.addBounds(bounds); + } + } + + class RigidContainer extends PIXI.Graphics { + constructor(width, height, { onResize = null } = {}) { + super(); + if (!width || !height) { + console.log( + `Width (${width}) or height (${height}) not set! If this is intended use another PIXI.DisplayObject instead.`, + this + ); + } + this._width = width; + this._height = height; + this.beginFill(0xffffff, 0.3); + this.drawRect(0, 0, this.width, this.height); + + this.onResize = new EventHandler('resize', { + listeners: onResize ? [onResize] : [] + }); + } + + /** + * Resizes the scattercontainer to the provided dimensions. + * + * @param {number} width - Target width of the resize. + * @param {number} height - Target height of the resize. + * @memberof RigidContainer + */ + resize(width, height) { + this._width = width; + this._height = height; + this._updateHitArea(); + this.onResize.call(this, 'resize', { x: width, y: height }); + } + + _updateHitArea() { + // this.hitArea = new PIXI.Rectangle(0,0, this.width, this.height) + } + + get width() { + return this._width + } + get height() { + return this._height + } + } + + /** + * Specialization of the DisplayObjectScatter with special behavior needed for maps. + */ + class AdvancedScatter extends DisplayObjectScatter { + /** + * Animates the throw and ensures that the map is always visible. This + * is different from the standard behavior. + * + * @param {*} time + * @memberof AdvancedScatter + */ + animateThrow(time) { + // In rare cases animateThrow is called when the displayObject is already removed + if (this.displayObject.parent == null) { + return + } + + //Moved this outside, as the time continously increases, + //when the object is not thrown. + + if (this.velocity != null) { + let dt = this._throwDeltaTime(); + // This seems to just correct the velocity when its calculated wrong. + // In what case can it get bigger? Velocity tends to always shrink, when no force is added. + let d = this._getThrowDelta(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(); + } + + get parent() { + // Returns the parent is a scatter get the parent scatter. + // If it's a scatter container, get the displayObject. + // Other cases are not allowed. + return this.displayObject.parent != null && this.displayObject.parent.scatter != null + ? this.displayObject.parent.scatter + : this.displayObject.parent + } + + _getThrowDelta(dt) { + this.velocity = this.nextVelocity(this.velocity); + if (this.velocity != null) { + return Points.multiplyScalar(this.velocity, dt) + } + return { x: 0, y: 0 } + } + + freeze({ translatable = false, scalable = false, rotatable = false, movableX = false, movableY = false } = {}) { + this.translatable = translatable; + this.scalable = scalable; + this.rotatable = rotatable; + this.movableX = movableX; + this.movableY = movableY; + } + + unfreeze({ translatable = true, scalable = true, rotatable = true, movableX = true, movableY = true } = {}) { + this.freeze({ + translatable, + scalable, + rotatable: false, + movableX, + movableY + }); + } + } + + /** + * TODO + * A scatter which width does not increase when child elements are added. + */ + class CompactScatter extends AdvancedScatter {} + + /** + * A problem with PIXI Objects is, that the bounding box changes + * with child elements. Therefore the sized scatter has a rigid size, that is not + * changed by elements, but only with their width and height property. + */ + class CoverScatter extends AdvancedScatter { + constructor(a, b, opts) { + super(a, b, opts); + + opts = Object.assign( + { + debug: false, + cover: true, + boundaries: { + min: { x: 0, y: 0 }, + max: { x: 1, y: 1 } + } + }, + opts + ); + + this.debug = opts.debug; + this.cover = opts.cover; + this._boundaries = opts.boundaries; + + if (this.debug) { + this.debugGraphics = new PIXI.Graphics(); + this.displayObject.addChild(this.debugGraphics); + this.debugGraphics.lineStyle(0.2, 0x00ff00); + this.debugGraphics.drawRect( + this.width * this.boundaries.min.x, + this.height * this.boundaries.min.y, + this.width * (this.boundaries.max.x - this.boundaries.min.x), + this.height * (this.boundaries.max.y - this.boundaries.min.y) + ); + + this.debugGraphics.endFill(); + } + + if (this.cover) { + // The reference to the element handler needs to be stored, + // that we can remove it later on. + const eventHandler = this._applyInitialCover.bind(this, null, true); + this._applyInitialCover(eventHandler); + } + } + + _applyInitialCover(eventHandler, removeListener = false) { + if (this.debug) console.log('ApplyInitialCover: ', parent); + if (removeListener) { + this.displayObject.off('added', eventHandler); + } + + if (this.displayObject.parent) + this.forceCover(this.displayObject.parent.width, this.displayObject.parent.height); + else { + this.displayObject.on('added', eventHandler); + } + } + + get boundaries() { + if (this._boundaries) return this._boundaries + else + return { + min: { x: 0, y: 0 }, + max: { x: 1, y: 1 } + } + } + + transform(translate, zoom, rotate, anchor) { + if (this.cover) { + if (!this.parent) { + return new PIXI.Rectangle(0, 0, window.innerWidth, window.innerHeight) + } + + /** + * If the scaling violates the minCoverScale, the zoom is adjusted to perfectly match + * the minCoverScale. + */ + let minCoverScale = this.calculateMinCoverScale(this.parent.width, this.parent.height); + + let { zoom: fixedZoom, scale } = this.calculateScale(zoom); + if (scale < minCoverScale) { + zoom = minCoverScale / this.scale; + } + } + + super.transform(translate, zoom, rotate, anchor); + if (this.cover) { + let postTranslate = this.outsideBoundaries(); + super.transform(postTranslate, 1, 0, { x: 0, y: 0 }); + } + } + + outsideBoundaries(movement = { x: 0, y: 0 }, { boundaries = this.boundaries } = {}) { + if (this.parent == null) { + return { x: 0, y: 0 } + } + let left = -(this.displayObject.position.x + movement.x + this.width * boundaries.min.x); + let right = -left + this.width * (boundaries.max.x - boundaries.min.x) - this.parent.width; + + let top = -(this.displayObject.position.y + movement.y + this.height * boundaries.min.y); + let bot = -top + this.height * (boundaries.max.y - boundaries.min.y) - this.parent.height; + + // Helper function to limit an Axis inside a container, or 'glue' it + // to the lower side, if the size is smaller than the size of the + // container. + function limitAxis(low, high, contentSize, containerSize) { + let val = 0; + if (low < 0) { + val = low; + } else if (high < 0) { + if (contentSize > containerSize) { + // ... and the element is bigger as the container + // - reset it to the containersize. + val = -high; + } else { + // ... and the size is not high enough, then + // 'glue' it to the low axis. + val = low; + } + } + + return val + } + + // Use the helper function to correct the movement to cover x and y. + let correctionX = limitAxis(left, right, this.width * (boundaries.max.x - boundaries.min.x), this.parent.width); + let correctionY = limitAxis(top, bot, this.height * (boundaries.max.y - boundaries.min.y), this.parent.height); + let fixedMovement = { + x: correctionX == 0 ? movement.x : correctionX + movement.x, + y: correctionY == 0 ? movement.y : correctionY + movement.y + }; + + return fixedMovement + } + + calculateMinCoverScale(width, height) { + let scale = 0; + + if (!(this.width == 0 && this.height == 0)) { + let actualWidth = this.width / this.scale; + let actualHeight = this.height / this.scale; + + let boundaryWidth = (this.boundaries.max.x - this.boundaries.min.x) * actualWidth; + let boundaryHeight = (this.boundaries.max.y - this.boundaries.min.y) * actualHeight; + + let coverWidth = width / boundaryWidth; + let coverHeight = height / boundaryHeight; + + scale = Math.max(coverWidth, coverHeight); + } + + return scale + } + + _getThrowDelta(dt) { + let delta = super._getThrowDelta(dt); + if (this.cover) { + delta = this.outsideBoundaries(delta, { + boundaries: this.boundaries + }); + } + return delta + } + + containerChanged(width, height) { + if (this.cover) this.forceCover(width, height); + } + + forceCover() { + this.requestScale(this.scale); + let translate = this.outsideBoundaries(); + this.transform(translate, 1, 0, { x: 0, y: 0 }); + } + + requestScale(scale) { + if (this.scalable && this.parent != null) { + if (this.cover) { + let minCoverScale = this.calculateMinCoverScale(this.parent.width, this.parent.height); + scale = scale < minCoverScale ? minCoverScale : scale; + } + this.scale = scale; + } + } + + requestFocus(point) { + let allowedMovement = this.outsideBoundaries(Points.subtract(point, this.position)); + this.transform(allowedMovement, 1, 0, { x: 0, y: 0 }); + } + } + + /** + * The MapObjectScatter somewhat inverts the behaviour of + * regular throwables. Regular throwables should bounce of the wall, when they + * overlap to a certain amount. Maps on the otherhand are intended to overlap with the + * scatter container heavily to hide irrelevant parts of the map without revealing + * any background. + * + * @class + * @extends DisplayObjectScatter + */ + + class MapObjectScatter extends CoverScatter { + /** + * x=null, y=null, + minScale=0.1, + maxScale=1.0, + startScale=1.0, + autoBringToFront=true, + translatable=true, scalable=true, rotatable=true, resizable=false, + movableX=true, + movableY=true, + throwVisibility=44, + throwDamping = 0.95, + autoThrow=true, + rotationDegrees=null, + rotation=null, + onTransform = null } + */ + constructor(displayObject, renderer, opts = {}) { + opts = Object.assign( + { + debug: false, + cover: false, + startScale: 1, + boundaries: { + min: { x: 0, y: 0 }, + max: { x: 1, y: 1 } + }, + autoBringToFront: false + }, + opts + ); + + if (!renderer) { + console.error('Renderer was not set!'); + return + } + + super(displayObject, renderer, opts); + this.cover = opts.cover; + } + + moveTo() { + super.moveTo(...arguments); + } + + lock() { + this.rotatable = false; + this.moveable = false; + this.scaleable = false; + } + + unlock() { + this.rotatable = true; + this.moveable = true; + this.scaleable = true; + } + + getWorldScatter() { + return this.parent + } + } + + /** + * MapData contains the informations about how + * a Map has to be interpreted. What are the bounds of the + * map and how to translate coordinates into + * image positions. + * + * @class + * @see {@link mapdata.html} + */ + class MapData { + /** + * Creates instance of MapData + * + * @constructor + * @param {Projection}[projection] - Specifies the projection of the map (e.g. Mercator Projection). + * @param {object}[opts] - Addiditonal options. + * @param {[[minLat, minLng],[maxLat, maxLng]]}[opts.bounds] - Describes the minimum and maximum coordinates on the map + * @param {Point}[opts.translate] - Defines a translation, when clipping is not an option (e.g. when the whole world is shown, but translated.) + */ + constructor(projection, opts = {}) { + this.opts = Object.assign( + { + translate: { x: 0, y: 0 } + }, + opts + ); + + this.projection = projection; + + if (this.clip) { + let _cmin = this.projection.forward(this.opts.clip.min); + let _cmax = this.projection.forward(this.opts.clip.max); + + // Swaps the y values, Mercator has it's origin bottom right, + // browser coordinates start top right. + let cmin = { x: _cmin.x, y: _cmax.y }; + let cmax = { x: _cmax.x, y: _cmin.y }; + + this.clipExt = { + coords: { + min: this.opts.clip.min, + max: this.opts.clip.max + }, + point: { + min: cmin, + max: cmax + } + }; + } + } + + toCoordinates(point) { + if (this.clip) { + let min = this.clipExt.point.min; + let max = this.clipExt.point.max; + + let width = max.x - min.x; + let height = max.y - min.y; + + point.x *= width; + point.y *= height; + + point.x += min.x; + point.y += min.y; + } + + let coordinates = this.projection.backward(point); + + if (this.opts.translate) { + coordinates.x -= this.opts.translate.x; + coordinates.y -= this.opts.translate.y; + } + + return coordinates + } + + toPixel(coordinates) { + let coords = { x: coordinates.x, y: coordinates.y }; + if (this.opts.translate) { + coords.x += this.opts.translate.x; + coords.y += this.opts.translate.y; + } + + let point = this.projection.forward(coords); + + if (this.opts.clip) { + let min = this.clipExt.point.min; + let max = this.clipExt.point.max; + + let clippedPoint = { + x: point.x - min.x, + y: point.y - min.y + }; + + let width = max.x - min.x; + let height = max.y - min.y; + + point.x = clippedPoint.x / width; + point.y = clippedPoint.y / height; + } + + return point + } + + get clip() { + return this.opts.clip + } + + /** + * Bounds to pixel transforms some bounds in form of {min:{x:minLat, y:minLng},max:{x:maxLat, y:maxLng}} + * to pixel coordinates. + * + * @param {*} bounds + */ + boundsToPixel(bounds) { + let min = this.toPixel(bounds.min); + let max = this.toPixel(bounds.max); + + // Y values needs to be swapped, as PIXI has it's origin + // in the top-left corner and a regular map in the bottom-left corner. + let boundaries = { + min: { x: min.x, y: max.y }, + max: { x: max.x, y: min.y } + }; + + return boundaries + } + + get maxViewport() { + return this.opts.clip ? this.opts.clip : this.projection.maxViewport + } + } + + class DeepZoomMapData extends MapData { + constructor(projection, tilesConfig, opts = {}) { + if (!opts.app) console.error('Deepzoom Mapdata needs an app set in the options.'); + if (tilesConfig.clip) { + opts.clip = { + min: { + x: tilesConfig.clip.bounds.min[0], + y: tilesConfig.clip.bounds.min[1] + }, + max: { + x: tilesConfig.clip.bounds.max[0], + y: tilesConfig.clip.bounds.max[1] + } + }; + } + + console.log(tilesConfig, opts); + + super(projection, opts); + this.app = opts.app; + } + } + + /** + * A projection determines how a geographical card has to + * be interpreted to map coordinate to pixels. + * + * Most used transformation is the mercator projection, + * which projects a sphere on a cylinder. + * + * @abstract + */ + + class Projection { + /** + * Transforms a coordinate to a normalized position on the map. + * + * @param {*} coords + * @memberof Projection + */ + forward(coords) { + console.error('You must override the forward function in ' + this.name + '.'); + } + + /** + * Transforms a normalized point on the map to a coordinate. + * + * @param {*} point + * @memberof Projection + */ + backward(point) { + console.error('You must override the backward fuction in ' + this.name + '.'); + } + + toString() { + return 'Projection (abstract)' + } + + get name() { + return this.toString() + } + + get maxViewport() { + return { min: new PIXI.Point(-90, -180), max: new PIXI.Point(90, 180) } + } + } + + /** + * This is a projection file, that grants access to the + * MERCATOR projection. + * + * Regulary only few Projections will be used in one + * project, therefore only required one's should be + * loaded. + */ + + class Mercator extends Projection { + forward(coords) { + let lat = coords.x; + let lng = coords.y; + + const PI_180 = Math.PI / 180.0; + const PI_4 = Math.PI * 4; + + const sinLatitude = Math.sin(lat * PI_180); + let y = 0.5 - Math.log((1 + sinLatitude) / (1 - sinLatitude)) / PI_4; + let x = (lng + 180) / 360; + + y = y < 0 ? 0 : y > 1 ? 1 : y; + + return new PIXI.Point(x, y) + } + + backward(point) { + let lng = point.x * 360 - 180; + let lat = (Math.asin(-2 / (Math.exp(4 * Math.PI * (0.5 - point.y)) + 1) + 1) * 180) / Math.PI; + + return new PIXI.Point(lat, lng) + } + + toString() { + return 'Mercator Projection' + } + + get maxViewport() { + return { min: new PIXI.Point(-85, -180), max: new PIXI.Point(85, 180) } + } + } + + /** + * The GeoMap class displays a map, that it gets from MapData object. + * It handles the current location on the map, the zoom factor, interactions and + * the viewport, the area of the map, the user can see and navigate to. + * + * You should not instantiate GeoMap on it's own. Use the implemented child classes + * or derive a new one from it. + * + * Note: The name Map is already in use. Therefore GeoMap was used instead. + * + + * @abstract + * @class + * @see {@link maps.html} + */ + + class GeoMap { + /** + * Creates instance of GeoMap + * + * @constructor + * @param {MapData}[mapdata={}] - The mapdata describes how the map has to be interpreted by the Map class. E.g. what are the boundaries of the map? + * @param {object}[opts={}] - With the opts, the created MapObjectScatter can be adjusted. + * @param {boolean}[opts.cover=false] - Enables covering behaviour of a map object. Normally maps should cover the whole app. + */ + constructor( + mapdata = {}, + { + debug = true, + cover = true, + // Scatter Options + alpha = 1, + startScale = 1, + minScale = 0, + maxScale = Infinity, + translatable = true, + scalable = true, + rotatable = false, // Many functionalities are not supported when rotating the map. Mainly the cover mechanism. + viewport = mapdata.maxViewport, + // Events + onLoad = null, + onTransform = null + } = {} + ) { + this._id = GeoMap.counter++; + this.onLoad = new EventHandler('loaded', { listeners: onLoad }); + this.onTransform = new EventHandler('transform', { listeners: onTransform }); + + this.alpha = alpha; + this.cover = cover; + this.debug = debug; + + //TODO discuss if this is required here. + // Those are just scatter options and the information + // is redundant in the map class and the scatter. + this.startScale = startScale; + this.minScale = minScale; + this.maxScale = maxScale; + this.rotatable = rotatable; + this.translatable = translatable; + this.scalable = scalable; + this.viewport = viewport; + + this.mapdata = mapdata; + this.overlays = {}; + + /** + * Adjust the viewport depending on the mapdata clipping. + */ + if (this.mapdata.clip) { + const vp = this.viewport; + const cp = this.mapdata.clip; + let bounds = { + min: { + x: vp.min.x > cp.min.x ? vp.min.x : cp.min.x, + y: vp.min.y > cp.min.y ? vp.min.y : cp.min.y + }, + max: { + x: vp.max.x < cp.max.x ? vp.max.x : cp.max.x, + y: vp.max.y < cp.max.y ? vp.max.y : cp.max.y + } + }; + + this.viewport = bounds; + } + } + + flushHandlers() { + // this.onLoaded + this.onTransform.empty(); + } + + lock({ rotatable = false, translatable = false, movableX = false, movableY = false, scalable = false } = {}) { + if (this.image && this.image.scatter) { + this.image.scatter.translatable = rotatable; + this.image.scatter.movableX = translatable; + this.image.scatter.movableY = movableX; + this.image.scatter.rotatable = movableY; + this.image.scatter.scalable = scalable; + } + + // Issue #001: This causes the map to not be displayed at the correct position on + // map change. + // // Rotation does not yet work with the cover mechanism. + // //this.rotatable = false + // this.translatable = false + // this.scalable = false + } + + unlock({ rotatable = true, translatable = true, movableX = true, movableY = true, scalable = true } = {}) { + if (this.image && this.image.scatter) { + this.image.scatter.translatable = translatable; + this.image.scatter.movableX = movableX; + this.image.scatter.movableY = movableY; + this.image.scatter.rotatable = rotatable; + this.image.scatter.scalable = scalable; + } + // Issue #001 + // // Rotation does not yet work with the cover mechanism. + // //this.rotatable = true + // this.translatable = true + // this.scalable = true + } + + unload() { + this.image.parent.removeChild(this.image); + this.image.scatter = null; + } + + /** + * Is called when the scatter object is transformed. + * + * @private + * @param {ScatterEvent}[e] - Contains informations on how the element was transformed. + */ + transformed(e) { + this.onTransform.call(this, e); + } + + /** + * Wrapps the display object around a scatter object. + * + * @private + * @param {DisplayObject} displayObject - Defines the display object that will be wrapped inside the scatter object. + * @param {PIXI.Container} container - Defines the frame in which the map will be displayed. + */ + load(image, renderer, frame = null, scatter = null) { + if (this.debug) console.log('Load image: ', image, frame); + + this.image = image; + if (frame) this.setFrame(frame); + + let min = this.mapdata.toPixel(this.viewport.min); + let max = this.mapdata.toPixel(this.viewport.max); + let boundaries = { + min: { x: min.x, y: max.y }, + max: { x: max.x, y: min.y } + }; + + let scatterOpts = Object.assign({ + cover: this.cover, + scaleable: this.scaleable, + translatable: this.translatable, + rotatable: this.rotatable, + debug: this.debug, + startScale: this.startScale, + minScale: this.minScale, + maxScale: this.maxScale, + boundaries, + onTransform: this.transformed.bind(this) + }); + + this.scatter = new MapObjectScatter(image, renderer, scatterOpts); + this.image.scatter = scatter == null ? this.scatter : scatter; + + this.onLoad.call(this); + } + + /** + * Creates an identical copy of the current map. + */ + clone() { + console.error('Method clone() is abstract. Overload it in subclass', this); + } + + /** + * + * Moves the 'camera' to another location on the map. + * + * @abstract + * @param {object} coordinates - Target coordinates in form {x: latitude, y: longitude} + * @param {number} scale - Target scale. + */ + moveTo(coordinates, zoom = null, { animate = false } = {}) { + console.error(this, 'The method moveTo(coordinates, zoom, opts) has not been defined in child class.'); + } + + zoomTo(zoomFactor) { + console.error('Method zoomTo() is abstract. Overload it in subclass', this, zoomFactor); + } + + /** + * Reloads the textures of the image, when it was unloaded and is required again. + * + * @memberof GeoMap + */ + reloadImage() { + console.error(`Call of abstract method reloadImage(). Overwrite in subclass.`, this); + } + + /** + * Unloads the textures of the image, when not in use. + * + * @memberof GeoMap + */ + unloadImage() { + console.error(`Call of abstract method unloadImage(). Overwrite in subclass.`, this); + } + + /** + * Transforms a point at pixel position {x,y} + * to a coordinate with latitude and longitude. + * + * + * @param {object} point - Point in form of {x: x_val, y: y_val}. + * @returns {object} - Coordinates on the map in form of {x: latitude, y: longitude}. + */ + coordinatesFromPoint(point) { + let coords = this.mapdata.toCoordinates(this.toRelativePosition(point)); + return coords + } + + /** + * Transform coordinates in the map into pixel positions on the deep zoom image. + * + * @param {object} coords - Coordinates of a map position in form {x: latitude, y: longitude}. + * @return - Returns a image position in form of {x: x_val, y: y_val}. + */ + coordinatesToPoint(coordinates) { + return this.toAbsolutePixelCoordinates(this.mapdata.toPixel(coordinates)) + } + + toRelativePosition(point) { + let _point = new PIXI.Point(); + + _point.x = point.x / this.width; + _point.y = point.y / this.height; + + return _point + } + + toAbsolutePixelCoordinates(point) { + let _point = new PIXI.Point(); + _point.x = point.x * this.width; + _point.y = point.y * this.height; + + return _point + } + + // /** + // * Appends the object to a PIXI container. This is important, + // * to notify the map, that it's parent has changed. + // * + // * If you want to use PIXI's addChild, make sure you call + // * appended right afterwards. + // * + // * @param {PIXI.Container} container + // * @returns Returns the map object to allow chaining. + // */ + // appendTo(container) { + // container.addChild(this.image) + // return this.appended(container) + // } + + get width() { + return this.image.scatter.width / this.image.scatter.scale + } + + get height() { + return this.image.scatter.height / this.image.scatter.scale + } + + get distance() { + console.error('Overload get distance in subclass.'); + } + + /** + * Returns a screen point for a coordinate. + */ + positionInWindow(coordinates) { + let pos = this.coordinatesToPoint(coordinates); + + let framePos = { + x: this.image.position.x + pos.x * this.image.scale.x, + y: this.image.position.y + pos.y * this.image.scale.y + }; + + return framePos + } + + /** + * Returns the coordinates for a screen point. + */ + coordinatesInWindow(point) { + let mapPos = { + x: (point.x - this.image.position.x) / this.image.scale.x, + y: (point.y - this.image.position.y) / this.image.scale.y + }; + + let coords = this.coordinatesFromPoint(mapPos); + return coords + } + + setFrame(frame) { + if (this.debug) console.log('Set Frame: ', frame); + this.frame = frame; + } + + /** + * Frame is the display in which the map is shown. + * Normally it's the app, but it can be another element, + * for example when in a submap. + */ + getFrame() { + let frame = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + + if (this.frame) { + //The app does not have an x and y attribute. + //Therefore we need the additional check. + frame = { + x: this.frame.x ? this.frame.x : 0, + y: this.frame.y ? this.frame.y : 0, + width: this.frame.width, + height: this.frame.height + }; + } else if (this.image.parent) { + let parent = this.image.parent; + + for (let key of Object.keys(frame)) { + frame[key] = parent[key]; + } + } + + frame.localCenter = { + x: frame.width / 2, + y: frame.height / 2 + }; + + console.log(frame); + frame.center = { + x: frame.x + frame.localCenter.x, + y: frame.y + frame.localCenter.y + }; + + return frame + } + + /** + * Retrieves all maps from a json object. + * + * @static + * @param {object} json - The parsed map data object. + * @returns {object} - Returns an object with the names as keys and the GeoMaps as value. + * @memberof GeoMap + */ + static allFromJson(json) { + let error = { message: '' }; + let maps = {}; + if (GeoMap._validateJson(json, error)) { + for (let [mapname, data] of Object.entries(json)) { + maps[mapname] = GeoMap._createMap(data); + } + } else console.error('Could not validate JSON: ' + error.message); + + return maps + } + + /** + *Extracts a single map from a JSON map file. + * + * @static + * @param {string} map - Name of the map. + * @param {object} json - Parsed json object. + * @returns {GeoMap} - Returns a geomap, if the map was in the data and valid. Otherwise false. + * @memberof GeoMap + */ + static mapFromJson(map, json) { + if (json[map]) { + const data = json[map]; + if (this._validJsonMap(data)) return GeoMap._createMap(data) + else console.error('Map was not in a valid format.'); + } else console.error('Map was not in data.'); + + return null + } + + /** + * + */ + + /** + * Validates if the map data contains valid data + * for creating the maps. + * + * @static + * @param {object} json - The object containing multiple map data sets. + * @param {error-object} error - An object that contains an parameter message: {message = ""}. This is faking a call by reference. + * @returns {boolean} - True if all sets were valid. False otherwise. + * @memberof GeoMap + */ + static _validateJson(json, error) { + let isValid = true; + + if (Object.keys(json).length == 0) { + isValid = false; + error.message += 'The provided JSON object did not contain any items.'; + } + for (let [name, data] of Object.entries(json)) { + if (!GeoMap._validJsonMap(data)) { + error.message += `${name} was not valid. `; + isValid = false; + } + } + + if (error.message != '') error.message = 'Maps could not be validated: ' + error.message; + + return isValid + } + + static changePathsInJson(json, path) { + for (let key of Object.keys(json)) { + json[key].icon = path + json[key].icon; + json[key].tiles.path = path + json[key].tiles.path; + } + } + + /** + *Validates of a single data set contains the valid data for creating a map. + * + * @static + * @param {object} json - The object containing a single set of map data. + * @returns {boolean} - True if valid, otherwise false. + * @memberof GeoMap + */ + static _validJsonMap(json) { + let isJsonValid = true; + const required = ['projection', 'type', 'icon']; + for (const requirement of Object.values(required)) { + if (!json.hasOwnProperty(requirement)) { + isJsonValid = false; + break + } + } + + if (isJsonValid && json.type.toLowerCase() == 'deepzoom') { + if (!json.hasOwnProperty('tiles')) isJsonValid = false; + } + + return isJsonValid + } + + static _createMap(data) { + switch (data.type.toLowerCase()) { + case 'deepzoom': + return GeoMap._createDeepZoomMap(data) + default: + console.error(`Datatype is invalid or not implemented yet: ${data.type}`); + } + } + + static _createDeepZoomMap(data) { + const projection = GeoMap._getProjectionByName(data.projection); + const tilesConfig = data.tiles; + const options = data.options; + + const mapdata = new DeepZoomMapData(projection, tilesConfig); + return new DeepZoomMap(mapdata, tilesConfig, options) + } + + static _getProjectionByName(projection) { + switch (projection.toLowerCase()) { + case 'mercator': + return new Mercator() + default: + console.error(`Projection is invalid or not implemented yet ${projection}.`); + return null + } + } + } + + GeoMap.counter = 0; + + /** + * The DeepZoomMap class extends the GeoMap to create + * maps as deepzoom images from maptiles. + * + * @extends GeoMap + * @class + * @see {@link maps.html} + */ + + class DeepZoomMap extends GeoMap { + /** + * @constructor + * @param {object} tilesConfig - The tiles config object, that defines at what path and in which format the tiles are. + * @param {MapData} mapdata - A MapData object, that contains informations of how the given map has to be interpreted. + * @param {object} opts - Additional options to specify the behaviour of the deep zoom image. + */ + constructor(mapdata, tilesConfig, opts = {}) { + if (!tilesConfig.app) console.error('App was not set in the tilesConfig.'); + opts = Object.assign( + { + maxScale: Math.min(tilesConfig.width, tilesConfig.height) / tilesConfig.tileSize, + minScale: mapdata.getMinScale, + highResolution: true, + debug: false + }, + opts + ); + super(mapdata, opts); + + this.tilesConfig = tilesConfig; + this._verifyMapdata(); + } + + _verifyMapdata() { + if (!(this.mapdata instanceof MapData)) { + console.error('Use the MapData object for creating maps!'); + } else { + if (this.mapdata instanceof DeepZoomMapData) { + if (!this.mapdata.app) console.error('No app was set on the mapdata!'); + } else { + console.error('Use the DeepZoomMapData object.'); + } + } + } + + /** + * Creates the DeepZoomImage. + * + * @private + */ + load(container = null, scatter = null) { + this.info = new DeepZoomInfo(this.tilesConfig); + console.log('APP:', this.mapdata.app); + let image = new DeepZoomImage(this.info, { + app: this.mapdata.app, + alpha: this.alpha, + debug: this.debug, + world: scatter == null ? scatter : scatter.getWorldScatter() + }); + + super.load(image, container, scatter); + + if (this.debug) console.log('Loaded image: ', image, 'With options: ', this.info); + } + + unloadImage() { + if (this.image) { + this.image.deactivate(); + } + } + + reloadImage() { + this.image.activate(); + } + + /** + * Event function that is invoked by the deep zoom image, when it has been transformed. + * + * @private + * @param {ScatterEvent} e + */ + transformed(e) { + this.image.transformed(e); + super.transformed(e); + } + + /** + * Jumps to a certain point on the map and adjusts the size of the + * map depending on the distance (zoom). + * + * + * @param {*} coordinates + * @param {*} [zoom=null] + * @param {*} [{ animate = 0 }={}] + * @memberof DeepZoomMap + */ + moveTo(coordinates, zoom = null, { animate = 0 } = {}) { + if (this.image.scatter == null) { + return + } + //TODO: This is jaggy on submaps. Make it more smooth. Severin Opel 08-05-2018 + if (zoom) { + /** + * Zoom's the view to a specified distance in DZI-Levels. + * The input level is clipped to allowed levels. + * */ + let targetScale = this.scaleForFloatingLevel(zoom); + + // Don't scale beyond boundaries. + if (this.maxScale) targetScale = Math.min(this.maxScale, targetScale); + + if (this.minScale) targetScale = Math.max(targetScale, this.minScale); + + this.image.scatter.requestScale(targetScale); + } + + if (coordinates) { + //Get pixel position of lat lang values + let point = this.coordinatesToPoint(coordinates); + + let containerCenter; + if (this.frame) { + containerCenter = this.getFrame().localCenter; + //console.log("HAS FRAME: ", containerCenter) + } else { + containerCenter = { + x: this.image.parent.width / 2, + y: this.image.parent.height / 2 + }; + + //console.log("GET CONTAINER CENTER") + } + + //Calculate the delta depending on image position and center the + // desired position in the frame. + let delta = { + x: containerCenter.x - point.x * this.image.scatter.scale - this.scatter.position.x, + y: containerCenter.y - point.y * this.image.scatter.scale - this.scatter.position.y + }; + // console.log("MOVE TO: " , this.image.scatter.translatable,point, delta) + + if (animate > 0) { + let last = 0; + TweenMax.to({ x: 0, y: 0 }, animate, { + ease: Power0.easeNone, + x: delta.x, + y: delta.y, + onUpdateParams: ['{self}'], + onUpdate: tween => { + let step = tween.ratio - last; + last = tween.ratio; + this.image.scatter.transform(Points.multiplyScalar(delta, step), /*ratio*/ 1, 0, { x: 0, y: 0 }); + } + }); + } else { + this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 }); + } + } + // return this.image.scatter.position + } + + scaleForFloatingLevel(level) { + level = Math.max(0, level - this.image.info.baseLevel); + return Math.pow(2, level) / this.image.resolution + } + + floatingLevelForScale(scale) { + let level = Math.log2(scale * this.image.resolution); + let newLevel = this.image.info.baseLevel + Math.max(level, 0); + return Math.min(newLevel, this.image.info.maxLoadableLevel) + } + + get distance() { + return this.floatingLevelForScale(this.scatter.scale) + } + + clone(container, scatter = null) { + const map = new DeepZoomMap(this.mapdata, this.tilesConfig, { + alpha: this.alpha, + cover: this.cover, + debug: this.debug, + startScale: this.startScale, + minScale: this.minScale, + maxScale: this.maxScale, + rotatable: this.rotatable, + translatable: this.translatable, + scalable: this.scalable, + viewport: this.viewport + }); + + //TODO: If frame is not set, the map does not work anymore. + + //map.load(container, scatter) + + // this.image.parent.children.forEach(child=>{ + // if(child instanceof PIXI.DisplayObject){ + + // if(child instanceof PIXI.Graphics){ + // // let clone = child.clone() + // // const position = child.position + // // clone.position.set(position.x, position.y) + // // map.addChild(clone) + // // console.log(map.mapLayer) + + // }else console.log("DisplayObject is not yet implemented for cloning.", child) + + // }else console.error("Not implemented object to clone: ", child) + // }) + + return map + } + + tint() { + let color = DeepZoomMap.tintcolors[DeepZoomMap.tintcolor++ % DeepZoomMap.tintcolors.length]; + this.image.children[0].children.forEach(tiles => { + tiles.children.forEach(tile => { + tile.tint = color; + }); + }); + } + + // /** Returns the tile layer level that corresponds to the given scale. + // * @param {number} scale - the scale factor + // **/ + // levelForScale(scale) { + // let level = Math.round(Math.log2(scale * this.resolution)) // Math.floor(Math.log2(event.scale))+1 + // let newLevel = this.info.baseLevel + Math.max(level, 0) + // return Math.min(newLevel, this.info.maxLoadableLevel) + // } + + // /** + // * Returns the scale factor that correspond to a given level. + // * @param {number} level - the requested level + // * @returns {number} - Returns the scale level for the given level. + // */ + // scaleForLevel(level){ + // level = Math.max(0, level - this.info.baseLevel) + // return Math.pow(2, level) / this.resolution + // } + } + + DeepZoomMap.tintcolors = [0xff0000, 0xff00ff, 0xffff00, 0x00ff00, 0x00ffff, 0x0000ff]; + DeepZoomMap.tintcolor = 0; + + /** + * + * ImageMap extends GeoMap to display simple images + * as maps. + */ + + class ImageMap extends GeoMap { + constructor(sprite, mapdata, opts = {}) { + super(mapdata, opts); + + this.sprite = sprite; + + this.baseZoomHeight = opts.baseZoomHeight ? opts.baseZoomHeight : this.sprite.texture.height; + } + + load(container = null, scatter = null) { + super.load(this.sprite, container, scatter); + this.image.alpha = this.alpha; + this.image.interactive = true; + } + + clone(container = null, scatter = null) { + const map = new ImageMap(new PIXI.Sprite(this.sprite.texture), this.mapdata, { + alpha: this.alpha, + cover: this.cover, + debug: this.debug, + startScale: this.startScale, + minScale: this.minScale, + maxScale: this.maxScale, + rotatable: this.rotatable, + translatable: this.translatable, + scalable: this.scalable, + viewport: this.viewport + }); + map.load(container, scatter); + + console.log(map.image); + return map + } + + // transformed(e) { + // for (let key in this.overlays) { + // this.overlays[key].forEach((overlay) => { + // overlay.sprite.scale.x = overlay.scale / e.scale + // overlay.sprite.scale.y = overlay.scale / e.scale + // }) + // } + // } + + /** + * + * + * @param {*} coordinates + * @param {*} [zoom=null] + * @param {*} [{ animate = 0 }={}] + * @memberof ImageMap + */ + moveTo(coordinates, zoom = null, { animate = 0 } = {}) { + // console.log('Coordinates: ', coordinates) + + // if (zoom) { + // this.image.scatter.requestScale(zoom) + // } + + // if (coordinates) { + // //Get pixel position of lat lang values + // let offset = Points.multiplyScalar(this.coordinatesToPoint(coordinates), this.scatter.scale) + + // let frame = this.getFrame() + // console.log(frame) + + // //Calculate the delta depending on image position and center the + // // desired position in the frame. + // let delta = { + // x: frame.center.x - offset.x - this.scatter.position.x, + // y: frame.center.y - offset.y - this.scatter.position.y + // } + + // console.log('Move to: ', offset, frame, delta) + + // if (this.image) { + // if (animate > 0) { + // console.log(animate) + // //TODO Replace this with a more robus manipulation method (RequestMovement + animation) + // this.image.scatter.move(delta, { animate }) + // //Requesting the transform dhoul be okay. Maybe also replace with an request mehtod. + // } else this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 }) + // } else console.error('Imge was not loaded yet.') + // } + + if (this.image.scatter == null) { + return + } + //TODO: This is jaggy on submaps. Make it more smooth. Severin Opel 08-05-2018 + if (zoom) { + /** + * Zoom's the view to a specified distance in DZI-Levels. + * The input level is clipped to allowed levels. + * */ + + let targetScale = zoom * this.zoomFactor; + + // Don't scale beyond boundaries. + if (this.maxScale) targetScale = Math.min(this.maxScale, targetScale); + + if (this.minScale) targetScale = Math.max(targetScale, this.minScale); + + this.image.scatter.requestScale(targetScale); + } + + //coordinates = null + if (coordinates) { + //Get pixel position of lat lang values + let point = this.coordinatesToPoint(coordinates); + + let containerCenter; + if (this.frame) { + containerCenter = this.getFrame().localCenter; + //console.log("HAS FRAME: ", containerCenter) + } else { + containerCenter = { + x: this.image.parent.width / 2, + y: this.image.parent.height / 2 + }; + + //console.log("GET CONTAINER CENTER") + } + + //Calculate the delta depending on image position and center the + // desired position in the frame. + + let delta = { + x: containerCenter.x - point.x * this.image.scatter.scale - this.scatter.position.x, + y: containerCenter.y - point.y * this.image.scatter.scale - this.scatter.position.y + }; + // console.log("MOVE TO: " , this.image.scatter.translatable,point, delta) + + if (animate > 0) { + let last = 0; + TweenMax.to({ x: 0, y: 0 }, animate, { + ease: Power0.easeNone, + x: delta.x, + y: delta.y, + onUpdateParams: ['{self}'], + onUpdate: tween => { + let step = tween.ratio - last; + last = tween.ratio; + this.image.scatter.transform(Points.multiplyScalar(delta, step), /*ratio*/ 1, 0, { x: 0, y: 0 }); + } + }); + } else { + this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 }); + } + } + } + + get zoom() { + return this.scatter.scale / this.zoomFactor + } + + get zoomFactor() { + let factor = this.baseZoomHeight / this.sprite.texture.height; + console.log(factor); + return factor + } + } + + /** + * The MapView class is responsible for a consistent map view. + * It is aware of the current viewposition, the scale and viewport. + * It ensures, that maps can be changed, without the user noticing it. + * + */ + class MapView { + /** + * + * @param {object} [focus = {x:0, y:0}] - Defines the startup focuspoint of the app. + * @param {number} [zoom = 0] - Defines the startup zoom of the app. Note that this is just a request. + * The MapView will prioritize a full scale app, than displaying the demanded zoom factor + */ + constructor({ + map = null, + focus = null, + zoom = null, + viewport = { min: { x: -85, y: -180 }, max: { x: 85, y: 180 } } + } = {}) { + this.viewport = viewport; + this._focus = focus; + this._zoom = zoom; + this._map = map; + this.referenceHeight = 256; + } + + get focus() { + return this._focus + } + get zoom() { + return this._zoom + } + + get map() { + return this._map + } + + /** + * Sets the map to the given focuspoint and zoom factor. + */ + setMap(map) { + this._map = map; + this.update(); + return true + } + + update() { + this.map.moveTo(this._focus, this._zoom); + } + + transformed(e) { + this.updateZoom(); + this.updateFocusPoint(); + } + + applyCameraPosition() { + this.updateFocusPoint(); + this.updateZoom(); + } + + updateFocusPoint() { + const frame = this.map.getFrame(); + this._focus = this.coordinatesFromWindowPoint(frame.localCenter); + } + + updateZoom() { + /** + * TODO: This relies on the fact, that all maps have the same tileSize, + * if a set would have a smaller tileSize. Improve that. + */ + if (this.map instanceof DeepZoomMap) this._zoom = this.map.floatingLevelForScale(this.map.image.scatter.scale); + else { + this._zoom = this.map.zoom; + console.warn('Zoom is not yet correctly implemented in this Map type: ' + this.map); + } + } + + mapPointToWindowPoint(point) { + let container = this.map.image.parent; + + let _point = new PIXI.Point( + this.map.scatter.position.x + this.map.scatter.scale * point.x, + this.map.scatter.position.y + this.map.scatter.scale * point.y + ); + + return container.toGlobal(_point) + } + + windowPointToMapPoint(point) { + let offset = this.map.image.parent.toGlobal({ x: 0, y: 0 }); + let _point = new PIXI.Point( + (point.x - this.map.scatter.position.x - offset.x) / this.map.scatter.scale, + (point.y - this.map.scatter.position.y - offset.y) / this.map.scatter.scale + ); + + return _point + } + + /** + * Gets the coordinates of a specific point in the viewport. + * + * @param {PIXI.Point | {x,y}} point - Pixel position in the viewport. + * @returns {{x,y}} Coordinates on the map of the provided position. + * @memberof MapView + */ + coordinatesFromWindowPoint(point) { + let position = { + x: point.x - this.map.scatter.position.x, + y: point.y - this.map.scatter.position.y + }; + + let normalized = { + x: position.x / (this.map.width * this.map.scatter.scale), + y: position.y / (this.map.height * this.map.scatter.scale) + }; + + let coordinates = this.map.mapdata.toCoordinates(normalized); + + return coordinates + } + } + + /* */ + + class Robinson extends Projection { + constructor(lng = 0) { + super(); + + this.lng0 = lng; + this.lengthOfParallel = [ + 1.0, + 0.9986, + 0.9954, + 0.99, + 0.9822, + 0.973, + 0.96, + 0.9427, + 0.9216, + 0.8962, + 0.8679, + 0.835, + 0.7986, + 0.7597, + 0.7186, + 0.6732, + 0.6213, + 0.5722, + 0.5322 + ]; + this.distancesFromEquator = [ + 0.0, + 0.062, + 0.124, + 0.186, + 0.248, + 0.31, + 0.372, + 0.434, + 0.4958, + 0.5571, + 0.6176, + 0.6769, + 0.7346, + 0.7903, + 0.8435, + 0.8936, + 0.9394, + 0.9761, + 1.0 + ]; + } + forward(coords) { + let { x: lat, y: lng } = coords; + + lng = this._adjustLng(lng); + + // Get the required indices, the remainder in between low and hight as ratio + // and the sign of the found indices, as the tables are only in positive direction. + let { low, high, ratio, sign } = this._getInterpolationValues(lat, 90); + + // Values that lie inbetween two indices are interpolated. + let y = this._interpolate(this.distancesFromEquator[low], this.distancesFromEquator[high], ratio); + + // Reapply the sign to the vertical position. + y *= sign; + + // The center of the projection is in the center of the map. Therefore we shift the + // center to the top left corner. + y = 1 - (y + 1) / 2; + + // The lengthOfParallel table provides us with the corresponding scaling factor + // for a specific latitude. Inbetween values are interpolated as before. + let proportionalLength = this._interpolate(this.lengthOfParallel[low], this.lengthOfParallel[high], ratio); + + //To normalize the value to a range from -1 to 1. + let x = (proportionalLength * lng) / 180; + + x = (x + 1) / 2; + + return { x, y } + } + + backward(position) { + let { x, y } = position; + + y = 1 - 2 * y; + let sign = Math.sign(y); + y = Math.abs(y); + + let low = 0; + let high = 0; + for (let i = 0; i < this.distancesFromEquator.length - 1 && y > this.distancesFromEquator[i]; i++) { + low = i; + high = i + 1; + } + + let lowDist = this.distancesFromEquator[low]; + let highDist = this.distancesFromEquator[high]; + + let ratio = highDist - lowDist == 0 ? 0 : (y - lowDist) / (highDist - lowDist); + + let lat = low * 5 + ratio * 5; + + let parallelLengthMin = this.lengthOfParallel[low]; + let parallelLengthMax = this.lengthOfParallel[high]; + + let completeLength = parallelLengthMin + (parallelLengthMax - parallelLengthMin) * ratio; + + x = x * 2 - 1; + let normalizedLength = x / completeLength; + + let lng = normalizedLength * 180; + + return { x: lat * sign, y: this._adjustLng(lng, true) } + } + + _adjustLng(lng, inv = false) { + let moved = inv ? lng + this.lng0 : lng - this.lng0; + if (moved < -180) moved += 360; + if (moved > 180) moved -= 360; + + return moved + } + + _interpolate(a, b, ratio) { + return a * (1 - ratio) + b * ratio + } + _getInterpolationValues(value, max) { + let sign = Math.sign(value); + value = Math.min(Math.abs(value), max); + // Note that min and max can be the same. Which is true + // when lat is dividable by 5. This also covers the edge cases 0 and 90. + let minIndex = Math.floor(value / 5); + let maxIndex = Math.ceil(value / 5); + let ratio = (value % 5) / 5; + // console.log({ value, minIndex, maxIndex, ratio }) + // console.log(this.lengthOfParallel.length) + return { low: minIndex, high: maxIndex, ratio, sign } + } + + toString() { + return + } + get name() { + return 'Robinson Projection' + } + } + + /** + * The class CoordinateDisplay shows the coordinates of the center of the sceen + * in the top left of the renderer. + * + * @private + * @class + * @extends PIXI.Graphics + * @see {@link http://pixijs.download/dev/docs/PIXI.Graphics.html|PIXI.Graphics} + */ + class CoordinateDisplay extends PIXI.Graphics { + /** + * Creates an instance of a CoordinateDisplay. + * + * @constructor + * @param {MapApp} mapApp - The MapApp where the coordinates should be displayed. + */ + constructor(mapApp) { + super(); + + this.app = mapApp; + + this.crosshair = new PIXI.Graphics(); + + this.outerSize = 15; + this.innerSize = 5; + this.thickness = 2; + this.color = 0xe73230; + + this.crosshair.lineStyle(this.thickness, this.color); + this.crosshair.moveTo(-this.outerSize, 0); + this.crosshair.lineTo(-this.innerSize, 0); + this.crosshair.moveTo(this.innerSize, 0); + this.crosshair.lineTo(this.outerSize, 0); + + this.crosshair.moveTo(0, -this.outerSize); + this.crosshair.lineTo(0, -this.innerSize); + this.crosshair.moveTo(0, this.innerSize); + this.crosshair.lineTo(0, this.outerSize); + + this.text = new PIXI.Text( + 'Pending ...', + new PIXI.TextStyle({ + fontFamily: 'Arial', + fontSize: 14, + fontWeight: 'bold', + fill: '#f6f6f6', + stroke: '#434f4f', + strokeThickness: 3 + }) + ); + + this.refreshCoordinates(); + + this.yOffset = 20; + this.volume = 32; + this.margin = 5; + + this._updateFrame(); + + this.text.position.set(20 + this.margin, this.yOffset + this.volume + 2 * this.margin); + + this.addChild(this.text); + this.addChild(this.crosshair); + this.setCrosshair(); + + window.setInterval(this.refreshCoordinates.bind(this), 300); + } + + _updateFrame() { + this.clear(); + this.lineStyle(3, 0x434f4f, 1) + .beginFill(0x434f4f, 0.6) + .drawRoundedRect(20, this.yOffset + this.volume + this.margin, this.text.width + 2 * this.margin, 32, 5) + .endFill(); + } + + toggleCrosshair() { + if (this.crosshair.parent) this.disableCrosshair(); + else this.enableCrosshair(); + } + + enableCrosshair() { + this.addChild(this.crosshair); + } + + disableCrosshair() { + this.removeChild(this.crosshair); + } + + /** + * Refreshes displayed coordinates. + * + * @return {MapApp} Returns the MapApp object for chaining. + */ + refreshCoordinates() { + if (this.app.mapLayer.mapview.focus && this.app.mapLayer.mapview.zoom) { + //TODO pass a mapview to avoid global access. + var coords = this.app.mapLayer.mapview.focus; + this.setCrosshair(); + this.text.text = + 'Lat: ' + + coords.x.toFixed(4) + + '| Lng: ' + + coords.y.toFixed(4) + + '| Zoom: ' + + this.app.mapLayer.mapview.zoom.toFixed(2); + this._updateFrame(); + } + } + setCrosshair() { + this.crosshair.position.set(this.app.center.x, this.app.center.y); + } + } + + /** + * The Layers Framework makes managing layers, that may have a different underlying + * technology (e.g. PIXI and DOM) more convenient. + * + * Layer is an abstract class, which makes the Framework quite generic and expandable. + */ + class Layer { + constructor({ name = 'unnamed', layers = [], debug = false, container = null, root = null } = {}) { + this.parent = null; + + this._container = container; + if (this.container) { + this._defineAsLayer(); + } + + // Layers should be distinct from regular elements. + this.name = name; + //Object to backup values when manipulating the Dom. + this.backup = {}; + + this.layers = []; + layers.forEach(layer => this.addLayer(layer)); + + this.elements = []; + + // Adds the root Element to the page. + if (root) { + this._appendRoot(root); + } + + this.debug = debug; + if (debug) this._visualizeLayer(); + } + + /** + * If the element is a root element, it has to know + * how it appends to the page. + */ + _appendRoot(ground) { + console.error('Method _appendRoot(_appendRoot) is abstract. Overload it in subclass', this, ground); + } + + /** + * The container has to know which layer it corresponds to. + * Therefore we need to set an attribute to identify the layer. + * This may change depending on the elements. + * Therefore subclasses have to overload it. + */ + _defineAsLayer() { + console.error('Method _defineAsLayer is abstract. Overload it in subclass.', this); + } + + /** + * Adds an element as child, whether it's an element or an layer. + * + * @param {object} elem - Element or Layer to add as child to the layer. + */ + place(elem) { + if (elem instanceof Layer) this.placeLayer(elem); + else { + this.placeElement(elem); + } + } + + /** + * Adds an layer as child of the layer- + * + * @param {Layer} layer - Layer to be added as child. + */ + placeLayer(layer) { + if (this.container) this.placeElement(layer.container); + this.layers.push(layer); + layer.parent = this; + return true + } + + remove(elem) { + if (elem instanceof Layer) { + this.removeLayer(elem); + } else this._removeElement(elem); + } + + removeLayer(layer) { + let idx = this.layers.indexOf(layer); + let removedArr = this.layers.splice(idx, 1); + if (removedArr.length > 0) { + let container = removedArr[0].container; + if (container) container.parent.removeChild(container); + } + } + + /** + * Removes an element from the Layer. + * Calls the abstract method removeElement for custom behaviours in + * subclasses. + * + * @private + * @param {object} element - Element within the 'this.children' array to be removed. + * @memberof Layer + */ + _removeElement(element) { + let elementIndex = this.elements.indexOf(element); + + if (elementIndex === -1) { + console.error('The element you wanted to remove was not a child of the layer.', element, this); + } else { + this.elements.splice(elementIndex, 1); + this.elements.forEach(child => console.log(child)); + this.removeElement(element); + } + } + + /** + * Removes an element from the Layer. + * + * @publuc + * @param {object} element - Element within the 'this.children' array to be removed. + * @memberof Layer + */ + removeElement(element) { + console.error('Method removeElement(element) is abstract. Overload it in subclass.', this, element); + } + + getLayerByName(name) { + for (let i = 0; i < this.layers.length; i++) { + if (this.layers[i].name == name) { + return this.layers[i] + } + } + return null + } + + /** + * The container, that holds the child elements of + * the respective technology. + */ + get container() { + return this._container + } + + /** + * Adds a non-layer child to the layer. + */ + placeElement(element) { + console.error('Method placeElement(element) is abstract. Overload it in subclass.', this, element); + } + + /** + * Moves the layer one up. + */ + up() { + if (this.parent) this.parent.requestUp(this); + else console.warn("Root layer can't be moved up: ", this); + } + + /** + * Moves a child element one layer up. + * + * @param {object} child - Child element to change. + */ + requestUp(child) { + console.error('Method requestUp(child) is abstract. Overload it in subclass.', child); + } + + /** + * Moves the layer one down. + */ + down() { + if (this.parent) this.parent.requestDown(this); + else console.warn("Root layer can't moved down: ", this); + } + + /** + * Moves a child element one layer down. + * + * @param {object} child - Child element to change. + */ + requestDown(child) { + console.error('Method requestDown(child) is abstract. Overload it in subclass.', child); + } + + /** + * Bring the element on Top of the layer. + * + * @param {object} element - Child element, that should be put on top. + */ + toFront() { + console.log(this); + if (this.parent && this.container) this.parent.requestToFront(this.container); + else console.warn("Root layer can't be put toFront: ", this); + } + + /** + * Brings the element to the top of the layer. + * + * @param {object} element - Child element, that should be put on top. + */ + requestToFront(element) { + console.error('Method requestToFront(element) is abstract. Overload it in subclass.', element); + } + + /** + * Brings the layer to the bottom. + */ + toBottom() { + if (this.parent && this.container) this.parent.requestToBottom(this.container); + else console.warn("Root layer can't be put ToBottom: ", this); + } + + /** + * Brings the element to the bottom of the layer. + * + * @param {object} element - Child element, that should be put on bottom. + */ + requestToBottom(element) { + console.error('Method requestToBottom(element) is abstract. Overload it in subclass.', element); + } + + get position() { + console.error('Getter position is abstract. Overload is in subclass:', this); + } + + set position(value) { + console.error('Setter position is abstract. Overload is in subclass:', this, value); + } + + /* + * Hides the layer. + */ + hide() { + console.error('Method hide() is abstract. Overload is in subclass:', this); + } + + /* + * Shows the layer. + */ + show() { + console.error('Method show() is abstract. Overload is in subclass:', this); + } + + get visible() { + console.error("Getter 'visible' is abstract. Overload it in subclass.", this); + } + + toggle() { + if (this.visible) this.hide(); + else this.show(); + } + + get root() { + if (this.parent) return this.parent.root + else return this + } + + static find(element) { + console.error('Call the find method of an appropriate child method: ', element); + } + + subscribeEvent(name, callback, { bubbleOnly = false, captureOnly = false, stopPropagation = false } = {}) { + let event = { + callback, + options: { bubbleOnly, captureOnly, stopPropagation } + }; + if (!this.eventListeners.hasOwnProperty(name)) { + this.eventListeners.name = event; + } else { + if (Array.isArray(this.eventListeners.name)) { + this.eventListeners.name.push(event); + } else { + let arr = [this.eventListeners.name]; + this.eventListeners.name = arr; + this.eventListeners.name.push(event); + } + } + } + + dispatchEvent(name, data, { bubble = false, capture = false } = {}) { + if (bubble && this.parent) this.parent._eventOccured(name, data, this, 'bubble'); + if (capture) + this.layers.forEach(layer => { + layer.dispatchEvent(name, data, this, 'capture'); + }); + } + + _eventOccured(name, data, target, phase) { + if (this.eventListeners.hasOwnProperty(name)) { + this.eventListeners.name.callback({ name, data, target, phase }); + } + } + + /** + * Clones the layer with all its children. + */ + clone() { + console.error('Call of abstract method Layer.clone(). Inherit in subclass.', this); + } + + /** + * Clones an element on the layer. + */ + static cloneElement(element) { + console.error('Call of abstract static method Layer.cloneElement(element). Inherit in subclass.', this); + } + } + + /** + * The Layer class for DOM elements. + */ + class DOMLayer extends Layer { + constructor(opts) { + super( + Object.assign( + { + container: DOMLayer._createContainer() + }, + opts + ) + ); + } + + _appendRoot(ground) { + ground.appendChild(this.container); + } + + _visualizeLayer() { + if (this.container) { + let span = document.createElement('span'); + span.innerHTML = this.name; + this.container.appendChild(span); + } + } + + _defineAsLayer() { + this._container.layer = this; + } + + static _createContainer() { + let container = document.createElement('div'); + return container + } + + placeLayer(layer) { + layer.container.position = 'absolute'; + super.placeLayer(layer); + } + + placeElement(domElement) { + if (domElement instanceof HTMLElement) { + this.elements.push(domElement); + this._container.appendChild(domElement); + } else console.error('Could not add element, it is not a DOM Element.', domElement); + } + + static createLayer(style) { + let dom = document.createElement('div'); + + return dom + } + + static find(element) { + if (element.layer) return element.layer + + while (element.parentNode) { + element = element.parentNode; + if (element.layer) return element.layer + } + + return null + } + + /** + * Moves the layer one up. + */ + up() { + if (this.parent && this.container) this.parent.requestUp(this.container); + else console.warn("Root layer can't be moved up: ", this); + } + + /** + * Moves a child element one layer up. + * + * @param {object} child - Child element to change. + */ + requestUp(child) { + if (child.nextSibling) child.parentNode.insertBefore(child.nextSibling, child); + } + + /** + * Moves the layer one down. + */ + down() { + if (this.parent && this.container) this.parent.requestDown(this.container); + else console.warn("Root layer can't moved down: ", this); + } + + /** + * Moves a child element one layer down. + * + * @param {object} child - Child element to change. + */ + requestDown(child) { + if (child.previousSibling) child.parentNode.insertBefore(child, child.previousSibling); + } + + /** + * Brings the element to the top of the layer. + * + * @param {object} element - Child element, that should be put on top. + */ + requestToFront(element) { + console.log(element); + element.parentNode.appendChild(element); + } + + /** + * Brings the element to the bottom of the layer. + * + * @param {object} element - Child element, that should be put on bottom. + */ + requestToBottom(element) { + element.parentNode.prepend(element); + } + + /* + * Hides the layer. + */ + hide() { + if (this._container.style.display != 'none') { + this.backup.display = this._container.style.display; + } + this._container.style.display = 'none'; + } + + /* + * Shows the layer. + */ + show() { + this._container.style.display = this.backup.display ? this.backup.display : 'initial'; + } + + get visible() { + return this._container.style.display == 'none' + } + + get position() { + let x = this.container && this.container.style.left ? parseInt(this.container.style.left) : 0; + let y = this.container && this.container.style.top ? parseInt(this.container.style.top) : 0; + return { x, y } + } + + set position(value) { + if (this.container) { + this.container.style.left = value.x + 'px'; + this.container.style.top = value.y + 'px'; + } + } + } + + /** + * The Layer class for PIXI Elements. + */ + class PIXILayer extends Layer { + constructor(opts = {}) { + opts = Object.assign( + { + container: new PIXI.Graphics(), + debug: false + }, + opts + ); + super(opts); + + if (opts.debug) { + this.container.interactive = true; + this.container.on('pointerdown', event => { + console.log(this.name); + }); + } + } + + static cloneElement(element) { + if (element instanceof PIXI.Graphics) { + let clone = element.clone(); + console.log('Clone Parent: ', clone.parent); + // PIXI does not clone the transform properties. + // We have to apply them manually. + + clone.position = element.position; + clone.rotation = element.rotation; + clone.scale = element.scale; + + return clone + } else { + console.error('Cloning of PIXI element not yet implemented: ', element); + } + } + + _appendRoot(ground) { + ground.stage.addChild(this.container); + } + + _defineAsLayer() { + this._container.layer = this; + } + + transform(event) { + if (event.about) { + let relScale = this.scale / event.scale - 1; + let localAnchor = this.toLocal(event.about); + event.translate = Points.add(event.translate, Points.multiplyScalar(localAnchor, relScale)); + } + + super.transform(event); + } + + toLocal(point) { + return Points.subtract(point, this.position) + } + + _visualizeLayer() { + this.place(new PIXI.Text(this.name)); + } + + _update(delta) { + const margin = 30; + const length = 100; + + let origin = new PIXI.Polygon([ + new PIXI.Point(this.position.x, this.position.y + length * this.scale), + new PIXI.Point(this.position.x, this.position.y), + new PIXI.Point(this.position.x, this.position.y - length * this.scale), + new PIXI.Point(this.position.x, this.position.y), + new PIXI.Point(this.position.x - length * this.scale, this.position.y), + new PIXI.Point(this.position.x, this.position.y), + new PIXI.Point(this.position.x + length * this.scale, this.position.y) + ]); + + let rect = new PIXI.Rectangle( + this.position.x + margin * this.scale, + this.position.y + margin * this.scale, + length * this.scale, + length * this.scale + ); + + this.container.drawShape(origin); + this.container.drawShape(rect); + } + + placeElement(pixiElement) { + try { + pixiElement.layer = this; + this._container.addChild(pixiElement); + this.elements.push(pixiElement); + return true + } catch (e) { + console.error(e, this, pixiElement); + } + return false + } + + requestUp(child) { + if (child instanceof Layer) { + if (child.container) child = child.container; + else return + } + + const oldIdx = this.children.indexOf(child); + if (oldIdx > -1) { + const newIdx = oldIdx + 1; + + if (newIdx < this.children.length) { + let elem = this.children.splice(oldIdx, 1); + this.children.splice(newIdx, 0, elem[0]); + } + } + } + + requestDown(child) { + if (child instanceof Layer) { + if (child.container) child = child.container; + else return + } + + const oldIdx = this.children.indexOf(child); + if (oldIdx > -1) { + const newIdx = oldIdx - 1; + + if (newIdx >= 0) { + let elem = this.children.splice(oldIdx, 1); + this.children.splice(newIdx, 0, elem[0]); + } + } + } + + requestToFront(child) { + const oldIdx = this.children.indexOf(child); + this.children.splice(oldIdx, 1); + this.children.push(child); + } + + requestToBottom(child) { + const oldIdx = this.children.indexOf(child); + this.children.splice(oldIdx, 1); + this.children.unshift(child); + } + + get children() { + return this.container.children + } + + follow(layer) { + super.follow(layer); + this.container.position = this.position; + this.container.scale = { x: this.scale, y: this.scale }; + } + + set position(value) { + if (this.container) this.container.position = value; + } + + get position() { + if (this.container) return this.container.position + } + + show() { + this.container.visible = true; + } + + hide() { + this.container.visible = false; + } + + get visible() { + return this.container.visible + } + + static find(element) { + if (element.layer) return element.layer + + while (element.parent) { + element = element.parent; + if (element.layer) return element.layer + } + + return null + } + + removeElement(element) { + element.parent.removeChild(element); + } + } + + //import { GeoGraphics } from "../pixi/geographics.js" + + /** + * The GeoLayer is a special PIXILayer, that recognizes other GeoLayers and + * GeoGraphics. The layer can be adapted to a map and notifies all Geo-Children + * of the Adaption. + */ + class GeoLayer extends PIXILayer { + constructor(opts = {}) { + super(opts); + + /** + * When setting the map and mapLayer with the options paramter. + * The GeoLayer becomes a RootLayer, when the root layer should not be a MapLayer. + */ + if (opts.map) this._map = opts.map; + if (opts.map) this._mapLayer = opts.mapLayer; + + this.geographics = []; + } + + /** + * Adapts to a map. If the maplayer should adapt to the parent maplayer, + * no parameter must be specified. + */ + adapt(map = null) { + if (!map) map = this.map; + + if (map) { + this.geographics.forEach(geographic => { + geographic.adaptTo(map); + }); + + this.layers.forEach(layer => { + if (layer.adapt) layer.adapt(map); + }); + } else console.error('There was no map specified.', this); + } + + place(geographic) { + if (geographic.constructor.name.startsWith('Geo') && geographic.graphics) { + // Fix to remove the rollupjs circular dependency + //if (geographic instanceof GeoGraphics) { + this.geographics.push(geographic); + super.place(geographic.graphics); + } else super.place(geographic); + } + + placeLayer(layer) { + if (layer instanceof GeoLayer || layer instanceof MapLayer) { + super.placeLayer(layer); + if (this.map) layer.geographics.forEach(geographics => geographics.adaptTo(this.map)); + } else + console.error('Could not place layer. Only MapLayer and GeoLayers can be child layers of GeoLayers.', layer); + } + + //GeoLayers have to be children of a map layer, + // therefore we can recursively get the map. + get map() { + return this._map ? this._map : this.parent.map + } + + get mapLayer() { + return this._mapLayer ? this._mapLayer : this.parent.mapLayer + } + + clone(mapLayerClone) { + const opts = { + mapLayer: mapLayerClone, + map: mapLayerClone.map + }; + let geoLayerClone = new GeoLayer(opts); + + this.layers.forEach(layer => { + let layerClone = layer.clone(opts); + if (layerClone) { + geoLayerClone.placeLayer(layerClone); + } + }); + + this.geographics.forEach(geographics => { + let clone = geographics.clone(); + if (clone) { + geoLayerClone.place(clone); + } + }); + + return geoLayerClone + } + } + + /** + * TODO: It may be a good idea to inherit maplayer from geo layer. + */ + class MapLayer extends PIXILayer { + constructor(active, maps, scatterContainer, opts = {}) { + super( + Object.assign( + { + container: new PIXI.Container() + }, + opts + ) + ); + + this.opts = opts; + + this.transformHandler = new EventHandler('onTransform', { + listeners: () => { + this.labelVisibility(); + } + }); + + this.scatterContainer = scatterContainer; + + if (!maps[active]) console.error('No map was set!'); + else opts.map = maps[active]; + + this.mapview = new MapView(opts); + this.changeHandler = new EventHandler('onChange', { + listeners: opts.onChange + }); + + //TODO Implement error handling here. + this.maps = maps; + this.changeMap(active); + if (opts.map) this.placeMap(opts.map); + this.dynamicElements = new Map(); + } + + labelVisibility() { + const visibility = this.childrenVisibility; + + if (visibility) { + const zoom = this.mapview.zoom; + + const min = visibility.min || 0; + const max = visibility.max || Number.MAX_VALUE; + + if (zoom > min && zoom < max) { + this.elements.forEach(it => (it.visible = true)); + + this.elements.forEach(it => { + const scale = 1 / it.parent.scale.x; + + // it.children are poi groups + // it.children[0] is the poi group of the tübingen poi + // it.children[0].children are the text containers (not PIXI.Text), about 20 pieces + + if (it.children.length > 0) { + it.children[0].children.forEach(poi => { + if (poi.children.length === 1) { + poi.scale.set(scale, scale); + } + }); + } + }); + } else { + this.elements.forEach(it => (it.visible = false)); + } + } + } + + adapt() { + this.layers.forEach(layer => { + if (layer.adapt) layer.adapt(this.map); + }); + } + + placeLayer(layer) { + super.placeLayer(layer); + if (layer instanceof GeoLayer && this.map) { + layer.adapt(this.map); + } + } + + placeMap(map) { + if (map instanceof GeoMap) { + this.scatterContainer.addChild(map.image); + this.map.onTransform.add(this.transformed.bind(this)); + this.mapview.setMap(this.map); + + this.map.image.addChild(this.container); + + this.adapt(); + } else { + console.error("Could not set map, it's not of type GeoMap.", map); + } + } + + placeElement(elem) { + if (elem instanceof PIXI.DisplayObject) { + this.map.image.addChild(elem); + this.elements.push(elem); + } else { + console.error('Element need to be of type PIXI.DisplayObject.', elem); + } + } + + transformed(e) { + this.mapview.transformed(e); + this.transformHandler.call(this); + } + + clone(container = null) { + let clone = {}; + for (let name of Object.keys(this.maps)) { + //console.info(this.maps[name]) + clone[name] = this.maps[name].clone(container); + } + + //console.info(this.active) + let mapLayerClone = new MapLayer(this.active, clone, container, { + name: MapLayer.idx++, + viewport: this.mapview.viewport, + focus: this.mapview.focus, + zoom: this.mapview.zoom + }); + //mapLayerClone._map = clone['luftbild'] + mapLayerClone.childrenVisibility = this.childrenVisibility; + return mapLayerClone + } + + changeMap( + key, + { + useScatterAsContainer = true // If set to false, the normal container is used. This is necessary when using submaps and the container need to be a RigidContainer. + } = {} + ) { + Logging$1.log(`Map change: ${key}`); + + if (this.active !== key) { + if (this.maps.hasOwnProperty(key)) { + let old = this.map ? this.map : null; + this._map = this.maps[key]; + this._map.name = key; + this.active = key; + + let container = useScatterAsContainer ? this.scatterContainer : this.container; + + this.map.load(container); + + // Copies all layers. + this.layers.forEach(layer => { + if (old) this.map.image.addChild(layer.container); + }); + + this.placeMap(this.map); + + /** + * TODO: Improve + * + * I'm quite sure if I made a design mistake here. + * In an earlier version I did not need to migrate the + * layers manually from the map to the next map. + * + * I believe the old version was a container next to the + * map, which got updated on transform. + * + * -SO + */ + if (old) old.unload(); + + this.changeHandler.call(this, old); + } else { + let keys = Object.keys(this.maps); + + if (keys.length == 0) console.error('There is no map set for the map layer!'); + else { + let fallbackMap = keys[0]; + console.error( + `A map with the key (${key}) does not exists within the mapapp. Fallback to map: ${fallbackMap}.` + ); + this.changeMap(fallbackMap, { + useScatterAsContainer + }); + } + } + } + } + + get map() { + return this._map + } + + /** + * This is required for the consistency of georelated layers. + * The request traverses up to the mapLayer where it then returns + * the responsible map layer. + */ + get mapLayer() { + return this + } + } + + MapLayer.idx = 0; + + /** + * MapApp is responsible for showing fullscreen + * map applications. + * + */ + class MapApp extends PIXIApp { + constructor(opts = {}) { + super(opts); + + // Default values. + opts = Object.assign( + { + debug: false, + maps: {}, //required + view: null, + startmap: null, + coordsLogging: false, + overlays: {}, + keycodes: {}, + showHotkeys: false, + imageMapZoomHeight: 256, //Defines the zoomvalue 1 for all image maps inside the mapapp. + focus: null, + zoom: 1, + onMapChanged: null, + onSizeChanged: null, + onTransform: null + }, + opts, + { + alpha: 0.5 + } + ); + + this.submaps = []; + this.overlayElements = new Map(); + this.debug = opts.debug; + this.fpsLogging = opts.fpsLogging; + this.showHotkeys = opts.showHotkeys; + this.keycodes = this._extractKeyCodes(opts.keycodes); + this.coordsLogging = opts.coordsLogging; + this.startmap = opts.startmap; + this.overlays = opts.overlays; + this.maps = opts.maps; + this.focus = opts.focus; + this.zoom = opts.zoom; + + this.onMapChanged = new EventHandler('mapChanged', { + listeners: opts.onMapChanged + }); + this.onSizeChanged = new EventHandler('sizeChanged', { + listeners: opts.onSizeChanged + }); + this.onTransform = new EventHandler('transformed', { + listeners: opts.onTransform + }); + + /** + * When in debug mode, this allows the user to copy the center coordinates to the clipboard. + */ + if (this.debug) { + this.DRAW_MODES = { + PIXI_POINT: 0, + PATH_MODE: 1, + POLYGON_MODE: 2, + + getName: function(num) { + let result = null; + for (const [key, val] of Object.entries(this)) { + if (val == num) result = key; + } + return result + } + }; + this.drawMode = this.DRAW_MODES.PIXI_POINT; + this.drawData = []; + } + this._setupKeyboardUtils(); + + Logging$1.log('Application start'); + } + + setup() { + super.setup(); + + // TODO get access to fps display + let fpsDisplay; + this.stage.children.forEach(element => { + if (element.refreshFps) fpsDisplay = element; + }); + + this.pixiLayer = new PIXILayer({ + name: 'Pixi Root', + container: this.scene + }); + + this.domLayer = new DOMLayer({ + name: 'DOM Root', + container: document.body + }); + + if (!this.startmap) { + let firstMap = Object.keys(this.maps)[0]; + if (firstMap != null) this.startmap = firstMap; + else { + console.error('No map was set. Set a map first, before running the setup command!'); + return + } + } + + //console.log('startup', this.startmap, this.maps) + + this.mapLayer = new MapLayer(this.startmap, this.maps, this.scene, { + name: 'Map Layer', + focus: this.focus, + zoom: this.zoom + }); + + this.mapLayer.changeHandler.add(this._mapChanged.bind(this)); + this.pixiLayer.place(this.mapLayer); + this._mapChanged(null); + + this.pixiUiLayer = new PIXILayer({ name: 'Pixi UI' }); + this.pixiLayer.placeLayer(this.pixiUiLayer); + + this.domUiLayer = new DOMLayer({ name: 'DOM UI' }); + this.domLayer.placeLayer(this.domUiLayer); + + if (this.fpsLogging && fpsDisplay) this.pixiUiLayer.place(fpsDisplay); + + if (this.coordsLogging) { + this.coordsDisplay = new CoordinateDisplay(this); + this.pixiUiLayer.place(this.coordsDisplay); + } + + this.__dragging = false; + this.__events = new Map(); + + const scene = this.scene; + scene.interactive = true; + scene.on('pointercancel', this.__onEnd.bind(this)); + scene.on('pointerdown', this.__onStart.bind(this)); + scene.on('pointermove', this.__onMove.bind(this)); + scene.on('pointerout', this.__onEnd.bind(this)); + scene.on('pointerup', this.__onEnd.bind(this)); + scene.on('pointerupoutside', this.__onEnd.bind(this)); + + return this + } + + layout(width, height) { + this.scene.resize(width, height); + this.mapLayer.mapview.update(); + this.onSizeChanged.call(this); + } + + sceneFactory() { + return new RigidScatterContainer(this.width, this.height, this.renderer, { + app: this, + showBounds: true, + showTouches: true, + showPolygon: true, + container: this + }) + } + + addMaps(maps) { + for (let key in maps) { + this.addMap(key, maps[key]); + } + } + + selectMap(key) { + if (this.maps[key]) { + if (this.mapLayer) this.mapLayer.changeMap(key); + } else { + console.error(`Selected map ("${key}") was not (yet) added to the mapapp.`); + } + } + + setMap(key, map) { + this.addMap(key, map); + this.selectMap(key); + } + + addMap(key, map) { + this.maps[key] = map; + } + + transformed(event) { + this.onTransform.call(this, event); + } + + _mapChanged(lastMap) { + if (lastMap) { + lastMap.flushHandlers(); + } + + /** + * TODO: A problem is that the map layer has no container + * on its own and it lives in the ScatterContainer. + * Here we guarantee, that the layer order is as it + * is defined in the layers. + */ + this.pixiLayer.layers.forEach(layer => { + if (layer !== this.mapLayer) { + layer.parent.container.removeChild(layer.container); + layer.parent.container.addChild(layer.container); + } + }); + + this.map.onTransform.add(this.transformed.bind(this)); + this.transformed(); + this.onMapChanged.call(this, this.map); + } + + _doesOverlayElementExist(layer, type, name) { + let layerElements = this.overlayElements.get(layer); + return layerElements != undefined && layerElements[type] != null && layerElements[type][name] != null + } + + _getOverlayElement(layer, type, name) { + let layerElements = this.overlayElements.get(layer); + return layerElements[type][name] + } + + _setOverlayElement(layer, type, name, value) { + let obj = this.overlayElements.get(layer); + if (obj == undefined) obj = {}; + + if (obj[type] == null) obj[type] = {}; + + obj[type][name] = value; + + this.overlayElements.set(layer, obj); + } + + place(layer) { + if (layer instanceof PIXILayer) { + this.pixiLayer.place(layer); + } else if (layer instanceof DOMLayer) { + this.domLayer.place(layer); + } else { + console.error('Could not add layer to ', this, layer); + } + } + + addMapOverlay(layer) { + this.mapLayer.place(layer); + } + + /** + * Copies the current coordinates to the clipboard. + */ + locationToClipboard() { + let hidden = document.createElement('input'); + document.body.appendChild(hidden); + hidden.value = '"location":' + JSON.stringify(app.mapLayer.mapview.focus); + hidden.readOnly = true; + hidden.select(); + document.execCommand('Copy'); + this.showNotification('Copied location to clipboard.'); + document.body.removeChild(hidden); + } + + pathToClipboard() { + let hidden = document.createElement('input'); + document.body.appendChild(hidden); + + this.drawData.push(this.mapLayer.mapview.focus); + + if (this.drawMode == this.DRAW_MODES.POLYGON_MODE) { + let data = { + type: 'Polygon', + coordinates: [this.drawData] + }; + + hidden.value = '"geometry":' + JSON.stringify(data); + } else { + // PATH_MODE is default. + hidden.value = JSON.stringify(this.drawData); + } + hidden.select(); + document.execCommand('Copy'); + this.showNotification('Location appended in clipboard.'); + document.body.removeChild(hidden); + } + + get map() { + return this.mapLayer.map + } + + get activeMapKey() { + return this.mapLayer.active + } + + getRelativePosition(x, y) { + return { + x: x * app.width, + y: y * app.height + } + } + + clearDrawData() { + this.drawData = []; + } + + showNotification(msg) { + let notification = document.createElement('div'); + notification.classList.add('notification'); + + let text = document.createElement('p'); + text.innerHTML = msg; + notification.appendChild(text); + + /** + * TODO: move styling (except opacity) to css. + */ + Object.assign(notification.style, { + opacity: 0, + top: 0, + left: 0, + position: 'fixed', + display: 'flex', + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + pointerEvents: 'none' + }); + + document.body.appendChild(notification); + + let popAnimation = new TimelineLite({ + onComplete: () => { + notification.parentNode.removeChild(notification); + } + }); + let animationSpeed = 0.5; + + popAnimation + .to(notification, animationSpeed, { + opacity: 1 + }) + .to(notification, animationSpeed, { + opacity: 0 + }); + } + + _currentLocationToString() {} + + _matchKeyCode(event, keyCode) { + // If keycode does not exist or is invalid - return. + if (!keyCode || keyCode.key == null) return false + + let code = keyCode.key; + if (event.shiftKey) code -= 32; + const key = event.which == code || event.keyCode == code || event.charCode == code; + + const ctrl = keyCode.ctrlKey == event.ctrlKey; + const shift = keyCode.shiftKey == event.shiftKey; + const alt = keyCode.altKey == event.altKey; + + return key && ctrl && shift && alt + } + + _setupKeyboardUtils() { + document.body.addEventListener('keypress', event => { + this._checkForKeyCode(event); + }); + } + + _checkForKeyCode(event) { + if (this._matchKeyCode(event, this.keycodes.copyCoordinate)) { + event.preventDefault(); + + switch (this.drawMode) { + case this.DRAW_MODES.PIXI_POINT: + this.locationToClipboard(); + break + case this.DRAW_MODES.PATH_MODE: + case this.DRAW_MODES.POLYGON_MODE: + this.pathToClipboard(); + break + default: + console.error( + `Draw mode is not implemented yet: ${this.DRAW_MODES.getName(this.drawMode)}(${this.drawMode}).` + ); + } + } + + if (this._matchKeyCode(event, this.keycodes.togglePathMode)) { + if (this.drawMode == this.DRAW_MODES.PATH_MODE) { + this.showNotification('Path Mode disabled.'); + this._resetDrawMode(); + } else { + this.drawMode = this.DRAW_MODES.PATH_MODE; + this.showNotification('Path Mode enabled.'); + this.clearDrawData(); + } + } + + //When SHIFT+P is pressed POLYGON-MODE is toggled: + if (this._matchKeyCode(event, this.keycodes.togglePolygonMode)) { + if (this.drawMode == this.DRAW_MODES.POLYGON_MODE) { + this.showNotification('Polygon Mode disabled.'); + this._resetDrawMode(); + } else { + this.drawMode = this.DRAW_MODES.POLYGON_MODE; + this.showNotification('Polygon Mode enabled.'); + this.clearDrawData(); + } + } + + // When SHIFT+X is pressed toggle crosshair + if (this.keycodes.toggleUi && this._matchKeyCode(event, this.keycodes.toggleUi)) { + if (this.pixiUiLayer.visible) { + this.pixiUiLayer.hide(); + this.domUiLayer.hide(); + } else { + this.pixiUiLayer.show(); + this.domUiLayer.show(); + } + } + } + + _resetDrawMode() { + this.drawMode = this.DRAW_MODES.PIXI_POINT; + } + + _extractKeyCodes(keycodeText) { + let out = {}; + + for (let [name, combinationString] of Object.entries(keycodeText)) { + let keys = combinationString.split('+'); + out[name] = { + key: null, + ctrlKey: false, + shiftKey: false, + altKey: false + }; + let errors = []; + + const special = ['shift', 'ctrl', 'alt']; + keys.forEach(key => { + if (key.length == 1) { + if (out[name].key) { + const error = 'Tried to set multiple keys as keycode. This is currently not supported.'; + errors.push(error); + } else out[name].key = key.charCodeAt(0); + } else { + key = key.toLowerCase(); + if (special.indexOf(key) != -1) { + out[name][key + 'Key'] = true; + } else { + const error = 'Wrong / non-implemented special character OR typo in key: ' + key; + errors.push(error); + } + } + }); + + // Disable hotkey if an error occured. + if (errors.length > 0) + out[name] = { + key: null, + ctrlKey: false, + shiftKey: false, + altKey: false + }; + } + + return out + } + + __onStart(event) { + this.__dragging = true; + + let hittedSubmap = null; + let center = null; + for (const submap of this.submaps) { + const radius = submap.container.width / 2; + const distance = Points.distance(submap.center, event.data.global); + if (distance < radius) { + hittedSubmap = submap; + center = submap.center; + } + } + + this.__events.set(event.data.identifier, { + event, + submap: hittedSubmap, + center + }); + } + + __onMove(event) { + if (this.__dragging) { + const myevent = this.__events.get(event.data.identifier); + + if (myevent && myevent.submap) { + const submap = myevent.submap; + const center = submap.center; + const radius = submap.container.width / 2; + const distance = Points.distance(center, event.data.global) / submap.scatter.scale; + } + + // for (const submap of this.submaps) { + // const center = submap.center + // const radius = submap.container.width / 2 + // const distance = Points.distance(center, event.data.global) / submap.scatter.scale + // const inside = distance < radius + 10 + + // console.log(distance, radius) + + // if (inside) { + // // (this.width + 80) / 2 * this.scatter.scale + // //const width = (submap.width + 80) / 2 * submap.scatter.scale + // //console.log(width) + + // if (distance > radius) { + // submap.resize((distance) * 2, .2) + // } + // } else { + // if (distance < radius + 20) { + // //submap.resize((distance - 30) * 2, .2) + // } + // } + // } + } + } + + __onEnd(event) { + this.__dragging = false; + this.__events.delete(event.data.identifier); + } + } + + /* globals PIXI */ + + class FlagType { + static get bottomLeft() { + return { x: 1, y: -1 } + } + static get bottomRight() { + return { x: -1, y: -1 } + } + static get topLeft() { + return { x: -1, y: 1 } + } + static get topRight() { + return { x: 1, y: 1 } + } + + static toString(flagType) { + let str = ''; + if (flagType.x && flagType.y) { + if (flagType.y == 1) str += 'bottom'; + else if (flagType.y == -1) str += 'top'; + else str += '_INVALID_Y_'; + + if (flagType.x == 1) str += 'Right'; + else if (flagType.x == -1) str += 'Left'; + else str += '_INVALID_X_'; + } else str = 'Invalid FlagType: ' + flagType.toString(); + + return str + } + } + + class FlagPolygon extends PIXI.Polygon { + constructor({ + type = FlagType.bottomLeft, + width = 100, + height = 30, + notchSize = 10, + notchWidth = null, + notchHeight = null, + originOffset = { x: 0, y: 0 } + } = {}) { + let points = []; + + let dimensions = { x: width, y: height }; + dimensions = Points.multiply(dimensions, type); + + notchWidth = notchWidth == null ? notchSize : notchWidth; + notchHeight = notchHeight == null ? notchSize : notchHeight; + + notchSize = { x: notchWidth, y: notchHeight }; + notchSize = Points.multiply(notchSize, type); + + originOffset = Points.multiply(originOffset, type); + + let point = new PIXI.Point(originOffset.x, originOffset.y); + points.push(point.clone()); + + point.y += notchSize.y; + points.push(point.clone()); + + point.y += dimensions.y; + points.push(point.clone()); + + point.x += dimensions.x; + points.push(point.clone()); + + point.y -= dimensions.y; + points.push(point.clone()); + + point.x -= dimensions.x - notchSize.x; + points.push(point.clone()); + + // close polygon + points.push(points[0].clone()); + + super(points); + + this.type = type; + this.dimensions = dimensions; + this.notchSize = notchSize; + this.originOffset = originOffset; + } + + getPoint(i) { + if (i >= 0) { + let idx = i * 2; + return [this.points[idx], this.points[idx + 1]] + } else { + let idx = (Math.floor(this.points.length / 2) + i) * 2; + return [this.points[idx], this.points[idx + 1]] + } + } + + get notch() { + let points = [this.getPoint(0), this.getPoint(1), this.getPoint(-2), this.getPoint(0)]; + let notchPolygon = []; + + points.forEach(point => { + notchPolygon = notchPolygon.concat(point); + }); + + return notchPolygon + } + get rect() { + let points = [this.getPoint(1), this.getPoint(2), this.getPoint(3), this.getPoint(4), this.getPoint(1)]; + + let rectPolygon = []; + points.forEach(point => { + rectPolygon = rectPolygon.concat(point); + }); + return rectPolygon + } + + placeText(text, padding) { + text.position = Points.add(this.originOffset, { x: 0, y: this.notchSize.y }); + padding = Points.multiply(padding, this.type); + text.position = Points.add(text.position, padding); + + if (this.type.y == -1) text.position.y -= text.height; + + if (this.type.x == -1) text.position.x -= text.width; + } + } + + class Flag extends PIXI.Graphics { + constructor( + { + type = FlagType.bottomLeft, + width = 100, + height = 30, + notchSize = 10, + notchWidth = null, + notchHeight = null, + originOffset = { x: 0, y: 0 } + } = {}, + nativeLines = false + ) { + super(nativeLines); + + this.flagPolygon = new FlagPolygon({ + type, + width, + height, + notchSize, + notchWidth, + notchHeight, + originOffset + }); + + this.draw(); + } + + draw() { + this.drawPolygon(this.flagPolygon); + } + + get typeName() { + return FlagType.toString(this.type) + } + } + + class Label extends PIXI.Graphics { + constructor(text, textStyle = new PIXI.TextStyle(), nativeLines = false) { + super(nativeLines); + this._text = new PIXI.Text(text, textStyle); + this.addChild(this._text); + } + + get text() { + return this._text + } + } + + /** + * GeoGraphics are graphical objects, that does not store the graphics information + * in screen space, but in geographical coordinates. Therefore GeoGraphics must be + * placed on GeoLayers to work properly. + * + * (Note: As GeoLayers are always children of a map layer. When the map is changed + * all GeoLayers are notified via the 'adaptTo(map)' method.) + * + * The geolayers forward this 'adaptTo' to all children that are GeoGraphics. + * Which adjust their so called 'point' data to the new map. + * + * @abstract + */ + class GeoGraphics { + constructor(coordinates, { scale = 1, onDraw = null, onDrawEnd = null, debug = false } = {}) { + this.coordinates = coordinates; + this.debug = debug; + this.graphics = new PIXI.Graphics(); + this.scale = scale; + this.drawHandler = new EventHandler('onDraw', { listeners: onDraw }); + this.drawEndHandler = new EventHandler('onDrawEnd', { listeners: onDrawEnd }); + this._points = null; + this._position = null; + } + + clone() { + console.error(`Call of abstract method clone(). Overwrite in subclass.`, this); + } + + _cloneOptions() { + return { + debug: this.debug, + scale: this.scale + } + } + + /** + * The _adaptCoordinates is called first by the adaptTo Method. + * Here all coordinates are transformed into point coordinates. + * This must be overloaded in subclass. + * + * @abstract + */ + _adaptCoordinates(map) { + console.error(`Call of abstract method _adaptCoordinates(map). Overwrite in subclass.`, this); + } + + /** + * Gets all screen points in a single array. + * + * @abstract + * @returns {array} - Array of all points in the GeoGraphic. + */ + _getPoints() { + console.error(`Call of abstract method _getPoints(func). Overwrite in subclass.`, this); + } + + /** + * Manipulates all points depending on a function. + * Mainly used to transform points to local space. + * + * @abstract + * @param {function} func + * @memberof GeoGraphics + */ + _manipulatePoints(func) { + console.error(`Call of abstract method _manipulatePoints(func). Overwrite in subclass.`, this); + } + + /** + * The _draw method is called last on adaptation. It creates the GraphicData + * of the specified subclass. To manipulate the style of the graphic, hook an onDraw listener + * to the GeoGraphics object. It is called before the _draw and lets the user modify color and + * lineStyle of the drawn object. + * + * Note: It could also be used for more radical manipulations on the graphics object. + * But this should be used with care. + * + * @abstract + */ + _draw() { + console.error(`Call of abstract method _draw(). Overwrite in subclass.`, this); + } + + /** + * Called by the containing geo layer, when the map changes. + */ + adaptTo(map) { + this._points = this._adaptCoordinates(map); + this._updatePosition(); + this.draw(); + } + + + /** + * Redraws the graphics. + * + * This should be only called if you require an redraw independent of an adapt. + * + * @memberof GeoGraphics + */ + draw() { + this._prepareDraw(); + this.drawHandler.call(this, this.graphics); + this._draw(); + this.drawEndHandler.call(this, this.graphics); + } + + /** + * Retrieves the point data. + * Note: This data changes on adaptation. + */ + get points() { + return this._points + } + + /** + * Returns the screen position of the GeoGraphics. + */ + get position() { + return this._position + } + + get map() { + if ( + this.graphics.layer && + (this.graphics.layer instanceof GeoLayer || this.graphics.layer instanceof MapLayer) + ) { + return this.graphics.layer.map + } else return null + } + + get mapLayer() { + if ( + this.graphics.layer && + (this.graphics.layer instanceof GeoLayer || this.graphics.layer instanceof MapLayer) + ) { + return this.graphics.layer.mapLayer + } else return null + } + + /** + * Prepare draw is a private function, that prepares the graphics + * for the next draw call. It also fires the drawHandler. + * + * @private + * @memberof GeoGraphics + */ + _prepareDraw() { + this.graphics.clear(); + /* + Set a fillcolor and a stroke style for + debugging. Can be overloaded using the onDraw + event function. + */ + if (this.debug) this.graphics.beginFill(0xff00ff); + } + + _updatePosition() { + let points = this._getPoints(); + this._position = GeoGraphics.calculateCenterOfMass(points); + this._manipulatePoints(point => { + point = Points.subtract(point, this._position); + return point + }); + this.graphics.position = this._position; + } + + /** + * Calculates the center of Mass for a set of points. + * + * @static + * @param {Array} points - Array of points in the format {x: a, y:b} + * @returns {object} - Returns a point containing the center of mass of the polygon. + * @memberof GeoGraphics + */ + static calculateCenterOfMass(points) { + let com = new PIXI.Point(); + points.forEach(p => { + let point = new PIXI.Point(p.x, p.y); + com = Points.add(com, point); + }); + return Points.multiplyScalar(com, 1 / points.length) + } + } + + /** + * Represents a single point on the Map. + * + * This GeoGraphics does not provide any visual representation. + * Draw the desired shape in the onDraw callback. + */ + class GeoPoint extends GeoGraphics { + clone() { + return new GeoPoint(this.coordinates, this._cloneOptions()) + } + + _adaptCoordinates(map) { + let scale = 1; + + if (this.mapLayer.map instanceof DeepZoomMap) { + scale = this.mapLayer.map.image.scale.x; + } + + scale = scale / 4; + + return map.coordinatesToPoint(this.coordinates) + } + + _getPoints() { + return [this.points] + } + + _manipulatePoints(func) { + this._points = func(this._points); + } + + _draw() {} + } + + class GeoLine extends GeoGraphics { + /** + * @param {object} opts - Optional values + * @param {array} [opts.points=[]] - Initial points of the geo shape. + * @param {boolean} [closed=false] - Defines if the + */ + constructor(coordinates, { closed = false, size = 1, onDraw = null } = {}) { + super(coordinates, { + size, + onDraw + }); + + this._closed = closed; + } + + clone() { + return new GeoLine(this.coordinates, this._cloneOptions) + } + + _cloneOptions() { + let options = super._cloneOptions(); + Object.assign(options, { + closed: this.closed + }); + } + + /** + * Adds a point to the geo line. + */ + addPoint(coordinate) { + this.coordinates.push(coordinate); + } + + _manipulatePoints(func) { + this.points.forEach((point, idx, array) => { + array[idx] = func(point); + }); + } + + _getPoints() { + return this.points + } + + _adaptCoordinates(map) { + let points = []; + this.coordinates.forEach(point => { + points.push(map.coordinatesToPoint(point)); + }); + return points + } + + _prepareDraw() { + this.graphics.clear(); + if (this.debug) this.graphics.lineStyle(0.5, 0xff00ff); + this.drawHandler.call(this); + } + + _draw() { + /** + * This resets the fill. + * + * DISCUSS: SO: "I'm not sure how the line should be defined. + * On the one hand. The line is clearly intended to + * represent a line and not an area. On the other hand, + * why should the user be prevented from using a fill for the + * area within the line. But if he want's a fill, why don't take + * a Polygon in the first place? + * + * (But if it's a predefined GeoJSON object obtained through e.g. elasticsearch, + * then the user is not in full control of the object type and it may be a good + * addition to grant the user this additional design choice.) + * + * The opportunity to do so would result in additional conditions, when creating the + * GeoGraphics of an overlay." + * */ + this.graphics.beginFill(0, 0); + + if (this.points.length > 0) { + this.graphics.moveTo(this.points[0].x, this.points[0].y); + + for (let i = 1; i < this.points.length; i++) { + this.graphics.lineTo(this.points[i].x, this.points[i].y); + } + + if (this.closed) { + this.graphics.lineTo(this.points[0].x, this.points[0].y); + } + } + } + + get closed() { + return this._closed + } + + set closed(val) { + if (val != this._closed) { + this._closed = val; + this.draw(); + } + } + } + + class GeoShape extends GeoGraphics { + clone() { + return new GeoShape(this.coordinates, this._cloneOptions) + } + + _manipulatePoints(func) { + this.constructor._manipulatePoints(this.points, func); + } + + /** + * Mets the requirements for the _manipulatePointsMethod(). + * Primarily used in subclasses to get the processing steps from + * their superclass. + * + * @static + * @protected + * @param {Array.} points - The points array that shold be manipulated. + * @param {function} func - The function that changes the single point value. Has to return a new point. + * @memberof GeoShape + */ + static _manipulatePoints(points, func) { + points.forEach((pointArray, arrIdx) => { + pointArray.forEach((point, idx) => { + points[arrIdx][idx] = func(point); + }); + }); + } + + _getPoints() { + return this.constructor._getPointsFrom(this.points) + } + + /** + * Returns all points of a polygon array. + * Useful for when getting sub polygons in child class. + */ + static _getPointsFrom(shape) { + let concatArray = []; + shape.forEach(array => { + concatArray = concatArray.concat(array); + }); + + return concatArray + } + + _adaptCoordinates(map) { + let val = this.constructor._adaptPoint(this.coordinates, map); + return val + } + + static _adaptPoint(coordinates, map) { + return coordinates.map(array => { + return array.map(point => { + return map.coordinatesToPoint(point) + }) + }) + } + + _draw() { + this._drawFrom(this.points); + this.graphics.position = this.position; + } + + _drawFrom(shape) { + const { polygon, hole } = this.constructor._pointsToShape(shape); + this._drawShape(polygon, hole); + } + + /** + * Separates the points array into it's two parts: + * - the solid polygon + * - a hole that is cut into the polygon (optional) + * + * @private + * @returns {object} - Returns an object containing the polygon and the hole in the form of {polygon: [...PIXI.Point], hole: [...PIXI.Point]} + */ + static _pointsToShape(points) { + let polygon = GeoShape._transformToPIXI(points[0]); + let hole = points[1] ? GeoShape._transformToPIXI(points[1]) : []; + return { polygon, hole } + } + + /** + * Transform an array of poins into an array of PIXI.Points. + * Note: When creating PIXI.Polygons, for some reason the points + * need to be a PIXI.Points object (at least the first one). + * + * @param {array} points - Points in the form of {x:a,y:b} + * @returns An array of PIXI.Points + * @memberof GeoPolygon + */ + static _transformToPIXI(points = []) { + let polygon = []; + points.forEach(point => { + polygon.push(new PIXI.Point(point.x, point.y)); + }); + + return polygon + } + + /** + * Draws a single shape onto the graphics object. + * Useful when being called from subclass. + * + * @protected + * @param {Array.} polygon - An array of PIXI.Points for drawing a polygon. + * @param {Array.} [hole=[]] - An Array of PIXI.Points for cutting a hole into the polygon + * @memberof GeoShape + */ + _drawShape(polygon, hole = []) { + // We save the fill specified in the onDraw event handler. + // + // Consider: Maybe it would be a good idea to add a 'onHoleDraw' + // callback to make the hole customizable. Maybe you want + // to fill it with a different color or an mediocre alpha value. + // then feel free to implement it. + let { fill, alpha } = PIXIUtils.saveFill(this.graphics); + + /** + * This may seem redundant, but it's required + * to make the hole clickable. + * + * It was a bit confusing, so I made a CodePen + * for it: https://codepen.io/Ukmasmu/pen/WJEaoK + */ + if (hole.length > 0) { + this.graphics.beginFill(0x0000ff, 0.0000001); + this.graphics.drawPolygon(hole); + } + + this.graphics.beginFill(fill, alpha); + this.graphics.drawPolygon(polygon); + + if (hole.length > 0) { + this.graphics.beginHole(); + this.graphics.drawPolygon(hole); + this.graphics.endHole(); + } + } + } + + class GeoMultiShape extends GeoShape { + static _manipulatePoints(points, func) { + points.forEach(shape => { + GeoShape._manipulatePoints(shape, func); + }); + } + + static _getPointsFrom(multiShapeArray) { + let points = []; + multiShapeArray.forEach(shape => { + points = points.concat(GeoShape._getPointsFrom(shape)); + }); + return points + } + + static _adaptPoint(coordinates, map) { + let points = []; + coordinates.forEach(shape => { + let adaptedPoint = GeoShape._adaptPoint(shape, map); + points.push(adaptedPoint); + }); + return points + } + + calculateLocation() { + let coms = []; + this.coordinates.forEach(polygon => { + coms.push(GeoGraphics.calculateCenterOfMass(polygon)); + }); + + return GeoGraphics.calculateCenterOfMass(coms) + } + + _drawFrom(multiShape) { + multiShape.forEach(shape => { + super._drawFrom(shape); + }); + } + } + + class PIXIUtils { + /* + * Transform a pixi text to it's actual screensize, + * ignoring it's local transforms + */ + static toScreenFontSize(pixiText, fontSize = null) { + pixiText._recursivePostUpdateTransform(); + + let normalizedScale = { + x: pixiText.scale.x / pixiText.transform.worldTransform.a, + y: pixiText.scale.x / pixiText.transform.worldTransform.d + }; + + pixiText.scale = { x: normalizedScale.x, y: normalizedScale.y }; + if (fontSize) pixiText.style.fontSize = fontSize; + } + + static saveFill(graphics) { + return { + fill: graphics.fill.color, + alpha: graphics.fill.alpha + } + } + } + + /** + * Helper class for handling GeoJson data. + * As specified by [RFC7946](https://tools.ietf.org/html/rfc7946). + * + * @static + * @export + * @class GeoJsonGraphics + * @extends {GeoGraphics} + */ + class GeoJson { + static isLineType(type) { + return type == 'LineString' || type == 'MultiLineString' + } + + static _getFormatStringOfType(type) { + let description = ', where p represents a coordinate point'; + let format = ''; + switch (type) { + case 'Point': + format = 'p'; + break + case 'LineString': + format = '[p1,p2,p3,...,pn]'; + break + case 'Polygon': + format = '[ [p1,p2,...,pn], [h1,h2,...,hn] ]'; + description += ' and h also represents a coordinate point, but it creates a hole.'; + break + default: + format = type; + description = " is either not valid or not yet implemented in method '_getFormatStringOfType(type)'."; + } + + return format + ' - ' + description + } + + static get types() { + return ['Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon'] + } + + static unwrapFeatureCollection(featureCollection) { + if (featureCollection.features == null) { + console.error( + 'Error at GeoJson.unrwapFeatureCollection(collection): Provided object was no valid FeatureCollection.', + featureCollection + ); + return + } + + let list = []; + + featureCollection.features.forEach(feature => { + let { type, coordinates } = feature; + + if (feature.type.toLowerCase() == 'feature') { + ({ type, coordinates } = feature.geometry); + } + + list.push({ type, coordinates }); + // console.log({type, coordinates}) + }); + + return list + } + + static validateAndConvert(type, coordinates) { + if (!GeoJson.validateType(type)) throw new GeoJson.InvalidTypeError(type) + else { + if (GeoJson.validateCoordinates(type, coordinates)) { + let converted = GeoJson.convert(type, coordinates); + return converted + } else { + console.error( + `Coordinates are invalid. They must be in format of type '${type} - ${GeoJson._getFormatStringOfType( + type + )}'` + ); + } + } + } + + static validateType(type) { + return GeoJson.types.indexOf(type) != -1 + } + + static validateCoordinates(type, coordinates) { + let valid = false; + + switch (type) { + case 'Point': + valid = !!GeoJson.validateAndConvertPoint(coordinates); + break + case 'LineString': + valid = GeoJson.validateLineString(coordinates); + break + case 'Polygon': + valid = GeoJson.validatePolygon(coordinates); + break + case 'MultiPolygon': + valid = true; + for (let i = 0; i < coordinates.length; i++) { + if (!GeoJson.validatePolygon(coordinates[i])) { + valid = false; + break + } + } + break + case 'MultiPoint': + case 'MultiLineString': + default: + console.error('Type was not yet implemented: ', type); + } + return valid + } + + /** + * Validates a point if it's an valid coordinate. + * + * NOTE: Here we are not following the GeoJSON standard. + * For convenience multiple forms of representing a coordinate are + * considered valid. A complete list is provided in the GeoUtils. + * + * @param {object} point - The point that is tested for validity. + * @returns + * @memberof GeoJson + */ + static validateAndConvertPoint(point) { + return GeoUtils.validateCoordinate(point) + } + + /** + *Validates if the given points represent a 'LineString'. + * + * @param {array} points - A list of coordinates that represent a line. + * @returns {boolean} - Returns true, if the array is in formes as: [x1,x2,x3,...,xn]. Where x# represent a valid coordinate. + * @memberof GeoJson + */ + static validateLineString(points) { + let valid = false; + if (Array.isArray(points)) valid = points.every(GeoJson.validateAndConvertPoint); + return valid + } + + static validatePolygon(points) { + let valid = false; + if ((Array.isArray(points) && points.length >= 1) || points.length <= 2) + valid = points.every(this.validateLineString); + return valid + } + + static convert(type, coordinates) { + let converted = null; + switch (type) { + case 'Point': + converted = GeoJson.validateAndConvertPoint(coordinates); + break + case 'LineString': + converted = GeoJson._convertLineString(coordinates); + break + case 'Polygon': + converted = GeoJson._convertPolygon(coordinates); + break + case 'MultiPolygon': + converted = GeoJson._convertMultiPolygon(coordinates); + break + default: + throw new GeoJson.InvalidTypeError(type) + } + + return converted + } + + static _convertLineString(coordinates) { + return coordinates.map(point => { + return GeoJson.validateAndConvertPoint(point) + }) + } + + static _convertPolygon(coordinates) { + return coordinates.map(shape => { + return GeoJson._convertLineString(shape) + }) + } + static _convertMultiPolygon(coordinates) { + return coordinates.map(polygon => { + return GeoJson._convertPolygon(polygon) + }) + } + } + + GeoJson.InvalidTypeError = class extends Error { + constructor(type) { + super(`The requested Type was not implemented: ${type}.`); + } + }; + + /** + * GeoUtils contains a collection of useful functions when working with maps. + * + * @static + */ + class GeoUtils { + static transformToGeoGraphics(list) { + let geographicsList = []; + list.forEach(item => { + if (item.type == 'FeatureCollection') { + item.features.forEach(feature => { + let { type, coordinates } = feature; + + if (type.toLowerCase() == 'feature') ({ type, coordinates } = feature.geometry); + + coordinates = GeoJson.validateAndConvert(type, coordinates); + + let geographics = this.fromGeoJsonToGeoGraphics(type, coordinates); + if (geographics) geographicsList.push(geographics); + }); + } else { + let geo = fromGeoJsonToGeoGraphics(item.type, item.geometry); + if (geo) geographicsList.push(geo); + } + }); + return geographicsList + } + + static resolveFeatureCollection(collection) { + if (!collection.features) { + console.error( + 'Error in GeoUtils.resolveFeatureCollection(colelction): Passed parameter was no feature collection.', + collection + ); + return + } + let geojson = []; + collection.features.forEach(feature => { + let { type, coordinates } = feature; + + if (feature.type == feature) { + coordinates = feature.geometry.coordinates; + type = feature.geometry.type; + } + + geojson.push({ type, coordinates }); + }); + + return geojson + } + + /** + * Creates the appropriate GeoGraphics object from a GeoJson type. + * The coordinates need to be alread in the appropriate PIXI format. + * If not already - this can be achieved by calling 'GeoJson.validateAndConvert(type, points)' + * beforehand. + * + * @static + * @param {string} type - Any of the GeoJson types ('Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon'). + * @param {array} coordinates - Array of coordinates that fit the type. The positions within these must be in PIXI format: {x:a, y:b}. + * @returns {GeoGraphics} - Returns a GeoGraphics object. If the conversion fails, it returns null. + * @memberof GeoGraphics + */ + static fromGeoJsonToGeoGraphics(type, coordinates, opts = {}) { + let geographics = null; + + /** + * TODO: REMOVE + * Just for initial debugging purposes + */ + Object.assign(opts, { + debug: true + }); + + switch (type) { + case 'Polygon': + geographics = new GeoShape(coordinates, opts); + break + + case 'MultiPolygon': + geographics = new GeoMultiShape(coordinates, opts); + break + case 'LineString': + geographics = new GeoLine(coordinates, opts); + break + case 'Point': + geographics = new GeoPoint(coordinates, opts); + break + default: + console.log('Could not create Geographics for type: ' + type + '. This was not implemented yet.'); + //Nothing + } + + return geographics + } + + /** + * Validates an object, if it's an coordinate object. + * Coordinate objects can be in the following forms: + * + * [lng, lat] - GeoJSON array format. !Attention lng and lat are swapped compared to the x,y format. + * {x: lat, y: lng} - 'correct' PIXI.format + * {lat, lng} + * {latitude: lat, longitude: lng} + * + * @static + * @param {object / array} coordinate - Coordinate to be tested, if it is an valid coordinate. + * @returns - Returns the coordinate properly transformed. If transformation was not possible, it returns null. + * @memberof GeoGraphics + */ + static validateCoordinate(coordinate) { + if (Array.isArray(coordinate)) { + if (coordinate.length == 2 && typeof coordinate[0] == 'number' && typeof coordinate[1] == 'number') + return new PIXI.Point(coordinate[1], coordinate[0]) + else return false + } else { + const latvalues = ['x', 'lat', 'latitude']; + const lngvalues = ['y', 'lng', 'longitude']; + + let result = {}; + for (let key of Object.keys(coordinate)) { + let target = key.toLowerCase(); + if (latvalues.indexOf(target) !== -1) result.x = coordinate[key]; + else if (lngvalues.indexOf(target) !== -1) result.y = coordinate[key]; + } + + if (result.hasOwnProperty('x') && result.hasOwnProperty('y')) return new PIXI.Point(result.x, result.y) + else return false + } + } + } + + /** + * The Overlay-Class shall provide and easy to use frameworkd + * of marking points of interest on a map, using shapes, geometries, + * items or text. + * + * It's highly configurable and adaptable to a variety of scenarios. + * + */ + + class Overlay { + /** + *Creates an instance of Overlay. + * @param {*} [{ + * name = "unnamed", {string} + * connector = false, + * connectorType = "line", + * location = null, + * label = false, + * labelType = null, + * labelLocation = null, + * labelAlignment = "center", + * labelVerticalAlign = "underneath", + * labelTarget = null, + * shape = "geometry", {string} - Defines the shape to be used: geometry, circle, rectangle + * geometry = null, + * geometryType = "point", + * geometryFallback = "circle", + * informationPopup = "infocard", + * + * }={}] + * @memberof Overlay + */ + constructor(opts = {}) { + let defaultTextStyle = new PIXI.TextStyle(); + + Object.assign( + this, + { + type: 'auto', + name: 'unnamed', + + borderColor: 0x000000, + borderThickness: 5, + + disabledAlpha: 1, + disabledColor: 0xaa1111, + disabledBorderColor: 0x000000, + enabled: true, + labelScale: 1, + location: null, + label: false, + labelType: null, + labelLocation: null, + labelAlignment: 'center', + labelVerticalAlign: 'underneath', + labelSpacing: 10, + labelTarget: null, + geometry: null, + information: '', + informationPath: null, + informationType: 'popup', + icon: null, + iconAlpha: 1, + iconAnchor: { x: 0.5, y: 0.5 }, + iconColor: 0xffffff, + iconScale: 1, + iconOffset: { x: 0, y: 0 }, + items: [], + color: 0xff00ff, + fillAlpha: 1, + size: 50, + scale: 1, + rescale: false, + popoverOffset: { x: 0, y: 0 }, + + /** + * The following Attributes are taken from the TextStyle class + * of PIXI. All can be overwritten in the overlay's JSON file. + */ + labelMultiLineAlign: defaultTextStyle.align, + labelBreakWords: defaultTextStyle.breakWords, + labelDropShadow: defaultTextStyle.dropShadow, + labelDropShadowAlpha: defaultTextStyle.dropShadowAlpha, + labelDropShadowAngle: defaultTextStyle.dropShadowAngle, + labelDropShadowBlur: defaultTextStyle.dropShadowBlur, + labelDropShadowColor: defaultTextStyle.dropShadowColor, + labelDropShadowDistance: defaultTextStyle.dropShadowDistance, + fontColor: 0x000000, + fontFamily: defaultTextStyle.fontFamily, + fontSize: defaultTextStyle.fontSize, + fontStyle: defaultTextStyle.fontStyle, + fontWeight: defaultTextStyle.fontWeight, + labelStroke: defaultTextStyle.stroke, + labelStrokeThickness: defaultTextStyle.strokeThickness, + wordWrap: defaultTextStyle.wordWrap, + wordWrapWidth: defaultTextStyle.wordWrapWidth, + labelFill: defaultTextStyle.fill, + labelFillGradientType: defaultTextStyle.fillGradientType, + labelFillGradientStops: defaultTextStyle.fillGradientStops, + fontVariant: defaultTextStyle.fontVariant, + labelLeading: defaultTextStyle.leading, + letterSpacing: defaultTextStyle.letterSpacing, + labelLineHeight: defaultTextStyle.lineHeight, + labelLineJoin: defaultTextStyle.lineJoin, + labelMiterLimit: defaultTextStyle.miterLimit, + labelPadding: defaultTextStyle.padding, + labelTrim: defaultTextStyle.trim, + textBaseline: defaultTextStyle.textBaseline, + labelWhiteSpace: defaultTextStyle.whiteSpace + }, + opts + ); + } + + get textureParameters() { + return ['icon'] + } + + setTexture(parameter, key, path) { + let obj = (this[parameter] = {}); + obj._key = key; + obj._path = path; + obj._texture = null; + + obj.initialize = function(texture) { + if (!texture) console.error('Could not initialize texture. Invalid value provided: ', texture); + this._texture = texture; + }.bind(obj); + + obj.__defineGetter__('texture', function() { + if (!obj._texture) { + console.error('Texture was not yet initialized! ', obj); + } else { + return obj._texture + } + }); + + obj.__defineGetter__('key', () => { + return obj._key + }); + obj.__defineGetter__('path', () => { + return obj._path + }); + } + + apply(parameter, func, obj = null) { + if (obj == null) return + if (Array.isArray(obj)) { + obj.forEach(child => { + if (typeof child == 'object') this.apply(parameter, func, child); + }); + } else { + for (let [key, val] of Object.entries(obj)) { + if (val == null) continue + if (key == parameter) { + func.call(obj, val); + } + + if (typeof val == 'object') { + this.apply(parameter, func, val); + } + } + } + } + + /** + * Creates an overlay from the current Object. + * + * @param {settings} object - Object to configure the creation process. + * @param {settings.excludeItems} function - You may provide a function, that excludes elements selectively. The function must return true for items to exclude them. + * @param {settings.adjustItems} function - You may adjust the created PIXI items, using the adjustItems function. + * @returns {GeoLayer} - Returns the created GeoLayer. + * @memberof Overlay + */ + create({ + excludeItems = item => { + return false + }, + informationCallback = null, + adjustItems = null + } = {}) { + const name = this.name[0].toUpperCase() + this.name.slice(1).toLowerCase() + ' Overlay'; + let geoLayer = new GeoLayer({ name }); + if (this.rescale) geoLayer.rescale = this.rescale; + this.items.forEach(item => { + if (!excludeItems(item)) { + if (adjustItems) { + adjustItems(item); + } + + item.overlay = this; + let graphics = this.createItem(item, informationCallback); + geoLayer.place(graphics); + } + }); + return geoLayer + } + + getItemProperty(item, property) { + let propertyValue = null; + const propertyExistsOnItem = item[property] !== undefined; + const propertyExistsOnOverlay = this[property] !== undefined; + + if (propertyExistsOnItem) propertyValue = item[property]; + else if (propertyExistsOnOverlay) propertyValue = this[property]; + else { + console.error(`Invalid property: ${property} on`, item); + } + + propertyValue = this._resolveReference(item, propertyValue); + + return propertyValue + } + + _isReference(property) { + if (property && typeof property == 'object' && property.hasOwnProperty('@property')) return true + else return false + } + + _resolveReference(item, property) { + if (this._isReference(property)) { + let referencedProperty = property['@property']; + return this.getItemProperty(item, referencedProperty) + } else { + return property + } + } + + findAllTextures() { + let textures = []; + + this.textureParameters.forEach(parameter => { + if (this.hasOwnProperty(parameter) && this[parameter] != null) { + textures.push(this[parameter]); + } + + this.items.forEach(item => { + if (item.hasOwnProperty(parameter) && item[parameter] != null) { + textures.push(item[parameter]); + } + }); + }); + + return textures + } + + selectTextures(textures) { + this.textureParameters.forEach(parameter => { + if (this.hasOwnProperty(parameter) && this[parameter]) { + let path = this[parameter]; + const texture = textures.get(path); + if (!texture) console.error('Texture could not be selected: ', path); + this[parameter] = { + texture, + path + }; + } + this.items.forEach((item, idx) => { + if (this.items[idx].hasOwnProperty(parameter) && this.items[idx][parameter]) { + const path = this.items[idx][parameter]; + const texture = textures.get(path); + + this.items[idx][parameter] = { + path, + texture + }; + } + }); + }); + } + + createItem(item, informationCallback = null) { + let geographics; + + /** + * Use a factory to draw the items, if a type is specified. + */ + if (this.type != 'auto') { + let geographicsFactory = Overlay.requestFactory(this.type); + if (!geographicsFactory) + console.error( + 'Invalid Overlay Mode: ' + + this.type + + '. Fallback to auto mode. Overlaymodes must be registeres beforehand. Valid modes are: ' + + Overlay.listFactories().join(',') + + '.' + ); + else { + let actualItem = Object.assign({}, this, item); + geographics = geographicsFactory(actualItem); + } + } + + /** + * Otherwise draw the default geographics. + */ + if (!geographics) { + if (item.geometry == null || item.geometry == undefined) { + if (item.location) { + geographics = this._drawPoint(item, informationCallback); + const scale = this.getItemProperty(item, 'scale'); + geographics.graphics.scale.set(scale, scale); + } else console.warn("Item could not be drawn. Specify a 'location' or a 'geometry'.", item); + } else { + geographics = this._drawGeometry(item); + this._drawRoutine(geographics, item, informationCallback); + } + + this._drawLabel(item, geographics); + } + + return geographics + } + + _drawLabel(item, geographics) { + let label = this.getItemProperty(item, 'label'); + if (label) { + let textStyle = this._gatherFontStyle(item); + let text = new PIXI.Text(label, textStyle); + + let labelScale = this.getItemProperty(item, 'labelScale'); + text.scale.set(labelScale, labelScale); + + let position = new PIXI.Point(); + + let align = this.getItemProperty(item, 'labelAlignment'); + if (align == 'left'); + else if (align == 'center') position.set(text.position.x - text.width / 2, text.position.y); + else if (align == 'right') position.set(text.position.x - text.width, text.position.y); + else this._logPropertyNotImplemented('labelAlignment', align); + + let verticalAlign = this.getItemProperty(item, 'labelVerticalAlignment'); + if (verticalAlign == 'underneath') { + let size = this.getItemProperty(item, 'size'); + let scale = this.getItemProperty(item, 'scale'); + + let labelSpacing = this.getItemProperty(item, 'labelSpacing'); + + position.y += size * scale + labelSpacing; + } else if (verticalAlign == 'above') { + let size = this.getItemProperty(item, 'size'); + let scale = this.getItemProperty(item, 'scale'); + + let labelSpacing = this.getItemProperty(item, 'labelSpacing'); + + position.y -= size * scale + text.height + labelSpacing; + } else this._logPropertyNotImplemented('labelVerticalAlignment', verticalAlign); + + text.position.set(position.x, position.y); + + geographics.graphics.addChild(text); + } + } + + _gatherFontStyle(item) { + return { + align: this.getItemProperty(item, 'labelMultiLineAlign'), + breakWords: this.getItemProperty(item, 'labelBreakWords'), + dropShadow: this.getItemProperty(item, 'labelDropShadow'), + dropShadowAlpha: this.getItemProperty(item, 'labelDropShadowAlpha'), + dropShadowAngle: this.getItemProperty(item, 'labelDropShadowAngle'), + dropShadowBlur: this.getItemProperty(item, 'labelDropShadowBlur'), + dropShadowColor: this.getItemProperty(item, 'labelDropShadowColor'), + dropShadowDistance: this.getItemProperty(item, 'labelDropShadowDistance'), + fontFamily: this.getItemProperty(item, 'fontFamily'), + fontSize: this.getItemProperty(item, 'fontSize'), + fontStyle: this.getItemProperty(item, 'fontStyle'), + fontWeight: this.getItemProperty(item, 'fontWeight'), + stroke: this.getItemProperty(item, 'labelStroke'), + strokeThickness: this.getItemProperty(item, 'labelStrokeThickness'), + wordWrap: this.getItemProperty(item, 'wordWrap'), + wordWrapWidth: this.getItemProperty(item, 'wordWrapWidth'), + + fill: this.getItemProperty(item, 'labelFill'), + fillGradientType: this.getItemProperty(item, 'labelFillGradientType'), + fillGradientStops: this.getItemProperty(item, 'labelFillGradientStops'), + fontVariant: this.getItemProperty(item, 'fontVariant'), + leading: this.getItemProperty(item, 'labelLeading'), + letterSpacing: this.getItemProperty(item, 'letterSpacing'), + lineHeight: this.getItemProperty(item, 'labelLineHeight'), + lineJoin: this.getItemProperty(item, 'labelLineJoin'), + miterLimit: this.getItemProperty(item, 'labelMiterLimit'), + padding: this.getItemProperty(item, 'labelPadding'), + trim: this.getItemProperty(item, 'labelTrim'), + textBaseline: this.getItemProperty(item, 'textBaseline'), + whiteSpace: this.getItemProperty(item, 'labelWhiteSpace') + } + } + + _drawGeometry(item) { + let multiPolygonArray = []; + + if (Array.isArray(item.geometry)) + item.geometry.forEach(geometry => { + let polygon = this._drawPolygon(geometry); + if (polygon != null && polygon != []) multiPolygonArray.push(polygon); + }); + else { + let polygon = this._drawPolygon(item.geometry); + if (polygon != null && polygon != []) multiPolygonArray.push(polygon); + } + const overlay = this; + return new GeoMultiShape(multiPolygonArray, { + onDraw: function() { + overlay._fill.call(overlay, this, item); + } + }) + } + + _drawPolygon(geometry) { + let polygon = []; + if (geometry.type == 'Polygon') { + if (GeoJson.validateCoordinates(geometry.type, geometry.coordinates)) { + polygon = GeoJson.convert(geometry.type, geometry.coordinates); + } else console.error(`Could not validate coordinates for type '${geometry.type}'.`, geometry.coordinates); + } else { + console.warn(`Geometry of type ${geometry.type} is not implemented yet, hence it was ignored.`); + } + + return polygon + } + + _drawRoutine(geographics, item, informationCallback = null) { + this._createIcon(geographics, item); + + const enabled = this.getItemProperty(item, 'enabled'); + if (enabled) this._createInformation(geographics, item, informationCallback); + } + + _createIcon(geographics, item) { + let icon = this.getItemProperty(item, 'icon'); + if (icon) { + if (icon.texture) { + let sprite = new PIXI.Sprite(icon.texture); + + const iconAnchor = this.getItemProperty(item, 'iconAnchor'); + sprite.anchor.set(iconAnchor.x, iconAnchor.y); + + const iconScale = this.getItemProperty(item, 'iconScale'); + if (iconScale) sprite.scale.set(iconScale, iconScale); + + const iconOffset = this.getItemProperty(item, 'iconOffset'); + if (iconOffset && iconOffset.x != null && iconOffset.y != null) + sprite.position.set(iconOffset.x, iconOffset.y); + + const iconColor = this.getItemProperty(item, 'iconColor'); + if (iconColor) sprite.tint = iconColor; + + const iconAlpha = this.getItemProperty(item, 'iconAlpha'); + if (iconAlpha) sprite.alpha = iconAlpha; + + geographics.graphics.addChild(sprite); + } + } + } + + _createInformation(geographics, item, callback = null) { + if (item.information) { + /** + * SO: The overlay class is quite convenient. + * But managing the information creation solely inside this + * class restricts us massively. + * + * Maybe a restructuring would be good, that we can handle + * the creation of information with a callback and can adapt + * to any occuring situation. + * + * e.g. + * + * overlay.informationHandler((geo, type, item)=>{ + * switch(type){ + * case "popup": + * createPopup(item.information) + * break; + * + * .... + * } + * }) + */ + + geographics.graphics.interactive = true; + const informationType = this.getItemProperty(item, 'informationType'); + + if (callback) callback.call(this, geographics, informationType, item); + else { + this.defaulInformationCallback(geographics, informationType, item); + } + } + } + + defaulInformationCallback(geographics, informationType, item) { + // Make the items interactive. + geographics.graphics.interactive = true; + geographics.graphics.on('pointerdown', () => { + if (!geographics.active) { + geographics.active = true; + + // Create and place the popup. + let popup = new Popup({ + header: item.name, + content: item.information, + maxWidth: 350 + }); + + popup.position.set( + geographics.graphics.position.x - popup.width / 2, + geographics.graphics.position.y - popup.height - 20 + ); + + geographics.graphics.parent.addChild(popup); + + // Remove the items after n milliseconds. + const removeTime = 2000; + setTimeout(() => { + geographics.active = false; + popup.parent.removeChild(popup); + }, removeTime); + } + }); + } + + _fill(geographics, item) { + const enabled = this.getItemProperty(item, 'enabled'); + const color = enabled ? this.getItemProperty(item, 'color') : this.getItemProperty(item, 'disabledColor'); + let alpha = enabled ? this.getItemProperty(item, 'fillAlpha') : this.getItemProperty(item, 'disabledAlpha'); + geographics.graphics.beginFill(color, alpha); + } + + _drawPoint(item, informationCallback = null) { + const overlay = this; + let geographic = new GeoPoint(item.location, { + onDraw: function() { + overlay._fill.call(overlay, this, item); + const size = overlay.getItemProperty(item, 'size') ? overlay.getItemProperty(item, 'size') : 0; + this.graphics.drawCircle(0, 0, size); + overlay._drawRoutine.call(overlay, this, item, informationCallback); + } + }); + return geographic + } + + _logPropertyNotImplemented(property, value) { + console.error(`Value '${value}' is not yet implemented in property '${property}'.`); + } + } + + Overlay.Modes = { auto: null }; + + Overlay.createFactory = function(name, geographicsFactory) { + if (Overlay.Modes.hasOwnProperty(name)) + console.warn(`The mode ${name} was already implemented and was overwritten!`); + Overlay.Modes[name] = geographicsFactory; + }; + + Overlay.requestFactory = function(name) { + return Overlay.Modes[name] + }; + + Overlay.listFactories = function() { + let modes = []; + for (let key of Object.keys(Overlay.Modes)) { + modes.push(key); + } + return modes + }; + /* Needed to ensure that rollup.js includes class definitions and the classes are visible inside doctests. */ @@ -16843,4 +22073,34 @@ window.FontInfo = FontInfo; window.Text = Text; + window.MapData = MapData; + window.DeepZoomMapData = DeepZoomMapData; + + window.GeoMap = GeoMap; + window.ImageMap = ImageMap; + window.DeepZoomMap = DeepZoomMap; + window.Projection = { + Mercator, + Robinson + }; + + window.MapView = MapView; + + window.MapApp = MapApp; + + window.AdvancedScatterContainer = AdvancedScatterContainer; + window.RigidContainer = RigidContainer; + window.CompactScatter = CompactScatter; + window.CoverScatter = CoverScatter; + window.GeoLayer = GeoLayer; + window.MapLayer = MapLayer; + + window.GeoGraphics = GeoGraphics; + window.GeoPoint = GeoPoint; + window.GeoLine = GeoLine; + window.GeoShape = GeoShape; + window.GeoMultiShape = GeoMultiShape; + + window.Overlay = Overlay; + }()); diff --git a/lib/doctest.js b/lib/doctest.js index 679eb19..8fa87b9 100755 --- a/lib/doctest.js +++ b/lib/doctest.js @@ -90,7 +90,7 @@ export default class Doctest { let pre = document.createElement('pre') // 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.split('\n') + 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(')) { diff --git a/lib/examples/maps.json b/lib/examples/maps.json new file mode 100644 index 0000000..37909ca --- /dev/null +++ b/lib/examples/maps.json @@ -0,0 +1,105 @@ +{ + "osm": { + "projection": "mercator", + "type": "deepzoom", + "tiles": { + "clip": { + "minLevel": 0, + "maxLevel": 20, + "startCol": 275200, + "startRow": 180992, + "bounds": { + "min": [ + 48.458353, + 8.96484374976547 + ], + "max": [ + 48.5747899110263, + 9.14062499976523 + ] + } + }, + "tileSize": 512, + "format": "png", + "overlap": 0, + "type": "map", + "height": 131072, + "width": 131072, + "path": "./var/stadtplan/2018", + "urlTileTemplate": "{path}/{level}/{row}/{column}.{format}" + }, + "stadtplan": { + "projection": "mercator", + "type": "deepzoom", + "icon": "./var/img/map_thumbnails/stadtplan.jpg", + "tiles": { + "compression": [ + "dds" + ], + "clip": { + "minLevel": 12, + "maxLevel": 20, + "startCol": 275200, + "startRow": 180992, + "bounds": { + "min": [ + 48.458353, + 8.96484374976547 + ], + "max": [ + 48.5747899110263, + 9.14062499976523 + ] + } + }, + "tileSize": 512, + "format": "png", + "overlap": 0, + "type": "map", + "height": 131072, + "width": 131072, + "path": "./var/stadtplan/2018", + "urlTileTemplate": "{path}/{level}/{row}/{column}.{format}" + }, + "options": { + "cover": true + } + }, + "luftbild": { + "projection": "mercator", + "type": "deepzoom", + "icon": "./var/img/map_thumbnails/luftbild.jpg", + "tiles": { + "compression": [ + "dds" + ], + "clip": { + "minLevel": 12, + "maxLevel": 20, + "startCol": 275200, + "startRow": 180992, + "bounds": { + "min": [ + 48.458353, + 8.96484374976547 + ], + "max": [ + 48.5747899110263, + 9.14062499976523 + ] + } + }, + "tileSize": 512, + "format": "png", + "overlap": 0, + "type": "map", + "height": 131072, + "width": 131072, + "path": "./var/luftbild/2018", + "urlTileTemplate": "{path}/{level}/{row}/{column}.{format}" + }, + "options": { + "cover": true + } + } +} \ No newline at end of file diff --git a/lib/interaction.js b/lib/interaction.js index 830af1e..c97d35f 100755 --- a/lib/interaction.js +++ b/lib/interaction.js @@ -999,6 +999,7 @@ export class InteractionMapper extends InteractionDelegate { if (found != null) { this.interaction.addTarget(key, found) } + console.log(this.target) } let size = this.interaction.current.size let limit = this.logInteractionsAbove diff --git a/lib/pixi/assets/maps/osm/0/0/0.png b/lib/pixi/assets/maps/osm/0/0/0.png new file mode 100644 index 0000000..76ea931 Binary files /dev/null and b/lib/pixi/assets/maps/osm/0/0/0.png differ diff --git a/lib/pixi/assets/maps/osm/1/0/0.png b/lib/pixi/assets/maps/osm/1/0/0.png new file mode 100644 index 0000000..0298ddc Binary files /dev/null and b/lib/pixi/assets/maps/osm/1/0/0.png differ diff --git a/lib/pixi/assets/maps/osm/1/0/1.png b/lib/pixi/assets/maps/osm/1/0/1.png new file mode 100644 index 0000000..fea3ed5 Binary files /dev/null and b/lib/pixi/assets/maps/osm/1/0/1.png differ diff --git a/lib/pixi/assets/maps/osm/1/1/0.png b/lib/pixi/assets/maps/osm/1/1/0.png new file mode 100644 index 0000000..eff2af9 Binary files /dev/null and b/lib/pixi/assets/maps/osm/1/1/0.png differ diff --git a/lib/pixi/assets/maps/osm/1/1/1.png b/lib/pixi/assets/maps/osm/1/1/1.png new file mode 100644 index 0000000..0b4e5b1 Binary files /dev/null and b/lib/pixi/assets/maps/osm/1/1/1.png differ diff --git a/lib/pixi/assets/maps/osm/2/0/0.png b/lib/pixi/assets/maps/osm/2/0/0.png new file mode 100644 index 0000000..7351462 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/0/0.png differ diff --git a/lib/pixi/assets/maps/osm/2/0/1.png b/lib/pixi/assets/maps/osm/2/0/1.png new file mode 100644 index 0000000..8c0d88d Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/0/1.png differ diff --git a/lib/pixi/assets/maps/osm/2/0/2.png b/lib/pixi/assets/maps/osm/2/0/2.png new file mode 100644 index 0000000..d1bee3a Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/0/2.png differ diff --git a/lib/pixi/assets/maps/osm/2/0/3.png b/lib/pixi/assets/maps/osm/2/0/3.png new file mode 100644 index 0000000..b0fe63d Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/0/3.png differ diff --git a/lib/pixi/assets/maps/osm/2/1/0.png b/lib/pixi/assets/maps/osm/2/1/0.png new file mode 100644 index 0000000..e01e8b4 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/1/0.png differ diff --git a/lib/pixi/assets/maps/osm/2/1/1.png b/lib/pixi/assets/maps/osm/2/1/1.png new file mode 100644 index 0000000..4dbe0dd Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/1/1.png differ diff --git a/lib/pixi/assets/maps/osm/2/1/2.png b/lib/pixi/assets/maps/osm/2/1/2.png new file mode 100644 index 0000000..b80e596 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/1/2.png differ diff --git a/lib/pixi/assets/maps/osm/2/1/3.png b/lib/pixi/assets/maps/osm/2/1/3.png new file mode 100644 index 0000000..239a614 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/1/3.png differ diff --git a/lib/pixi/assets/maps/osm/2/2/0.png b/lib/pixi/assets/maps/osm/2/2/0.png new file mode 100644 index 0000000..f607ae0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/2/0.png differ diff --git a/lib/pixi/assets/maps/osm/2/2/1.png b/lib/pixi/assets/maps/osm/2/2/1.png new file mode 100644 index 0000000..40fd0c4 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/2/1.png differ diff --git a/lib/pixi/assets/maps/osm/2/2/2.png b/lib/pixi/assets/maps/osm/2/2/2.png new file mode 100644 index 0000000..d3a351e Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/2/2.png differ diff --git a/lib/pixi/assets/maps/osm/2/2/3.png b/lib/pixi/assets/maps/osm/2/2/3.png new file mode 100644 index 0000000..2aa3387 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/2/3.png differ diff --git a/lib/pixi/assets/maps/osm/2/3/0.png b/lib/pixi/assets/maps/osm/2/3/0.png new file mode 100644 index 0000000..dfb60f5 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/3/0.png differ diff --git a/lib/pixi/assets/maps/osm/2/3/1.png b/lib/pixi/assets/maps/osm/2/3/1.png new file mode 100644 index 0000000..24d22e7 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/3/1.png differ diff --git a/lib/pixi/assets/maps/osm/2/3/2.png b/lib/pixi/assets/maps/osm/2/3/2.png new file mode 100644 index 0000000..f37f9b7 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/3/2.png differ diff --git a/lib/pixi/assets/maps/osm/2/3/3.png b/lib/pixi/assets/maps/osm/2/3/3.png new file mode 100644 index 0000000..3e16239 Binary files /dev/null and b/lib/pixi/assets/maps/osm/2/3/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/0/0.png b/lib/pixi/assets/maps/osm/3/0/0.png new file mode 100644 index 0000000..f607ae0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/0/0.png differ diff --git a/lib/pixi/assets/maps/osm/3/0/1.png b/lib/pixi/assets/maps/osm/3/0/1.png new file mode 100644 index 0000000..c8d4f29 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/0/1.png differ diff --git a/lib/pixi/assets/maps/osm/3/0/2.png b/lib/pixi/assets/maps/osm/3/0/2.png new file mode 100644 index 0000000..670240d Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/0/2.png differ diff --git a/lib/pixi/assets/maps/osm/3/0/3.png b/lib/pixi/assets/maps/osm/3/0/3.png new file mode 100644 index 0000000..2f673c0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/0/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/0/4.png b/lib/pixi/assets/maps/osm/3/0/4.png new file mode 100644 index 0000000..3eaae5e Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/0/4.png differ diff --git a/lib/pixi/assets/maps/osm/3/0/5.png b/lib/pixi/assets/maps/osm/3/0/5.png new file mode 100644 index 0000000..4f32183 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/0/5.png differ diff --git a/lib/pixi/assets/maps/osm/3/0/6.png b/lib/pixi/assets/maps/osm/3/0/6.png new file mode 100644 index 0000000..a669fe3 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/0/6.png differ diff --git a/lib/pixi/assets/maps/osm/3/0/7.png b/lib/pixi/assets/maps/osm/3/0/7.png new file mode 100644 index 0000000..f607ae0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/0/7.png differ diff --git a/lib/pixi/assets/maps/osm/3/1/0.png b/lib/pixi/assets/maps/osm/3/1/0.png new file mode 100644 index 0000000..987193b Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/1/0.png differ diff --git a/lib/pixi/assets/maps/osm/3/1/1.png b/lib/pixi/assets/maps/osm/3/1/1.png new file mode 100644 index 0000000..35f7163 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/1/1.png differ diff --git a/lib/pixi/assets/maps/osm/3/1/2.png b/lib/pixi/assets/maps/osm/3/1/2.png new file mode 100644 index 0000000..e179288 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/1/2.png differ diff --git a/lib/pixi/assets/maps/osm/3/1/3.png b/lib/pixi/assets/maps/osm/3/1/3.png new file mode 100644 index 0000000..a9caff4 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/1/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/1/4.png b/lib/pixi/assets/maps/osm/3/1/4.png new file mode 100644 index 0000000..24fe5f7 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/1/4.png differ diff --git a/lib/pixi/assets/maps/osm/3/1/5.png b/lib/pixi/assets/maps/osm/3/1/5.png new file mode 100644 index 0000000..b5c3727 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/1/5.png differ diff --git a/lib/pixi/assets/maps/osm/3/1/6.png b/lib/pixi/assets/maps/osm/3/1/6.png new file mode 100644 index 0000000..b8f704d Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/1/6.png differ diff --git a/lib/pixi/assets/maps/osm/3/1/7.png b/lib/pixi/assets/maps/osm/3/1/7.png new file mode 100644 index 0000000..9ce86a8 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/1/7.png differ diff --git a/lib/pixi/assets/maps/osm/3/2/0.png b/lib/pixi/assets/maps/osm/3/2/0.png new file mode 100644 index 0000000..ccf039e Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/2/0.png differ diff --git a/lib/pixi/assets/maps/osm/3/2/1.png b/lib/pixi/assets/maps/osm/3/2/1.png new file mode 100644 index 0000000..8b86845 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/2/1.png differ diff --git a/lib/pixi/assets/maps/osm/3/2/2.png b/lib/pixi/assets/maps/osm/3/2/2.png new file mode 100644 index 0000000..8c3b0de Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/2/2.png differ diff --git a/lib/pixi/assets/maps/osm/3/2/3.png b/lib/pixi/assets/maps/osm/3/2/3.png new file mode 100644 index 0000000..59a2a01 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/2/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/2/4.png b/lib/pixi/assets/maps/osm/3/2/4.png new file mode 100644 index 0000000..90c83d4 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/2/4.png differ diff --git a/lib/pixi/assets/maps/osm/3/2/5.png b/lib/pixi/assets/maps/osm/3/2/5.png new file mode 100644 index 0000000..af6a45d Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/2/5.png differ diff --git a/lib/pixi/assets/maps/osm/3/2/6.png b/lib/pixi/assets/maps/osm/3/2/6.png new file mode 100644 index 0000000..86945a0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/2/6.png differ diff --git a/lib/pixi/assets/maps/osm/3/2/7.png b/lib/pixi/assets/maps/osm/3/2/7.png new file mode 100644 index 0000000..805d882 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/2/7.png differ diff --git a/lib/pixi/assets/maps/osm/3/3/0.png b/lib/pixi/assets/maps/osm/3/3/0.png new file mode 100644 index 0000000..2bd1a0a Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/3/0.png differ diff --git a/lib/pixi/assets/maps/osm/3/3/1.png b/lib/pixi/assets/maps/osm/3/3/1.png new file mode 100644 index 0000000..1a06a10 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/3/1.png differ diff --git a/lib/pixi/assets/maps/osm/3/3/2.png b/lib/pixi/assets/maps/osm/3/3/2.png new file mode 100644 index 0000000..1abfef5 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/3/2.png differ diff --git a/lib/pixi/assets/maps/osm/3/3/3.png b/lib/pixi/assets/maps/osm/3/3/3.png new file mode 100644 index 0000000..4899769 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/3/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/3/4.png b/lib/pixi/assets/maps/osm/3/3/4.png new file mode 100644 index 0000000..f2829e0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/3/4.png differ diff --git a/lib/pixi/assets/maps/osm/3/3/5.png b/lib/pixi/assets/maps/osm/3/3/5.png new file mode 100644 index 0000000..078a849 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/3/5.png differ diff --git a/lib/pixi/assets/maps/osm/3/3/6.png b/lib/pixi/assets/maps/osm/3/3/6.png new file mode 100644 index 0000000..e07a93c Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/3/6.png differ diff --git a/lib/pixi/assets/maps/osm/3/3/7.png b/lib/pixi/assets/maps/osm/3/3/7.png new file mode 100644 index 0000000..37dfa70 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/3/7.png differ diff --git a/lib/pixi/assets/maps/osm/3/4/0.png b/lib/pixi/assets/maps/osm/3/4/0.png new file mode 100644 index 0000000..f607ae0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/4/0.png differ diff --git a/lib/pixi/assets/maps/osm/3/4/1.png b/lib/pixi/assets/maps/osm/3/4/1.png new file mode 100644 index 0000000..2978a5a Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/4/1.png differ diff --git a/lib/pixi/assets/maps/osm/3/4/2.png b/lib/pixi/assets/maps/osm/3/4/2.png new file mode 100644 index 0000000..35d7204 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/4/2.png differ diff --git a/lib/pixi/assets/maps/osm/3/4/3.png b/lib/pixi/assets/maps/osm/3/4/3.png new file mode 100644 index 0000000..db47378 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/4/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/4/4.png b/lib/pixi/assets/maps/osm/3/4/4.png new file mode 100644 index 0000000..8708fc4 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/4/4.png differ diff --git a/lib/pixi/assets/maps/osm/3/4/5.png b/lib/pixi/assets/maps/osm/3/4/5.png new file mode 100644 index 0000000..df0d904 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/4/5.png differ diff --git a/lib/pixi/assets/maps/osm/3/4/6.png b/lib/pixi/assets/maps/osm/3/4/6.png new file mode 100644 index 0000000..2d35e71 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/4/6.png differ diff --git a/lib/pixi/assets/maps/osm/3/4/7.png b/lib/pixi/assets/maps/osm/3/4/7.png new file mode 100644 index 0000000..0b5f41d Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/4/7.png differ diff --git a/lib/pixi/assets/maps/osm/3/5/0.png b/lib/pixi/assets/maps/osm/3/5/0.png new file mode 100644 index 0000000..f607ae0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/5/0.png differ diff --git a/lib/pixi/assets/maps/osm/3/5/1.png b/lib/pixi/assets/maps/osm/3/5/1.png new file mode 100644 index 0000000..f607ae0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/5/1.png differ diff --git a/lib/pixi/assets/maps/osm/3/5/2.png b/lib/pixi/assets/maps/osm/3/5/2.png new file mode 100644 index 0000000..2935533 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/5/2.png differ diff --git a/lib/pixi/assets/maps/osm/3/5/3.png b/lib/pixi/assets/maps/osm/3/5/3.png new file mode 100644 index 0000000..72c2da7 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/5/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/5/4.png b/lib/pixi/assets/maps/osm/3/5/4.png new file mode 100644 index 0000000..f607ae0 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/5/4.png differ diff --git a/lib/pixi/assets/maps/osm/3/5/5.png b/lib/pixi/assets/maps/osm/3/5/5.png new file mode 100644 index 0000000..c172664 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/5/5.png differ diff --git a/lib/pixi/assets/maps/osm/3/5/6.png b/lib/pixi/assets/maps/osm/3/5/6.png new file mode 100644 index 0000000..5066069 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/5/6.png differ diff --git a/lib/pixi/assets/maps/osm/3/5/7.png b/lib/pixi/assets/maps/osm/3/5/7.png new file mode 100644 index 0000000..829d9ea Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/5/7.png differ diff --git a/lib/pixi/assets/maps/osm/3/6/0.png b/lib/pixi/assets/maps/osm/3/6/0.png new file mode 100644 index 0000000..27546eb Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/6/0.png differ diff --git a/lib/pixi/assets/maps/osm/3/6/1.png b/lib/pixi/assets/maps/osm/3/6/1.png new file mode 100644 index 0000000..8070b7c Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/6/1.png differ diff --git a/lib/pixi/assets/maps/osm/3/6/2.png b/lib/pixi/assets/maps/osm/3/6/2.png new file mode 100644 index 0000000..939e8cc Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/6/2.png differ diff --git a/lib/pixi/assets/maps/osm/3/6/3.png b/lib/pixi/assets/maps/osm/3/6/3.png new file mode 100644 index 0000000..b873c63 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/6/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/6/4.png b/lib/pixi/assets/maps/osm/3/6/4.png new file mode 100644 index 0000000..96164ff Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/6/4.png differ diff --git a/lib/pixi/assets/maps/osm/3/6/5.png b/lib/pixi/assets/maps/osm/3/6/5.png new file mode 100644 index 0000000..5d38c36 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/6/5.png differ diff --git a/lib/pixi/assets/maps/osm/3/6/6.png b/lib/pixi/assets/maps/osm/3/6/6.png new file mode 100644 index 0000000..be25aad Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/6/6.png differ diff --git a/lib/pixi/assets/maps/osm/3/6/7.png b/lib/pixi/assets/maps/osm/3/6/7.png new file mode 100644 index 0000000..48d5bb2 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/6/7.png differ diff --git a/lib/pixi/assets/maps/osm/3/7/0.png b/lib/pixi/assets/maps/osm/3/7/0.png new file mode 100644 index 0000000..ba57365 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/7/0.png differ diff --git a/lib/pixi/assets/maps/osm/3/7/1.png b/lib/pixi/assets/maps/osm/3/7/1.png new file mode 100644 index 0000000..ba57365 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/7/1.png differ diff --git a/lib/pixi/assets/maps/osm/3/7/2.png b/lib/pixi/assets/maps/osm/3/7/2.png new file mode 100644 index 0000000..ba57365 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/7/2.png differ diff --git a/lib/pixi/assets/maps/osm/3/7/3.png b/lib/pixi/assets/maps/osm/3/7/3.png new file mode 100644 index 0000000..ba57365 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/7/3.png differ diff --git a/lib/pixi/assets/maps/osm/3/7/4.png b/lib/pixi/assets/maps/osm/3/7/4.png new file mode 100644 index 0000000..ba57365 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/7/4.png differ diff --git a/lib/pixi/assets/maps/osm/3/7/5.png b/lib/pixi/assets/maps/osm/3/7/5.png new file mode 100644 index 0000000..ba57365 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/7/5.png differ diff --git a/lib/pixi/assets/maps/osm/3/7/6.png b/lib/pixi/assets/maps/osm/3/7/6.png new file mode 100644 index 0000000..ba57365 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/7/6.png differ diff --git a/lib/pixi/assets/maps/osm/3/7/7.png b/lib/pixi/assets/maps/osm/3/7/7.png new file mode 100644 index 0000000..ba57365 Binary files /dev/null and b/lib/pixi/assets/maps/osm/3/7/7.png differ diff --git a/lib/pixi/assets/maps/pixabay/europe-license.txt b/lib/pixi/assets/maps/pixabay/europe-license.txt new file mode 100644 index 0000000..5470570 --- /dev/null +++ b/lib/pixi/assets/maps/pixabay/europe-license.txt @@ -0,0 +1,20 @@ +author: Hans Braxmeier (https://pixabay.com/de/users/hans-2/) +url: https://pixabay.com/de/illustrations/karte-mitteleuropa-zentraleuropa-1804891/ +license: +Vereinfachte Pixabay Lizenz: + +Unsere Lizenz gibt Nutzern umfassende Freiheiten und Sicherheit - und gleichzeitig schützt sie die Bildautoren und unsere Community vor Mißbrauch. Die rechtliche Seite halten wir dabei so einfach wie möglich und fassen alle wichtigen Punkte hier zusammen. + +Was ist erlaubt? +- Du darfst alle Pixabay Inhalte kostenlos nutzen, für kommerzielle und nicht-kommerzielle Anwendungen, gedruckt und digital. Beachte dabei die Einschränkungen unter "Was ist nicht erlaubt". +- Du musst weder vom Bildautor noch von Pixabay eine Genehmigung einholen und auch eine Quellenangabe ist nicht erforderlich, wobei wir uns über eine freiwillige Nennung freuen. +- Du darfst Pixabay Inhalte verändern. + + +Was ist nicht erlaubt? + +Dieser Abschnitt betrifft ausschließlich Bildnutzer und nicht die jeweiligen Bildautoren. +- Verbreite oder verkaufe die Werke nicht auf anderen Bilder-, Wallpaper- bzw. Vertriebsseiten. +- Verkaufe keine unveränderten Kopien von Bildern, z.B. auf einer Stockfoto-Seite, als Poster oder als Ausdruck auf einem physischen Produkt. +- Erkennbare Personen dürfen nicht in einer anstößigen Weise dargestellt werden. +- Suggeriere nicht, dass Bildautor oder erkennbare Personen bzw. Marken auf den Bildern Dein Produkt empfehlen oder befürworten. \ No newline at end of file diff --git a/lib/pixi/assets/maps/pixabay/europe.jpg b/lib/pixi/assets/maps/pixabay/europe.jpg new file mode 100644 index 0000000..7827726 Binary files /dev/null and b/lib/pixi/assets/maps/pixabay/europe.jpg differ diff --git a/lib/pixi/assets/maps/wikimedia-world-robinson/2000px-BlankMap-World.png b/lib/pixi/assets/maps/wikimedia-world-robinson/2000px-BlankMap-World.png new file mode 100644 index 0000000..84a7f3c Binary files /dev/null and b/lib/pixi/assets/maps/wikimedia-world-robinson/2000px-BlankMap-World.png differ diff --git a/lib/pixi/assets/maps/wikimedia-world-robinson/license.txt b/lib/pixi/assets/maps/wikimedia-world-robinson/license.txt new file mode 100644 index 0000000..e626e54 --- /dev/null +++ b/lib/pixi/assets/maps/wikimedia-world-robinson/license.txt @@ -0,0 +1,4 @@ +lincense: Public Domain +date: 29-10-2019 +source: https://commons.wikimedia.org/wiki/File:BlankMap-World.svg +author: Canuckguy \ No newline at end of file diff --git a/lib/pixi/bundle.js b/lib/pixi/bundle.js index 15856b8..3b56d52 100755 --- a/lib/pixi/bundle.js +++ b/lib/pixi/bundle.js @@ -60,3 +60,52 @@ window.List = List window.LabeledGraphics = LabeledGraphics window.FontInfo = FontInfo window.Text = Text + +//Maps +import MapView from './maps/mapview.js' +import { GeoMap, ImageMap, DeepZoomMap } from './maps/map.js' +import { MapData, DeepZoomMapData } from './maps/mapdata.js' + +window.MapData = MapData +window.DeepZoomMapData = DeepZoomMapData + +window.GeoMap = GeoMap +window.ImageMap = ImageMap +window.DeepZoomMap = DeepZoomMap + +import Mercator from './maps/projections/mercator.js' +import Robinson from './maps/projections/robinson.js' +window.Projection = { + Mercator, + Robinson +} + +window.MapView = MapView + +import MapApp from './maps/mapapp.js' + +window.MapApp = MapApp + +import { AdvancedScatterContainer, RigidContainer, CompactScatter, CoverScatter } from './maps/scatter.js' + +window.AdvancedScatterContainer = AdvancedScatterContainer +window.RigidContainer = RigidContainer +window.CompactScatter = CompactScatter +window.CoverScatter = CoverScatter + +import { GeoLayer, MapLayer } from './maps/geolayer.js' +window.GeoLayer = GeoLayer +window.MapLayer = MapLayer + +import { GeoGraphics, GeoPoint, GeoLine, GeoShape, GeoMultiShape } from './maps/geographics.js' + +window.GeoGraphics = GeoGraphics +window.GeoPoint = GeoPoint +window.GeoLine = GeoLine +window.GeoShape = GeoShape +window.GeoMultiShape = GeoMultiShape + + +import Overlay from "./maps/overlay.js" + +window.Overlay = Overlay diff --git a/lib/pixi/graphics/label.js b/lib/pixi/graphics/label.js new file mode 100644 index 0000000..f7275a0 --- /dev/null +++ b/lib/pixi/graphics/label.js @@ -0,0 +1,187 @@ +import { Points } from '../../utils.js' + +/* globals PIXI */ + +export class FlagType { + static get bottomLeft() { + return { x: 1, y: -1 } + } + static get bottomRight() { + return { x: -1, y: -1 } + } + static get topLeft() { + return { x: -1, y: 1 } + } + static get topRight() { + return { x: 1, y: 1 } + } + + static toString(flagType) { + let str = '' + if (flagType.x && flagType.y) { + if (flagType.y == 1) str += 'bottom' + else if (flagType.y == -1) str += 'top' + else str += '_INVALID_Y_' + + if (flagType.x == 1) str += 'Right' + else if (flagType.x == -1) str += 'Left' + else str += '_INVALID_X_' + } else str = 'Invalid FlagType: ' + flagType.toString() + + return str + } +} + +export class FlagPolygon extends PIXI.Polygon { + constructor({ + type = FlagType.bottomLeft, + width = 100, + height = 30, + notchSize = 10, + notchWidth = null, + notchHeight = null, + originOffset = { x: 0, y: 0 } + } = {}) { + let points = [] + + let dimensions = { x: width, y: height } + dimensions = Points.multiply(dimensions, type) + + notchWidth = notchWidth == null ? notchSize : notchWidth + notchHeight = notchHeight == null ? notchSize : notchHeight + + notchSize = { x: notchWidth, y: notchHeight } + notchSize = Points.multiply(notchSize, type) + + originOffset = Points.multiply(originOffset, type) + + let point = new PIXI.Point(originOffset.x, originOffset.y) + points.push(point.clone()) + + point.y += notchSize.y + points.push(point.clone()) + + point.y += dimensions.y + points.push(point.clone()) + + point.x += dimensions.x + points.push(point.clone()) + + point.y -= dimensions.y + points.push(point.clone()) + + point.x -= dimensions.x - notchSize.x + points.push(point.clone()) + + // close polygon + points.push(points[0].clone()) + + super(points) + + this.type = type + this.dimensions = dimensions + this.notchSize = notchSize + this.originOffset = originOffset + } + + getPoint(i) { + if (i >= 0) { + let idx = i * 2 + return [this.points[idx], this.points[idx + 1]] + } else { + let idx = (Math.floor(this.points.length / 2) + i) * 2 + return [this.points[idx], this.points[idx + 1]] + } + } + + get notch() { + let points = [this.getPoint(0), this.getPoint(1), this.getPoint(-2), this.getPoint(0)] + let notchPolygon = [] + + points.forEach(point => { + notchPolygon = notchPolygon.concat(point) + }) + + return notchPolygon + } + get rect() { + let points = [this.getPoint(1), this.getPoint(2), this.getPoint(3), this.getPoint(4), this.getPoint(1)] + + let rectPolygon = [] + points.forEach(point => { + rectPolygon = rectPolygon.concat(point) + }) + return rectPolygon + } + + placeText(text, padding) { + text.position = Points.add(this.originOffset, { x: 0, y: this.notchSize.y }) + padding = Points.multiply(padding, this.type) + text.position = Points.add(text.position, padding) + + if (this.type.y == -1) text.position.y -= text.height + + if (this.type.x == -1) text.position.x -= text.width + } +} + +export class Flag extends PIXI.Graphics { + constructor( + { + type = FlagType.bottomLeft, + width = 100, + height = 30, + notchSize = 10, + notchWidth = null, + notchHeight = null, + originOffset = { x: 0, y: 0 } + } = {}, + nativeLines = false + ) { + super(nativeLines) + + this.flagPolygon = new FlagPolygon({ + type, + width, + height, + notchSize, + notchWidth, + notchHeight, + originOffset + }) + + this.draw() + } + + draw() { + this.drawPolygon(this.flagPolygon) + } + + get typeName() { + return FlagType.toString(this.type) + } +} + +export class Label extends PIXI.Graphics { + constructor(text, textStyle = new PIXI.TextStyle(), nativeLines = false) { + super(nativeLines) + this._text = new PIXI.Text(text, textStyle) + this.addChild(this._text) + } + + get text() { + return this._text + } +} + +export class FlagLabel extends Label { + constructor(text, textStyle, flagStyle, nativeLines = false) { + super(text, textStyle, nativeLines) + this.flagPolygon = new FlagPolygon(flagStyle) + this.draw() + } + + draw() { + this.drawPolygon(this.flagPolygon) + } +} diff --git a/lib/pixi/maps/geographics.html b/lib/pixi/maps/geographics.html new file mode 100644 index 0000000..a0f5df7 --- /dev/null +++ b/lib/pixi/maps/geographics.html @@ -0,0 +1,287 @@ + + + + + + GeoGraphics + + + + + + + + + + + + + + + +

GeoGraphics

+

GeoGraphics are graphical objects, that does not store the graphics information + in screen space, but in geographical coordinates. Therefore GeoGraphics must be + placed on GeoLayers to work properly.

+

+ Note: As GeoLayers are always children of a map layer. When the map is changed + all GeoLayers are notified via the 'adaptTo(map)' method.

+

+ The geolayers forward this 'adaptTo' to all children that are GeoGraphics. + Which adjust their so called 'point' data to the new map.

+ + + + +
+

GeoPoint

+

GeoPoint is a single coordinate in the map.

+ +
+ +
+ + + +
+
+

GeoLine

+

Geo line is a set of points, that are rendered as a line and can be updated individually.

+ +
+ + +
+ +
+ +
+ +

Geoshape

+

GeoGraphics represent Polygons, that are drawn on a map. If the map change, the graphic adapts to the new map + and represents + the same geographical shape.

+
+
+ +
+
+ + + +
+ + + \ No newline at end of file diff --git a/lib/pixi/maps/geographics.js b/lib/pixi/maps/geographics.js new file mode 100644 index 0000000..cddfab7 --- /dev/null +++ b/lib/pixi/maps/geographics.js @@ -0,0 +1,737 @@ +import { Points } from '../../utils.js' +import { EventHandler } from './utils.js' +import { FlagPolygon } from '../graphics/label.js' +import { GeoLayer, MapLayer } from './geolayer.js' +import { DeepZoomMap } from './map.js' + +/** + * GeoGraphics are graphical objects, that does not store the graphics information + * in screen space, but in geographical coordinates. Therefore GeoGraphics must be + * placed on GeoLayers to work properly. + * + * (Note: As GeoLayers are always children of a map layer. When the map is changed + * all GeoLayers are notified via the 'adaptTo(map)' method.) + * + * The geolayers forward this 'adaptTo' to all children that are GeoGraphics. + * Which adjust their so called 'point' data to the new map. + * + * @abstract + */ +export class GeoGraphics { + constructor(coordinates, { scale = 1, onDraw = null, onDrawEnd = null, debug = false } = {}) { + this.coordinates = coordinates + this.debug = debug + this.graphics = new PIXI.Graphics() + this.scale = scale + this.drawHandler = new EventHandler('onDraw', { listeners: onDraw }) + this.drawEndHandler = new EventHandler('onDrawEnd', { listeners: onDrawEnd }) + this._points = null + this._position = null + } + + clone() { + console.error(`Call of abstract method clone(). Overwrite in subclass.`, this) + } + + _cloneOptions() { + return { + debug: this.debug, + scale: this.scale + } + } + + /** + * The _adaptCoordinates is called first by the adaptTo Method. + * Here all coordinates are transformed into point coordinates. + * This must be overloaded in subclass. + * + * @abstract + */ + _adaptCoordinates(map) { + console.error(`Call of abstract method _adaptCoordinates(map). Overwrite in subclass.`, this) + } + + /** + * Gets all screen points in a single array. + * + * @abstract + * @returns {array} - Array of all points in the GeoGraphic. + */ + _getPoints() { + console.error(`Call of abstract method _getPoints(func). Overwrite in subclass.`, this) + } + + /** + * Manipulates all points depending on a function. + * Mainly used to transform points to local space. + * + * @abstract + * @param {function} func + * @memberof GeoGraphics + */ + _manipulatePoints(func) { + console.error(`Call of abstract method _manipulatePoints(func). Overwrite in subclass.`, this) + } + + /** + * The _draw method is called last on adaptation. It creates the GraphicData + * of the specified subclass. To manipulate the style of the graphic, hook an onDraw listener + * to the GeoGraphics object. It is called before the _draw and lets the user modify color and + * lineStyle of the drawn object. + * + * Note: It could also be used for more radical manipulations on the graphics object. + * But this should be used with care. + * + * @abstract + */ + _draw() { + console.error(`Call of abstract method _draw(). Overwrite in subclass.`, this) + } + + /** + * Called by the containing geo layer, when the map changes. + */ + adaptTo(map) { + this._points = this._adaptCoordinates(map) + this._updatePosition() + this.draw() + } + + + /** + * Redraws the graphics. + * + * This should be only called if you require an redraw independent of an adapt. + * + * @memberof GeoGraphics + */ + draw() { + this._prepareDraw() + this.drawHandler.call(this, this.graphics) + this._draw() + this.drawEndHandler.call(this, this.graphics) + } + + /** + * Retrieves the point data. + * Note: This data changes on adaptation. + */ + get points() { + return this._points + } + + /** + * Returns the screen position of the GeoGraphics. + */ + get position() { + return this._position + } + + get map() { + if ( + this.graphics.layer && + (this.graphics.layer instanceof GeoLayer || this.graphics.layer instanceof MapLayer) + ) { + return this.graphics.layer.map + } else return null + } + + get mapLayer() { + if ( + this.graphics.layer && + (this.graphics.layer instanceof GeoLayer || this.graphics.layer instanceof MapLayer) + ) { + return this.graphics.layer.mapLayer + } else return null + } + + /** + * Prepare draw is a private function, that prepares the graphics + * for the next draw call. It also fires the drawHandler. + * + * @private + * @memberof GeoGraphics + */ + _prepareDraw() { + this.graphics.clear() + /* + Set a fillcolor and a stroke style for + debugging. Can be overloaded using the onDraw + event function. + */ + if (this.debug) this.graphics.beginFill(0xff00ff) + } + + _updatePosition() { + let points = this._getPoints() + this._position = GeoGraphics.calculateCenterOfMass(points) + this._manipulatePoints(point => { + point = Points.subtract(point, this._position) + return point + }) + this.graphics.position = this._position + } + + /** + * Calculates the center of Mass for a set of points. + * + * @static + * @param {Array} points - Array of points in the format {x: a, y:b} + * @returns {object} - Returns a point containing the center of mass of the polygon. + * @memberof GeoGraphics + */ + static calculateCenterOfMass(points) { + let com = new PIXI.Point() + points.forEach(p => { + let point = new PIXI.Point(p.x, p.y) + com = Points.add(com, point) + }) + return Points.multiplyScalar(com, 1 / points.length) + } +} + +/** + * Represents a single point on the Map. + * + * This GeoGraphics does not provide any visual representation. + * Draw the desired shape in the onDraw callback. + */ +export class GeoPoint extends GeoGraphics { + clone() { + return new GeoPoint(this.coordinates, this._cloneOptions()) + } + + _adaptCoordinates(map) { + let scale = 1 + + if (this.mapLayer.map instanceof DeepZoomMap) { + scale = this.mapLayer.map.image.scale.x + } + + scale = scale / 4 + + return map.coordinatesToPoint(this.coordinates) + } + + _getPoints() { + return [this.points] + } + + _manipulatePoints(func) { + this._points = func(this._points) + } + + _draw() {} +} + +export class GeoLine extends GeoGraphics { + /** + * @param {object} opts - Optional values + * @param {array} [opts.points=[]] - Initial points of the geo shape. + * @param {boolean} [closed=false] - Defines if the + */ + constructor(coordinates, { closed = false, size = 1, onDraw = null } = {}) { + super(coordinates, { + size, + onDraw + }) + + this._closed = closed + } + + clone() { + return new GeoLine(this.coordinates, this._cloneOptions) + } + + _cloneOptions() { + let options = super._cloneOptions() + Object.assign(options, { + closed: this.closed + }) + } + + /** + * Adds a point to the geo line. + */ + addPoint(coordinate) { + this.coordinates.push(coordinate) + } + + _manipulatePoints(func) { + this.points.forEach((point, idx, array) => { + array[idx] = func(point) + }) + } + + _getPoints() { + return this.points + } + + _adaptCoordinates(map) { + let points = [] + this.coordinates.forEach(point => { + points.push(map.coordinatesToPoint(point)) + }) + return points + } + + _prepareDraw() { + this.graphics.clear() + if (this.debug) this.graphics.lineStyle(0.5, 0xff00ff) + this.drawHandler.call(this) + } + + _draw() { + /** + * This resets the fill. + * + * DISCUSS: SO: "I'm not sure how the line should be defined. + * On the one hand. The line is clearly intended to + * represent a line and not an area. On the other hand, + * why should the user be prevented from using a fill for the + * area within the line. But if he want's a fill, why don't take + * a Polygon in the first place? + * + * (But if it's a predefined GeoJSON object obtained through e.g. elasticsearch, + * then the user is not in full control of the object type and it may be a good + * addition to grant the user this additional design choice.) + * + * The opportunity to do so would result in additional conditions, when creating the + * GeoGraphics of an overlay." + * */ + this.graphics.beginFill(0, 0) + + if (this.points.length > 0) { + this.graphics.moveTo(this.points[0].x, this.points[0].y) + + for (let i = 1; i < this.points.length; i++) { + this.graphics.lineTo(this.points[i].x, this.points[i].y) + } + + if (this.closed) { + this.graphics.lineTo(this.points[0].x, this.points[0].y) + } + } + } + + get closed() { + return this._closed + } + + set closed(val) { + if (val != this._closed) { + this._closed = val + this.draw() + } + } +} + +export class GeoShape extends GeoGraphics { + clone() { + return new GeoShape(this.coordinates, this._cloneOptions) + } + + _manipulatePoints(func) { + this.constructor._manipulatePoints(this.points, func) + } + + /** + * Mets the requirements for the _manipulatePointsMethod(). + * Primarily used in subclasses to get the processing steps from + * their superclass. + * + * @static + * @protected + * @param {Array.} points - The points array that shold be manipulated. + * @param {function} func - The function that changes the single point value. Has to return a new point. + * @memberof GeoShape + */ + static _manipulatePoints(points, func) { + points.forEach((pointArray, arrIdx) => { + pointArray.forEach((point, idx) => { + points[arrIdx][idx] = func(point) + }) + }) + } + + _getPoints() { + return this.constructor._getPointsFrom(this.points) + } + + /** + * Returns all points of a polygon array. + * Useful for when getting sub polygons in child class. + */ + static _getPointsFrom(shape) { + let concatArray = [] + shape.forEach(array => { + concatArray = concatArray.concat(array) + }) + + return concatArray + } + + _adaptCoordinates(map) { + let val = this.constructor._adaptPoint(this.coordinates, map) + return val + } + + static _adaptPoint(coordinates, map) { + return coordinates.map(array => { + return array.map(point => { + return map.coordinatesToPoint(point) + }) + }) + } + + _draw() { + this._drawFrom(this.points) + this.graphics.position = this.position + } + + _drawFrom(shape) { + const { polygon, hole } = this.constructor._pointsToShape(shape) + this._drawShape(polygon, hole) + } + + /** + * Separates the points array into it's two parts: + * - the solid polygon + * - a hole that is cut into the polygon (optional) + * + * @private + * @returns {object} - Returns an object containing the polygon and the hole in the form of {polygon: [...PIXI.Point], hole: [...PIXI.Point]} + */ + static _pointsToShape(points) { + let polygon = GeoShape._transformToPIXI(points[0]) + let hole = points[1] ? GeoShape._transformToPIXI(points[1]) : [] + return { polygon, hole } + } + + /** + * Transform an array of poins into an array of PIXI.Points. + * Note: When creating PIXI.Polygons, for some reason the points + * need to be a PIXI.Points object (at least the first one). + * + * @param {array} points - Points in the form of {x:a,y:b} + * @returns An array of PIXI.Points + * @memberof GeoPolygon + */ + static _transformToPIXI(points = []) { + let polygon = [] + points.forEach(point => { + polygon.push(new PIXI.Point(point.x, point.y)) + }) + + return polygon + } + + /** + * Draws a single shape onto the graphics object. + * Useful when being called from subclass. + * + * @protected + * @param {Array.} polygon - An array of PIXI.Points for drawing a polygon. + * @param {Array.} [hole=[]] - An Array of PIXI.Points for cutting a hole into the polygon + * @memberof GeoShape + */ + _drawShape(polygon, hole = []) { + // We save the fill specified in the onDraw event handler. + // + // Consider: Maybe it would be a good idea to add a 'onHoleDraw' + // callback to make the hole customizable. Maybe you want + // to fill it with a different color or an mediocre alpha value. + // then feel free to implement it. + let { fill, alpha } = PIXIUtils.saveFill(this.graphics) + + /** + * This may seem redundant, but it's required + * to make the hole clickable. + * + * It was a bit confusing, so I made a CodePen + * for it: https://codepen.io/Ukmasmu/pen/WJEaoK + */ + if (hole.length > 0) { + this.graphics.beginFill(0x0000ff, 0.0000001) + this.graphics.drawPolygon(hole) + } + + this.graphics.beginFill(fill, alpha) + this.graphics.drawPolygon(polygon) + + if (hole.length > 0) { + this.graphics.beginHole() + this.graphics.drawPolygon(hole) + this.graphics.endHole() + } + } +} + +/** + * The MultiGraphics makes use of the other GeoGraphics to + * create a single graphics element out of multiple different + * GeoGraphics. + */ +class GeoMultiGraphics extends GeoGraphics { + /** + * The _adaptCoordinates is called first by the adaptTo Method. + * Here all coordinates are transformed into point coordinates. + * This must be overloaded in subclass. + * + * @abstract + */ + _adaptCoordinates(map) {} + + /** + * Gets all screen points in a single array. + * + * @abstract + * @returns {array} - Array of all points in the GeoGraphic. + */ + _getPoints() { + console.error(`Call of abstract method _getPoints(func). Overwrite in subclass.`, this) + } + + /** + * Manipulates all points depending on a function. + * Mainly used to transform points to local space. + * + * @abstract + * @param {function} func + * @memberof GeoGraphics + */ + _manipulatePoints(func) { + console.error(`Call of abstract method _manipulatePoints(func). Overwrite in subclass.`, this) + } + + /** + * The _draw method is called last on adaptation. It creates the GraphicData + * of the specified subclass. To manipulate the style of the graphic, hook an onDraw listener + * to the GeoGraphics object. It is called before the _draw and lets the user modify color and + * lineStyle of the drawn object. + * + * Note: It could also be used for more radical manipulations on the graphics object. + * But this should be used with care. + * + * @abstract + */ + _draw() { + console.error(`Call of abstract method _draw(). Overwrite in subclass.`, this) + } +} + +export class GeoText extends GeoPoint { + constructor(coordinates, text, opts) { + super(coordinates, opts) + this.align = opts.align + this.textStyle = Object.assign(new PIXI.TextStyle(), opts.textStyle) + this._text = new PIXI.Text(text, this.textStyle) + + //TODO: Make this more generic: + // We have 8 layers (12-20) for each map so this temporarily works fine. + // Outsource it to the map class. + //let textScale = Math.pow(2, 7) + + // let textScale = 5 + // this.text.scale.set(1 / textScale, 1 / textScale) + + switch (this.align) { + case 'left': + break + case 'center': + this.text.position.x -= this.text.width / 2 + break + case 'right': + this.text.position.x -= this.text.width + break + default: + console.error('Align type is not implemented: ' + this.align + ". Use 'left', 'right' or 'center'.") + } + this.text.position.y -= this.text.height / 2 + this.graphics.addChild(this.text) + } + + get text() { + return this._text + } + + _draw() { + super._draw() + } + + adaptTo(map) { + super.adaptTo(map) + } +} + +/** + * A geotext with a notch at a certain Position. + */ +export class GeoFlagLabel extends GeoText { + constructor(coordinates, text, opts) { + super( + coordinates, + text, + Object.assign(opts, { + align: 'left' + }) + ) + + if (!opts.flag) { + opts.flag = {} + } + + const { + alpha = 1, + color = 0xffffff, + borderAlpha = 1, + borderColor = 0x000000, + borderThickness = 0, + fillNotch = false + } = opts + + Object.assign(this, { + alpha, + color, + fillNotch, + borderAlpha, + borderColor, + borderThickness + }) + + this.flagOptions = Object.assign( + { + notchSize: 10, + notchWidth: null, + notchHeight: null, + padding: { x: 0, y: 0 }, + originOffset: { x: 0, y: 0 } + }, + opts.flag + ) + + this.border = this.borderThickness > 0 + + Object.assign(this.flagOptions, { + width: this.text.width + this.flagOptions.padding.x * 2, + height: this.text.height + this.flagOptions.padding.y * 2 + }) + + this.flagPolygon = new FlagPolygon(this.flagOptions) + + // hover effect + //-------------------- + const duration = 0.2 + this.graphics.interactive = true + this.graphics.on('pointerover', event => { + TweenLite.to(this.graphics, duration, { alpha: 0.6 }) + }) + this.graphics.on('pointerout', event => { + TweenLite.to(this.graphics, duration, { alpha: 1 }) + }) + this.graphics.on('pointerupoutside', event => { + TweenLite.to(this.graphics, duration, { alpha: 1 }) + }) + this.graphics.on('pointercancel', event => { + TweenLite.to(this.graphics, duration, { alpha: 1 }) + }) + this.graphics.on('pointertap', event => { + TweenLite.to(this.graphics, duration, { alpha: 1 }) + }) + // this.graphics.on('pointerdown', event => { + // TweenLite.to(this.graphics, duration, { alpha: 0.45 }) + // }) + // this.graphics.on('pointerup', event => { + // TweenLite.to(this.graphics, duration, { alpha: 0.6 }) + // }) + } + + _draw() { + super._draw() + + this.graphics.beginFill(this.color, this.alpha) + this.graphics.drawPolygon(this.flagPolygon) + this.flagPolygon.placeText(this.text, this.flagOptions.padding) + + if (this.border) { + const lineStyle = [this.borderThickness, this.borderColor, this.borderAlpha] + if (this.fillNotch) { + this.graphics.beginFill(this.borderColor) + this.graphics.drawPolygon(this.flagPolygon.notch) + this.graphics.endFill() + + this.graphics.lineStyle(...lineStyle) + this.graphics.drawPolygon(this.flagPolygon.rect) + this.graphics.drawPolygon(this.flagPolygon.notch) + } else { + this.graphics.lineStyle(...lineStyle) + this.graphics.drawPolygon(this.flagPolygon) + } + } + } +} + +export class GeoMultiShape extends GeoShape { + static _manipulatePoints(points, func) { + points.forEach(shape => { + GeoShape._manipulatePoints(shape, func) + }) + } + + static _getPointsFrom(multiShapeArray) { + let points = [] + multiShapeArray.forEach(shape => { + points = points.concat(GeoShape._getPointsFrom(shape)) + }) + return points + } + + static _adaptPoint(coordinates, map) { + let points = [] + coordinates.forEach(shape => { + let adaptedPoint = GeoShape._adaptPoint(shape, map) + points.push(adaptedPoint) + }) + return points + } + + calculateLocation() { + let coms = [] + this.coordinates.forEach(polygon => { + coms.push(GeoGraphics.calculateCenterOfMass(polygon)) + }) + + return GeoGraphics.calculateCenterOfMass(coms) + } + + _drawFrom(multiShape) { + multiShape.forEach(shape => { + super._drawFrom(shape) + }) + } +} + +class PIXIUtils { + /* + * Transform a pixi text to it's actual screensize, + * ignoring it's local transforms + */ + static toScreenFontSize(pixiText, fontSize = null) { + pixiText._recursivePostUpdateTransform() + + let normalizedScale = { + x: pixiText.scale.x / pixiText.transform.worldTransform.a, + y: pixiText.scale.x / pixiText.transform.worldTransform.d + } + + pixiText.scale = { x: normalizedScale.x, y: normalizedScale.y } + if (fontSize) pixiText.style.fontSize = fontSize + } + + static saveFill(graphics) { + return { + fill: graphics.fill.color, + alpha: graphics.fill.alpha + } + } +} diff --git a/lib/pixi/maps/geojson.html b/lib/pixi/maps/geojson.html new file mode 100644 index 0000000..bfc1910 --- /dev/null +++ b/lib/pixi/maps/geojson.html @@ -0,0 +1,380 @@ + + + + + + GeoJson + + + + + + + + + + + + +

GeoJson

+

GeoJson is a standardized format of how to display geometry in a geographical context, using latitude/longitude + pairs + to display one (or multiple) Point, Line or Polygon. +

+

This implementation is roughly based on + RFC7946. +

+

GeoJson objects consist of two objects: +

    +
  • type: The type of the object. All listed in the table below.
  • +
  • coordinates: The coordinate point(s)* of which the object is composed.
  • +
+ The points are an array of exactly two values in the format [longitude, latitude]. Here the implementation + breaks with the + standardized format, as multiple point formats can be used for convenience (see + Point section). +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeImplemented
Point + +
Line + +
Polygon + +
MultiPoint + +
MultiLine + +
MultiPolygon + +
+ + + + +

Point

+

Points represent a single point on the map. In the following all valid coordinate types are shown

+ +

Line

+

The line can be used to show a path or connection between multiple points.

+ + +

Polygon

+

Polygons are used to represent shapes. They are an array of two pointarrays. The first represent the shape, the + optional + second one a hole that can be cut into the shape.

+ +

MultiPolygon

+

Multipolygons are an array of polygons.

+ +

Applying GeoJson Objects to the Map

+

The GeoJson data is used to create a + GeoGraphics object.

+ + + + \ No newline at end of file diff --git a/lib/pixi/maps/geojson.js b/lib/pixi/maps/geojson.js new file mode 100644 index 0000000..4f6e919 --- /dev/null +++ b/lib/pixi/maps/geojson.js @@ -0,0 +1,329 @@ +import { GeoGraphics, GeoShape, GeoMultiShape, GeoLine, GeoPoint } from './geographics.js' + +/** + * Helper class for handling GeoJson data. + * As specified by [RFC7946](https://tools.ietf.org/html/rfc7946). + * + * @static + * @export + * @class GeoJsonGraphics + * @extends {GeoGraphics} + */ +export default class GeoJson { + static isLineType(type) { + return type == 'LineString' || type == 'MultiLineString' + } + + static _getFormatStringOfType(type) { + let description = ', where p represents a coordinate point' + let format = '' + switch (type) { + case 'Point': + format = 'p' + break + case 'LineString': + format = '[p1,p2,p3,...,pn]' + break + case 'Polygon': + format = '[ [p1,p2,...,pn], [h1,h2,...,hn] ]' + description += ' and h also represents a coordinate point, but it creates a hole.' + break + default: + format = type + description = " is either not valid or not yet implemented in method '_getFormatStringOfType(type)'." + } + + return format + ' - ' + description + } + + static get types() { + return ['Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon'] + } + + static unwrapFeatureCollection(featureCollection) { + if (featureCollection.features == null) { + console.error( + 'Error at GeoJson.unrwapFeatureCollection(collection): Provided object was no valid FeatureCollection.', + featureCollection + ) + return + } + + let list = [] + + featureCollection.features.forEach(feature => { + let { type, coordinates } = feature + + if (feature.type.toLowerCase() == 'feature') { + ;({ type, coordinates } = feature.geometry) + } + + list.push({ type, coordinates }) + // console.log({type, coordinates}) + }) + + return list + } + + static validateAndConvert(type, coordinates) { + if (!GeoJson.validateType(type)) throw new GeoJson.InvalidTypeError(type) + else { + if (GeoJson.validateCoordinates(type, coordinates)) { + let converted = GeoJson.convert(type, coordinates) + return converted + } else { + console.error( + `Coordinates are invalid. They must be in format of type '${type} - ${GeoJson._getFormatStringOfType( + type + )}'` + ) + } + } + } + + static validateType(type) { + return GeoJson.types.indexOf(type) != -1 + } + + static validateCoordinates(type, coordinates) { + let valid = false + + switch (type) { + case 'Point': + valid = !!GeoJson.validateAndConvertPoint(coordinates) + break + case 'LineString': + valid = GeoJson.validateLineString(coordinates) + break + case 'Polygon': + valid = GeoJson.validatePolygon(coordinates) + break + case 'MultiPolygon': + valid = true + for (let i = 0; i < coordinates.length; i++) { + if (!GeoJson.validatePolygon(coordinates[i])) { + valid = false + break + } + } + break + case 'MultiPoint': + case 'MultiLineString': + default: + console.error('Type was not yet implemented: ', type) + } + return valid + } + + /** + * Validates a point if it's an valid coordinate. + * + * NOTE: Here we are not following the GeoJSON standard. + * For convenience multiple forms of representing a coordinate are + * considered valid. A complete list is provided in the GeoUtils. + * + * @param {object} point - The point that is tested for validity. + * @returns + * @memberof GeoJson + */ + static validateAndConvertPoint(point) { + return GeoUtils.validateCoordinate(point) + } + + /** + *Validates if the given points represent a 'LineString'. + * + * @param {array} points - A list of coordinates that represent a line. + * @returns {boolean} - Returns true, if the array is in formes as: [x1,x2,x3,...,xn]. Where x# represent a valid coordinate. + * @memberof GeoJson + */ + static validateLineString(points) { + let valid = false + if (Array.isArray(points)) valid = points.every(GeoJson.validateAndConvertPoint) + return valid + } + + static validatePolygon(points) { + let valid = false + if ((Array.isArray(points) && points.length >= 1) || points.length <= 2) + valid = points.every(this.validateLineString) + return valid + } + + static convert(type, coordinates) { + let converted = null + switch (type) { + case 'Point': + converted = GeoJson.validateAndConvertPoint(coordinates) + break + case 'LineString': + converted = GeoJson._convertLineString(coordinates) + break + case 'Polygon': + converted = GeoJson._convertPolygon(coordinates) + break + case 'MultiPolygon': + converted = GeoJson._convertMultiPolygon(coordinates) + break + default: + throw new GeoJson.InvalidTypeError(type) + } + + return converted + } + + static _convertLineString(coordinates) { + return coordinates.map(point => { + return GeoJson.validateAndConvertPoint(point) + }) + } + + static _convertPolygon(coordinates) { + return coordinates.map(shape => { + return GeoJson._convertLineString(shape) + }) + } + static _convertMultiPolygon(coordinates) { + return coordinates.map(polygon => { + return GeoJson._convertPolygon(polygon) + }) + } +} + +GeoJson.InvalidTypeError = class extends Error { + constructor(type) { + super(`The requested Type was not implemented: ${type}.`) + } +} + +/** + * GeoUtils contains a collection of useful functions when working with maps. + * + * @static + */ +export class GeoUtils { + static transformToGeoGraphics(list) { + let geographicsList = [] + list.forEach(item => { + if (item.type == 'FeatureCollection') { + item.features.forEach(feature => { + let { type, coordinates } = feature + + if (type.toLowerCase() == 'feature') ({ type, coordinates } = feature.geometry) + + coordinates = GeoJson.validateAndConvert(type, coordinates) + + let geographics = this.fromGeoJsonToGeoGraphics(type, coordinates) + if (geographics) geographicsList.push(geographics) + }) + } else { + let geo = fromGeoJsonToGeoGraphics(item.type, item.geometry) + if (geo) geographicsList.push(geo) + } + }) + return geographicsList + } + + static resolveFeatureCollection(collection) { + if (!collection.features) { + console.error( + 'Error in GeoUtils.resolveFeatureCollection(colelction): Passed parameter was no feature collection.', + collection + ) + return + } + let geojson = [] + collection.features.forEach(feature => { + let { type, coordinates } = feature + + if (feature.type == feature) { + coordinates = feature.geometry.coordinates + type = feature.geometry.type + } + + geojson.push({ type, coordinates }) + }) + + return geojson + } + + /** + * Creates the appropriate GeoGraphics object from a GeoJson type. + * The coordinates need to be alread in the appropriate PIXI format. + * If not already - this can be achieved by calling 'GeoJson.validateAndConvert(type, points)' + * beforehand. + * + * @static + * @param {string} type - Any of the GeoJson types ('Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon'). + * @param {array} coordinates - Array of coordinates that fit the type. The positions within these must be in PIXI format: {x:a, y:b}. + * @returns {GeoGraphics} - Returns a GeoGraphics object. If the conversion fails, it returns null. + * @memberof GeoGraphics + */ + static fromGeoJsonToGeoGraphics(type, coordinates, opts = {}) { + let geographics = null + + /** + * TODO: REMOVE + * Just for initial debugging purposes + */ + Object.assign(opts, { + debug: true + }) + + switch (type) { + case 'Polygon': + geographics = new GeoShape(coordinates, opts) + break + + case 'MultiPolygon': + geographics = new GeoMultiShape(coordinates, opts) + break + case 'LineString': + geographics = new GeoLine(coordinates, opts) + break + case 'Point': + geographics = new GeoPoint(coordinates, opts) + break + default: + console.log('Could not create Geographics for type: ' + type + '. This was not implemented yet.') + //Nothing + } + + return geographics + } + + /** + * Validates an object, if it's an coordinate object. + * Coordinate objects can be in the following forms: + * + * [lng, lat] - GeoJSON array format. !Attention lng and lat are swapped compared to the x,y format. + * {x: lat, y: lng} - 'correct' PIXI.format + * {lat, lng} + * {latitude: lat, longitude: lng} + * + * @static + * @param {object / array} coordinate - Coordinate to be tested, if it is an valid coordinate. + * @returns - Returns the coordinate properly transformed. If transformation was not possible, it returns null. + * @memberof GeoGraphics + */ + static validateCoordinate(coordinate) { + if (Array.isArray(coordinate)) { + if (coordinate.length == 2 && typeof coordinate[0] == 'number' && typeof coordinate[1] == 'number') + return new PIXI.Point(coordinate[1], coordinate[0]) + else return false + } else { + const latvalues = ['x', 'lat', 'latitude'] + const lngvalues = ['y', 'lng', 'longitude'] + + let result = {} + for (let key of Object.keys(coordinate)) { + let target = key.toLowerCase() + if (latvalues.indexOf(target) !== -1) result.x = coordinate[key] + else if (lngvalues.indexOf(target) !== -1) result.y = coordinate[key] + } + + if (result.hasOwnProperty('x') && result.hasOwnProperty('y')) return new PIXI.Point(result.x, result.y) + else return false + } + } +} diff --git a/lib/pixi/maps/geolayer.js b/lib/pixi/maps/geolayer.js new file mode 100644 index 0000000..d2c58d2 --- /dev/null +++ b/lib/pixi/maps/geolayer.js @@ -0,0 +1,302 @@ +import { GeoMap } from './map.js' +import MapView from './mapview.js' +import { EventHandler } from './utils.js' + +import { PIXILayer } from '../../../../src/layers/js/layer.js' +import Logging from '../../logging.js' +//import { GeoGraphics } from "../pixi/geographics.js" + +/** + * The GeoLayer is a special PIXILayer, that recognizes other GeoLayers and + * GeoGraphics. The layer can be adapted to a map and notifies all Geo-Children + * of the Adaption. + */ +export class GeoLayer extends PIXILayer { + constructor(opts = {}) { + super(opts) + + /** + * When setting the map and mapLayer with the options paramter. + * The GeoLayer becomes a RootLayer, when the root layer should not be a MapLayer. + */ + if (opts.map) this._map = opts.map + if (opts.map) this._mapLayer = opts.mapLayer + + this.geographics = [] + } + + /** + * Adapts to a map. If the maplayer should adapt to the parent maplayer, + * no parameter must be specified. + */ + adapt(map = null) { + if (!map) map = this.map + + if (map) { + this.geographics.forEach(geographic => { + geographic.adaptTo(map) + }) + + this.layers.forEach(layer => { + if (layer.adapt) layer.adapt(map) + }) + } else console.error('There was no map specified.', this) + } + + place(geographic) { + if (geographic.constructor.name.startsWith('Geo') && geographic.graphics) { + // Fix to remove the rollupjs circular dependency + //if (geographic instanceof GeoGraphics) { + this.geographics.push(geographic) + super.place(geographic.graphics) + } else super.place(geographic) + } + + placeLayer(layer) { + if (layer instanceof GeoLayer || layer instanceof MapLayer) { + super.placeLayer(layer) + if (this.map) layer.geographics.forEach(geographics => geographics.adaptTo(this.map)) + } else + console.error('Could not place layer. Only MapLayer and GeoLayers can be child layers of GeoLayers.', layer) + } + + //GeoLayers have to be children of a map layer, + // therefore we can recursively get the map. + get map() { + return this._map ? this._map : this.parent.map + } + + get mapLayer() { + return this._mapLayer ? this._mapLayer : this.parent.mapLayer + } + + clone(mapLayerClone) { + const opts = { + mapLayer: mapLayerClone, + map: mapLayerClone.map + } + let geoLayerClone = new GeoLayer(opts) + + this.layers.forEach(layer => { + let layerClone = layer.clone(opts) + if (layerClone) { + geoLayerClone.placeLayer(layerClone) + } + }) + + this.geographics.forEach(geographics => { + let clone = geographics.clone() + if (clone) { + geoLayerClone.place(clone) + } + }) + + return geoLayerClone + } +} + +/** + * TODO: It may be a good idea to inherit maplayer from geo layer. + */ +export class MapLayer extends PIXILayer { + constructor(active, maps, scatterContainer, opts = {}) { + super( + Object.assign( + { + container: new PIXI.Container() + }, + opts + ) + ) + + this.opts = opts + + this.transformHandler = new EventHandler('onTransform', { + listeners: () => { + this.labelVisibility() + } + }) + + this.scatterContainer = scatterContainer + + if (!maps[active]) console.error('No map was set!') + else opts.map = maps[active] + + this.mapview = new MapView(opts) + this.changeHandler = new EventHandler('onChange', { + listeners: opts.onChange + }) + + //TODO Implement error handling here. + this.maps = maps + this.changeMap(active) + if (opts.map) this.placeMap(opts.map) + this.dynamicElements = new Map() + } + + labelVisibility() { + const visibility = this.childrenVisibility + + if (visibility) { + const zoom = this.mapview.zoom + + const min = visibility.min || 0 + const max = visibility.max || Number.MAX_VALUE + + if (zoom > min && zoom < max) { + this.elements.forEach(it => (it.visible = true)) + + this.elements.forEach(it => { + const scale = 1 / it.parent.scale.x + + // it.children are poi groups + // it.children[0] is the poi group of the tübingen poi + // it.children[0].children are the text containers (not PIXI.Text), about 20 pieces + + if (it.children.length > 0) { + it.children[0].children.forEach(poi => { + if (poi.children.length === 1) { + poi.scale.set(scale, scale) + } + }) + } + }) + } else { + this.elements.forEach(it => (it.visible = false)) + } + } + } + + adapt() { + this.layers.forEach(layer => { + if (layer.adapt) layer.adapt(this.map) + }) + } + + placeLayer(layer) { + super.placeLayer(layer) + if (layer instanceof GeoLayer && this.map) { + layer.adapt(this.map) + } + } + + placeMap(map) { + if (map instanceof GeoMap) { + this.scatterContainer.addChild(map.image) + this.map.onTransform.add(this.transformed.bind(this)) + this.mapview.setMap(this.map) + + this.map.image.addChild(this.container) + + this.adapt() + } else { + console.error("Could not set map, it's not of type GeoMap.", map) + } + } + + placeElement(elem) { + if (elem instanceof PIXI.DisplayObject) { + this.map.image.addChild(elem) + this.elements.push(elem) + } else { + console.error('Element need to be of type PIXI.DisplayObject.', elem) + } + } + + transformed(e) { + this.mapview.transformed(e) + this.transformHandler.call(this) + } + + clone(container = null) { + let clone = {} + for (let name of Object.keys(this.maps)) { + //console.info(this.maps[name]) + clone[name] = this.maps[name].clone(container) + } + + //console.info(this.active) + let mapLayerClone = new MapLayer(this.active, clone, container, { + name: MapLayer.idx++, + viewport: this.mapview.viewport, + focus: this.mapview.focus, + zoom: this.mapview.zoom + }) + //mapLayerClone._map = clone['luftbild'] + mapLayerClone.childrenVisibility = this.childrenVisibility + return mapLayerClone + } + + changeMap( + key, + { + useScatterAsContainer = true // If set to false, the normal container is used. This is necessary when using submaps and the container need to be a RigidContainer. + } = {} + ) { + Logging.log(`Map change: ${key}`) + + if (this.active !== key) { + if (this.maps.hasOwnProperty(key)) { + let old = this.map ? this.map : null + this._map = this.maps[key] + this._map.name = key + this.active = key + + let container = useScatterAsContainer ? this.scatterContainer : this.container + + this.map.load(container) + + // Copies all layers. + this.layers.forEach(layer => { + if (old) this.map.image.addChild(layer.container) + }) + + this.placeMap(this.map) + + /** + * TODO: Improve + * + * I'm quite sure if I made a design mistake here. + * In an earlier version I did not need to migrate the + * layers manually from the map to the next map. + * + * I believe the old version was a container next to the + * map, which got updated on transform. + * + * -SO + */ + if (old) old.unload() + + this.changeHandler.call(this, old) + } else { + let keys = Object.keys(this.maps) + + if (keys.length == 0) console.error('There is no map set for the map layer!') + else { + let fallbackMap = keys[0] + console.error( + `A map with the key (${key}) does not exists within the mapapp. Fallback to map: ${fallbackMap}.` + ) + this.changeMap(fallbackMap, { + useScatterAsContainer + }) + } + } + } + } + + get map() { + return this._map + } + + /** + * This is required for the consistency of georelated layers. + * The request traverses up to the mapLayer where it then returns + * the responsible map layer. + */ + get mapLayer() { + return this + } +} + +MapLayer.idx = 0 diff --git a/lib/pixi/maps/map.js b/lib/pixi/maps/map.js new file mode 100644 index 0000000..90486d3 --- /dev/null +++ b/lib/pixi/maps/map.js @@ -0,0 +1,914 @@ +import { MapObjectScatter } from './scatter.js' +import { DeepZoomImage, DeepZoomInfo } from '../deepzoom/image.js' +import { MapData, DeepZoomMapData } from './mapdata.js' +import { Points } from '../../utils.js' +import { EventHandler } from './utils.js' +import Mercator from './projections/mercator.js' + +/** +* The GeoMap class displays a map, that it gets from MapData object. +* It handles the current location on the map, the zoom factor, interactions and +* the viewport, the area of the map, the user can see and navigate to. +* +* You should not instantiate GeoMap on it's own. Use the implemented child classes +* or derive a new one from it. +* +* Note: The name Map is already in use. Therefore GeoMap was used instead. +* + +* @abstract +* @class +* @see {@link maps.html} +*/ + +export class GeoMap { + /** + * Creates instance of GeoMap + * + * @constructor + * @param {MapData}[mapdata={}] - The mapdata describes how the map has to be interpreted by the Map class. E.g. what are the boundaries of the map? + * @param {object}[opts={}] - With the opts, the created MapObjectScatter can be adjusted. + * @param {boolean}[opts.cover=false] - Enables covering behaviour of a map object. Normally maps should cover the whole app. + */ + constructor( + mapdata = {}, + { + debug = true, + cover = true, + // Scatter Options + alpha = 1, + startScale = 1, + minScale = 0, + maxScale = Infinity, + translatable = true, + scalable = true, + rotatable = false, // Many functionalities are not supported when rotating the map. Mainly the cover mechanism. + viewport = mapdata.maxViewport, + // Events + onLoad = null, + onTransform = null + } = {} + ) { + this._id = GeoMap.counter++ + this.onLoad = new EventHandler('loaded', { listeners: onLoad }) + this.onTransform = new EventHandler('transform', { listeners: onTransform }) + + this.alpha = alpha + this.cover = cover + this.debug = debug + + //TODO discuss if this is required here. + // Those are just scatter options and the information + // is redundant in the map class and the scatter. + this.startScale = startScale + this.minScale = minScale + this.maxScale = maxScale + this.rotatable = rotatable + this.translatable = translatable + this.scalable = scalable + this.viewport = viewport + + this.mapdata = mapdata + this.overlays = {} + + /** + * Adjust the viewport depending on the mapdata clipping. + */ + if (this.mapdata.clip) { + const vp = this.viewport + const cp = this.mapdata.clip + let bounds = { + min: { + x: vp.min.x > cp.min.x ? vp.min.x : cp.min.x, + y: vp.min.y > cp.min.y ? vp.min.y : cp.min.y + }, + max: { + x: vp.max.x < cp.max.x ? vp.max.x : cp.max.x, + y: vp.max.y < cp.max.y ? vp.max.y : cp.max.y + } + } + + this.viewport = bounds + } + } + + flushHandlers() { + // this.onLoaded + this.onTransform.empty() + } + + lock({ rotatable = false, translatable = false, movableX = false, movableY = false, scalable = false } = {}) { + if (this.image && this.image.scatter) { + this.image.scatter.translatable = rotatable + this.image.scatter.movableX = translatable + this.image.scatter.movableY = movableX + this.image.scatter.rotatable = movableY + this.image.scatter.scalable = scalable + } + + // Issue #001: This causes the map to not be displayed at the correct position on + // map change. + // // Rotation does not yet work with the cover mechanism. + // //this.rotatable = false + // this.translatable = false + // this.scalable = false + } + + unlock({ rotatable = true, translatable = true, movableX = true, movableY = true, scalable = true } = {}) { + if (this.image && this.image.scatter) { + this.image.scatter.translatable = translatable + this.image.scatter.movableX = movableX + this.image.scatter.movableY = movableY + this.image.scatter.rotatable = rotatable + this.image.scatter.scalable = scalable + } + // Issue #001 + // // Rotation does not yet work with the cover mechanism. + // //this.rotatable = true + // this.translatable = true + // this.scalable = true + } + + unload() { + this.image.parent.removeChild(this.image) + this.image.scatter = null + } + + /** + * Is called when the scatter object is transformed. + * + * @private + * @param {ScatterEvent}[e] - Contains informations on how the element was transformed. + */ + transformed(e) { + this.onTransform.call(this, e) + } + + /** + * Wrapps the display object around a scatter object. + * + * @private + * @param {DisplayObject} displayObject - Defines the display object that will be wrapped inside the scatter object. + * @param {PIXI.Container} container - Defines the frame in which the map will be displayed. + */ + load(image, renderer, frame = null, scatter = null) { + if (this.debug) console.log('Load image: ', image, frame) + + this.image = image + if (frame) this.setFrame(frame) + + let min = this.mapdata.toPixel(this.viewport.min) + let max = this.mapdata.toPixel(this.viewport.max) + let boundaries = { + min: { x: min.x, y: max.y }, + max: { x: max.x, y: min.y } + } + + let scatterOpts = Object.assign({ + cover: this.cover, + scaleable: this.scaleable, + translatable: this.translatable, + rotatable: this.rotatable, + debug: this.debug, + startScale: this.startScale, + minScale: this.minScale, + maxScale: this.maxScale, + boundaries, + onTransform: this.transformed.bind(this) + }) + + this.scatter = new MapObjectScatter(image, renderer, scatterOpts) + this.image.scatter = scatter == null ? this.scatter : scatter + + this.onLoad.call(this) + } + + /** + * Creates an identical copy of the current map. + */ + clone() { + console.error('Method clone() is abstract. Overload it in subclass', this) + } + + /** + * + * Moves the 'camera' to another location on the map. + * + * @abstract + * @param {object} coordinates - Target coordinates in form {x: latitude, y: longitude} + * @param {number} scale - Target scale. + */ + moveTo(coordinates, zoom = null, { animate = false } = {}) { + console.error(this, 'The method moveTo(coordinates, zoom, opts) has not been defined in child class.') + } + + zoomTo(zoomFactor) { + console.error('Method zoomTo() is abstract. Overload it in subclass', this, zoomFactor) + } + + /** + * Reloads the textures of the image, when it was unloaded and is required again. + * + * @memberof GeoMap + */ + reloadImage() { + console.error(`Call of abstract method reloadImage(). Overwrite in subclass.`, this) + } + + /** + * Unloads the textures of the image, when not in use. + * + * @memberof GeoMap + */ + unloadImage() { + console.error(`Call of abstract method unloadImage(). Overwrite in subclass.`, this) + } + + /** + * Transforms a point at pixel position {x,y} + * to a coordinate with latitude and longitude. + * + * + * @param {object} point - Point in form of {x: x_val, y: y_val}. + * @returns {object} - Coordinates on the map in form of {x: latitude, y: longitude}. + */ + coordinatesFromPoint(point) { + let coords = this.mapdata.toCoordinates(this.toRelativePosition(point)) + return coords + } + + /** + * Transform coordinates in the map into pixel positions on the deep zoom image. + * + * @param {object} coords - Coordinates of a map position in form {x: latitude, y: longitude}. + * @return - Returns a image position in form of {x: x_val, y: y_val}. + */ + coordinatesToPoint(coordinates) { + return this.toAbsolutePixelCoordinates(this.mapdata.toPixel(coordinates)) + } + + toRelativePosition(point) { + let _point = new PIXI.Point() + + _point.x = point.x / this.width + _point.y = point.y / this.height + + return _point + } + + toAbsolutePixelCoordinates(point) { + let _point = new PIXI.Point() + _point.x = point.x * this.width + _point.y = point.y * this.height + + return _point + } + + // /** + // * Appends the object to a PIXI container. This is important, + // * to notify the map, that it's parent has changed. + // * + // * If you want to use PIXI's addChild, make sure you call + // * appended right afterwards. + // * + // * @param {PIXI.Container} container + // * @returns Returns the map object to allow chaining. + // */ + // appendTo(container) { + // container.addChild(this.image) + // return this.appended(container) + // } + + get width() { + return this.image.scatter.width / this.image.scatter.scale + } + + get height() { + return this.image.scatter.height / this.image.scatter.scale + } + + get distance() { + console.error('Overload get distance in subclass.') + } + + /** + * Returns a screen point for a coordinate. + */ + positionInWindow(coordinates) { + let pos = this.coordinatesToPoint(coordinates) + + let framePos = { + x: this.image.position.x + pos.x * this.image.scale.x, + y: this.image.position.y + pos.y * this.image.scale.y + } + + return framePos + } + + /** + * Returns the coordinates for a screen point. + */ + coordinatesInWindow(point) { + let mapPos = { + x: (point.x - this.image.position.x) / this.image.scale.x, + y: (point.y - this.image.position.y) / this.image.scale.y + } + + let coords = this.coordinatesFromPoint(mapPos) + return coords + } + + setFrame(frame) { + if (this.debug) console.log('Set Frame: ', frame) + this.frame = frame + } + + /** + * Frame is the display in which the map is shown. + * Normally it's the app, but it can be another element, + * for example when in a submap. + */ + getFrame() { + let frame = { + x: 0, + y: 0, + width: 0, + height: 0 + } + + if (this.frame) { + //The app does not have an x and y attribute. + //Therefore we need the additional check. + frame = { + x: this.frame.x ? this.frame.x : 0, + y: this.frame.y ? this.frame.y : 0, + width: this.frame.width, + height: this.frame.height + } + } else if (this.image.parent) { + let parent = this.image.parent + + for (let key of Object.keys(frame)) { + frame[key] = parent[key] + } + } + + frame.localCenter = { + x: frame.width / 2, + y: frame.height / 2 + } + + console.log(frame) + frame.center = { + x: frame.x + frame.localCenter.x, + y: frame.y + frame.localCenter.y + } + + return frame + } + + /** + * Retrieves all maps from a json object. + * + * @static + * @param {object} json - The parsed map data object. + * @returns {object} - Returns an object with the names as keys and the GeoMaps as value. + * @memberof GeoMap + */ + static allFromJson(json) { + let error = { message: '' } + let maps = {} + if (GeoMap._validateJson(json, error)) { + for (let [mapname, data] of Object.entries(json)) { + maps[mapname] = GeoMap._createMap(data) + } + } else console.error('Could not validate JSON: ' + error.message) + + return maps + } + + /** + *Extracts a single map from a JSON map file. + * + * @static + * @param {string} map - Name of the map. + * @param {object} json - Parsed json object. + * @returns {GeoMap} - Returns a geomap, if the map was in the data and valid. Otherwise false. + * @memberof GeoMap + */ + static mapFromJson(map, json) { + if (json[map]) { + const data = json[map] + if (this._validJsonMap(data)) return GeoMap._createMap(data) + else console.error('Map was not in a valid format.') + } else console.error('Map was not in data.') + + return null + } + + /** + * + */ + + /** + * Validates if the map data contains valid data + * for creating the maps. + * + * @static + * @param {object} json - The object containing multiple map data sets. + * @param {error-object} error - An object that contains an parameter message: {message = ""}. This is faking a call by reference. + * @returns {boolean} - True if all sets were valid. False otherwise. + * @memberof GeoMap + */ + static _validateJson(json, error) { + let isValid = true + + if (Object.keys(json).length == 0) { + isValid = false + error.message += 'The provided JSON object did not contain any items.' + } + for (let [name, data] of Object.entries(json)) { + if (!GeoMap._validJsonMap(data)) { + error.message += `${name} was not valid. ` + isValid = false + } + } + + if (error.message != '') error.message = 'Maps could not be validated: ' + error.message + + return isValid + } + + static changePathsInJson(json, path) { + for (let key of Object.keys(json)) { + json[key].icon = path + json[key].icon + json[key].tiles.path = path + json[key].tiles.path + } + } + + /** + *Validates of a single data set contains the valid data for creating a map. + * + * @static + * @param {object} json - The object containing a single set of map data. + * @returns {boolean} - True if valid, otherwise false. + * @memberof GeoMap + */ + static _validJsonMap(json) { + let isJsonValid = true + const required = ['projection', 'type', 'icon'] + for (const requirement of Object.values(required)) { + if (!json.hasOwnProperty(requirement)) { + isJsonValid = false + break + } + } + + if (isJsonValid && json.type.toLowerCase() == 'deepzoom') { + if (!json.hasOwnProperty('tiles')) isJsonValid = false + } + + return isJsonValid + } + + static _createMap(data) { + switch (data.type.toLowerCase()) { + case 'deepzoom': + return GeoMap._createDeepZoomMap(data) + default: + console.error(`Datatype is invalid or not implemented yet: ${data.type}`) + } + } + + static _createDeepZoomMap(data) { + const projection = GeoMap._getProjectionByName(data.projection) + const tilesConfig = data.tiles + const options = data.options + + const mapdata = new DeepZoomMapData(projection, tilesConfig) + return new DeepZoomMap(mapdata, tilesConfig, options) + } + + static _getProjectionByName(projection) { + switch (projection.toLowerCase()) { + case 'mercator': + return new Mercator() + default: + console.error(`Projection is invalid or not implemented yet ${projection}.`) + return null + } + } +} + +GeoMap.counter = 0 + +/** + * The DeepZoomMap class extends the GeoMap to create + * maps as deepzoom images from maptiles. + * + * @extends GeoMap + * @class + * @see {@link maps.html} + */ + +export class DeepZoomMap extends GeoMap { + /** + * @constructor + * @param {object} tilesConfig - The tiles config object, that defines at what path and in which format the tiles are. + * @param {MapData} mapdata - A MapData object, that contains informations of how the given map has to be interpreted. + * @param {object} opts - Additional options to specify the behaviour of the deep zoom image. + */ + constructor(mapdata, tilesConfig, opts = {}) { + if (!tilesConfig.app) console.error('App was not set in the tilesConfig.') + opts = Object.assign( + { + maxScale: Math.min(tilesConfig.width, tilesConfig.height) / tilesConfig.tileSize, + minScale: mapdata.getMinScale, + highResolution: true, + debug: false + }, + opts + ) + super(mapdata, opts) + + this.tilesConfig = tilesConfig + this._verifyMapdata() + } + + _verifyMapdata() { + if (!(this.mapdata instanceof MapData)) { + console.error('Use the MapData object for creating maps!') + } else { + if (this.mapdata instanceof DeepZoomMapData) { + if (!this.mapdata.app) console.error('No app was set on the mapdata!') + } else { + console.error('Use the DeepZoomMapData object.') + } + } + } + + /** + * Creates the DeepZoomImage. + * + * @private + */ + load(container = null, scatter = null) { + this.info = new DeepZoomInfo(this.tilesConfig) + console.log('APP:', this.mapdata.app) + let image = new DeepZoomImage(this.info, { + app: this.mapdata.app, + alpha: this.alpha, + debug: this.debug, + world: scatter == null ? scatter : scatter.getWorldScatter() + }) + + super.load(image, container, scatter) + + if (this.debug) console.log('Loaded image: ', image, 'With options: ', this.info) + } + + unloadImage() { + if (this.image) { + this.image.deactivate() + } + } + + reloadImage() { + this.image.activate() + } + + /** + * Event function that is invoked by the deep zoom image, when it has been transformed. + * + * @private + * @param {ScatterEvent} e + */ + transformed(e) { + this.image.transformed(e) + super.transformed(e) + } + + /** + * Jumps to a certain point on the map and adjusts the size of the + * map depending on the distance (zoom). + * + * + * @param {*} coordinates + * @param {*} [zoom=null] + * @param {*} [{ animate = 0 }={}] + * @memberof DeepZoomMap + */ + moveTo(coordinates, zoom = null, { animate = 0 } = {}) { + if (this.image.scatter == null) { + return + } + //TODO: This is jaggy on submaps. Make it more smooth. Severin Opel 08-05-2018 + if (zoom) { + /** + * Zoom's the view to a specified distance in DZI-Levels. + * The input level is clipped to allowed levels. + * */ + let targetScale = this.scaleForFloatingLevel(zoom) + + // Don't scale beyond boundaries. + if (this.maxScale) targetScale = Math.min(this.maxScale, targetScale) + + if (this.minScale) targetScale = Math.max(targetScale, this.minScale) + + this.image.scatter.requestScale(targetScale) + } + + if (coordinates) { + //Get pixel position of lat lang values + let point = this.coordinatesToPoint(coordinates) + + let containerCenter + if (this.frame) { + containerCenter = this.getFrame().localCenter + //console.log("HAS FRAME: ", containerCenter) + } else { + containerCenter = { + x: this.image.parent.width / 2, + y: this.image.parent.height / 2 + } + + //console.log("GET CONTAINER CENTER") + } + + //Calculate the delta depending on image position and center the + // desired position in the frame. + let delta = { + x: containerCenter.x - point.x * this.image.scatter.scale - this.scatter.position.x, + y: containerCenter.y - point.y * this.image.scatter.scale - this.scatter.position.y + } + // console.log("MOVE TO: " , this.image.scatter.translatable,point, delta) + + if (animate > 0) { + let last = 0 + TweenMax.to({ x: 0, y: 0 }, animate, { + ease: Power0.easeNone, + x: delta.x, + y: delta.y, + onUpdateParams: ['{self}'], + onUpdate: tween => { + let step = tween.ratio - last + last = tween.ratio + this.image.scatter.transform(Points.multiplyScalar(delta, step), /*ratio*/ 1, 0, { x: 0, y: 0 }) + } + }) + } else { + this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 }) + } + } + // return this.image.scatter.position + } + + scaleForFloatingLevel(level) { + level = Math.max(0, level - this.image.info.baseLevel) + return Math.pow(2, level) / this.image.resolution + } + + floatingLevelForScale(scale) { + let level = Math.log2(scale * this.image.resolution) + let newLevel = this.image.info.baseLevel + Math.max(level, 0) + return Math.min(newLevel, this.image.info.maxLoadableLevel) + } + + get distance() { + return this.floatingLevelForScale(this.scatter.scale) + } + + clone(container, scatter = null) { + const map = new DeepZoomMap(this.mapdata, this.tilesConfig, { + alpha: this.alpha, + cover: this.cover, + debug: this.debug, + startScale: this.startScale, + minScale: this.minScale, + maxScale: this.maxScale, + rotatable: this.rotatable, + translatable: this.translatable, + scalable: this.scalable, + viewport: this.viewport + }) + + //TODO: If frame is not set, the map does not work anymore. + + //map.load(container, scatter) + + // this.image.parent.children.forEach(child=>{ + // if(child instanceof PIXI.DisplayObject){ + + // if(child instanceof PIXI.Graphics){ + // // let clone = child.clone() + // // const position = child.position + // // clone.position.set(position.x, position.y) + // // map.addChild(clone) + // // console.log(map.mapLayer) + + // }else console.log("DisplayObject is not yet implemented for cloning.", child) + + // }else console.error("Not implemented object to clone: ", child) + // }) + + return map + } + + tint() { + let color = DeepZoomMap.tintcolors[DeepZoomMap.tintcolor++ % DeepZoomMap.tintcolors.length] + this.image.children[0].children.forEach(tiles => { + tiles.children.forEach(tile => { + tile.tint = color + }) + }) + } + + // /** Returns the tile layer level that corresponds to the given scale. + // * @param {number} scale - the scale factor + // **/ + // levelForScale(scale) { + // let level = Math.round(Math.log2(scale * this.resolution)) // Math.floor(Math.log2(event.scale))+1 + // let newLevel = this.info.baseLevel + Math.max(level, 0) + // return Math.min(newLevel, this.info.maxLoadableLevel) + // } + + // /** + // * Returns the scale factor that correspond to a given level. + // * @param {number} level - the requested level + // * @returns {number} - Returns the scale level for the given level. + // */ + // scaleForLevel(level){ + // level = Math.max(0, level - this.info.baseLevel) + // return Math.pow(2, level) / this.resolution + // } +} + +DeepZoomMap.tintcolors = [0xff0000, 0xff00ff, 0xffff00, 0x00ff00, 0x00ffff, 0x0000ff] +DeepZoomMap.tintcolor = 0 + +/** + * + * ImageMap extends GeoMap to display simple images + * as maps. + */ + +export class ImageMap extends GeoMap { + constructor(sprite, mapdata, opts = {}) { + super(mapdata, opts) + + this.sprite = sprite + + this.baseZoomHeight = opts.baseZoomHeight ? opts.baseZoomHeight : this.sprite.texture.height + } + + load(container = null, scatter = null) { + super.load(this.sprite, container, scatter) + this.image.alpha = this.alpha + this.image.interactive = true + } + + clone(container = null, scatter = null) { + const map = new ImageMap(new PIXI.Sprite(this.sprite.texture), this.mapdata, { + alpha: this.alpha, + cover: this.cover, + debug: this.debug, + startScale: this.startScale, + minScale: this.minScale, + maxScale: this.maxScale, + rotatable: this.rotatable, + translatable: this.translatable, + scalable: this.scalable, + viewport: this.viewport + }) + map.load(container, scatter) + + console.log(map.image) + return map + } + + // transformed(e) { + // for (let key in this.overlays) { + // this.overlays[key].forEach((overlay) => { + // overlay.sprite.scale.x = overlay.scale / e.scale + // overlay.sprite.scale.y = overlay.scale / e.scale + // }) + // } + // } + + /** + * + * + * @param {*} coordinates + * @param {*} [zoom=null] + * @param {*} [{ animate = 0 }={}] + * @memberof ImageMap + */ + moveTo(coordinates, zoom = null, { animate = 0 } = {}) { + // console.log('Coordinates: ', coordinates) + + // if (zoom) { + // this.image.scatter.requestScale(zoom) + // } + + // if (coordinates) { + // //Get pixel position of lat lang values + // let offset = Points.multiplyScalar(this.coordinatesToPoint(coordinates), this.scatter.scale) + + // let frame = this.getFrame() + // console.log(frame) + + // //Calculate the delta depending on image position and center the + // // desired position in the frame. + // let delta = { + // x: frame.center.x - offset.x - this.scatter.position.x, + // y: frame.center.y - offset.y - this.scatter.position.y + // } + + // console.log('Move to: ', offset, frame, delta) + + // if (this.image) { + // if (animate > 0) { + // console.log(animate) + // //TODO Replace this with a more robus manipulation method (RequestMovement + animation) + // this.image.scatter.move(delta, { animate }) + // //Requesting the transform dhoul be okay. Maybe also replace with an request mehtod. + // } else this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 }) + // } else console.error('Imge was not loaded yet.') + // } + + if (this.image.scatter == null) { + return + } + //TODO: This is jaggy on submaps. Make it more smooth. Severin Opel 08-05-2018 + if (zoom) { + /** + * Zoom's the view to a specified distance in DZI-Levels. + * The input level is clipped to allowed levels. + * */ + + let targetScale = zoom * this.zoomFactor + + // Don't scale beyond boundaries. + if (this.maxScale) targetScale = Math.min(this.maxScale, targetScale) + + if (this.minScale) targetScale = Math.max(targetScale, this.minScale) + + this.image.scatter.requestScale(targetScale) + } + + //coordinates = null + if (coordinates) { + //Get pixel position of lat lang values + let point = this.coordinatesToPoint(coordinates) + + let containerCenter + if (this.frame) { + containerCenter = this.getFrame().localCenter + //console.log("HAS FRAME: ", containerCenter) + } else { + containerCenter = { + x: this.image.parent.width / 2, + y: this.image.parent.height / 2 + } + + //console.log("GET CONTAINER CENTER") + } + + //Calculate the delta depending on image position and center the + // desired position in the frame. + + let delta = { + x: containerCenter.x - point.x * this.image.scatter.scale - this.scatter.position.x, + y: containerCenter.y - point.y * this.image.scatter.scale - this.scatter.position.y + } + // console.log("MOVE TO: " , this.image.scatter.translatable,point, delta) + + if (animate > 0) { + let last = 0 + TweenMax.to({ x: 0, y: 0 }, animate, { + ease: Power0.easeNone, + x: delta.x, + y: delta.y, + onUpdateParams: ['{self}'], + onUpdate: tween => { + let step = tween.ratio - last + last = tween.ratio + this.image.scatter.transform(Points.multiplyScalar(delta, step), /*ratio*/ 1, 0, { x: 0, y: 0 }) + } + }) + } else { + this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 }) + } + } + } + + get zoom() { + return this.scatter.scale / this.zoomFactor + } + + get zoomFactor() { + let factor = this.baseZoomHeight / this.sprite.texture.height + console.log(factor) + return factor + } +} diff --git a/lib/pixi/maps/mapapp.html b/lib/pixi/maps/mapapp.html new file mode 100644 index 0000000..7754845 --- /dev/null +++ b/lib/pixi/maps/mapapp.html @@ -0,0 +1,105 @@ + + + + + + MapApp + + + + + + + + + + + + + +

MapApp

+

+ This class extends the PIXIApp to simplify the process of rendering + Maps in the canvas. For that reason, it contains useful functions + for an easier handling of maps in the canvas. +

+ +
+

WHAT TO SEE: The map should focus Paris.

+ + + + \ No newline at end of file diff --git a/lib/pixi/maps/mapapp.js b/lib/pixi/maps/mapapp.js new file mode 100644 index 0000000..6102923 --- /dev/null +++ b/lib/pixi/maps/mapapp.js @@ -0,0 +1,548 @@ +import PIXIApp from '../app.js' +import { CoordinateDisplay } from '../../../../js/display.js' +import { DOMLayer, PIXILayer } from '../../../../src/layers/js/layer.js' +import { MapLayer } from './geolayer.js' +import { RigidScatterContainer } from './scatter.js' +import { EventHandler } from './utils.js' +import { Points } from '../../utils.js' +import Logging from '../../logging.js' + +/** + * MapApp is responsible for showing fullscreen + * map applications. + * + */ +export default class MapApp extends PIXIApp { + constructor(opts = {}) { + super(opts) + + // Default values. + opts = Object.assign( + { + debug: false, + maps: {}, //required + view: null, + startmap: null, + coordsLogging: false, + overlays: {}, + keycodes: {}, + showHotkeys: false, + imageMapZoomHeight: 256, //Defines the zoomvalue 1 for all image maps inside the mapapp. + focus: null, + zoom: 1, + onMapChanged: null, + onSizeChanged: null, + onTransform: null + }, + opts, + { + alpha: 0.5 + } + ) + + this.submaps = [] + this.overlayElements = new Map() + this.debug = opts.debug + this.fpsLogging = opts.fpsLogging + this.showHotkeys = opts.showHotkeys + this.keycodes = this._extractKeyCodes(opts.keycodes) + this.coordsLogging = opts.coordsLogging + this.startmap = opts.startmap + this.overlays = opts.overlays + this.maps = opts.maps + this.focus = opts.focus + this.zoom = opts.zoom + + this.onMapChanged = new EventHandler('mapChanged', { + listeners: opts.onMapChanged + }) + this.onSizeChanged = new EventHandler('sizeChanged', { + listeners: opts.onSizeChanged + }) + this.onTransform = new EventHandler('transformed', { + listeners: opts.onTransform + }) + + /** + * When in debug mode, this allows the user to copy the center coordinates to the clipboard. + */ + if (this.debug) { + this.DRAW_MODES = { + PIXI_POINT: 0, + PATH_MODE: 1, + POLYGON_MODE: 2, + + getName: function(num) { + let result = null + for (const [key, val] of Object.entries(this)) { + if (val == num) result = key + } + return result + } + } + this.drawMode = this.DRAW_MODES.PIXI_POINT + this.drawData = [] + } + this._setupKeyboardUtils() + + Logging.log('Application start') + } + + setup() { + super.setup() + + // TODO get access to fps display + let fpsDisplay + this.stage.children.forEach(element => { + if (element.refreshFps) fpsDisplay = element + }) + + this.pixiLayer = new PIXILayer({ + name: 'Pixi Root', + container: this.scene + }) + + this.domLayer = new DOMLayer({ + name: 'DOM Root', + container: document.body + }) + + if (!this.startmap) { + let firstMap = Object.keys(this.maps)[0] + if (firstMap != null) this.startmap = firstMap + else { + console.error('No map was set. Set a map first, before running the setup command!') + return + } + } + + //console.log('startup', this.startmap, this.maps) + + this.mapLayer = new MapLayer(this.startmap, this.maps, this.scene, { + name: 'Map Layer', + focus: this.focus, + zoom: this.zoom + }) + + this.mapLayer.changeHandler.add(this._mapChanged.bind(this)) + this.pixiLayer.place(this.mapLayer) + this._mapChanged(null) + + this.pixiUiLayer = new PIXILayer({ name: 'Pixi UI' }) + this.pixiLayer.placeLayer(this.pixiUiLayer) + + this.domUiLayer = new DOMLayer({ name: 'DOM UI' }) + this.domLayer.placeLayer(this.domUiLayer) + + if (this.fpsLogging && fpsDisplay) this.pixiUiLayer.place(fpsDisplay) + + if (this.coordsLogging) { + this.coordsDisplay = new CoordinateDisplay(this) + this.pixiUiLayer.place(this.coordsDisplay) + } + + this.__dragging = false + this.__events = new Map() + + const scene = this.scene + scene.interactive = true + scene.on('pointercancel', this.__onEnd.bind(this)) + scene.on('pointerdown', this.__onStart.bind(this)) + scene.on('pointermove', this.__onMove.bind(this)) + scene.on('pointerout', this.__onEnd.bind(this)) + scene.on('pointerup', this.__onEnd.bind(this)) + scene.on('pointerupoutside', this.__onEnd.bind(this)) + + return this + } + + layout(width, height) { + this.scene.resize(width, height) + this.mapLayer.mapview.update() + this.onSizeChanged.call(this) + } + + sceneFactory() { + return new RigidScatterContainer(this.width, this.height, this.renderer, { + app: this, + showBounds: true, + showTouches: true, + showPolygon: true, + container: this + }) + } + + addMaps(maps) { + for (let key in maps) { + this.addMap(key, maps[key]) + } + } + + selectMap(key) { + if (this.maps[key]) { + if (this.mapLayer) this.mapLayer.changeMap(key) + } else { + console.error(`Selected map ("${key}") was not (yet) added to the mapapp.`) + } + } + + setMap(key, map) { + this.addMap(key, map) + this.selectMap(key) + } + + addMap(key, map) { + this.maps[key] = map + } + + transformed(event) { + this.onTransform.call(this, event) + } + + _mapChanged(lastMap) { + if (lastMap) { + lastMap.flushHandlers() + } + + /** + * TODO: A problem is that the map layer has no container + * on its own and it lives in the ScatterContainer. + * Here we guarantee, that the layer order is as it + * is defined in the layers. + */ + this.pixiLayer.layers.forEach(layer => { + if (layer !== this.mapLayer) { + layer.parent.container.removeChild(layer.container) + layer.parent.container.addChild(layer.container) + } + }) + + this.map.onTransform.add(this.transformed.bind(this)) + this.transformed() + this.onMapChanged.call(this, this.map) + } + + _doesOverlayElementExist(layer, type, name) { + let layerElements = this.overlayElements.get(layer) + return layerElements != undefined && layerElements[type] != null && layerElements[type][name] != null + } + + _getOverlayElement(layer, type, name) { + let layerElements = this.overlayElements.get(layer) + return layerElements[type][name] + } + + _setOverlayElement(layer, type, name, value) { + let obj = this.overlayElements.get(layer) + if (obj == undefined) obj = {} + + if (obj[type] == null) obj[type] = {} + + obj[type][name] = value + + this.overlayElements.set(layer, obj) + } + + place(layer) { + if (layer instanceof PIXILayer) { + this.pixiLayer.place(layer) + } else if (layer instanceof DOMLayer) { + this.domLayer.place(layer) + } else { + console.error('Could not add layer to ', this, layer) + } + } + + addMapOverlay(layer) { + this.mapLayer.place(layer) + } + + /** + * Copies the current coordinates to the clipboard. + */ + locationToClipboard() { + let hidden = document.createElement('input') + document.body.appendChild(hidden) + hidden.value = '"location":' + JSON.stringify(app.mapLayer.mapview.focus) + hidden.readOnly = true + hidden.select() + document.execCommand('Copy') + this.showNotification('Copied location to clipboard.') + document.body.removeChild(hidden) + } + + pathToClipboard() { + let hidden = document.createElement('input') + document.body.appendChild(hidden) + + this.drawData.push(this.mapLayer.mapview.focus) + + if (this.drawMode == this.DRAW_MODES.POLYGON_MODE) { + let data = { + type: 'Polygon', + coordinates: [this.drawData] + } + + hidden.value = '"geometry":' + JSON.stringify(data) + } else { + // PATH_MODE is default. + hidden.value = JSON.stringify(this.drawData) + } + hidden.select() + document.execCommand('Copy') + this.showNotification('Location appended in clipboard.') + document.body.removeChild(hidden) + } + + get map() { + return this.mapLayer.map + } + + get activeMapKey() { + return this.mapLayer.active + } + + getRelativePosition(x, y) { + return { + x: x * app.width, + y: y * app.height + } + } + + clearDrawData() { + this.drawData = [] + } + + showNotification(msg) { + let notification = document.createElement('div') + notification.classList.add('notification') + + let text = document.createElement('p') + text.innerHTML = msg + notification.appendChild(text) + + /** + * TODO: move styling (except opacity) to css. + */ + Object.assign(notification.style, { + opacity: 0, + top: 0, + left: 0, + position: 'fixed', + display: 'flex', + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + pointerEvents: 'none' + }) + + document.body.appendChild(notification) + + let popAnimation = new TimelineLite({ + onComplete: () => { + notification.parentNode.removeChild(notification) + } + }) + let animationSpeed = 0.5 + + popAnimation + .to(notification, animationSpeed, { + opacity: 1 + }) + .to(notification, animationSpeed, { + opacity: 0 + }) + } + + _currentLocationToString() {} + + _matchKeyCode(event, keyCode) { + // If keycode does not exist or is invalid - return. + if (!keyCode || keyCode.key == null) return false + + let code = keyCode.key + if (event.shiftKey) code -= 32 + const key = event.which == code || event.keyCode == code || event.charCode == code + + const ctrl = keyCode.ctrlKey == event.ctrlKey + const shift = keyCode.shiftKey == event.shiftKey + const alt = keyCode.altKey == event.altKey + + return key && ctrl && shift && alt + } + + _setupKeyboardUtils() { + document.body.addEventListener('keypress', event => { + this._checkForKeyCode(event) + }) + } + + _checkForKeyCode(event) { + if (this._matchKeyCode(event, this.keycodes.copyCoordinate)) { + event.preventDefault() + + switch (this.drawMode) { + case this.DRAW_MODES.PIXI_POINT: + this.locationToClipboard() + break + case this.DRAW_MODES.PATH_MODE: + case this.DRAW_MODES.POLYGON_MODE: + this.pathToClipboard() + break + default: + console.error( + `Draw mode is not implemented yet: ${this.DRAW_MODES.getName(this.drawMode)}(${this.drawMode}).` + ) + } + } + + if (this._matchKeyCode(event, this.keycodes.togglePathMode)) { + if (this.drawMode == this.DRAW_MODES.PATH_MODE) { + this.showNotification('Path Mode disabled.') + this._resetDrawMode() + } else { + this.drawMode = this.DRAW_MODES.PATH_MODE + this.showNotification('Path Mode enabled.') + this.clearDrawData() + } + } + + //When SHIFT+P is pressed POLYGON-MODE is toggled: + if (this._matchKeyCode(event, this.keycodes.togglePolygonMode)) { + if (this.drawMode == this.DRAW_MODES.POLYGON_MODE) { + this.showNotification('Polygon Mode disabled.') + this._resetDrawMode() + } else { + this.drawMode = this.DRAW_MODES.POLYGON_MODE + this.showNotification('Polygon Mode enabled.') + this.clearDrawData() + } + } + + // When SHIFT+X is pressed toggle crosshair + if (this.keycodes.toggleUi && this._matchKeyCode(event, this.keycodes.toggleUi)) { + if (this.pixiUiLayer.visible) { + this.pixiUiLayer.hide() + this.domUiLayer.hide() + } else { + this.pixiUiLayer.show() + this.domUiLayer.show() + } + } + } + + _resetDrawMode() { + this.drawMode = this.DRAW_MODES.PIXI_POINT + } + + _extractKeyCodes(keycodeText) { + let out = {} + + for (let [name, combinationString] of Object.entries(keycodeText)) { + let keys = combinationString.split('+') + out[name] = { + key: null, + ctrlKey: false, + shiftKey: false, + altKey: false + } + let errors = [] + + const special = ['shift', 'ctrl', 'alt'] + keys.forEach(key => { + if (key.length == 1) { + if (out[name].key) { + const error = 'Tried to set multiple keys as keycode. This is currently not supported.' + errors.push(error) + } else out[name].key = key.charCodeAt(0) + } else { + key = key.toLowerCase() + if (special.indexOf(key) != -1) { + out[name][key + 'Key'] = true + } else { + const error = 'Wrong / non-implemented special character OR typo in key: ' + key + errors.push(error) + } + } + }) + + // Disable hotkey if an error occured. + if (errors.length > 0) + out[name] = { + key: null, + ctrlKey: false, + shiftKey: false, + altKey: false + } + } + + return out + } + + __onStart(event) { + this.__dragging = true + + let hittedSubmap = null + let center = null + for (const submap of this.submaps) { + const radius = submap.container.width / 2 + const distance = Points.distance(submap.center, event.data.global) + if (distance < radius) { + hittedSubmap = submap + center = submap.center + } + } + + this.__events.set(event.data.identifier, { + event, + submap: hittedSubmap, + center + }) + } + + __onMove(event) { + if (this.__dragging) { + const myevent = this.__events.get(event.data.identifier) + + if (myevent && myevent.submap) { + const submap = myevent.submap + const center = submap.center + const radius = submap.container.width / 2 + const distance = Points.distance(center, event.data.global) / submap.scatter.scale + if (distance > radius) { + //submap.resize((distance) * 2, .2) + //submap.centerAt(myevent.center) + } + } + + // for (const submap of this.submaps) { + // const center = submap.center + // const radius = submap.container.width / 2 + // const distance = Points.distance(center, event.data.global) / submap.scatter.scale + // const inside = distance < radius + 10 + + // console.log(distance, radius) + + // if (inside) { + // // (this.width + 80) / 2 * this.scatter.scale + // //const width = (submap.width + 80) / 2 * submap.scatter.scale + // //console.log(width) + + // if (distance > radius) { + // submap.resize((distance) * 2, .2) + // } + // } else { + // if (distance < radius + 20) { + // //submap.resize((distance - 30) * 2, .2) + // } + // } + // } + } + } + + __onEnd(event) { + this.__dragging = false + this.__events.delete(event.data.identifier) + } +} diff --git a/lib/pixi/maps/mapdata.html b/lib/pixi/maps/mapdata.html new file mode 100644 index 0000000..8d26a08 --- /dev/null +++ b/lib/pixi/maps/mapdata.html @@ -0,0 +1,201 @@ + + + + + + MapData + + + + + + + + + + + + + +

MapData

+

Mapdata calculates is responsible for transforming map coordinates to pixel coordinates and backwards.

+ +

Static Squared World Map

+

The most simple example is a squared world map, thats projected with mercator transformation. Ranging from + Longitude + -180 to 180 and Latitude from -90 to 90 (Exclusive, because mercator gets infinite at +/-90).

+

Coordinates: + +

+
+ Image is missing. +
+ + + +

Clipped Map

+

Often we don't use the whole map, or our map is a subsection of the world. MapData can clip those cases, using + the a + bounding box of min and max coordinates.

+

Coordinates: + +

+
+ Image is missing. +
+ + +

Clipped And Translated Map

+

Maps can be also translated, if the whole world is shown and clipping is not an option.

+

Coordinates: + +

+
+
+ + + + + + \ No newline at end of file diff --git a/lib/pixi/maps/mapdata.js b/lib/pixi/maps/mapdata.js new file mode 100644 index 0000000..19974f1 --- /dev/null +++ b/lib/pixi/maps/mapdata.js @@ -0,0 +1,155 @@ +/** + * MapData contains the informations about how + * a Map has to be interpreted. What are the bounds of the + * map and how to translate coordinates into + * image positions. + * + * @class + * @see {@link mapdata.html} + */ +export class MapData { + /** + * Creates instance of MapData + * + * @constructor + * @param {Projection}[projection] - Specifies the projection of the map (e.g. Mercator Projection). + * @param {object}[opts] - Addiditonal options. + * @param {[[minLat, minLng],[maxLat, maxLng]]}[opts.bounds] - Describes the minimum and maximum coordinates on the map + * @param {Point}[opts.translate] - Defines a translation, when clipping is not an option (e.g. when the whole world is shown, but translated.) + */ + constructor(projection, opts = {}) { + this.opts = Object.assign( + { + translate: { x: 0, y: 0 } + }, + opts + ) + + this.projection = projection + + if (this.clip) { + let _cmin = this.projection.forward(this.opts.clip.min) + let _cmax = this.projection.forward(this.opts.clip.max) + + // Swaps the y values, Mercator has it's origin bottom right, + // browser coordinates start top right. + let cmin = { x: _cmin.x, y: _cmax.y } + let cmax = { x: _cmax.x, y: _cmin.y } + + this.clipExt = { + coords: { + min: this.opts.clip.min, + max: this.opts.clip.max + }, + point: { + min: cmin, + max: cmax + } + } + } + } + + toCoordinates(point) { + if (this.clip) { + let min = this.clipExt.point.min + let max = this.clipExt.point.max + + let width = max.x - min.x + let height = max.y - min.y + + point.x *= width + point.y *= height + + point.x += min.x + point.y += min.y + } + + let coordinates = this.projection.backward(point) + + if (this.opts.translate) { + coordinates.x -= this.opts.translate.x + coordinates.y -= this.opts.translate.y + } + + return coordinates + } + + toPixel(coordinates) { + let coords = { x: coordinates.x, y: coordinates.y } + if (this.opts.translate) { + coords.x += this.opts.translate.x + coords.y += this.opts.translate.y + } + + let point = this.projection.forward(coords) + + if (this.opts.clip) { + let min = this.clipExt.point.min + let max = this.clipExt.point.max + + let clippedPoint = { + x: point.x - min.x, + y: point.y - min.y + } + + let width = max.x - min.x + let height = max.y - min.y + + point.x = clippedPoint.x / width + point.y = clippedPoint.y / height + } + + return point + } + + get clip() { + return this.opts.clip + } + + /** + * Bounds to pixel transforms some bounds in form of {min:{x:minLat, y:minLng},max:{x:maxLat, y:maxLng}} + * to pixel coordinates. + * + * @param {*} bounds + */ + boundsToPixel(bounds) { + let min = this.toPixel(bounds.min) + let max = this.toPixel(bounds.max) + + // Y values needs to be swapped, as PIXI has it's origin + // in the top-left corner and a regular map in the bottom-left corner. + let boundaries = { + min: { x: min.x, y: max.y }, + max: { x: max.x, y: min.y } + } + + return boundaries + } + + get maxViewport() { + return this.opts.clip ? this.opts.clip : this.projection.maxViewport + } +} + +export class DeepZoomMapData extends MapData { + constructor(projection, tilesConfig, opts = {}) { + if (!opts.app) console.error('Deepzoom Mapdata needs an app set in the options.') + if (tilesConfig.clip) { + opts.clip = { + min: { + x: tilesConfig.clip.bounds.min[0], + y: tilesConfig.clip.bounds.min[1] + }, + max: { + x: tilesConfig.clip.bounds.max[0], + y: tilesConfig.clip.bounds.max[1] + } + } + } + + console.log(tilesConfig, opts) + + super(projection, opts) + this.app = opts.app + } +} diff --git a/lib/pixi/maps/maps.html b/lib/pixi/maps/maps.html new file mode 100644 index 0000000..280a5f6 --- /dev/null +++ b/lib/pixi/maps/maps.html @@ -0,0 +1,340 @@ + + + + + + Maps + + + + + + + + + + + + + +

Maps

+

Maps represent a geographical image in a PIXI.Application. Preferably in a MapApp to have more convenient methods to + handle the maps.

+ +

Cover Test

+

Usually maps should cover the whole app, or a window inside the app. Therefore they support a cover option. + This option allows the +

+ + + + +

Map Types

+
+
+ + +

Image Map

+

A map, that is represented by a single image.

+
+
+ +

Deep Zoom Map

+

The map representation is a DeepZoomImage, that can be scaled much more deeply, without losing quality by increasing + the amount of tiles, that represent the image.

+

+ You should see a map of the world with a set of cities highlighted with dots. When pressing the button all views should jump + to the same point. +

+
+
+ +

Movement

+

+ Its crucial to focus certain points in a map application. The following tests the behavioud of maps inside a mapapp and maps + inside of windows. +

+ + +

Map Frame

+

+ The map's frame specifies the stage of the map. When moving to the focus point normally, the map focuses in the center, the + frame defines the new area, the map will be centered in. This is important when trying to mask a map. +

+ +
+ + + + +
+ + + + + + \ No newline at end of file diff --git a/lib/pixi/maps/mapview.js b/lib/pixi/maps/mapview.js new file mode 100644 index 0000000..40eead1 --- /dev/null +++ b/lib/pixi/maps/mapview.js @@ -0,0 +1,123 @@ +import { DeepZoomMap } from './map.js' + +/** + * The MapView class is responsible for a consistent map view. + * It is aware of the current viewposition, the scale and viewport. + * It ensures, that maps can be changed, without the user noticing it. + * + */ +export default class MapView { + /** + * + * @param {object} [focus = {x:0, y:0}] - Defines the startup focuspoint of the app. + * @param {number} [zoom = 0] - Defines the startup zoom of the app. Note that this is just a request. + * The MapView will prioritize a full scale app, than displaying the demanded zoom factor + */ + constructor({ + map = null, + focus = null, + zoom = null, + viewport = { min: { x: -85, y: -180 }, max: { x: 85, y: 180 } } + } = {}) { + this.viewport = viewport + this._focus = focus + this._zoom = zoom + this._map = map + this.referenceHeight = 256 + } + + get focus() { + return this._focus + } + get zoom() { + return this._zoom + } + + get map() { + return this._map + } + + /** + * Sets the map to the given focuspoint and zoom factor. + */ + setMap(map) { + this._map = map + this.update() + return true + } + + update() { + this.map.moveTo(this._focus, this._zoom) + } + + transformed(e) { + this.updateZoom() + this.updateFocusPoint() + } + + applyCameraPosition() { + this.updateFocusPoint() + this.updateZoom() + } + + updateFocusPoint() { + const frame = this.map.getFrame() + this._focus = this.coordinatesFromWindowPoint(frame.localCenter) + } + + updateZoom() { + /** + * TODO: This relies on the fact, that all maps have the same tileSize, + * if a set would have a smaller tileSize. Improve that. + */ + if (this.map instanceof DeepZoomMap) this._zoom = this.map.floatingLevelForScale(this.map.image.scatter.scale) + else { + this._zoom = this.map.zoom + console.warn('Zoom is not yet correctly implemented in this Map type: ' + this.map) + } + } + + mapPointToWindowPoint(point) { + let container = this.map.image.parent + + let _point = new PIXI.Point( + this.map.scatter.position.x + this.map.scatter.scale * point.x, + this.map.scatter.position.y + this.map.scatter.scale * point.y + ) + + return container.toGlobal(_point) + } + + windowPointToMapPoint(point) { + let offset = this.map.image.parent.toGlobal({ x: 0, y: 0 }) + let _point = new PIXI.Point( + (point.x - this.map.scatter.position.x - offset.x) / this.map.scatter.scale, + (point.y - this.map.scatter.position.y - offset.y) / this.map.scatter.scale + ) + + return _point + } + + /** + * Gets the coordinates of a specific point in the viewport. + * + * @param {PIXI.Point | {x,y}} point - Pixel position in the viewport. + * @returns {{x,y}} Coordinates on the map of the provided position. + * @memberof MapView + */ + coordinatesFromWindowPoint(point) { + let position = { + x: point.x - this.map.scatter.position.x, + y: point.y - this.map.scatter.position.y + } + + let normalized = { + x: position.x / (this.map.width * this.map.scatter.scale), + y: position.y / (this.map.height * this.map.scatter.scale) + } + + let coordinates = this.map.mapdata.toCoordinates(normalized) + + return coordinates + } +} diff --git a/lib/pixi/maps/overlay.html b/lib/pixi/maps/overlay.html new file mode 100644 index 0000000..d5580e3 --- /dev/null +++ b/lib/pixi/maps/overlay.html @@ -0,0 +1,261 @@ + + + + + + Overlay + + + + + + + + + + + + + +

Overlay

+

+ The overlayclass creates a convenient way to create and design + complex map overlays. +

+ + + + + + + \ No newline at end of file diff --git a/lib/pixi/maps/overlay.js b/lib/pixi/maps/overlay.js new file mode 100644 index 0000000..5253990 --- /dev/null +++ b/lib/pixi/maps/overlay.js @@ -0,0 +1,571 @@ +import { GeoLayer } from './geolayer.js' +import { GeoPoint, GeoMultiShape } from './geographics.js' +import GeoJson from './geojson.js' + +import Popup from '../popup.js' + +/** + * The Overlay-Class shall provide and easy to use frameworkd + * of marking points of interest on a map, using shapes, geometries, + * items or text. + * + * It's highly configurable and adaptable to a variety of scenarios. + * + */ + +export default class Overlay { + /** + *Creates an instance of Overlay. + * @param {*} [{ + * name = "unnamed", {string} + * connector = false, + * connectorType = "line", + * location = null, + * label = false, + * labelType = null, + * labelLocation = null, + * labelAlignment = "center", + * labelVerticalAlign = "underneath", + * labelTarget = null, + * shape = "geometry", {string} - Defines the shape to be used: geometry, circle, rectangle + * geometry = null, + * geometryType = "point", + * geometryFallback = "circle", + * informationPopup = "infocard", + * + * }={}] + * @memberof Overlay + */ + constructor(opts = {}) { + let defaultTextStyle = new PIXI.TextStyle() + + Object.assign( + this, + { + type: 'auto', + name: 'unnamed', + + borderColor: 0x000000, + borderThickness: 5, + + disabledAlpha: 1, + disabledColor: 0xaa1111, + disabledBorderColor: 0x000000, + enabled: true, + labelScale: 1, + location: null, + label: false, + labelType: null, + labelLocation: null, + labelAlignment: 'center', + labelVerticalAlign: 'underneath', + labelSpacing: 10, + labelTarget: null, + geometry: null, + information: '', + informationPath: null, + informationType: 'popup', + icon: null, + iconAlpha: 1, + iconAnchor: { x: 0.5, y: 0.5 }, + iconColor: 0xffffff, + iconScale: 1, + iconOffset: { x: 0, y: 0 }, + items: [], + color: 0xff00ff, + fillAlpha: 1, + size: 50, + scale: 1, + rescale: false, + popoverOffset: { x: 0, y: 0 }, + + /** + * The following Attributes are taken from the TextStyle class + * of PIXI. All can be overwritten in the overlay's JSON file. + */ + labelMultiLineAlign: defaultTextStyle.align, + labelBreakWords: defaultTextStyle.breakWords, + labelDropShadow: defaultTextStyle.dropShadow, + labelDropShadowAlpha: defaultTextStyle.dropShadowAlpha, + labelDropShadowAngle: defaultTextStyle.dropShadowAngle, + labelDropShadowBlur: defaultTextStyle.dropShadowBlur, + labelDropShadowColor: defaultTextStyle.dropShadowColor, + labelDropShadowDistance: defaultTextStyle.dropShadowDistance, + fontColor: 0x000000, + fontFamily: defaultTextStyle.fontFamily, + fontSize: defaultTextStyle.fontSize, + fontStyle: defaultTextStyle.fontStyle, + fontWeight: defaultTextStyle.fontWeight, + labelStroke: defaultTextStyle.stroke, + labelStrokeThickness: defaultTextStyle.strokeThickness, + wordWrap: defaultTextStyle.wordWrap, + wordWrapWidth: defaultTextStyle.wordWrapWidth, + labelFill: defaultTextStyle.fill, + labelFillGradientType: defaultTextStyle.fillGradientType, + labelFillGradientStops: defaultTextStyle.fillGradientStops, + fontVariant: defaultTextStyle.fontVariant, + labelLeading: defaultTextStyle.leading, + letterSpacing: defaultTextStyle.letterSpacing, + labelLineHeight: defaultTextStyle.lineHeight, + labelLineJoin: defaultTextStyle.lineJoin, + labelMiterLimit: defaultTextStyle.miterLimit, + labelPadding: defaultTextStyle.padding, + labelTrim: defaultTextStyle.trim, + textBaseline: defaultTextStyle.textBaseline, + labelWhiteSpace: defaultTextStyle.whiteSpace + }, + opts + ) + } + + get textureParameters() { + return ['icon'] + } + + setTexture(parameter, key, path) { + let obj = (this[parameter] = {}) + obj._key = key + obj._path = path + obj._texture = null + + obj.initialize = function(texture) { + if (!texture) console.error('Could not initialize texture. Invalid value provided: ', texture) + this._texture = texture + }.bind(obj) + + obj.__defineGetter__('texture', function() { + if (!obj._texture) { + console.error('Texture was not yet initialized! ', obj) + } else { + return obj._texture + } + }) + + obj.__defineGetter__('key', () => { + return obj._key + }) + obj.__defineGetter__('path', () => { + return obj._path + }) + } + + apply(parameter, func, obj = null) { + if (obj == null) return + if (Array.isArray(obj)) { + obj.forEach(child => { + if (typeof child == 'object') this.apply(parameter, func, child) + }) + } else { + for (let [key, val] of Object.entries(obj)) { + if (val == null) continue + if (key == parameter) { + func.call(obj, val) + } + + if (typeof val == 'object') { + this.apply(parameter, func, val) + } + } + } + } + + /** + * Creates an overlay from the current Object. + * + * @param {settings} object - Object to configure the creation process. + * @param {settings.excludeItems} function - You may provide a function, that excludes elements selectively. The function must return true for items to exclude them. + * @param {settings.adjustItems} function - You may adjust the created PIXI items, using the adjustItems function. + * @returns {GeoLayer} - Returns the created GeoLayer. + * @memberof Overlay + */ + create({ + excludeItems = item => { + return false + }, + informationCallback = null, + adjustItems = null + } = {}) { + const name = this.name[0].toUpperCase() + this.name.slice(1).toLowerCase() + ' Overlay' + let geoLayer = new GeoLayer({ name }) + if (this.rescale) geoLayer.rescale = this.rescale + this.items.forEach(item => { + if (!excludeItems(item)) { + if (adjustItems) { + adjustItems(item) + } + + item.overlay = this + let graphics = this.createItem(item, informationCallback) + geoLayer.place(graphics) + } + }) + return geoLayer + } + + getItemProperty(item, property) { + let propertyValue = null + const propertyExistsOnItem = item[property] !== undefined + const propertyExistsOnOverlay = this[property] !== undefined + + if (propertyExistsOnItem) propertyValue = item[property] + else if (propertyExistsOnOverlay) propertyValue = this[property] + else { + console.error(`Invalid property: ${property} on`, item) + } + + propertyValue = this._resolveReference(item, propertyValue) + + return propertyValue + } + + _isReference(property) { + if (property && typeof property == 'object' && property.hasOwnProperty('@property')) return true + else return false + } + + _resolveReference(item, property) { + if (this._isReference(property)) { + let referencedProperty = property['@property'] + return this.getItemProperty(item, referencedProperty) + } else { + return property + } + } + + findAllTextures() { + let textures = [] + + this.textureParameters.forEach(parameter => { + if (this.hasOwnProperty(parameter) && this[parameter] != null) { + textures.push(this[parameter]) + } + + this.items.forEach(item => { + if (item.hasOwnProperty(parameter) && item[parameter] != null) { + textures.push(item[parameter]) + } + }) + }) + + return textures + } + + selectTextures(textures) { + this.textureParameters.forEach(parameter => { + if (this.hasOwnProperty(parameter) && this[parameter]) { + let path = this[parameter] + const texture = textures.get(path) + if (!texture) console.error('Texture could not be selected: ', path) + this[parameter] = { + texture, + path + } + } + this.items.forEach((item, idx) => { + if (this.items[idx].hasOwnProperty(parameter) && this.items[idx][parameter]) { + const path = this.items[idx][parameter] + const texture = textures.get(path) + + this.items[idx][parameter] = { + path, + texture + } + } + }) + }) + } + + createItem(item, informationCallback = null) { + let geographics + + /** + * Use a factory to draw the items, if a type is specified. + */ + if (this.type != 'auto') { + let geographicsFactory = Overlay.requestFactory(this.type) + if (!geographicsFactory) + console.error( + 'Invalid Overlay Mode: ' + + this.type + + '. Fallback to auto mode. Overlaymodes must be registeres beforehand. Valid modes are: ' + + Overlay.listFactories().join(',') + + '.' + ) + else { + let actualItem = Object.assign({}, this, item) + geographics = geographicsFactory(actualItem) + } + } + + /** + * Otherwise draw the default geographics. + */ + if (!geographics) { + if (item.geometry == null || item.geometry == undefined) { + if (item.location) { + geographics = this._drawPoint(item, informationCallback) + const scale = this.getItemProperty(item, 'scale') + geographics.graphics.scale.set(scale, scale) + } else console.warn("Item could not be drawn. Specify a 'location' or a 'geometry'.", item) + } else { + geographics = this._drawGeometry(item) + this._drawRoutine(geographics, item, informationCallback) + } + + this._drawLabel(item, geographics) + } + + return geographics + } + + _drawLabel(item, geographics) { + let label = this.getItemProperty(item, 'label') + if (label) { + let textStyle = this._gatherFontStyle(item) + let text = new PIXI.Text(label, textStyle) + + let labelScale = this.getItemProperty(item, 'labelScale') + text.scale.set(labelScale, labelScale) + + let position = new PIXI.Point() + + let align = this.getItemProperty(item, 'labelAlignment') + if (align == 'left'); + else if (align == 'center') position.set(text.position.x - text.width / 2, text.position.y) + else if (align == 'right') position.set(text.position.x - text.width, text.position.y) + else this._logPropertyNotImplemented('labelAlignment', align) + + let verticalAlign = this.getItemProperty(item, 'labelVerticalAlignment') + if (verticalAlign == 'underneath') { + let size = this.getItemProperty(item, 'size') + let scale = this.getItemProperty(item, 'scale') + + let labelSpacing = this.getItemProperty(item, 'labelSpacing') + + position.y += size * scale + labelSpacing + } else if (verticalAlign == 'above') { + let size = this.getItemProperty(item, 'size') + let scale = this.getItemProperty(item, 'scale') + + let labelSpacing = this.getItemProperty(item, 'labelSpacing') + + position.y -= size * scale + text.height + labelSpacing + } else this._logPropertyNotImplemented('labelVerticalAlignment', verticalAlign) + + text.position.set(position.x, position.y) + + geographics.graphics.addChild(text) + } + } + + _gatherFontStyle(item) { + return { + align: this.getItemProperty(item, 'labelMultiLineAlign'), + breakWords: this.getItemProperty(item, 'labelBreakWords'), + dropShadow: this.getItemProperty(item, 'labelDropShadow'), + dropShadowAlpha: this.getItemProperty(item, 'labelDropShadowAlpha'), + dropShadowAngle: this.getItemProperty(item, 'labelDropShadowAngle'), + dropShadowBlur: this.getItemProperty(item, 'labelDropShadowBlur'), + dropShadowColor: this.getItemProperty(item, 'labelDropShadowColor'), + dropShadowDistance: this.getItemProperty(item, 'labelDropShadowDistance'), + fontFamily: this.getItemProperty(item, 'fontFamily'), + fontSize: this.getItemProperty(item, 'fontSize'), + fontStyle: this.getItemProperty(item, 'fontStyle'), + fontWeight: this.getItemProperty(item, 'fontWeight'), + stroke: this.getItemProperty(item, 'labelStroke'), + strokeThickness: this.getItemProperty(item, 'labelStrokeThickness'), + wordWrap: this.getItemProperty(item, 'wordWrap'), + wordWrapWidth: this.getItemProperty(item, 'wordWrapWidth'), + + fill: this.getItemProperty(item, 'labelFill'), + fillGradientType: this.getItemProperty(item, 'labelFillGradientType'), + fillGradientStops: this.getItemProperty(item, 'labelFillGradientStops'), + fontVariant: this.getItemProperty(item, 'fontVariant'), + leading: this.getItemProperty(item, 'labelLeading'), + letterSpacing: this.getItemProperty(item, 'letterSpacing'), + lineHeight: this.getItemProperty(item, 'labelLineHeight'), + lineJoin: this.getItemProperty(item, 'labelLineJoin'), + miterLimit: this.getItemProperty(item, 'labelMiterLimit'), + padding: this.getItemProperty(item, 'labelPadding'), + trim: this.getItemProperty(item, 'labelTrim'), + textBaseline: this.getItemProperty(item, 'textBaseline'), + whiteSpace: this.getItemProperty(item, 'labelWhiteSpace') + } + } + + _drawGeometry(item) { + let multiPolygonArray = [] + + if (Array.isArray(item.geometry)) + item.geometry.forEach(geometry => { + let polygon = this._drawPolygon(geometry) + if (polygon != null && polygon != []) multiPolygonArray.push(polygon) + }) + else { + let polygon = this._drawPolygon(item.geometry) + if (polygon != null && polygon != []) multiPolygonArray.push(polygon) + } + const overlay = this + return new GeoMultiShape(multiPolygonArray, { + onDraw: function() { + overlay._fill.call(overlay, this, item) + } + }) + } + + _drawPolygon(geometry) { + let polygon = [] + if (geometry.type == 'Polygon') { + if (GeoJson.validateCoordinates(geometry.type, geometry.coordinates)) { + polygon = GeoJson.convert(geometry.type, geometry.coordinates) + } else console.error(`Could not validate coordinates for type '${geometry.type}'.`, geometry.coordinates) + } else { + console.warn(`Geometry of type ${geometry.type} is not implemented yet, hence it was ignored.`) + } + + return polygon + } + + _drawRoutine(geographics, item, informationCallback = null) { + this._createIcon(geographics, item) + + const enabled = this.getItemProperty(item, 'enabled') + if (enabled) this._createInformation(geographics, item, informationCallback) + } + + _createIcon(geographics, item) { + let icon = this.getItemProperty(item, 'icon') + if (icon) { + if (icon.texture) { + let sprite = new PIXI.Sprite(icon.texture) + + const iconAnchor = this.getItemProperty(item, 'iconAnchor') + sprite.anchor.set(iconAnchor.x, iconAnchor.y) + + const iconScale = this.getItemProperty(item, 'iconScale') + if (iconScale) sprite.scale.set(iconScale, iconScale) + + const iconOffset = this.getItemProperty(item, 'iconOffset') + if (iconOffset && iconOffset.x != null && iconOffset.y != null) + sprite.position.set(iconOffset.x, iconOffset.y) + + const iconColor = this.getItemProperty(item, 'iconColor') + if (iconColor) sprite.tint = iconColor + + const iconAlpha = this.getItemProperty(item, 'iconAlpha') + if (iconAlpha) sprite.alpha = iconAlpha + + geographics.graphics.addChild(sprite) + } + } + } + + _createInformation(geographics, item, callback = null) { + if (item.information) { + /** + * SO: The overlay class is quite convenient. + * But managing the information creation solely inside this + * class restricts us massively. + * + * Maybe a restructuring would be good, that we can handle + * the creation of information with a callback and can adapt + * to any occuring situation. + * + * e.g. + * + * overlay.informationHandler((geo, type, item)=>{ + * switch(type){ + * case "popup": + * createPopup(item.information) + * break; + * + * .... + * } + * }) + */ + + geographics.graphics.interactive = true + const informationType = this.getItemProperty(item, 'informationType') + + if (callback) callback.call(this, geographics, informationType, item) + else { + this.defaulInformationCallback(geographics, informationType, item) + } + } + } + + defaulInformationCallback(geographics, informationType, item) { + // Make the items interactive. + geographics.graphics.interactive = true + geographics.graphics.on('pointerdown', () => { + if (!geographics.active) { + geographics.active = true + + // Create and place the popup. + let popup = new Popup({ + header: item.name, + content: item.information, + maxWidth: 350 + }) + + popup.position.set( + geographics.graphics.position.x - popup.width / 2, + geographics.graphics.position.y - popup.height - 20 + ) + + geographics.graphics.parent.addChild(popup) + + // Remove the items after n milliseconds. + const removeTime = 2000 + setTimeout(() => { + geographics.active = false + popup.parent.removeChild(popup) + }, removeTime) + } + }) + } + + _fill(geographics, item) { + const enabled = this.getItemProperty(item, 'enabled') + const color = enabled ? this.getItemProperty(item, 'color') : this.getItemProperty(item, 'disabledColor') + let alpha = enabled ? this.getItemProperty(item, 'fillAlpha') : this.getItemProperty(item, 'disabledAlpha') + geographics.graphics.beginFill(color, alpha) + } + + _drawPoint(item, informationCallback = null) { + const overlay = this + let geographic = new GeoPoint(item.location, { + onDraw: function() { + overlay._fill.call(overlay, this, item) + const size = overlay.getItemProperty(item, 'size') ? overlay.getItemProperty(item, 'size') : 0 + this.graphics.drawCircle(0, 0, size) + overlay._drawRoutine.call(overlay, this, item, informationCallback) + } + }) + return geographic + } + + _logPropertyNotImplemented(property, value) { + console.error(`Value '${value}' is not yet implemented in property '${property}'.`) + } +} + +Overlay.Modes = { auto: null } + +Overlay.createFactory = function(name, geographicsFactory) { + if (Overlay.Modes.hasOwnProperty(name)) + console.warn(`The mode ${name} was already implemented and was overwritten!`) + Overlay.Modes[name] = geographicsFactory +} + +Overlay.requestFactory = function(name) { + return Overlay.Modes[name] +} + +Overlay.listFactories = function() { + let modes = [] + for (let key of Object.keys(Overlay.Modes)) { + modes.push(key) + } + return modes +} diff --git a/lib/pixi/maps/projections/mercator.js b/lib/pixi/maps/projections/mercator.js new file mode 100644 index 0000000..0349d21 --- /dev/null +++ b/lib/pixi/maps/projections/mercator.js @@ -0,0 +1,43 @@ +import Projection from './projection.js' + +/** + * This is a projection file, that grants access to the + * MERCATOR projection. + * + * Regulary only few Projections will be used in one + * project, therefore only required one's should be + * loaded. + */ + +export default class Mercator extends Projection { + forward(coords) { + let lat = coords.x + let lng = coords.y + + const PI_180 = Math.PI / 180.0 + const PI_4 = Math.PI * 4 + + const sinLatitude = Math.sin(lat * PI_180) + let y = 0.5 - Math.log((1 + sinLatitude) / (1 - sinLatitude)) / PI_4 + let x = (lng + 180) / 360 + + y = y < 0 ? 0 : y > 1 ? 1 : y + + return new PIXI.Point(x, y) + } + + backward(point) { + let lng = point.x * 360 - 180 + let lat = (Math.asin(-2 / (Math.exp(4 * Math.PI * (0.5 - point.y)) + 1) + 1) * 180) / Math.PI + + return new PIXI.Point(lat, lng) + } + + toString() { + return 'Mercator Projection' + } + + get maxViewport() { + return { min: new PIXI.Point(-85, -180), max: new PIXI.Point(85, 180) } + } +} diff --git a/lib/pixi/maps/projections/projection.js b/lib/pixi/maps/projections/projection.js new file mode 100644 index 0000000..68acd62 --- /dev/null +++ b/lib/pixi/maps/projections/projection.js @@ -0,0 +1,43 @@ +/** + * A projection determines how a geographical card has to + * be interpreted to map coordinate to pixels. + * + * Most used transformation is the mercator projection, + * which projects a sphere on a cylinder. + * + * @abstract + */ + +export default class Projection { + /** + * Transforms a coordinate to a normalized position on the map. + * + * @param {*} coords + * @memberof Projection + */ + forward(coords) { + console.error('You must override the forward function in ' + this.name + '.') + } + + /** + * Transforms a normalized point on the map to a coordinate. + * + * @param {*} point + * @memberof Projection + */ + backward(point) { + console.error('You must override the backward fuction in ' + this.name + '.') + } + + toString() { + return 'Projection (abstract)' + } + + get name() { + return this.toString() + } + + get maxViewport() { + return { min: new PIXI.Point(-90, -180), max: new PIXI.Point(90, 180) } + } +} diff --git a/lib/pixi/maps/projections/projections.html b/lib/pixi/maps/projections/projections.html new file mode 100644 index 0000000..a7ac5c4 --- /dev/null +++ b/lib/pixi/maps/projections/projections.html @@ -0,0 +1,242 @@ + + + + + + Projections + + + + + + + + + + + +

Projections

+

+ Projections are used on the mapdata to translate coordinates to pixelpositions. There are various + projections that can be used. All implemented ones are showcased here. +

+ +
+

Mercator Projection

+

The most commonly used projection is the mercator projection.

+ +
+ +
+ + + + +
+
+

Robinson Projection

+

+ The robinson projection is a more 'artistic' approach, as it follows a table instead of a strict + formula. It has severe distortions at the poles, but that distortion declines rapidly heading towards + the equator. +

+ +
+ +
+ + +
+ + + \ No newline at end of file diff --git a/lib/pixi/maps/projections/robinson.js b/lib/pixi/maps/projections/robinson.js new file mode 100644 index 0000000..d64550f --- /dev/null +++ b/lib/pixi/maps/projections/robinson.js @@ -0,0 +1,148 @@ +import Projection from './projection' + +/* */ + +export default class Robinson extends Projection { + constructor(lng = 0) { + super() + + this.lng0 = lng + this.lengthOfParallel = [ + 1.0, + 0.9986, + 0.9954, + 0.99, + 0.9822, + 0.973, + 0.96, + 0.9427, + 0.9216, + 0.8962, + 0.8679, + 0.835, + 0.7986, + 0.7597, + 0.7186, + 0.6732, + 0.6213, + 0.5722, + 0.5322 + ] + this.distancesFromEquator = [ + 0.0, + 0.062, + 0.124, + 0.186, + 0.248, + 0.31, + 0.372, + 0.434, + 0.4958, + 0.5571, + 0.6176, + 0.6769, + 0.7346, + 0.7903, + 0.8435, + 0.8936, + 0.9394, + 0.9761, + 1.0 + ] + } + forward(coords) { + let { x: lat, y: lng } = coords + + lng = this._adjustLng(lng) + + // Get the required indices, the remainder in between low and hight as ratio + // and the sign of the found indices, as the tables are only in positive direction. + let { low, high, ratio, sign } = this._getInterpolationValues(lat, 90) + + // Values that lie inbetween two indices are interpolated. + let y = this._interpolate(this.distancesFromEquator[low], this.distancesFromEquator[high], ratio) + + // Reapply the sign to the vertical position. + y *= sign + + // The center of the projection is in the center of the map. Therefore we shift the + // center to the top left corner. + y = 1 - (y + 1) / 2 + + // The lengthOfParallel table provides us with the corresponding scaling factor + // for a specific latitude. Inbetween values are interpolated as before. + let proportionalLength = this._interpolate(this.lengthOfParallel[low], this.lengthOfParallel[high], ratio) + + //To normalize the value to a range from -1 to 1. + let x = (proportionalLength * lng) / 180 + + x = (x + 1) / 2 + + return { x, y } + } + + backward(position) { + let { x, y } = position + + y = 1 - 2 * y + let sign = Math.sign(y) + y = Math.abs(y) + + let low = 0 + let high = 0 + for (let i = 0; i < this.distancesFromEquator.length - 1 && y > this.distancesFromEquator[i]; i++) { + low = i + high = i + 1 + } + + let lowDist = this.distancesFromEquator[low] + let highDist = this.distancesFromEquator[high] + + let ratio = highDist - lowDist == 0 ? 0 : (y - lowDist) / (highDist - lowDist) + + let lat = low * 5 + ratio * 5 + + let parallelLengthMin = this.lengthOfParallel[low] + let parallelLengthMax = this.lengthOfParallel[high] + + let completeLength = parallelLengthMin + (parallelLengthMax - parallelLengthMin) * ratio + + x = x * 2 - 1 + let normalizedLength = x / completeLength + + let lng = normalizedLength * 180 + + return { x: lat * sign, y: this._adjustLng(lng, true) } + } + + _adjustLng(lng, inv = false) { + let moved = inv ? lng + this.lng0 : lng - this.lng0 + if (moved < -180) moved += 360 + if (moved > 180) moved -= 360 + + return moved + } + + _interpolate(a, b, ratio) { + return a * (1 - ratio) + b * ratio + } + _getInterpolationValues(value, max) { + let sign = Math.sign(value) + value = Math.min(Math.abs(value), max) + // Note that min and max can be the same. Which is true + // when lat is dividable by 5. This also covers the edge cases 0 and 90. + let minIndex = Math.floor(value / 5) + let maxIndex = Math.ceil(value / 5) + let ratio = (value % 5) / 5 + // console.log({ value, minIndex, maxIndex, ratio }) + // console.log(this.lengthOfParallel.length) + return { low: minIndex, high: maxIndex, ratio, sign } + } + + toString() { + return + } + get name() { + return 'Robinson Projection' + } +} diff --git a/lib/pixi/maps/projections/robinsontest.html b/lib/pixi/maps/projections/robinsontest.html new file mode 100644 index 0000000..9b53c3e --- /dev/null +++ b/lib/pixi/maps/projections/robinsontest.html @@ -0,0 +1,50 @@ + + + + + + + + Document + + + + + + + + + + +
+ +
+ + + + \ No newline at end of file diff --git a/lib/pixi/maps/scatter.html b/lib/pixi/maps/scatter.html new file mode 100644 index 0000000..250c80e --- /dev/null +++ b/lib/pixi/maps/scatter.html @@ -0,0 +1,151 @@ + + + + + + Scatter + + + + + + + + + + + +

Scatter

+

The Scatter in Tüsch specifies some classes to make them more suitable for Map applications.

+ +

CoverScatter

+

Usually maps should cover the whole app, or a window inside the app. Therefore they support a cover option. This option + allows prevents the scatter to move inside the window. + + + +

+

+

Legend

+
    +
  • + Bounds: The bounds represent the bounding boxes of the elements. +
  • +
  • + Polygon: The polygon property of scatters describe their actual extends and are used for e.g. calculating + overlap with the scatter container. +
  • +
  • + Boundaries: You can set boundaries in the options in the form of + opts = {..., boundaries: {min: {x:0,y:0}, + max{x:1,y:1},...}} these will be applied relatively to the image dimensions.
  • +
+

+ + + + + +

Note

+
    +
  • + Cover behaviour is not yet working on rotated elements. +
  • +
+ + + \ No newline at end of file diff --git a/lib/pixi/maps/scatter.js b/lib/pixi/maps/scatter.js new file mode 100644 index 0000000..fdc6900 --- /dev/null +++ b/lib/pixi/maps/scatter.js @@ -0,0 +1,558 @@ +/* ES Lint */ +/* globals PIXI, requestAnimationFrame, performance, app*/ + +import { DisplayObjectScatter, ScatterContainer } from '../scatter.js' +import { Points } from '../../utils.js' +import { EventHandler } from './utils.js' + +/** + * The AdvancedScatterContainer extends the ScatterContainer, but + * uses the findTargetNew instead of the findTarget method, which takes interactive elements + * into account, when calculating the target. + */ +export class AdvancedScatterContainer extends ScatterContainer { + applyToChildScattersRecursively(parent, root, applyFunc) { + for (let child of parent.children) { + if (child.scatter) { + applyFunc(child, root) + this.applyToChildScattersRecursively(child, root, applyFunc) + } + } + } + + /** + * The update method was adjusted to draw bounds and polygpns + */ + update(dt) { + this.clear() + this.lineStyle(1, 0x0000ff) + if (this.showBounds) { + this.applyToChildScattersRecursively(this, this, (child, root) => { + let position = child.worldTransform.apply(new PIXI.Point(0, 0)) + + this.drawShape(child.scatter.bounds) + + let center = { + x: child.scatter.width / 2 / child.scatter.scale, + y: child.scatter.height / 2 / child.scatter.scale + } + center = child.worldTransform.apply(center) + + this.drawCircle(center.x, center.y, 4) + this.drawCircle(position.x, position.y, 4) + }) + + this.lineStyle(2, 0x0000ff) + this.drawShape(this.bounds) + } + if (this.showPolygon) { + this.lineStyle(2, 0xff0000) + for (let child of this.children) { + if (child.scatter) { + let polygon = child.scatter.polygon + let shape = new PIXI.Polygon(polygon.flatAbsolutePoints()) + if ( + shape.points[0] !== shape.points[shape.points.length - 2] || + shape.points[1] !== shape.points[shape.points.length - 1] + ) { + shape.points.push(shape.points[0], shape.points[1]) + } + this.drawShape(shape) + } + } + } + if (this.showTouches) { + let current = this.delegate.interaction.current + for (let [key, point] of current.entries()) { + let local = this.mapPositionToPoint(point) + this.drawCircle(local.x, local.y, 12) + } + } + } + + get width() { + return this.bounds.width + } + + get height() { + return this.bounds.width + } + + findTarget(event, local, global) { + // UO: still problematic. Does not find non interactive elements + // which are needed for some stylus applications + if (event.claimedByScatter) { + return null + } + + this.hitScatter = null + let interactionManager = this.renderer.plugins.interaction + + let displayObject = interactionManager.hitTest(local, this) + console.log(displayObject.dontBlockScatter, displayObject.parent) + if (displayObject.dontBlockScatter && displayObject.parent != null) { + displayObject = interactionManager.hitTest(local, displayObject.parent) + } + + if (displayObject != null && displayObject.scatter != null) this.hitScatter = displayObject.scatter + if (this.claimEvents) event.claimedByScatter = this.hitScatter + + console.log(displayObject) + return this.hitScatter + } +} + +export class RigidScatterContainer extends AdvancedScatterContainer { + constructor(width, height, renderer, opts) { + super(renderer, opts) + this._width = width + this._height = height + } + + resize(width, height) { + this._width = width + this._height = height + } + + get width() { + return this._width + } + + get height() { + return this._height + } + + _calculateBounds() { + this._bounds.clear() + let bounds = new PIXI.Bounds() + bounds.minX = this.parent.position.x + bounds.minY = this.parent.position.y + bounds.maxX = this.parent.position.x + this.width + bounds.maxY = this.parent.position.y + this.height + this._bounds.addBounds(bounds) + } +} + +export class RigidContainer extends PIXI.Graphics { + constructor(width, height, { onResize = null } = {}) { + super() + if (!width || !height) { + console.log( + `Width (${width}) or height (${height}) not set! If this is intended use another PIXI.DisplayObject instead.`, + this + ) + } + this._width = width + this._height = height + this.beginFill(0xffffff, 0.3) + this.drawRect(0, 0, this.width, this.height) + + this.onResize = new EventHandler('resize', { + listeners: onResize ? [onResize] : [] + }) + } + + /** + * Resizes the scattercontainer to the provided dimensions. + * + * @param {number} width - Target width of the resize. + * @param {number} height - Target height of the resize. + * @memberof RigidContainer + */ + resize(width, height) { + this._width = width + this._height = height + this._updateHitArea() + this.onResize.call(this, 'resize', { x: width, y: height }) + } + + _updateHitArea() { + // this.hitArea = new PIXI.Rectangle(0,0, this.width, this.height) + } + + get width() { + return this._width + } + get height() { + return this._height + } +} + +/** + * Specialization of the DisplayObjectScatter with special behavior needed for maps. + */ +class AdvancedScatter extends DisplayObjectScatter { + /** + * Animates the throw and ensures that the map is always visible. This + * is different from the standard behavior. + * + * @param {*} time + * @memberof AdvancedScatter + */ + animateThrow(time) { + // In rare cases animateThrow is called when the displayObject is already removed + if (this.displayObject.parent == null) { + return + } + + //Moved this outside, as the time continously increases, + //when the object is not thrown. + + if (this.velocity != null) { + let dt = this._throwDeltaTime() + // This seems to just correct the velocity when its calculated wrong. + // In what case can it get bigger? Velocity tends to always shrink, when no force is added. + let d = this._getThrowDelta(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() + } + + get parent() { + // Returns the parent is a scatter get the parent scatter. + // If it's a scatter container, get the displayObject. + // Other cases are not allowed. + return this.displayObject.parent != null && this.displayObject.parent.scatter != null + ? this.displayObject.parent.scatter + : this.displayObject.parent + } + + _getThrowDelta(dt) { + this.velocity = this.nextVelocity(this.velocity) + if (this.velocity != null) { + return Points.multiplyScalar(this.velocity, dt) + } + return { x: 0, y: 0 } + } + + freeze({ translatable = false, scalable = false, rotatable = false, movableX = false, movableY = false } = {}) { + this.translatable = translatable + this.scalable = scalable + this.rotatable = rotatable + this.movableX = movableX + this.movableY = movableY + } + + unfreeze({ translatable = true, scalable = true, rotatable = true, movableX = true, movableY = true } = {}) { + this.freeze({ + translatable, + scalable, + rotatable: false, + movableX, + movableY + }) + } +} + +export class SubmapScatter extends DisplayObjectScatter { + get width() { + return this.displayObject.width * this.displayObject.scale.x + } + + get height() { + return this.displayObject.height * this.displayObject.scale.y + } + + freeze({ translatable = false, scalable = false, rotatable = false, movableX = false, movableY = false } = {}) { + this.translatable = translatable + this.scalable = scalable + this.rotatable = rotatable + this.movableX = movableX + this.movableY = movableY + } + + unfreeze({ translatable = true, scalable = true, rotatable = true, movableX = true, movableY = true } = {}) { + this.freeze({ + translatable, + scalable, + rotatable: false, + movableX, + movableY + }) + } + + onZoomed(about) { + super.onZoomed(about) + } +} + +/** + * TODO + * A scatter which width does not increase when child elements are added. + */ +export class CompactScatter extends AdvancedScatter {} + +/** + * A problem with PIXI Objects is, that the bounding box changes + * with child elements. Therefore the sized scatter has a rigid size, that is not + * changed by elements, but only with their width and height property. + */ +export class CoverScatter extends AdvancedScatter { + constructor(a, b, opts) { + super(a, b, opts) + + opts = Object.assign( + { + debug: false, + cover: true, + boundaries: { + min: { x: 0, y: 0 }, + max: { x: 1, y: 1 } + } + }, + opts + ) + + this.debug = opts.debug + this.cover = opts.cover + this._boundaries = opts.boundaries + + if (this.debug) { + this.debugGraphics = new PIXI.Graphics() + this.displayObject.addChild(this.debugGraphics) + this.debugGraphics.lineStyle(0.2, 0x00ff00) + this.debugGraphics.drawRect( + this.width * this.boundaries.min.x, + this.height * this.boundaries.min.y, + this.width * (this.boundaries.max.x - this.boundaries.min.x), + this.height * (this.boundaries.max.y - this.boundaries.min.y) + ) + + this.debugGraphics.endFill() + } + + if (this.cover) { + // The reference to the element handler needs to be stored, + // that we can remove it later on. + const eventHandler = this._applyInitialCover.bind(this, null, true) + this._applyInitialCover(eventHandler) + } + } + + _applyInitialCover(eventHandler, removeListener = false) { + if (this.debug) console.log('ApplyInitialCover: ', parent) + if (removeListener) { + this.displayObject.off('added', eventHandler) + } + + if (this.displayObject.parent) + this.forceCover(this.displayObject.parent.width, this.displayObject.parent.height) + else { + this.displayObject.on('added', eventHandler) + } + } + + get boundaries() { + if (this._boundaries) return this._boundaries + else + return { + min: { x: 0, y: 0 }, + max: { x: 1, y: 1 } + } + } + + transform(translate, zoom, rotate, anchor) { + if (this.cover) { + if (!this.parent) { + return new PIXI.Rectangle(0, 0, window.innerWidth, window.innerHeight) + } + + /** + * If the scaling violates the minCoverScale, the zoom is adjusted to perfectly match + * the minCoverScale. + */ + let minCoverScale = this.calculateMinCoverScale(this.parent.width, this.parent.height) + + let { zoom: fixedZoom, scale } = this.calculateScale(zoom) + if (scale < minCoverScale) { + zoom = minCoverScale / this.scale + } + } + + super.transform(translate, zoom, rotate, anchor) + if (this.cover) { + let postTranslate = this.outsideBoundaries() + super.transform(postTranslate, 1, 0, { x: 0, y: 0 }) + } + } + + outsideBoundaries(movement = { x: 0, y: 0 }, { boundaries = this.boundaries } = {}) { + if (this.parent == null) { + return { x: 0, y: 0 } + } + let left = -(this.displayObject.position.x + movement.x + this.width * boundaries.min.x) + let right = -left + this.width * (boundaries.max.x - boundaries.min.x) - this.parent.width + + let top = -(this.displayObject.position.y + movement.y + this.height * boundaries.min.y) + let bot = -top + this.height * (boundaries.max.y - boundaries.min.y) - this.parent.height + + // Helper function to limit an Axis inside a container, or 'glue' it + // to the lower side, if the size is smaller than the size of the + // container. + function limitAxis(low, high, contentSize, containerSize) { + let val = 0 + if (low < 0) { + val = low + } else if (high < 0) { + if (contentSize > containerSize) { + // ... and the element is bigger as the container + // - reset it to the containersize. + val = -high + } else { + // ... and the size is not high enough, then + // 'glue' it to the low axis. + val = low + } + } + + return val + } + + // Use the helper function to correct the movement to cover x and y. + let correctionX = limitAxis(left, right, this.width * (boundaries.max.x - boundaries.min.x), this.parent.width) + let correctionY = limitAxis(top, bot, this.height * (boundaries.max.y - boundaries.min.y), this.parent.height) + let fixedMovement = { + x: correctionX == 0 ? movement.x : correctionX + movement.x, + y: correctionY == 0 ? movement.y : correctionY + movement.y + } + + return fixedMovement + } + + calculateMinCoverScale(width, height) { + let scale = 0 + + if (!(this.width == 0 && this.height == 0)) { + let actualWidth = this.width / this.scale + let actualHeight = this.height / this.scale + + let boundaryWidth = (this.boundaries.max.x - this.boundaries.min.x) * actualWidth + let boundaryHeight = (this.boundaries.max.y - this.boundaries.min.y) * actualHeight + + let coverWidth = width / boundaryWidth + let coverHeight = height / boundaryHeight + + scale = Math.max(coverWidth, coverHeight) + } + + return scale + } + + _getThrowDelta(dt) { + let delta = super._getThrowDelta(dt) + if (this.cover) { + delta = this.outsideBoundaries(delta, { + boundaries: this.boundaries + }) + } + return delta + } + + containerChanged(width, height) { + if (this.cover) this.forceCover(width, height) + } + + forceCover() { + this.requestScale(this.scale) + let translate = this.outsideBoundaries() + this.transform(translate, 1, 0, { x: 0, y: 0 }) + } + + requestScale(scale) { + if (this.scalable && this.parent != null) { + if (this.cover) { + let minCoverScale = this.calculateMinCoverScale(this.parent.width, this.parent.height) + scale = scale < minCoverScale ? minCoverScale : scale + } + this.scale = scale + } + } + + requestFocus(point) { + let allowedMovement = this.outsideBoundaries(Points.subtract(point, this.position)) + this.transform(allowedMovement, 1, 0, { x: 0, y: 0 }) + } +} + +/** + * The MapObjectScatter somewhat inverts the behaviour of + * regular throwables. Regular throwables should bounce of the wall, when they + * overlap to a certain amount. Maps on the otherhand are intended to overlap with the + * scatter container heavily to hide irrelevant parts of the map without revealing + * any background. + * + * @class + * @extends DisplayObjectScatter + */ + +export class MapObjectScatter extends CoverScatter { + /** + * x=null, y=null, + minScale=0.1, + maxScale=1.0, + startScale=1.0, + autoBringToFront=true, + translatable=true, scalable=true, rotatable=true, resizable=false, + movableX=true, + movableY=true, + throwVisibility=44, + throwDamping = 0.95, + autoThrow=true, + rotationDegrees=null, + rotation=null, + onTransform = null } + */ + constructor(displayObject, renderer, opts = {}) { + opts = Object.assign( + { + debug: false, + cover: false, + startScale: 1, + boundaries: { + min: { x: 0, y: 0 }, + max: { x: 1, y: 1 } + }, + autoBringToFront: false + }, + opts + ) + + if (!renderer) { + console.error('Renderer was not set!') + return + } + + super(displayObject, renderer, opts) + this.cover = opts.cover + } + + moveTo() { + super.moveTo(...arguments) + } + + lock() { + this.rotatable = false + this.moveable = false + this.scaleable = false + } + + unlock() { + this.rotatable = true + this.moveable = true + this.scaleable = true + } + + getWorldScatter() { + return this.parent + } +} diff --git a/lib/pixi/maps/utils.html b/lib/pixi/maps/utils.html new file mode 100644 index 0000000..c531761 --- /dev/null +++ b/lib/pixi/maps/utils.html @@ -0,0 +1,134 @@ + + + + + + Utils + + + + + + + + + + + + +

Utils

+

Some utility functionalities for the Tuesch.

+ +

Event Handler

+

+ The event handler class generalizes a common design principle in javascript. When an event occurs, that is of relevance for + other objects, then this event should be sent out to all objects, that are interested in that event. Objects interested + in that event, subscribe the event handler and get notified via a callback function. +

+ + +

DomUtils

+ + Utility functions that help handling the DOM. + +

positionOnElement(element, position)

+ Function that returns the global position for a normalized position. + +
+ + + + + + \ No newline at end of file diff --git a/lib/pixi/maps/utils.js b/lib/pixi/maps/utils.js new file mode 100644 index 0000000..fe6ae95 --- /dev/null +++ b/lib/pixi/maps/utils.js @@ -0,0 +1,482 @@ +import { Points } from '../../utils.js' + +export class ObjectUtils { + /** + * Finds the previous key in an object. + * + * @param {string} targetKey - The target key, of whom the predecessor is wanted. + */ + static findPredecessor(targetKey, obj) { + // Initialize pointers ... + let prev = null + let first = null + // ... and a hit flag. + let hit = false + + //Iterate over all available items. + for (let key in obj) + if (obj.hasOwnProperty(key)) { + if (!hit) { + //Assign both values, + //while not hit + prev = first + first = key + + //When hit, set flag. + if (key == targetKey) { + hit = true + if (prev) return prev //If it was not hit on the first item, we can return prev. + } + } else { + //Otherwise, when first item was hit, we iterate over the entire object. + first = key + } + } + + return first + } + + /** + * Finds the successing key in an object. + * + * @param {string} targetKey - The key of whom the successor key should be found. + * @param {object} obj - The object, that is scanned for the successor. + */ + static findSuccessor(targetKey, obj) { + let first = null + let next = false + + for (let key in obj) + if (obj.hasOwnProperty(key)) { + if (!first) first = key + + if (next) { + return key + } + + if (targetKey == key) { + next = true + } + } + + return first + } + + static fromPath(obj, path, separator = '.') { + let arr = path.split(separator) + let result = obj + + for (let i = 0; i < arr.length; i++) { + if (result[arr[i]] !== null) { + result = result[arr[i]] + } else return null + } + + return result + } +} + +export class PathUtils { + static fixTrailingSlash(url) { + return url.replace(/\/?$/, '/') + } +} + +export class TimeUtils { + static minutesToMs(minutes) { + return TimeUtils.minutesToSeconds(minutes) * 1000 + } + + static minutesToSeconds(minutes) { + return minutes * 60 + } +} + +export class StringUtils { + /* Used from: https://stackoverflow.com/questions/1026069/how-do-i-make-the-first-letter-of-a-string-uppercase-in-javascript */ + static capitalize(string) { + return string.charAt(0).toUpperCase() + string.slice(1) + } +} + +export class GeometryUtils { + static RandomRange(min, max) { + return GeometryUtils.toRadians(Math.random() * (max - min) + min) + } + + static RandomSign() { + return Math.sign(Math.random() - 0.5) + } + + static toDegrees(radians) { + return (radians * 180) / Math.PI + } + + static toRadians(degrees) { + return (degrees / 180) * Math.PI + } + + /* + Recursion is buggy. + */ + static subdivide(polygon, factor = 0.5, n = 0) { + console.log(polygon.length, factor) + let points = [] + for (let i = 0; i < polygon.length; i++) { + let prev = i - 1 < 0 ? polygon[polygon.length - 1] : polygon[i - 1] + let next = i + 1 < polygon.length ? polygon[i + 1] : polygon[0] + let current = polygon[i] + points.push(GeometryUtils.linearInterpolation(prev, current, factor)) + points.push(GeometryUtils.linearInterpolation(current, next, 1 - factor)) + } + if (n > 0) { + n-- + points = GeometryUtils.subdivide(points, n) + } + return points + } + + static linearInterpolation(first, second, n) { + let point = new PIXI.Point() + + point.x = first.x * n + second.x * (1 - n) + point.y = first.y * n + second.y * (1 - n) + return point + } +} + +export class Matrix { + constructor(a, b, d, e, c = 0, f = 0) { + Object.assign(this, { + a, + b, + c, + d, + e, + f, + g: 0, + h: 0, + i: 1 + }) + } + + static Transpose(m) { + let first = ['b', 'c', 'f'] + let second = ['d', 'g', 'h'] + + for (let i = 0; i < first.length; i++) { + let tmp = m[first[i]] + first[i] = second[i] + second[i] = tmp + } + } + + static Rotation(deg) { + let rad = (2 * Math.PI * deg) / 180 + return new Matrix(Math.cos(rad), -Math.sin(rad), Math.sin(rad), Math.cos(rad)) + } + + static Transform(x, y) { + return new Matrix(1, 0, 0, 1, x, y) + } + + static MultiplyPoint(m, p) { + x = p.x * m.a + m.b * p.y + m.c * 1 + y = p.x * m.d + p.y * m.e + m.f * 1 + + return { + x, + y + } + } +} + +export class DomUtils { + static getCenter(element) { + let x = (element.clientLeft + element.clientWidth) / 2 + let y = (element.clientTop + element.clientHeight) / 2 + return { x, y } + } + + static getTransformedPosition(element) { + let mat = this.getCSSMatrix(element) + return { x: mat[4], y: mat[5] } + } + + static getCSSMatrix(element) { + let matrix = window.getComputedStyle(element).transform + + if (matrix == 'none') { + return [1, 0, 0, 1, 0, 0] + } else { + const pre = 'matrix(' + let values = matrix.substring(pre.length, matrix.length - 1).split(',') + for (let i in values) { + values[i] = parseFloat(values[i]) + } + return values + } + } + + static positionOnElement(element, position) { + const matrix = this.getCSSMatrix(element) + + const inX = element.offsetWidth * (position.x - 0.5) + const inY = element.offsetHeight * (position.y - 0.5) + + let outX = matrix[0] * inX + matrix[2] * inY + matrix[4] + let outY = matrix[1] * inX + matrix[3] * inY + matrix[5] + + let out = { + x: outX, + y: outY + } + + return out + } + + static applyTransform(target, transformedPosition, size = 0) { + return { + x: target.offsetLeft + target.offsetWidth / 2 + transformedPosition.x - size / 2, + y: target.offsetTop + target.offsetHeight / 2 + transformedPosition.y - size / 2 + } + } +} + +export class Vector { + static length(vector) { + return Math.sqrt(vector.x * vector.x + vector.y * vector.y) + } + + static normalize(vector) { + return Points.multiplyScalar(vector, 1 / this.length(vector)) + } + + static scaleTo(vector, length) { + let normalized = this.normalize(vector) + return Points.multiplyScalar(normalized, length) + } +} + +/** + * The EventHandler class is used to take care of a event based design + * pattern. Callbacks can subscribe to an event and these unknown sources + * get notified whenever the event changes. + * + * @export + * @class EventHandler + */ +export class EventHandler { + /** + * Creates an instance of EventHandler. + * @param {any} name + * @param {any} [{ + * listeners = [] - With the listnerers parameter the user can specify a function, array of functions or null (no function - useful when used in constructor with optional parameter). + * }={}] + * @memberof EventHandler + */ + constructor(name, { listeners = [] } = {}) { + this.name = name + this.listeners = [] + this.onces = [] + + /** + * One may initialize the eventListener using a parameter + * that is either passed or null. + */ + + if (listeners == null) { + // Null is a valid value as the EventHandler assumes no listener is passed on purpose. + // This is useful, when a default parameter is passed as null. + } else if (Array.isArray(listeners)) this.listeners = listeners + else if (typeof listeners == 'function') { + this.listeners = [] + this.add(listeners) + } else { + console.warn( + "The provided 'listeners' is neither an Array of functions, nor a function. No eventcallback was added!", + listeners, + this + ) + } + } + + addMultiple(...callbacks) { + for (let callback of callbacks) { + this.listeners.push(callback) + } + } + + add(callback) { + this.listeners.push(callback) + return callback + } + + once(callback) { + this.onces.push(callback) + } + + remove(func) { + for (const [idx, listener] of this.listeners.entries()) { + if (listener === func) { + this.listeners.splice(idx, 1) + return true + } + } + return false + } + + empty() { + this.listeners = [] + } + + call(context, ...args) { + this.listeners.forEach(listener => listener.call(context, ...args)) + this.onces.forEach(listener => listener.call(context, ...args)) + this.onces = [] + } + + get length() { + return this.listeners.length + this.onces.length + } +} + +export class Dom { + /** + * Popups should be displayed right over the text. + * Normally we would expect the popup to appear right over + * the center of the text. A problem in HTML is, that it's hard + * to determine the position of a text link, when it has a line-break + * in it. + * + * This function solves this problem in the (so far) only possible way. + * + * 1. It removes the link from the dom tree. + * 2. It adds an empty copy A of the link to the dom tree. (Copy is important, as the same styles have to be applied.) + * 3. The contents of the link are added one by one to A. + * 4. If the resulting boundingRect is bigger than the previous one, a line break is detected. + * 5. The old line is tested, if the point was inside that boundingBox. If so save that bounding box (Goto: 7), else: + * 6. Saves the content to a preceding clone B. And repeats from 3. + * 7. Replace A with the initial content + * 8. Return the found BoundingBox. If none found. Return the last bounding box. + */ + static getTextHitRect(link, point) { + // We cannot use it as it produces axis aligned bounding boxes + /* if (true) { + let rects = link.getClientRects() + let target = null + for (let [idx, rect] of Object.entries(rects)) { + target = rect + if (Rect.contains(rect, point)) + break + } + return target + + } else {*/ + let processedText = link.cloneNode(true) + let content = processedText.innerHTML + let words = content.split(/ /g) + processedText.innerHTML = '' + event.target.innerHTML = '' + // let lineRect = event.target.getBoundingClientRect() + let local = Points.fromPageToNode(event.target.parentNode, point) + console.log(local) + + let target = event.target + let height = 0 + + while (words.length > 0) { + let word = words.pop() + target.innerHTML += word + ' ' + if (target.height != height) { + // New line was reached. + console.log('NEW LINE WAS REACHED!') + } + } + + return { + top: 0, + left: 0, + right: 100, + bottom: 100, + width: 100, + height: 100 + } + + // let total = words.length + // while (words.length > 0) { + // let lastRect = lineRect + // let lastContent = event.target.innerHTML + // let added = words.length == total ? "" : " " + // added += words.shift() + // event.target.innerHTML += added + // lineRect = event.target.getBoundingClientRect() + + // // When new line or last line: + // if (lineRect.height != lastRect.height) { + // //Reconstructure last line. + // event.target.innerHTML = lastContent + + // //Create Rect from last line. + // lineRect = event.target.getBoundingClientRect() + // rects.push(lineRect) + + // //Copy last line content to processed text. + // processedText.innerHTML += lastContent + + // // Create content of new line. + // event.target.innerHTML = added + + // if (!processedText.parentNode) event.target.parentNode.insertBefore(processedText, event.target) + + // if (Rect.contains(lineRect, point)) { + // break + // } + // } + + // if (words.length == 0) { + // lineRect = event.target.getBoundingClientRect() + // processedText.innerHTML += event.target.innerHTML + // rects.push(lineRect) + // } + // } + + // event.target.innerHTML = content + // if (processedText.parentNode) processedText.parentNode.removeChild(processedText) + + // return lineRect + // } + } + + static printDomRect(rect, { color = 'red', pad = 0, parent = document.body }) { + let element = document.createElement('div') + Object.assign(element.style, { + padding: pad + 'px', + borderColor: color, + borderWidth: '2px', + borderStyle: 'solid', + top: rect.top + 'px', + left: rect.left + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + position: 'absolute', + zIndex: 10000, + opacity: 0.3 + }) + parent.appendChild(element) + } + + static printDomPoint(point, { color = 'red', parent = document.body }) { + let element = document.createElement('div') + Object.assign(element.style, { + position: 'absolute', + top: point.y + 'px', + left: point.x + 'px', + width: '10px', + height: '10px', + backgroundColor: color, + zIndex: 10000 + }) + parent.appendChild(element) + } +} diff --git a/lib/pixi/scatter.js b/lib/pixi/scatter.js index cf4e76d..afeb273 100755 --- a/lib/pixi/scatter.js +++ b/lib/pixi/scatter.js @@ -78,6 +78,7 @@ export class ScatterContainer extends PIXI.Graphics { get bounds() { let x = 0 let y = 0 + // @container: We need to call the constant values, as the container // gets resized, when a child moves outside the original boundaries. let w = this.container ? this.containerDimensions.x : this.backgroundWidth || this.app.width @@ -161,9 +162,6 @@ export class ScatterContainer extends PIXI.Graphics { } findHitScatter(data, displayObject, hit) { - // if (hit) { - // console.log("findHitScatter", displayObject) - // } if (hit && this.hitScatter === null && typeof displayObject != undefined) { this.hitScatter = displayObject.scatter ? displayObject.scatter : null } @@ -203,11 +201,13 @@ export class ScatterContainer extends PIXI.Graphics { if (event.claimedByScatter) { return null } + this.hitScatter = null let interactionManager = this.renderer.plugins.interaction let displayObject = interactionManager.hitTest(local, this) if (displayObject != null && displayObject.scatter != null) this.hitScatter = displayObject.scatter if (this.claimEvents) event.claimedByScatter = this.hitScatter + return this.hitScatter } diff --git a/package-lock.json b/package-lock.json index 28f9d32..b7d62be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2845,8 +2845,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", - "dev": true, - "optional": true + "dev": true }, "expand-brackets": { "version": "2.1.4", @@ -3277,8 +3276,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -3299,14 +3297,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3321,20 +3317,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -3451,8 +3444,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -3464,7 +3456,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3479,7 +3470,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3487,14 +3477,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -3513,7 +3501,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -3594,8 +3581,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -3607,7 +3593,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -3693,8 +3678,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -3730,7 +3714,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3750,7 +3733,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3794,14 +3776,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } },