666 lines
20 KiB
JavaScript
666 lines
20 KiB
JavaScript
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: true,
|
|
showTouches: true,
|
|
showPolygon: true,
|
|
container: this
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
}
|