import { MapObjectScatter } from './scatter.js' import { DeepZoomImage, DeepZoomInfo } from '../deepzoom/image.js' import { MapProjection, DeepZoomMapProjection } from './mapprojection.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 MapProjection 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 {MapProjection}[mapProjection={}] - The map projection 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( mapProjection = {}, { 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 = mapProjection.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._mapProjection = mapProjection this.overlays = {} /** * Adjust the viewport depending on the mapProjection clipping. */ if (this.mapProjection.clip) { const vp = this.viewport const cp = this.mapProjection.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 } } /** * Determines if the scatter covers the container. * @member {boolean} * @readonly * @memberof GeoMap */ get cover() { // For cloning we want to store the cover inside the map. // But the scatter is responsible for the cover state. // So we update the app's cover state every time // we get the value. if (this.image && this.image.scatter && this.image.scatter.cover != null) { this._cover = this.image.scatter.cover } return this._cover } /** * Returns the image object used by the GeoMap. * * @readonly * @memberof GeoMap */ get image() { return this._image } /** * The mapProjection of the map. * * @member {MapProjection} * @readonly * @memberof GeoMap */ get mapProjection() { return this._mapProjection } /** * Clears all EventHandlers. * * @memberof GeoMap */ flushHandlers() { this.onLoad.empty() this.onTransform.empty() } /** * Locks all transformations on the map. * Single parameters can be set if necessary. False means the value is locked, true means they can be modified. * * @public * @param {object} [{ rotatable = false, translatable = false, movableX = false, movableY = false, scalable = false }={}] * @memberof GeoMap */ lock({ rotatable = false, translatable = false, movableX = false, movableY = false, scalable = false } = {}) { 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 } } /** * Unlocks all transformations on the map. * Single parameters can be set if necessary. False means the value is locked, true means they can be modified. * * @public * @param {object} [{ rotatable = false, translatable = false, movableX = false, movableY = false, scalable = false }={}] * @memberof GeoMap */ unlock({ rotatable = true, translatable = true, movableX = true, movableY = true, scalable = true } = {}) { this.lock({ rotatable, translatable, movableX, movableY, scalable }) } /** * Unloads the image of the map. * * @public * @memberof GeoMap */ unload() { if (this.image) { if (this.image.parent) { this.image.parent.removeChild(this.image) } if (this.scatter) { this.scatter.killAnimation() this.image.scatter = null } } } /** * Removes the map, freeing all memory ba flushing handlers and removing the image. * * @public * @memberof GeoMap */ remove() { if (this.image) this.image.mask = null this.removeFrame() this.onTransform.empty() this.onLoad.empty() this.unload() } /** * 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. * creates the image. * * @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 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, 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, y}. * @returns {object} - Coordinates on the map in form of {x: latitude, y: longitude}. */ coordinatesFromPoint(point) { let coords = this.mapProjection.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 {Point} - Returns a image position in form of {x: x, y: y}. */ coordinatesToPoint(coordinates) { return this.toAbsolutePixelCoordinates(this.mapProjection.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 } 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.') } get alpha() { return this._alpha } /** * 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 } removeFrame() { this.frame = null } /** * The complete Triforce, or one or more components of the Triforce. * @typedef {Object} Frame * @property {number} x - X position of the frame. * @property {number} y - Y position of the frame. * @property {number} width - Width of the frame. * @property {number} height - Height od the frame. * @property {Point} localCenter - Local center of the map. * @property {Point} center - Global center of the map. */ /** * Sets the frame if the map. * * 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. * * @param {Frame} frame * @memberof GeoMap */ setFrame(frame) { this.frame = frame } /** * Gets the frame if the map. * * 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. * * @returns {Frame} - Returns the frame of the map. * @memberof GeoMap */ 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 } 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, root = './') { let error = { message: '' } let maps = {} if (GeoMap._validateJson(json, error)) { for (let [mapname, data] of Object.entries(json)) { data.tiles.path = root + data.tiles.path maps[mapname] = GeoMap._createMap(data) maps[mapname].name = mapname } } 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 * @private * @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 * @private * @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 mapProjection = new DeepZoomMapProjection(projection, tilesConfig) return new DeepZoomMap(mapProjection, 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. * * @export * @class DeepZoomMap * @extends {GeoMap} */ 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 {MapProjection} mapProjection - A MapProjection 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(mapProjection, tilesConfig, opts = {}) { opts = Object.assign( { maxScale: Math.min(tilesConfig.width, tilesConfig.height) / tilesConfig.tileSize, minScale: mapProjection.getMinScale, highResolution: true, debug: false }, opts ) super(mapProjection, opts) this.tilesConfig = tilesConfig this._verifyMapProjection() } _verifyMapProjection() { if (!(this.mapProjection instanceof MapProjection)) { console.error('Use the MapProjection object for creating maps!') } else { if (!(this.mapProjection instanceof DeepZoomMapProjection)) { console.error('Use the DeepZoomMapProjection object.') } } } /** * Creates the DeepZoomImage. * * @private */ load(container = null, scatter = null) { if (!this.mapProjection.app) console.error('App was not set in the mapProjection.') this.info = new DeepZoomInfo(this.tilesConfig) let image = new DeepZoomImage(this.info, { app: this.mapProjection.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 } else { containerCenter = { x: this.image.parent.width / 2, y: this.image.parent.height / 2 } } //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 } 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.mapProjection, 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 }) return map } tint() { let color = DeepZoomMap.tintcolors[DeepZoomMap.tintcolor++ % DeepZoomMap.tintcolors.length] this._forEachTile(tile => { tile.tint = color }) } _forEachTile(callback) { this.image.children[0].children.forEach(tiles => { tiles.children.forEach(callback) }) } setAlpha(alpha) { this._forEachTile(tile => { tile.alpha = alpha }) } // /** 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} */ export class ImageMap extends GeoMap { constructor(sprite, mapProjection, opts = {}) { super(mapProjection, opts) if (this.debug) console.log('Construct Image Map', sprite, mapProjection, 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.mapProjection, { 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) return map } /** * * * @param {*} coordinates * @param {*} [zoom=null] * @param {*} [{ animate = 0 }={}] * @memberof ImageMap */ 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 = 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 } else { containerCenter = { x: this.image.parent.width / 2, y: this.image.parent.height / 2 } } //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 } 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 return factor } }