/* * Copyright 2022 James Lyne * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { LiveAtlasAreaMarker, LiveAtlasCircleMarker, LiveAtlasComponentConfig, LiveAtlasDimension, LiveAtlasLineMarker, LiveAtlasMarker, LiveAtlasMarkerSet, LiveAtlasPartialComponentConfig, LiveAtlasPlayer, LiveAtlasPointMarker, LiveAtlasServerConfig, LiveAtlasServerMessageConfig, LiveAtlasWorldDefinition } from "@/index"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import {MutationTypes} from "@/store/mutation-types"; import MapProvider from "@/providers/MapProvider"; 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"; import {getDefaultPlayerImage} from "@/util/images"; export default class Pl3xmapMapProvider extends MapProvider { private configurationAbort?: AbortController = undefined; private markersAbort?: AbortController = undefined; private playersAbort?: AbortController = undefined; private updatesEnabled = false; private playerUpdateTimeout: null | ReturnType = null; private playerUpdateTimestamp: Date = new Date(); private playerUpdateInterval = 3000; private markerUpdateTimeout: null | ReturnType = null; private markerUpdateInterval = 3000; private worldPlayerUpdateIntervals: Map = new Map(); private worldMarkerUpdateIntervals: Map = new Map(); private worldComponents: Map = new Map(); private markerSets: Map = new Map(); private markers = new Map>(); 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 { return { title: (response.ui?.title || 'Pl3xmap').replace(titleColoursRegex, ''), expandUI: response.ui?.sidebar?.pinned === 'pinned', //Not used by pl3xmap defaultZoom: 1, defaultMap: undefined, defaultWorld: undefined, followMap: undefined, followZoom: undefined, }; } private static buildMessagesConfig(response: any): LiveAtlasServerMessageConfig { return { worldsHeading: response.ui?.sidebar?.world_list_label || '', playersHeading: response.ui?.sidebar?.player_list_label || '', //Not used by pl3xmap chatPlayerJoin: '', chatPlayerQuit: '', chatAnonymousJoin: '', chatAnonymousQuit: '', chatErrorNotAllowed: '', chatErrorRequiresLogin: '', chatErrorCooldown: '', } } private buildWorlds(serverResponse: any, worldResponses: any[]): Array { const worlds: Array = []; this.worldComponents.clear(); this.worldMarkerUpdateIntervals.clear(); this.worldPlayerUpdateIntervals.clear(); const filteredWorlds = (serverResponse.worlds || []).filter((w: any) => w && !!w.name) .sort((a: any, b: any) => a.order - b.order); filteredWorlds.forEach((world: any, index: number) => { const worldResponse = worldResponses[index], worldConfig: {components: LiveAtlasPartialComponentConfig } = { components: { players: { markers: undefined, imageUrl: getDefaultPlayerImage, grayHiddenPlayers: true, showImages: true, } }, }; this.worldMarkerUpdateIntervals.set(world.name, worldResponse.marker_update_interval || 3000); if(worldResponse.player_tracker?.enabled) { const health = !!worldResponse.player_tracker?.nameplates?.show_health, armor = !!worldResponse.player_tracker?.nameplates?.show_armor, images = !!worldResponse.player_tracker?.nameplates?.show_heads, updateInterval = worldResponse.player_tracker.update_interval ? worldResponse.player_tracker.update_interval * 1000 : 3000; this.worldPlayerUpdateIntervals.set(world.name, updateInterval); if(worldResponse.player_tracker?.nameplates?.heads_url) { worldConfig.components.players!.imageUrl = entry => worldResponse.player_tracker.nameplates.heads_url.replace('{uuid}', entry.uuid); } worldConfig.components.players!.markers = { hideByDefault: !!worldResponse.player_tracker?.default_hidden, layerName: worldResponse.player_tracker?.label || '', layerPriority: worldResponse.player_tracker?.priority, imageSize: images ? (health && armor ? 'large' : 'small') : 'none', showHealth: health, showArmor: armor, showYaw: true, } } this.worldComponents.set(world.name, worldConfig); if(!worldResponse) { console.warn(`World ${world.name} has no matching world config. Ignoring.`); return; } let dimension: LiveAtlasDimension = 'overworld'; if(world.type === 'nether') { dimension = 'nether'; } else if(world.type === 'the_end') { dimension = 'end'; } const maps: Set = new Set(); const w = { name: world.name || '(Unnamed world)', displayName: world.display_name || world.name, dimension, seaLevel: 0, maps, }; maps.add(Object.freeze(new LiveAtlasMapDefinition({ world: w, name: 'flat', displayName: 'Flat', icon: world.icon ? `${this.config}images/icon/${world.icon}.png` : undefined, baseUrl: `${this.config}tiles/${w.name}/`, imageFormat: 'png', tileSize: 512, background: 'transparent', backgroundDay: 'transparent', backgroundNight: 'transparent', nativeZoomLevels: worldResponse.zoom.max || 1, extraZoomLevels: worldResponse.zoom.extra, defaultZoom: worldResponse.zoom.def || 1, tileUpdateInterval: worldResponse.tiles_update_interval ? worldResponse.tiles_update_interval * 1000 : undefined, center: {x: worldResponse.spawn.x, y: 0, z: worldResponse.spawn.z}, }))); worlds.push(w); }); return Array.from(worlds.values()); } private static buildComponents(response: any): LiveAtlasComponentConfig { const components: LiveAtlasComponentConfig = { coordinatesControl: undefined, linkControl: !!response.ui?.link?.enabled, layerControl: !!response.ui?.coordinates?.enabled, players: { markers: undefined, //Configured per-world imageUrl: getDefaultPlayerImage, //Not configurable showImages: true, grayHiddenPlayers: true, }, //Not configurable markers: { showLabels: false, }, //Not used by pl3xmap chatBox: undefined, chatBalloons: false, clockControl: undefined, logoControls: [], login: false, }; if(response.ui?.coordinates?.enabled) { //Try to remove {x}/{z} placeholders are we aren't using them const label = (response.ui?.coordinates?.html || "Location: ").replace(/{x}.*{z}/gi, '').trim(), labelPlain = new DOMParser().parseFromString(label, 'text/html').body.textContent || ""; components.coordinatesControl = { showY: false, label: labelPlain, showRegion: false, showChunk: false, } } return components; } private async getMarkerSets(world: LiveAtlasWorldDefinition): Promise { const url = `${this.config}tiles/${world.name}/markers.json`; if(this.markersAbort) { this.markersAbort.abort(); } this.markersAbort = new AbortController(); const response = await Pl3xmapMapProvider.getJSON(url, this.markersAbort.signal); if(!Array.isArray(response)) { return; } response.forEach(set => { if(!set || !set.id) { console.warn('Ignoring marker set without id'); return; } const id = set.id, markers: Map = Object.freeze(new Map()); (set.markers || []).forEach((marker: any) => { let markerId; switch(marker.type) { case 'icon': markerId = `point_${markers.size}`; markers.set(markerId, this.buildMarker(markerId, marker)); break; case 'polyline': markerId = `line_${markers.size}`; markers.set(markerId, Pl3xmapMapProvider.buildLine(markerId, marker)); break; case 'rectangle': case 'polygon': markerId = `area_${markers.size}`; markers.set(markerId, Pl3xmapMapProvider.buildArea(markerId, marker)); break; case 'circle': case 'ellipse': markerId = `circle_${markers.size}`; markers.set(markerId, Pl3xmapMapProvider.buildCircle(markerId, marker)); break; default: console.warn('Marker type ' + marker.type + ' not supported'); } }); this.markerSets.set(id, { id, label: set.name || "Unnamed set", hidden: set.hide || false, priority: set.order || 0, showLabels: false }); this.markers.set(id, markers); }); } private buildMarker(id: string, marker: any): LiveAtlasPointMarker { return { id, type: LiveAtlasMarkerType.POINT, location: { x: marker.point?.x || 0, y: 0, z: marker.point?.z || 0, }, iconSize: marker.size ? [marker.size.x || 16, marker.size.z || 16] : [16, 16], iconUrl: `${this.config}images/icon/registered/${marker.icon || "default"}.png`, tooltip: marker.tooltip ? stripHTML(marker.tooltip) : '', tooltipHTML: marker.tooltip, popup: marker.popup, isPopupHTML: true, }; } private static buildArea(id: string, area: any): LiveAtlasAreaMarker { let points; if(area.type === 'rectangle') { points = [ {x: area.points[0].x, y: 0, z: area.points[0].z}, {x: area.points[0].x, y: 0, z: area.points[1].z}, {x: area.points[1].x, y: 0, z: area.points[1].z}, {x: area.points[1].x, y: 0, z: area.points[0].z}, ]; } else { points = this.addY(area.points); } const bounds = getBoundsFromPoints(points); return { id, type: LiveAtlasMarkerType.AREA, style: { stroke: (typeof area.stroke === 'undefined' || !!area.stroke) && !!area.color, color: area.color || '#3388ff', weight: area.weight || 3, opacity: typeof area.opacity !== 'undefined' ? area.opacity : 1, fill: (typeof area.fill === 'undefined' || !!area.fill) && !!area.fillColor, fillColor: area.fillColor || area.color || '#3388ff', fillOpacity: area.fillOpacity || 0.2, fillRule: area.fillRule, }, points, bounds, location: getMiddle(bounds), outline: false, tooltip: area.tooltip ? stripHTML(area.tooltip) : '', tooltipHTML: area.tooltip, popup: area.popup, isPopupHTML: true, }; } private static buildLine(id: string, line: any): LiveAtlasLineMarker { const points = this.addY(line.points), bounds = getBoundsFromPoints(points); return { id, type: LiveAtlasMarkerType.LINE, style: { stroke: (typeof line.stroke === 'undefined' || !!line.stroke) && !!line.color, color: line.color || '#3388ff', weight: line.weight || 3, opacity: typeof line.opacity !== 'undefined' ? line.opacity : 1, }, points, bounds, location: getMiddle(bounds), tooltip: line.tooltip ? stripHTML(line.tooltip) : '', tooltipHTML: line.tooltip, popup: line.popup, isPopupHTML: true, }; } private static buildCircle(id: string, circle: any): LiveAtlasCircleMarker { const radius = [circle.radiusX || circle.radius || 0, circle.radiusZ || circle.radius || 0] as PointTuple, location = { x: circle.center?.x || 0, y: 0, z: circle.center?.z || 0, }; return { id, type: LiveAtlasMarkerType.CIRCLE, location, radius, bounds: { max: {x: location.x + radius[0], y: 0, z: location.z + radius[1] }, min: {x: location.x - radius[0], y: 0, z: location.z - radius[1] }, }, style: { stroke: (typeof circle.stroke === 'undefined' || !!circle.stroke) && !!circle.color, color: circle.color || '#3388ff', weight: circle.weight || 3, opacity: typeof circle.opacity !== 'undefined' ? circle.opacity : 1, fill: (typeof circle.fill === 'undefined' || !!circle.fill) && !!circle.fillColor, fillColor: circle.fillColor || circle.color || '#3388ff', fillOpacity: circle.fillOpacity || 0.2, fillRule: circle.fillRule, }, tooltip: circle.tooltip ? stripHTML(circle.tooltip) : '', tooltipHTML: circle.tooltip, popup: circle.popup, isPopupHTML: true, }; } private static addY(points: any) { for (const point of points) { points.y = 0; } return points; } async loadServerConfiguration(): Promise { if(this.configurationAbort) { this.configurationAbort.abort(); } this.configurationAbort = new AbortController(); const baseUrl = this.config, response = await Pl3xmapMapProvider.getJSON(`${baseUrl}tiles/settings.json`, this.configurationAbort.signal); if (response.error) { throw new Error(response.error); } const config = Pl3xmapMapProvider.buildServerConfig(response), worldNames: string[] = (response.worlds || []).filter((world: any) => world && !!world.name) .map((world: any) => world.name); const worldResponses = await Promise.all(worldNames.map(name => Pl3xmapMapProvider.getJSON(`${baseUrl}tiles/${name}/settings.json`, this.configurationAbort!.signal))); this.store.commit(MutationTypes.SET_SERVER_CONFIGURATION, config); this.store.commit(MutationTypes.SET_SERVER_MESSAGES, Pl3xmapMapProvider.buildMessagesConfig(response)); this.store.commit(MutationTypes.SET_WORLDS, this.buildWorlds(response, worldResponses)); this.store.commit(MutationTypes.SET_COMPONENTS, Pl3xmapMapProvider.buildComponents(response)); } async populateWorld(world: LiveAtlasWorldDefinition) { const worldConfig = this.worldComponents.get(world.name); await this.getMarkerSets(world); this.playerUpdateInterval = this.worldPlayerUpdateIntervals.get(world.name) || 3000; this.markerUpdateInterval = this.worldMarkerUpdateIntervals.get(world.name) || 3000; this.store.commit(MutationTypes.SET_MARKER_SETS, this.markerSets); this.store.commit(MutationTypes.SET_MARKERS, this.markers); this.store.commit(MutationTypes.SET_COMPONENTS, worldConfig!.components); this.markerSets.clear(); this.markers.clear(); } createTileLayer(options: LiveAtlasTileLayerOptions): LiveAtlasTileLayer { return new Pl3xmapTileLayer(options); } private async getPlayers(): Promise> { const url = `${this.config}tiles/players.json`; if(this.playersAbort) { this.playersAbort.abort(); } this.playersAbort = new AbortController(); const response = await Pl3xmapMapProvider.getJSON(url, this.playersAbort.signal), players: Set = new Set(); (response.players || []).forEach((player: any) => { players.add({ name: (player.name || '').toLowerCase(), uuid: player.uuid, displayName: player.display_name || player.name || "", health: player.health || 0, armor: player.armor || 0, sort: 0, hidden: false, location: { //Add 0.5 to position in the middle of a block x: !isNaN(player.x) ? player.x + 0.5 : 0, y: 0, z: !isNaN(player.z) ? player.z + 0.5 : 0, world: player.world, }, yaw: !isNaN(player.yaw) ? parseFloat(player.yaw) + 180 : 0, }); }); // Extra fake players for testing // for(let i = 0; i < 450; i++) { // players.add({ // name: "VIDEO GAMES " + i, // displayName: "VIDEO GAMES " + i, // health: Math.round(Math.random() * 10), // armor: Math.round(Math.random() * 10), // sort: Math.round(Math.random() * 10), // hidden: false, // location: { // x: Math.round(Math.random() * 1000) - 500, // y: 0, // z: Math.round(Math.random() * 1000) - 500, // world: "world", // } // }); // } this.store.commit(MutationTypes.SET_MAX_PLAYERS, response.max || 0); return players; } startUpdates() { this.updatesEnabled = true; this.updatePlayers(); this.updateMarkers(); } private async updatePlayers() { try { if(this.store.getters.playerMarkersEnabled) { const players = await this.getPlayers(); this.playerUpdateTimestamp = new Date(); await this.store.dispatch(ActionTypes.SET_PLAYERS, players); } } finally { if(this.updatesEnabled) { if(this.playerUpdateTimeout) { clearTimeout(this.playerUpdateTimeout); } this.playerUpdateTimeout = setTimeout(() => this.updatePlayers(), this.playerUpdateInterval); } } } private async updateMarkers() { //TODO: Implement once Pl3xmap offers a way to do this without recreating all markers } stopUpdates() { this.updatesEnabled = false; if (this.markerUpdateTimeout) { clearTimeout(this.markerUpdateTimeout); } if (this.playerUpdateTimeout) { clearTimeout(this.playerUpdateTimeout); } this.markerUpdateTimeout = null; this.playerUpdateTimeout = null; if(this.configurationAbort) { this.configurationAbort.abort(); } if(this.playersAbort) { this.playersAbort.abort(); } if(this.markersAbort) { this.markersAbort.abort(); } } }