iwmlib/lib/pixi/maps/mapapp.js

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)
}
}