diff --git a/src/components/Map.vue b/src/components/Map.vue index c4f7788..40ea22e 100644 --- a/src/components/Map.vue +++ b/src/components/Map.vue @@ -169,6 +169,7 @@ export default defineComponent({ const store = useStore(); if(newValue) { + store.state.currentMapProvider!.populateWorld(newValue); let viewTarget = this.scheduledView || {} as LiveAtlasMapViewTarget; // Abort if follow target is present, to avoid panning twice diff --git a/src/components/map/layer/MapLayer.vue b/src/components/map/layer/MapLayer.vue index 0f4c6f6..8f0bbe4 100644 --- a/src/components/map/layer/MapLayer.vue +++ b/src/components/map/layer/MapLayer.vue @@ -18,10 +18,8 @@ import {defineComponent, onUnmounted, computed, watch} from "@vue/runtime-core"; import {Map} from 'leaflet'; import {useStore} from "@/store"; -import {DynmapTileLayer} from "@/leaflet/tileLayer/DynmapTileLayer"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import {LiveAtlasTileLayer} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; -import {Pl3xmapTileLayer} from "@/leaflet/tileLayer/Pl3xmapTileLayer"; export default defineComponent({ props: { @@ -54,17 +52,10 @@ export default defineComponent({ refreshTimeout = setTimeout(refresh, props.map.tileUpdateInterval); }; - if(store.state.currentServer?.type === 'dynmap') { - layer = new DynmapTileLayer({ - errorTileUrl: 'images/blank.png', - mapSettings: Object.freeze(JSON.parse(JSON.stringify(props.map))), - }); - } else { - layer = new Pl3xmapTileLayer({ - errorTileUrl: 'images/blank.png', - mapSettings: Object.freeze(JSON.parse(JSON.stringify(props.map))) - }); - } + layer = store.state.currentMapProvider!.createTileLayer({ + errorTileUrl: 'images/blank.png', + mapSettings: Object.freeze(JSON.parse(JSON.stringify(props.map))), + }); const enableLayer = () => { props.leaflet.addLayer(layer); diff --git a/src/index.d.ts b/src/index.d.ts index f5e5800..ad536e5 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -30,6 +30,7 @@ import {ClockControlOptions} from "@/leaflet/control/ClockControl"; import {LogoControlOptions} from "@/leaflet/control/LogoControl"; import {globalMessages, serverMessages} from "../messages"; import {LiveAtlasMarkerType} from "@/util/markers"; +import {LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; declare module "*.png" { const value: any; @@ -100,7 +101,6 @@ interface LiveAtlasGlobalConfig { interface LiveAtlasServerDefinition { id: string; label?: string; - type: 'dynmap' | 'pl3xmap'; dynmap?: DynmapUrlConfig; pl3xmap?: string; squaremap?: string; @@ -178,11 +178,11 @@ interface LiveAtlasMapProvider { populateWorld(world: LiveAtlasWorldDefinition): Promise; startUpdates(): void; stopUpdates(): void; + createTileLayer(options: LiveAtlasTileLayerOptions): LiveAtlasTileLayer; sendChatMessage(message: string): void; login(formData: FormData): void; logout(): void; register(formData: FormData): void; - destroy(): void; getPlayerHeadUrl(entry: HeadQueueEntry): string; getTilesUrl(): string; diff --git a/src/leaflet/tileLayer/DynmapTileLayer.ts b/src/leaflet/tileLayer/DynmapTileLayer.ts index 57f24f9..a51a8ba 100644 --- a/src/leaflet/tileLayer/DynmapTileLayer.ts +++ b/src/leaflet/tileLayer/DynmapTileLayer.ts @@ -18,7 +18,7 @@ */ import {Map as LeafletMap, Coords, DoneCallback} from 'leaflet'; -import {useStore} from "@/store"; +import {Store, useStore} from "@/store"; import {Coordinate, Coordinate2D} from "@/index"; import {LiveAtlasTileLayer, LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; import {computed, watch} from "@vue/runtime-core"; @@ -27,12 +27,12 @@ import {WatchStopHandle} from "vue"; import {ActionTypes} from "@/store/action-types"; import {TileInformation} from "dynmap"; -const store = useStore(); // noinspection JSUnusedGlobalSymbols export class DynmapTileLayer extends LiveAtlasTileLayer { private readonly _namedTiles: Map; private readonly _baseUrl: string; + private readonly _store: Store = useStore(); private readonly _night: ComputedRef; private readonly _pendingUpdates: ComputedRef; @@ -44,11 +44,11 @@ export class DynmapTileLayer extends LiveAtlasTileLayer { super('', options); this._mapSettings = options.mapSettings; - this._baseUrl = store.state.currentMapProvider!.getTilesUrl(); + this._baseUrl = this._store.state.currentMapProvider!.getTilesUrl(); this._namedTiles = Object.seal(new Map()); - this._pendingUpdates = computed(() => !!store.state.pendingTileUpdates.length); - this._night = computed(() => store.getters.night); + this._pendingUpdates = computed(() => !!this._store.state.pendingTileUpdates.length); + this._night = computed(() => this._store.getters.night); } onAdd(map: LeafletMap) { @@ -183,7 +183,7 @@ export class DynmapTileLayer extends LiveAtlasTileLayer { } private async handlePendingUpdates() { - const updates = await store.dispatch(ActionTypes.POP_TILE_UPDATES, 10); + const updates = await this._store.dispatch(ActionTypes.POP_TILE_UPDATES, 10); for(const update of updates) { this.updateNamedTile(update.name, update.timestamp); diff --git a/src/main.ts b/src/main.ts index 70c9953..7974a37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,10 +23,13 @@ import 'leaflet/dist/leaflet.css'; import '@/scss/style.scss'; import {MutationTypes} from "@/store/mutation-types"; -import {showSplashError} from "@/util/splash"; import { VueClipboard } from '@soerenmartius/vue3-clipboard'; import Notifications from '@kyvg/vue3-notification' -import {getServerDefinitions} from "@/util/config"; +import {loadConfig, registerMapProvider} from "@/util/config"; +import DynmapMapProvider from "@/providers/DynmapMapProvider"; +import Pl3xmapMapProvider from "@/providers/Pl3xmapMapProvider"; +import {showSplashError} from "@/util/splash"; +import ConfigurationError from "@/errors/ConfigurationError"; const splash = document.getElementById('splash'), svgs = import.meta.globEager('/assets/icons/*.svg'); @@ -49,10 +52,14 @@ store.subscribe((mutation, state) => { } }); -try { - const config = window.liveAtlasConfig; - config.servers = getServerDefinitions(config); +registerMapProvider('dynmap', DynmapMapProvider); +registerMapProvider('pl3xmap', Pl3xmapMapProvider); +registerMapProvider('squaremap', Pl3xmapMapProvider); +const config = window.liveAtlasConfig; + +try { + config.servers = loadConfig(config); store.commit(MutationTypes.INIT, config); if(store.state.servers.size > 1) { @@ -76,7 +83,12 @@ try { // app.config.performance = true; app.mount('#app'); -} catch(e) { - console.error('LiveAtlas configuration is invalid: ', e); - showSplashError('LiveAtlas configuration is invalid:\n' + e, true); +} catch (e) { + if(e instanceof ConfigurationError) { + console.error('LiveAtlas configuration is invalid:', e); + showSplashError('LiveAtlas configuration is invalid:\n' + e, true); + } else { + console.error('LiveAtlas failed to load:', e); + showSplashError('LiveAtlas failed to load:\n' + e, true); + } } diff --git a/src/providers/DynmapMapProvider.ts b/src/providers/DynmapMapProvider.ts index 0c8e2fb..11b43b8 100644 --- a/src/providers/DynmapMapProvider.ts +++ b/src/providers/DynmapMapProvider.ts @@ -18,7 +18,6 @@ import { HeadQueueEntry, LiveAtlasMarker, LiveAtlasMarkerSet, LiveAtlasPlayer, - LiveAtlasServerDefinition, LiveAtlasWorldDefinition } from "@/index"; import ChatError from "@/errors/ChatError"; @@ -36,6 +35,10 @@ import { } from "@/util/dynmap"; import {getImagePixelSize} from "@/util"; import {MarkerSet} from "dynmap"; +import {DynmapUrlConfig} from "@/dynmap"; +import ConfigurationError from "@/errors/ConfigurationError"; +import {DynmapTileLayer} from "@/leaflet/tileLayer/DynmapTileLayer"; +import {LiveAtlasTileLayer, LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; export default class DynmapMapProvider extends MapProvider { private configurationAbort?: AbortController = undefined; @@ -50,12 +53,41 @@ export default class DynmapMapProvider extends MapProvider { private markerSets: Map = new Map(); private markers = new Map>(); - constructor(config: LiveAtlasServerDefinition) { + constructor(config: DynmapUrlConfig) { super(config); + this.validateConfig(); + } + + private validateConfig() { + if(typeof this.config !== 'undefined') { + if (!this.config || this.config.constructor !== Object) { + throw new ConfigurationError(`Dynmap configuration object missing`); + } + + if (!this.config.configuration) { + throw new ConfigurationError(`Dynmap configuration URL missing`); + } + + if (!this.config.update) { + throw new ConfigurationError(`Dynmap update URL missing`); + } + + if (!this.config.markers) { + throw new ConfigurationError(`Dynmap markers URL missing`); + } + + if (!this.config.tiles) { + throw new ConfigurationError(`Dynmap tiles URL missing`); + } + + if (!this.config.sendmessage) { + throw new ConfigurationError(`Dynmap sendmessage URL missing`); + } + } } private async getMarkerSets(world: LiveAtlasWorldDefinition): Promise { - const url = `${this.config.dynmap!.markers}_markers_/marker_${world.name}.json`; + const url = `${this.config.markers}_markers_/marker_${world.name}.json`; if(this.markersAbort) { this.markersAbort.abort(); @@ -93,7 +125,7 @@ export default class DynmapMapProvider extends MapProvider { this.configurationAbort = new AbortController(); - const response = await this.getJSON(this.config.dynmap!.configuration, this.configurationAbort.signal); + const response = await this.getJSON(this.config.configuration, this.configurationAbort.signal); if (response.error) { throw new Error(response.error); @@ -122,8 +154,12 @@ export default class DynmapMapProvider extends MapProvider { this.markers.clear(); } + createTileLayer(options: LiveAtlasTileLayerOptions): LiveAtlasTileLayer { + return new DynmapTileLayer(options); + } + private async getUpdate(): Promise { - let url = this.config.dynmap!.update; + let url = this.config.update; url = url.replace('{world}', this.store.state.currentWorld!.name); url = url.replace('{timestamp}', this.updateTimestamp.getTime().toString()); @@ -200,7 +236,7 @@ export default class DynmapMapProvider extends MapProvider { return Promise.reject(this.store.state.messages.chatErrorDisabled); } - return fetch(this.config.dynmap!.sendmessage, { + return fetch(this.config.sendmessage, { method: 'POST', credentials: 'include', body: JSON.stringify({ @@ -259,14 +295,26 @@ export default class DynmapMapProvider extends MapProvider { } this.updateTimeout = null; + + if(this.configurationAbort) { + this.configurationAbort.abort(); + } + + if(this.updateAbort) { + this.updateAbort.abort(); + } + + if(this.markersAbort) { + this.markersAbort.abort(); + } } getTilesUrl(): string { - return this.config.dynmap!.tiles; + return this.config.tiles; } getPlayerHeadUrl(head: HeadQueueEntry): string { - const baseUrl = `${this.config.dynmap!.markers}faces/`; + const baseUrl = `${this.config.markers}faces/`; if(head.size === 'body') { return `${baseUrl}body/${head.name}.png`; @@ -277,7 +325,7 @@ export default class DynmapMapProvider extends MapProvider { } getMarkerIconUrl(icon: string): string { - return `${this.config.dynmap!.markers}_markers_/${icon}.png`; + return `${this.config.markers}_markers_/${icon}.png`; } async login(data: any) { @@ -294,7 +342,7 @@ export default class DynmapMapProvider extends MapProvider { body.append('j_password', data.password || ''); - const response = await DynmapMapProvider.fetchJSON(this.config.dynmap!.login, { + const response = await DynmapMapProvider.fetchJSON(this.config.login, { method: 'POST', body, }); @@ -323,7 +371,7 @@ export default class DynmapMapProvider extends MapProvider { } try { - await DynmapMapProvider.fetchJSON(this.config.dynmap!.login, { + await DynmapMapProvider.fetchJSON(this.config.login, { method: 'POST', }); @@ -348,7 +396,7 @@ export default class DynmapMapProvider extends MapProvider { body.append('j_verify_password', data.password || ''); body.append('j_passcode', data.code || ''); - const response = await DynmapMapProvider.fetchJSON(this.config.dynmap!.register, { + const response = await DynmapMapProvider.fetchJSON(this.config.register, { method: 'POST', body, }); @@ -374,22 +422,6 @@ export default class DynmapMapProvider extends MapProvider { } } - destroy() { - super.destroy(); - - if(this.configurationAbort) { - this.configurationAbort.abort(); - } - - if(this.updateAbort) { - this.updateAbort.abort(); - } - - if(this.markersAbort) { - this.markersAbort.abort(); - } - } - protected async getJSON(url: string, signal: AbortSignal) { return MapProvider.fetchJSON(url, {signal, credentials: 'include'}).then(response => { if(response.error === 'login-required') { diff --git a/src/providers/MapProvider.ts b/src/providers/MapProvider.ts index cd48aa2..01f70d3 100644 --- a/src/providers/MapProvider.ts +++ b/src/providers/MapProvider.ts @@ -17,31 +17,22 @@ import { HeadQueueEntry, LiveAtlasMapProvider, - LiveAtlasServerDefinition, LiveAtlasWorldDefinition } from "@/index"; import {useStore} from "@/store"; -import {computed, watch} from "@vue/runtime-core"; -import {WatchStopHandle} from "vue"; +import {LiveAtlasTileLayer, LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; export default abstract class MapProvider implements LiveAtlasMapProvider { protected readonly store = useStore(); - protected readonly config: LiveAtlasServerDefinition; - private readonly currentWorldUnwatch: WatchStopHandle; + protected config: any; - protected constructor(config: LiveAtlasServerDefinition) { + protected constructor(config: any) { this.config = config; - const currentWorld = computed(() => this.store.state.currentWorld); - - this.currentWorldUnwatch = watch(currentWorld, (newValue) => { - if (newValue) { - this.populateWorld(newValue); - } - }); } abstract loadServerConfiguration(): Promise; abstract populateWorld(world: LiveAtlasWorldDefinition): Promise; + abstract createTileLayer(options: LiveAtlasTileLayerOptions): LiveAtlasTileLayer; abstract startUpdates(): void; abstract stopUpdates(): void; @@ -50,7 +41,7 @@ export default abstract class MapProvider implements LiveAtlasMapProvider { abstract getTilesUrl(): string; abstract getMarkerIconUrl(icon: string): string; - sendChatMessage(message: string) { + sendChatMessage(message: string) { throw new Error('Provider does not support chat'); } @@ -66,10 +57,6 @@ export default abstract class MapProvider implements LiveAtlasMapProvider { throw new Error('Provider does not support registration'); } - destroy() { - this.currentWorldUnwatch(); - } - protected static async fetchJSON(url: string, options: any) { let response, json; diff --git a/src/providers/Pl3xmapMapProvider.ts b/src/providers/Pl3xmapMapProvider.ts index b0a1bf4..d674cc4 100644 --- a/src/providers/Pl3xmapMapProvider.ts +++ b/src/providers/Pl3xmapMapProvider.ts @@ -26,7 +26,6 @@ import { LiveAtlasPlayer, LiveAtlasPointMarker, LiveAtlasServerConfig, - LiveAtlasServerDefinition, LiveAtlasServerMessageConfig, LiveAtlasWorldDefinition } from "@/index"; @@ -37,6 +36,9 @@ import {ActionTypes} from "@/store/action-types"; import {getBoundsFromPoints, getMiddle, stripHTML, titleColoursRegex} from "@/util"; import {LiveAtlasMarkerType} from "@/util/markers"; import {PointTuple} from "leaflet"; +import ConfigurationError from "@/errors/ConfigurationError"; +import {Pl3xmapTileLayer} from "@/leaflet/tileLayer/Pl3xmapTileLayer"; +import {LiveAtlasTileLayer, LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; export default class Pl3xmapMapProvider extends MapProvider { private configurationAbort?: AbortController = undefined; @@ -61,8 +63,16 @@ export default class Pl3xmapMapProvider extends MapProvider { private markerSets: Map = new Map(); private markers = new Map>(); - constructor(config: LiveAtlasServerDefinition) { + constructor(config: string) { super(config); + + if(!this.config) { + throw new ConfigurationError("URL missing"); + } + + if(this.config.slice(-1) !== '/') { + this.config = `${config}/`; + } } private static buildServerConfig(response: any): LiveAtlasServerConfig { @@ -168,7 +178,7 @@ export default class Pl3xmapMapProvider extends MapProvider { background: 'transparent', backgroundDay: 'transparent', backgroundNight: 'transparent', - icon: world.icon ? `${this.config.pl3xmap}images/icon/${world.icon}.png` : undefined, + icon: world.icon ? `${this.config}images/icon/${world.icon}.png` : undefined, imageFormat: 'png', name: 'flat', displayName: 'Flat', @@ -226,7 +236,7 @@ export default class Pl3xmapMapProvider extends MapProvider { } private async getMarkerSets(world: LiveAtlasWorldDefinition): Promise { - const url = `${this.config.pl3xmap}tiles/${world.name}/markers.json`; + const url = `${this.config}tiles/${world.name}/markers.json`; if(this.markersAbort) { this.markersAbort.abort(); @@ -424,7 +434,7 @@ export default class Pl3xmapMapProvider extends MapProvider { this.configurationAbort = new AbortController(); - const baseUrl = this.config.pl3xmap, + const baseUrl = this.config, response = await Pl3xmapMapProvider.getJSON(`${baseUrl}tiles/settings.json`, this.configurationAbort.signal); if (response.error) { @@ -459,8 +469,12 @@ export default class Pl3xmapMapProvider extends MapProvider { this.markers.clear(); } + createTileLayer(options: LiveAtlasTileLayerOptions): LiveAtlasTileLayer { + return new Pl3xmapTileLayer(options); + } + private async getPlayers(): Promise> { - const url = `${this.config.pl3xmap}tiles/players.json`; + const url = `${this.config}tiles/players.json`; if(this.playersAbort) { this.playersAbort.abort(); @@ -557,23 +571,6 @@ export default class Pl3xmapMapProvider extends MapProvider { this.markerUpdateTimeout = null; this.playerUpdateTimeout = null; - } - - getTilesUrl(): string { - return `${this.config.pl3xmap}tiles/`; - } - - getPlayerHeadUrl(head: HeadQueueEntry): string { - //TODO: Listen to config - return 'https://mc-heads.net/avatar/{uuid}/16'.replace('{uuid}', head.uuid || ''); - } - - getMarkerIconUrl(icon: string): string { - return `${this.config.pl3xmap}images/icon/registered/${icon}.png`; - } - - destroy() { - super.destroy(); if(this.configurationAbort) { this.configurationAbort.abort(); @@ -587,4 +584,17 @@ export default class Pl3xmapMapProvider extends MapProvider { this.markersAbort.abort(); } } + + getTilesUrl(): string { + return `${this.config}tiles/`; + } + + getPlayerHeadUrl(head: HeadQueueEntry): string { + //TODO: Listen to config + return 'https://mc-heads.net/avatar/{uuid}/16'.replace('{uuid}', head.uuid || ''); + } + + getMarkerIconUrl(icon: string): string { + return `${this.config}images/icon/registered/${icon}.png`; + } } diff --git a/src/store/mutations.ts b/src/store/mutations.ts index 887257c..c606649 100644 --- a/src/store/mutations.ts +++ b/src/store/mutations.ts @@ -33,7 +33,6 @@ import { LiveAtlasServerMessageConfig, LiveAtlasPlayer, LiveAtlasMarkerSet, - LiveAtlasServerDefinition, LiveAtlasServerConfig, LiveAtlasChat, LiveAtlasPartialComponentConfig, @@ -41,9 +40,8 @@ import { LiveAtlasUIModal, LiveAtlasSidebarSectionState, LiveAtlasMarker, LiveAtlasMapViewTarget } from "@/index"; -import DynmapMapProvider from "@/providers/DynmapMapProvider"; -import Pl3xmapMapProvider from "@/providers/Pl3xmapMapProvider"; import {getGlobalMessages} from "@/util"; +import {getServerMapProvider} from "@/util/config"; export type CurrentMapPayload = { worldName: string; @@ -383,19 +381,9 @@ export const mutations: MutationTree & Mutations = { if(state.currentMapProvider) { state.currentMapProvider.stopUpdates(); - state.currentMapProvider.destroy(); } - switch(state.currentServer!.type) { - case 'pl3xmap': - state.currentMapProvider = Object.seal( - new Pl3xmapMapProvider(state.servers.get(serverName) as LiveAtlasServerDefinition)); - break; - case 'dynmap': - state.currentMapProvider = Object.seal( - new DynmapMapProvider(state.servers.get(serverName) as LiveAtlasServerDefinition)); - break; - } + state.currentMapProvider = getServerMapProvider(serverName); }, //Sets the currently active map/world diff --git a/src/util/config.ts b/src/util/config.ts index a61cd25..2e3409f 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -18,10 +18,27 @@ import {LiveAtlasGlobalConfig, LiveAtlasServerDefinition} from "@/index"; import ConfigurationError from "@/errors/ConfigurationError"; import {DynmapUrlConfig} from "@/dynmap"; import {useStore} from "@/store"; +import MapProvider from "@/providers/MapProvider"; +import DynmapMapProvider from "@/providers/DynmapMapProvider"; const expectedConfigVersion = 1; -const validateLiveAtlasConfiguration = (config: any): Map => { +const registeredProviders: Map MapProvider> = new Map(); +const serverProviders: Map = new Map(); + +export const registerMapProvider = (id: string, provider: new (config: any) => MapProvider) => { + if(registeredProviders.has(id)) { + throw new TypeError(`${id} is already registered`); + } + + registeredProviders.set(id, provider); +} + +export const getServerMapProvider = (server: string): MapProvider | undefined => { + return serverProviders.get(server); +} + +const loadLiveAtlasConfig = (config: any): Map => { const check = '\nCheck your server configuration in index.html is correct.', result = new Map(); @@ -35,100 +52,52 @@ const validateLiveAtlasConfiguration = (config: any): Map => { +const loadDefaultConfig = (config: DynmapUrlConfig): Map => { const check = '\nCheck your standalone/config.js file exists and is being loaded correctly.'; - - if (!config) { - throw new ConfigurationError(`Dynmap configuration is missing. ${check}`); - } - - if (!config.configuration) { - throw new ConfigurationError(`Dynmap configuration URL is missing. ${check}`); - } - - if (!config.update) { - throw new ConfigurationError(`Dynmap update URL is missing. ${check}`); - } - - if (!config.markers) { - throw new ConfigurationError(`Dynmap markers URL is missing. ${check}`); - } - - if (!config.tiles) { - throw new ConfigurationError(`Dynmap tiles URL is missing. ${check}`); - } - - if (!config.sendmessage) { - throw new ConfigurationError(`Dynmap sendmessage URL is missing. ${check}`); - } - const result = new Map(); result.set('dynmap', { id: 'dynmap', label: 'dynmap', - type: 'dynmap', dynmap: config }); + try { + serverProviders.set('dynmap', new DynmapMapProvider(config)); + } catch (e: any) { + e.message = `${e.message}. ${check}`; + throw e; + } + return result; }; -export const getServerDefinitions = (config: LiveAtlasGlobalConfig): Map => { +export const loadConfig = (config: LiveAtlasGlobalConfig): Map => { if (!config) { throw new ConfigurationError(`No configuration found.\nCheck for any syntax errors in your configuration in index.html. Your browser console may contain additional information.`); } @@ -138,9 +107,9 @@ export const getServerDefinitions = (config: LiveAtlasGlobalConfig): Map