Switched to vue-leaflet implementation for web client.

This commit is contained in:
Raimund Renkert
2022-03-02 15:00:25 +01:00
parent 481cf6e517
commit 8a1cd2272e
39 changed files with 6779 additions and 3366 deletions

View File

@ -0,0 +1,147 @@
<template>
<v-app dark>
<v-navigation-drawer
v-model="drawer"
:clipped="true"
fixed
app
>
<v-list>
<v-list-item>
<v-list-item-action>
<v-checkbox v-model="playersLayer"></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Players</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-app-bar :clipped-left="true" fixed app>
<v-app-bar-nav-icon @click.stop="drawer = !drawer" />
<v-toolbar-title v-text="title" />
<v-spacer />
<v-btn icon @click.stop="refreshContent">
<v-icon v-if="!autoUpdate">fa-refresh</v-icon>
</v-btn>
<v-btn icon @click.stop="autoUpdate = !autoUpdate">
<v-icon v-if="!autoUpdate">fa-play</v-icon>
<v-icon v-else>fa-pause</v-icon>
</v-btn>
</v-app-bar>
<v-main>
<MapView />
</v-main>
<v-footer :absolute="!fixed" app>
<span>&copy; {{ new Date().getFullYear() }}</span>
<v-spacer />
<span>X: {{coordinates[0]}} - Y: {{coordinates[1]}}</span>
</v-footer>
</v-app>
</template>
<script>
import MapView from './components/MapView.vue'
import {mapState} from 'vuex';
export default {
name: 'App',
components: {
MapView
},
data() {
return {
drawer: false,
fixed: false,
layerItems: [
{
icon: 'mdi-apps',
title: 'Points of Interest',
},
{
icon: 'mdi-chart-bubble',
title: 'Buildings',
},
],
title: 'Minetest - Map of the Real World',
}
},
computed: {
...mapState({
players: state => state.players,
}),
autoUpdate: {
get() {
return this.$store.state.autoUpdate;
},
set(value) {
if (value) {
this.$store.dispatch("runAutoUpdate")
}
else {
this.$store.dispatch("stopAutoUpdate")
}
this.$store.commit("setAutoUpdate", value);
}
},
coordinates() {
return this.$store.state.coordinates;
},
playersLayer: {
get() {
return this.$store.state.playersLayer;
},
set(value) {
this.$store.commit("updatePlayersLayer", value)
}
},
buildingsLayer: {
get() {
return this.$store.state.buildingsLayer;
},
set(value) {
this.$store.commit("updateBuildingsLayer", value)
}
},
poiLayer: {
get() {
return this.$store.state.poiLayer;
},
set(value) {
this.$store.commit("updatePoiLayer", value)
}
},
travelnetLayer: {
get() {
return this.$store.state.travelnetLayer;
},
set(value) {
this.$store.commit("updateTravelnetLayer", value)
}
},
otherLayer: {
get() {
return this.$store.state.otherLayer;
},
set(value) {
this.$store.commit("updateOtherLayer", value)
}
}
},
mounted() {
fetch("players").then(response => response.json().then((data) => {
this.$store.commit("setPlayers", data);
}))
},
methods: {
refreshContent() {
this.$store.dispatch("manualUpdate");
}
}
}
</script>
<style>
.v-navigation-drawer .v-navigation-drawer--fixed{
z-index: 1001;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,84 @@
<template>
<v-container fluid style="height: 100%; padding: 0px">
<l-map @mousemove="update" ref="map" style="height: 100%" :zoom="zoom" :center="center" :worldCopyJump="worldCopyJump" :crs="crs" :options="mapOptions">
<l-tile-layer ref="baseLayer" :url="url" :attribution="attribution" :tms="tms" continuousWorld="false" minZoom=0 :noWrap="noWrap" :options="layerOptions"></l-tile-layer>
<l-marker v-for="p in players" :key="p.name" :lat-lng="flipCoordinates(p.geometry.coordinates)" :icon="iconPlayer">
<l-popup>{{p.properties.name}}</l-popup>
</l-marker>
</l-map>
</v-container>
</template>
<script>
import L from 'leaflet';
import {mapState} from 'vuex';
L.Projection.NoWrap = {
project(latlng) {
return new L.Point(latlng.lat, latlng.lng);
},
unproject(point) {
return new L.LatLng(point.x, point.y, true);
},
bounds: L.bounds(L.point(-30928.0,30928.0),L.point(30928.0, -30928.0))
};
const ownCRS = L.CRS.Direct = L.Util.extend({}, L.CRS, {
code: 'Direct',
projection: L.Projection.NoWrap,
transformation: new L.Transformation(1.0/65536, 30928.0/65536, -1.0/65536, 34608.0/65536)
});
export default {
name: 'MapView',
data: () => {
return {
mapOptions: {
fadeAnimation: false
},
layerOptions: {
unloadInvisibleTiles: true
},
socket: null,
url: 'map/{z}/{x}/{y}.png',
zoom: 3,
noWrap: true,
center: [0,0],
worldCopyJump: false,
crs: ownCRS,
tms: true,
attribution: 'Demo World',
features: [],
iconPlayer: new L.Icon({
iconUrl: require('@/assets/marker-player.png'),
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
}),
}
},
computed: {
players() {
return this.$store.state.players;
},
...mapState({
autoUpdate: state => state.autoUpdate,
playersLayer: state => state.playersLayer,
}),
},
methods: {
update(evt) {
this.$store.commit('updateCoordinates', [evt.latlng.lat, evt.latlng.lng])
},
flipCoordinates(pos) {
return [pos[1], pos[0]];
}
},
}
</script>
<style>
.leaflet-touch .leaflet-bar a {
color: #f5f5f5;
background-color: #424242;
}
</style>

View File

@ -0,0 +1,14 @@
import Vue from 'vue'
import App from './App.vue'
import vuetify from './plugins/vuetify'
import './plugins/leaflet'
import '@babel/polyfill'
import store from './store'
Vue.config.productionTip = false
new Vue({
vuetify,
store,
render: h => h(App)
}).$mount('#app')

View File

@ -0,0 +1,12 @@
import Vue from 'vue';
import { LMap, LTileLayer, LMarker, LPopup } from 'vue2-leaflet';
import Vue2LeafletMarkerCluster from 'vue2-leaflet-markercluster'
import 'leaflet/dist/leaflet.css';
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
Vue.component('l-map', LMap);
Vue.component('l-tile-layer', LTileLayer);
Vue.component('l-marker', LMarker);
Vue.component('l-popup', LPopup);
Vue.component('l-marker-cluster', Vue2LeafletMarkerCluster);

View File

@ -0,0 +1,27 @@
import Vue from 'vue';
import Vuetify from 'vuetify';
import 'vuetify/dist/vuetify.min.css';
import colors from 'vuetify/es5/util/colors'
import '@fortawesome/fontawesome-free/css/all.css'
Vue.use(Vuetify);
export default new Vuetify({
icons: {
iconfont: 'fa',
},
theme: {
dark: true,
themes: {
dark: {
primary: colors.blue.darken2,
accent: colors.grey.darken3,
secondary: colors.amber.darken3,
info: colors.teal.lighten1,
warning: colors.amber.base,
error: colors.deepOrange.accent4,
success: colors.green.accent3,
},
},
},
});

View File

@ -0,0 +1,189 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
socket: null,
autoUpdate: false,
coordinates: [0, 0],
playersLayer: true,
buildingsLayer: true,
poiLayer: true,
travelnetLayer: true,
otherLayer: true,
players: []
},
mutations: {
updateCoordinates(state, value) {
state.coordinates = value;
},
setAutoUpdate(state, value) {
state.autoUpdate = value
},
updatePlayersLayer(state, value) {
state.playersLayer = value;
},
updateBuildingsLayer(state, value) {
state.buildingsLayer = value;
},
updatePoiLayer(state, value) {
state.poiLayer = value;
},
updateTravelnetLayer(state, value) {
state.travelnetLayer = value;
},
updateOtherLayer(state, value) {
state.otherLayer = value;
},
setPlayers(state, value) {
state.players = value;
},
setPlayerState(state, value) {
const p = state.players.find(p => p.name === value.name)
p.online = value.state;
}
},
actions: {
stopAutoUpdate: function({ state }) {
if (this.socket) {
var s = state.socket;
state.socket = null;
s.close();
}
},
manualUpdate: function({commit}) {
var tiles = document.getElementsByTagName("img");
for (var i = 0; i < tiles.length; i++) {
var img = tiles[i];
var cl = img.getAttribute("class");
if (cl.indexOf("leaflet-tile-loaded") >= 0) {
var src = img.src;
var idx = src.lastIndexOf("#");
if (idx >= 0) {
src = src.substring(0, idx);
}
img.src = src + "#" + Math.random();
}
}
fetch("players").then(response => response.json().then((data) => {
commit("setPlayers", data);
}));
},
runAutoUpdate: function({ commit, state, dispatch}) {
var me = this;
// TODO: Make the URL to websocket configurable via .env
state.socket = new WebSocket('ws://' + window.location.host + '/socket');
state.socket.onerror = function() {
commit('setAutoUpdate', false)
dispatch('stopAutoUpdate');
};
state.socket.onclose = function() {
commit('setAutoUpdate', false)
state.socket = null;
}
state.socket.onopen = function() {
// Sending pings every 5 secs to keep connection alive.
var heartbeat = function() {
if (heartbeat && me.socket) {
me.socket.send("PING");
setTimeout(heartbeat, 8000);
} else {
// Prevent sending pings to re-opened sockets.
heartbeat = null;
}
};
setTimeout(heartbeat, 8000);
};
state.socket.onmessage = function(evt) {
var json = evt.data;
if (!(typeof json === "string")) {
return;
}
var msg;
try {
msg = JSON.parse(json);
}
catch (err) {
return;
}
if (msg.players) {
commit('setPlayers', msg.players);
}
var tilesData = msg.tiles;
if (!tilesData) {
return;
}
var invalidate = function(td) {
var pyramid = new Array(9);
var last = new Object();
pyramid[8] = last;
for (var i = 0; i < td.length; i++) {
var xz = td[i];
last[xz.X + "#" + xz.Z] = xz;
}
for (var p = 7; p >= 0; p--) {
var prev = pyramid[p+1];
var curr = new Object();
pyramid[p] = curr;
for (var k in prev) {
if (Object.prototype.hasOwnProperty.call(prev, k)) {
var oxz = prev[k];
var nxz = { X: oxz.X >> 1, Z: oxz.Z >> 1 };
curr[nxz.X + "#" + nxz.Z] = nxz;
}
}
}
return function(x, y, z) {
if (y > 8) {
x >>= y - 8;
z >>= y - 8;
y = 8;
}
var level = pyramid[y];
var k = x + "#" + z;
return Object.prototype.hasOwnProperty.call(level, k);
};
} (tilesData);
var tiles = document.getElementsByTagName('img');
var re = /\/map\/([0-9]+)\/([0-9]+)\/([0-9]+).*/;
for (var i = 0; i < tiles.length; i++) {
var img = tiles[i];
var cl = img.getAttribute('class');
if (cl.indexOf('leaflet-tile-loaded') < 0) {
continue;
}
var src = img.src;
var coord = src.match(re);
if (coord == null) {
continue;
}
var y = parseInt(coord[1]);
var x = parseInt(coord[2]);
var z = parseInt(coord[3]);
if (invalidate(x, y, z)) {
var idx = src.lastIndexOf('#');
if (idx >= 0) {
src = src.substring(0, idx);
}
img.src = src + '#' + Math.random();
}
}
};
}
}
})