iwmlib/lib/pixi/maps/map.js

937 lines
28 KiB
JavaScript
Raw Normal View History

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
}
2019-12-11 15:29:59 +01:00
/**
* Returns the image object used by the GeoMap.
*
* @readonly
* @memberof GeoMap
*/
get image() {
return this._image
}
/**
* The mapProjection of the map.
2019-12-11 15:29:59 +01:00
*
* @member {MapProjection}
2019-12-11 15:29:59 +01:00
* @readonly
* @memberof GeoMap
*/
get mapProjection() {
return this._mapProjection
2019-12-11 15:29:59 +01:00
}
/**
* 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) {
2019-12-12 17:23:39 +01:00
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) {
2019-11-05 11:07:36 +01:00
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.
2019-12-11 15:29:59 +01:00
* 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)
2019-12-11 15:29:59 +01:00
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
}
2019-12-11 15:29:59 +01:00
/**
* 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
}
/**
2019-12-11 15:29:59 +01:00
* 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.
2019-12-11 15:29:59 +01:00
*
* @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)
2019-11-11 12:34:43 +01:00
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
2019-12-11 15:29:59 +01:00
* @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
2019-12-11 15:29:59 +01:00
* @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
/**
2019-12-11 15:29:59 +01:00
* The DeepZoomMap class extends the GeoMap to create
* maps as deepzoom images from maptiles.
*
2019-12-11 15:29:59 +01:00
* @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.
2019-12-11 15:29:59 +01:00
*
* @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
}
}