import PIXIApp from '../app.js' import { CoordinateDisplay } from './display.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' import { MapList } from './maplist.js' /** * A PIXI.Point or object in form {x,y}. * * @typedef {Object} Point * @property {number} x - Indicates whether the Courage component is present. * @property {number} y - Indicates whether the Power component is present. */ /** * A coordinate point is a PIXI.Point or object in form {x,y} that contains map coordinates * instead of pixel values, where x represents the latitude and y the longitude. * @typedef {Object} CoordinatePoint * @property {number} x - Indicates whether the Courage component is present. * @property {number} y - Indicates whether the Power component is present. */ /** * MapApp is responsible for showing fullscreen * map applications. * * @export * @class MapApp * @extends {PIXIApp} */ export default class MapApp extends PIXIApp { /** *Creates an instance of MapApp. * @memberof MapApp */ constructor(opts = {}) { super(opts) // Default values. opts = Object.assign( { debug: false, maps: {}, //required view: null, //required wrapper: null, // Required if you want to use DOM Overlays. startmap: null, coordsLogging: false, overlays: {}, keycodes: {}, 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.keycodes = this._extractKeyCodes(opts.keycodes) this.coordsLogging = opts.coordsLogging this.overlays = opts.overlays this.focus = opts.focus this.zoom = opts.zoom this.wrapper = opts.wrapper 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.mapList = new MapList(opts.startmap ? opts.startmap : null, opts.maps ? opts.maps : {}) this._setupKeyboardUtils() } /** * Log boundaries for a shown map. * This is for development purposes only, if you want * to find the boundaries of a shown map. * * @memberof MapApp */ logMapBoundaries() { let map = this.mapLayer.map let boundaries = { min: this.mapLayer.mapview.coordinatesFromWindowPoint(map, { x: 0, y: 0 }), max: this.mapLayer.mapview.coordinatesFromWindowPoint(map, { x: 0, y: 0 }) } console.log(JSON.stringify(boundaries)) } /** * Creates the MapLayer. * * @private * @memberof MapApp */ _setupMapLayer() { this.mapContainer = new PIXI.Container() this._mapLayer = new MapLayer(this.mapList, this.scene, this.mapContainer, { name: 'Root Map Layer', focus: this.focus, zoom: this.zoom, onChange: this._mapChanged.bind(this) }) } setup() { super.setup() // TODO get access to fps display let fpsDisplay = null this.stage.children.forEach(element => { if (element.refreshFps) fpsDisplay = element }) this._setupMapLayer() this.ui = new PIXI.Container() this.scene.addChild(this.ui) if (this.fpsLogging != null && fpsDisplay != null) this.ui.addChild(fpsDisplay) if (this.coordsLogging) { this.coordsDisplay = new CoordinateDisplay(this) this.ui.addChild(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 } /** * You can add Dom elements directly to the app's DomLayer by calling * appendChild(). The elements will be forced to use an absolute positioning. * This shall prevent unwanted sideeffects with the app. * * @param {HTMLElement} element - Element to add to DOMRoot. * @memberof MapApp */ appendChild(element) { if (!this.wrapper) console.error( `When using DOMElements on ${this.constructor.name} you must specify a wrapper in the constructor.` ) if (!(element instanceof HTMLElement)) console.error( 'You can only append HTMLElements to DOM. If you want to add PIXI elements to the scene call app.scene.addChild(element).' ) else { Object.assign(element.style, { position: 'absolute' }) this.wrapper.appendChild(element) } } /** * Relayouts the app. E.g. called when the window is resized. * * @param {number} width - Desired width of the app. * @param {number} height - Desired height of the app. * @memberof MapApp */ layout(width, height) { this.scene.resize(width, height) this.mapLayer.mapview.update() this.onSizeChanged.call(this) } /** * Overrides the sceneFactory of the PIXIApp to create a RigidScatterContainer instead of * a regular PIXI.Container() * * @private * @returns {RigidScatterContainer} - Returns the newly created RigidScatterContainer. * @memberof MapApp */ sceneFactory() { return new RigidScatterContainer(this.width, this.height, this.renderer, { app: this, showBounds: false, showTouches: false, showPolygon: false, container: this, stopEvents: false, claimEvents: false }) } /** * Changes the map to the given key. * * @param {string} key - Identifier of the map to change to. * @memberof MapApp */ selectMap(key) { if (this.debug) console.log('Select map', key, result) let result = this.mapList.select(key) if (result && this.mapLayer) { this.mapLayer.changeMap(this.mapList.map) } } /** * Adds and sets a map to the mapapp. * * @param {string} key - Name of the map. * @param {GeoMap} map - Map to add. * @memberof MapApp */ setMap(key, map) { this.addMap(key, map) this.selectMap(key) } /** * Adds a map to the maplist. * If no map is set, the added map will be set as default. * * @param {string} key - Identifier for the map. * @param {GeoMap} map - Map object to add. * @memberof MapApp */ addMap(key, map) { if (this.mapList) this.mapList.add(key, map) else console.error('Cannot access mapLayer. It was not initialized yet.') } /** * Adds multiple maps at once. * * @param {object} mapObject * @memberof MapApp */ addMaps(mapObject) { for (let [key, val] of Object.entries(mapObject)) { if (val instanceof GeoMap) { this.addMap(key, val) } else console.warn('Tried adding maps that are not og Type GeoMap.') } } transformed(event) { this.onTransform.call(this, event) } /** * * Called when the mapLayer changed the map. * * @private * @param {*} lastMap * @memberof MapApp */ _mapChanged(lastMap) { if (lastMap) { lastMap.flushHandlers() } this.transformed() this.onMapChanged.call(this, this.map) this.map.onTransform.add(this.transformed.bind(this)) if (this.ui && this.ui.parent) { const parent = this.ui.parent parent.removeChild(this.ui) parent.addChild(this.ui) } } /** * Returns the mapLayer of the map. * * @member {MapLayer} * @memberof MapApp */ get mapLayer() { return this._mapLayer } _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) } /** * Copies the current location to the clipboard. * * @memberof MapApp */ 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) } /** * Can be used to copy polygons to the clipboard. * * Useful for debugging or to roughly trace a shape in the map. * The generated pointarray can be used as geometry of a geographic * or inside an overlay to draw that shape onto the map. * * @memberof MapApp */ 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) } /** * Returns the active map. * * @readonly * @memberof MapApp */ get map() { return this.mapList.map } clearDrawData() { this.drawData = [] } /** * Logs a text field on the map. * The text element is a DOMElement. * * @param {string} msg - Message to log. * @memberof MapApp */ 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 }) } /** * @typedef KeyCode * @type {object} * @property {string} key - an ID. * @property {boolean} altKey - Defines if KeyCode requires the alt key to be pressed. * @property {boolean} shiftKey - Defines if KeyCode requires the shift key to be pressed. * @property {boolean} ctrlKey - Defines if KeyCode requires the ctrl key to be pressed. * */ /** * Check's if a key event matches a defined KeyCode. * * @private * @param {KeyboardEvent} event - Event that is fired on keydown, -pressed or -up. * @param {KeyCode} keyCode - KeyCode is an object in the form of : {key:number, altKey:boolean,shiftKey:boolean,ctrlKey:boolean } * @returns {boolean} - True, when the event matches the keycode, false otherwise. * @memberof MapApp */ _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) }) } /** * Checks on every key down if it matches a keycode. * * @private * @param {KeyboardEvent} event * @memberof MapApp */ _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 } /** * @typedef KeyCodePairs * @type {object} * @property {string} name - Name of the KeyCode. * @property {KeyCode} keyCode - KeyCode * */ /** * Extracts keycodes from a string. * * KeycodeStrings may look like this 'ctrl+shift+b' * * @private * @param {KeyCodePairs} nameKeyCodePairs * @returns {array} - Returns an array of KeyCode objects. * @memberof MapApp */ _extractKeyCodes(nameKeyCodePairs) { let out = {} for (let [name, combinationString] of Object.entries(nameKeyCodePairs)) { 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 } } } __onEnd(event) { this.__dragging = false this.__events.delete(event.data.identifier) } }