diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml index 58a1b80..109ed46 100644 --- a/.idea/copyright/profiles_settings.xml +++ b/.idea/copyright/profiles_settings.xml @@ -3,6 +3,7 @@ + \ No newline at end of file diff --git a/.idea/scopes/Original.xml b/.idea/scopes/Original.xml index bd38b95..9c2f03c 100644 --- a/.idea/scopes/Original.xml +++ b/.idea/scopes/Original.xml @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/src/index.d.ts b/src/index.d.ts index 4d3f468..297fde3 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 James Lyne + * 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. @@ -104,6 +104,7 @@ interface LiveAtlasServerDefinition { dynmap?: DynmapUrlConfig; pl3xmap?: string; squaremap?: string; + overviewer?: string; } // Messages defined directly in LiveAtlas and used for all servers diff --git a/src/leaflet/projection/OverviewerProjection.ts b/src/leaflet/projection/OverviewerProjection.ts new file mode 100644 index 0000000..0e6f35b --- /dev/null +++ b/src/leaflet/projection/OverviewerProjection.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2022 James Lyne + * + * Some portions of this file were taken from https://github.com/overviewer/Minecraft-Overviewer. + * These portions are Copyright 2022 Minecraft Overviewer Contributors. + * + * 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 {LatLng} from 'leaflet'; +import {Coordinate, LiveAtlasProjection} from "@/index"; + +export interface OverviewerProjectionOptions { + upperRight: number, + lowerRight: number, + lowerLeft: number, + northDirection: number, + nativeZoomLevels: number, + tileSize: number, +} + +export class OverviewerProjection implements LiveAtlasProjection { + private readonly upperRight: number; + private readonly lowerRight: number; + private readonly lowerLeft: number; + private readonly northDirection: number; + private readonly nativeZoomLevels: number; + private readonly tileSize: number; + private readonly perPixel: number; + + constructor(options: OverviewerProjectionOptions) { + this.upperRight = options.upperRight; + this.lowerRight = options.lowerRight; + this.lowerLeft = options.lowerLeft; + this.northDirection = options.northDirection; + this.nativeZoomLevels = options.nativeZoomLevels || 1; + this.tileSize = options.tileSize; + + this.perPixel = 1.0 / (this.tileSize * Math.pow(2, this.nativeZoomLevels)); + } + + locationToLatLng(location: Coordinate): LatLng { + let lng = 0.5 - (1.0 / Math.pow(2, this.nativeZoomLevels + 1)); + let lat = 0.5; + + if (this.northDirection === this.upperRight) { + const temp = location.x; + location.x = -location.z + 15; + location.z = temp; + } else if(this.northDirection === this.lowerRight) { + location.x = -location.x + 15; + location.z = -location.z + 15; + } else if(this.northDirection === this.lowerLeft) { + const temp = location.x; + location.x = location.z; + location.z = -temp + 15; + } + + lng += 12 * location.x * this.perPixel; + lat -= 6 * location.x * this.perPixel; + + lng += 12 * location.z * this.perPixel; + lat += 6 * location.z * this.perPixel; + + lng += 12 * this.perPixel; + lat += 12 * (256 - location.y) * this.perPixel; + + return new LatLng(-lat * this.tileSize, lng * this.tileSize); + } + + latLngToLocation(latLng: LatLng, y: number): Coordinate { + const lat = (-latLng.lat / this.tileSize) - 0.5; + const lng = (latLng.lng / this.tileSize) - (0.5 - (1.0 / Math.pow(2, this.nativeZoomLevels + 1))); + + const x = Math.floor((lng - 2 * lat) / (24 * this.perPixel)) + (256 - y), + z = Math.floor((lng + 2 * lat) / (24 * this.perPixel)) - (256 - y); + + if (this.northDirection == this.upperRight) { + return {x: z, y, z: -x + 15} + } else if (this.northDirection == this.lowerRight) { + return {x: -x + 15, y, z: -y + 15} + } else if (this.northDirection == this.lowerLeft) { + return {x: -z + 15, y, z: x} + } + + return {x, y, z}; + } +} diff --git a/src/leaflet/tileLayer/OverviewerTileLayer.ts b/src/leaflet/tileLayer/OverviewerTileLayer.ts new file mode 100644 index 0000000..5b225eb --- /dev/null +++ b/src/leaflet/tileLayer/OverviewerTileLayer.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2022 James Lyne + * + * Some portions of this file were taken from https://github.com/overviewer/Minecraft-Overviewer. + * These portions are Copyright 2022 Minecraft Overviewer Contributors. + * + * 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 {LiveAtlasTileLayer, LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; +import {Store, useStore} from "@/store"; +import {Coords, Util} from "leaflet"; + +// noinspection JSUnusedGlobalSymbols +export class OverviewerTileLayer extends LiveAtlasTileLayer { + private readonly _baseUrl: string; + private readonly _store: Store = useStore(); + + constructor(options: LiveAtlasTileLayerOptions) { + super('', options); + + options.zoomReverse = false; + + Util.setOptions(this, options); + + this._mapSettings = options.mapSettings; + this._baseUrl = this._store.state.currentMapProvider!.getTilesUrl(); + } + + getTileUrl(coords: Coords): string { + let url = this._mapSettings.name; + const zoom = coords.z, + urlBase = this._mapSettings.prefix; + + if(coords.x < 0 || coords.x >= Math.pow(2, zoom) || + coords.y < 0 || coords.y >= Math.pow(2, zoom)) { + url += '/blank'; + } else if(zoom === 0) { + url += '/base'; + } else { + for(let z = zoom - 1; z >= 0; --z) { + const x = Math.floor(coords.x / Math.pow(2, z)) % 2; + const y = Math.floor(coords.y / Math.pow(2, z)) % 2; + url += '/' + (x + 2 * y); + } + } + url = url + '.' + this._mapSettings.imageFormat; + // if(typeof overviewerConfig.map.cacheTag !== 'undefined') { + // url += '?c=' + overviewerConfig.map.cacheTag; + // } + return(this._baseUrl + urlBase + url); + } +} diff --git a/src/main.ts b/src/main.ts index 7974a37..c7eaa37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 James Lyne + * 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. @@ -30,6 +30,7 @@ import DynmapMapProvider from "@/providers/DynmapMapProvider"; import Pl3xmapMapProvider from "@/providers/Pl3xmapMapProvider"; import {showSplashError} from "@/util/splash"; import ConfigurationError from "@/errors/ConfigurationError"; +import OverviewerMapProvider from "@/providers/OverviewerMapProvider"; const splash = document.getElementById('splash'), svgs = import.meta.globEager('/assets/icons/*.svg'); @@ -55,6 +56,7 @@ store.subscribe((mutation, state) => { registerMapProvider('dynmap', DynmapMapProvider); registerMapProvider('pl3xmap', Pl3xmapMapProvider); registerMapProvider('squaremap', Pl3xmapMapProvider); +registerMapProvider('overviewer', OverviewerMapProvider); const config = window.liveAtlasConfig; diff --git a/src/providers/MapProvider.ts b/src/providers/MapProvider.ts index 6ac6122..a81a681 100644 --- a/src/providers/MapProvider.ts +++ b/src/providers/MapProvider.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 James Lyne + * 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. @@ -56,8 +56,8 @@ export default abstract class MapProvider implements LiveAtlasMapProvider { throw new Error('Provider does not support registration'); } - protected static async fetchJSON(url: string, options: any) { - let response, json; + protected static async fetch(url: string, options: any) { + let response; try { response = await fetch(url, options); @@ -76,6 +76,30 @@ export default abstract class MapProvider implements LiveAtlasMapProvider { throw new Error(`Network request failed (${response.statusText || 'Unknown'})`); } + return response; + } + + protected static async fetchText(url: string, options: any) { + const response = await this.fetch(url, options); + let text; + + try { + text = await response.text(); + } catch(e) { + if(e instanceof DOMException && e.name === 'AbortError') { + console.warn(`Request aborted (${url}`); + } + + throw e; + } + + return text; + } + + protected static async fetchJSON(url: string, options: any) { + const response = await this.fetch(url, options); + let json; + try { json = await response.json(); } catch(e) { @@ -90,6 +114,10 @@ export default abstract class MapProvider implements LiveAtlasMapProvider { return json; } + protected static async getText(url: string, signal: AbortSignal) { + return MapProvider.fetchText(url, {signal, credentials: 'include'}); + } + protected static async getJSON(url: string, signal: AbortSignal) { return MapProvider.fetchJSON(url, {signal, credentials: 'include'}); } diff --git a/src/providers/OverviewerMapProvider.ts b/src/providers/OverviewerMapProvider.ts new file mode 100644 index 0000000..7de29db --- /dev/null +++ b/src/providers/OverviewerMapProvider.ts @@ -0,0 +1,215 @@ +/* + * Copyright 2022 James Lyne + * + * Some portions of this file were taken from https://github.com/overviewer/Minecraft-Overviewer. + * These portions are Copyright 2022 Minecraft Overviewer Contributors. + * + * 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 { + LiveAtlasComponentConfig, LiveAtlasDimension, + LiveAtlasServerConfig, + LiveAtlasServerMessageConfig, + LiveAtlasWorldDefinition +} from "@/index"; +import {MutationTypes} from "@/store/mutation-types"; +import MapProvider from "@/providers/MapProvider"; +import { + getDefaultMinecraftHead, runSandboxed, +} from "@/util"; +import ConfigurationError from "@/errors/ConfigurationError"; +import {LiveAtlasTileLayer, LiveAtlasTileLayerOptions} from "@/leaflet/tileLayer/LiveAtlasTileLayer"; +import {OverviewerTileLayer} from "@/leaflet/tileLayer/OverviewerTileLayer"; +import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; +import {OverviewerProjection} from "@/leaflet/projection/OverviewerProjection"; + +export default class OverviewerMapProvider extends MapProvider { + private configurationAbort?: AbortController = undefined; + + 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: 'Minecraft Overviewer', + + //Not used by overviewer + expandUI: false, + defaultZoom: 1, + defaultMap: undefined, + defaultWorld: undefined, + followMap: undefined, + followZoom: undefined, + }; + } + + private static buildMessagesConfig(response: any): LiveAtlasServerMessageConfig { + return { + worldsHeading: 'Worlds', + playersHeading: 'Players', + + //Not used by pl3xmap + chatPlayerJoin: '', + chatPlayerQuit: '', + chatAnonymousJoin: '', + chatAnonymousQuit: '', + chatErrorNotAllowed: '', + chatErrorRequiresLogin: '', + chatErrorCooldown: '', + } + } + + private buildWorlds(serverResponse: any): Array { + const worlds: Map = new Map(); + + (serverResponse.worlds || []).forEach((world: string) => { + worlds.set(world, { + name: world, + displayName: world, + dimension: 'overworld' as LiveAtlasDimension, + seaLevel: 64, + center: {x: 0, y: 64, z: 0}, + defaultZoom: undefined, + maps: new Set(), + }); + }); + + (serverResponse.tilesets || []).forEach((tileset: any) => { + if(!tileset?.world || !worlds.has(tileset.world)) { + console.warn(`Ignoring tileset with unknown world ${tileset.world}`); + return; + } + + const world = worlds.get(tileset.world) as LiveAtlasWorldDefinition, + nativeZoomLevels = tileset.zoomLevels, + tileSize = serverResponse.CONST.tileSize; + + world.maps.add(new LiveAtlasMapDefinition({ + world, + name: tileset.path, + displayName: tileset.name || tileset.path, + background: tileset.bgcolor, + imageFormat: tileset.imgextension, + nativeZoomLevels, + extraZoomLevels: 0, + tileSize, + prefix: tileset.base, + projection: new OverviewerProjection({ + upperRight: serverResponse.CONST.UPPERRIGHT, + lowerLeft: serverResponse.CONST.LOWERLEFT, + lowerRight: serverResponse.CONST.LOWERRIGHT, + northDirection: tileset.north_direction, + nativeZoomLevels, + tileSize, + }), + })); + }); + + return Array.from(worlds.values()); + } + + private static buildComponents(response: any): LiveAtlasComponentConfig { + const components: LiveAtlasComponentConfig = { + coordinatesControl: undefined, + linkControl: true, + layerControl: response?.map?.controls?.overlays, + + //Not configurable + markers: { + showLabels: false, + }, + + //Not used by Overviewer + players: { + markers: undefined, + imageUrl: getDefaultMinecraftHead, + showImages: false, + grayHiddenPlayers: false, + }, + chatBox: undefined, + chatBalloons: false, + clockControl: undefined, + logoControls: [], + login: false, + }; + + if(response?.map?.controls?.coordsBox) { + components.coordinatesControl = { + showY: false, + label: 'Location: ', + showRegion: true, + showChunk: false, + } + } + + return components; + } + + async loadServerConfiguration(): Promise { + if(this.configurationAbort) { + this.configurationAbort.abort(); + } + + this.configurationAbort = new AbortController(); + + const baseUrl = this.config, + response = await OverviewerMapProvider.getText(`${baseUrl}overviewerConfig.js`, this.configurationAbort.signal); + + try { + const result = await runSandboxed(response + ' return overviewerConfig;'), + config = OverviewerMapProvider.buildServerConfig(result); + + this.store.commit(MutationTypes.SET_SERVER_CONFIGURATION, config); + this.store.commit(MutationTypes.SET_SERVER_MESSAGES, OverviewerMapProvider.buildMessagesConfig(result)); + this.store.commit(MutationTypes.SET_WORLDS, this.buildWorlds(result)); + this.store.commit(MutationTypes.SET_COMPONENTS, OverviewerMapProvider.buildComponents(result)); + } catch(e) { + console.error(e); + throw e; + } + } + + async populateWorld(world: LiveAtlasWorldDefinition) { + //TODO + } + + createTileLayer(options: LiveAtlasTileLayerOptions): LiveAtlasTileLayer { + return new OverviewerTileLayer(options); + } + + startUpdates() { + //TODO + } + + stopUpdates() { + //TODO + } + + getTilesUrl(): string { + return this.config; + } + + getMarkerIconUrl(icon: string): string { + return ''; //TODO + } +} diff --git a/src/util.ts b/src/util.ts index de6837c..e1656c9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -19,8 +19,10 @@ import {useStore} from "@/store"; import LiveAtlasMapDefinition from "@/model/LiveAtlasMapDefinition"; import { Coordinate, - HeadQueueEntry, LiveAtlasBounds, - LiveAtlasGlobalMessageConfig, LiveAtlasLocation, + HeadQueueEntry, + LiveAtlasBounds, + LiveAtlasGlobalMessageConfig, + LiveAtlasLocation, LiveAtlasMessageConfig, LiveAtlasPlayer, LiveAtlasPlayerImageSize, @@ -297,3 +299,73 @@ export const getMiddle = (bounds: LiveAtlasBounds): LiveAtlasLocation => { z: bounds.min.z + ((bounds.max.z - bounds.min.z) / 2), }; } + +const createIframeSandbox = () => { + const frame = document.createElement('iframe'); + frame.hidden = true; + frame.sandbox.add('allow-scripts'); + frame.srcdoc = ``; + + window.addEventListener('message', e => { + if(e.origin !== "null" || e.source !== frame.contentWindow) { + console.warn('Ignoring postmessage with invalid source'); + return; + } + + if(!e.data?.key) { + console.warn('Ignoring postmessage without key'); + return; + } + + if(!sandboxSuccessCallbacks.has(e.data.key)) { + console.warn('Ignoring postmessage with invalid key'); + return; + } + + if(e.data.success) { + sandboxSuccessCallbacks.get(e.data.key)!.call(this, e.data.result); + } else { + sandboxErrorCallbacks.get(e.data.key)!.call(this, e.data.error); + } + }); + + document.body.appendChild(frame); + return frame.contentWindow; +} + +const sandboxWindow: Window | null = createIframeSandbox(); +const sandboxSuccessCallbacks: Map void> = new Map(); +const sandboxErrorCallbacks: Map void> = new Map(); + +export const runSandboxed = async (code: string) => { + return new Promise((resolve, reject) => { + const key = Math.random(); + + sandboxSuccessCallbacks.set(key, resolve); + sandboxErrorCallbacks.set(key, reject); + + sandboxWindow!.postMessage({ + key, + code, + }, '*'); + }); +}