mirror of
https://github.com/owntracks/frontend.git
synced 2026-05-15 03:56:32 +00:00
Publish 2.0.0-alpha source
This commit is contained in:
107
src/App.vue
Normal file
107
src/App.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<AppHeader />
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
<Modal name="download">
|
||||
Not implemented.
|
||||
</Modal>
|
||||
<Modal name="information">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/owntracks/frontend">
|
||||
owntracks/frontend
|
||||
</a>
|
||||
({{ frontendVersion }})
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/owntracks/recorder">
|
||||
owntracks/recorder
|
||||
</a>
|
||||
({{ recorderVersion }})
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://owntracks.org/booklet/">
|
||||
OwnTracks Recorder Documentation
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://twitter.com/OwnTracks">@OwnTracks</a>
|
||||
</li>
|
||||
</ul>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapMutations, mapState } from "vuex";
|
||||
|
||||
import config from "@/config";
|
||||
import * as types from "@/store/mutation-types";
|
||||
import AppHeader from "@/components/AppHeader";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
export default {
|
||||
components: { AppHeader, Modal },
|
||||
created() {
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-accent",
|
||||
config.accentColor
|
||||
);
|
||||
this.populateStateFromQuery(this.$route.query);
|
||||
this.loadData();
|
||||
// Update URL query params when relevant state changes
|
||||
this.$store.subscribe(
|
||||
mutation =>
|
||||
[
|
||||
types.SET_SELECTED_USER,
|
||||
types.SET_SELECTED_DEVICE,
|
||||
types.SET_START_DATE,
|
||||
types.SET_END_DATE,
|
||||
types.SET_MAP_CENTER,
|
||||
types.SET_MAP_ZOOM,
|
||||
types.SET_MAP_LAYER_VISIBILITY,
|
||||
].includes(mutation.type) && this.updateUrlQuery()
|
||||
);
|
||||
// Initially update URL query params from state
|
||||
this.updateUrlQuery();
|
||||
},
|
||||
computed: {
|
||||
...mapState(["frontendVersion", "recorderVersion"]),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setModalVisibility: types.SET_MODAL_VISIBILITY,
|
||||
}),
|
||||
...mapActions(["populateStateFromQuery", "loadData"]),
|
||||
updateUrlQuery() {
|
||||
const {
|
||||
map,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
selectedUser: user,
|
||||
selectedDevice: device,
|
||||
} = this.$store.state;
|
||||
const activeLayers = Object.keys(map.layers).filter(
|
||||
key => map.layers[key] === true
|
||||
);
|
||||
const query = {
|
||||
lat: map.center.lat,
|
||||
lng: map.center.lng,
|
||||
zoom: map.zoom,
|
||||
start: start.toISOString().split("T")[0],
|
||||
end: end.toISOString().split("T")[0],
|
||||
...(user !== null && { user }),
|
||||
...(user !== null && device !== null && { device }),
|
||||
...(activeLayers.length > 0 && { layers: activeLayers.join(",") }),
|
||||
};
|
||||
this.$router.replace({ query }).catch(() => {}); // https://github.com/vuejs/vue-router/issues/2872#issuecomment-519073998
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "styles/main";
|
||||
</style>
|
||||
119
src/api.js
Normal file
119
src/api.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import { getApiUrl } from "@/util";
|
||||
|
||||
const fetchApi = (path, params = {}) => {
|
||||
const url = getApiUrl(path);
|
||||
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
|
||||
return fetch(url);
|
||||
};
|
||||
|
||||
export const getVersion = async () => {
|
||||
const response = await fetchApi("/api/0/version");
|
||||
const json = await response.json();
|
||||
const version = json.version;
|
||||
return version;
|
||||
};
|
||||
|
||||
export const getUsers = async () => {
|
||||
const response = await fetchApi("/api/0/list");
|
||||
const json = await response.json();
|
||||
const users = json.results;
|
||||
return users;
|
||||
};
|
||||
|
||||
export const getDevices = async users => {
|
||||
const devices = {};
|
||||
await Promise.all(
|
||||
users.map(async user => {
|
||||
const response = await fetchApi(`/api/0/list`, { user });
|
||||
const json = await response.json();
|
||||
const userDevices = json.results;
|
||||
devices[user] = userDevices;
|
||||
})
|
||||
);
|
||||
return devices;
|
||||
};
|
||||
|
||||
export const getLastLocations = async (user, device) => {
|
||||
const params = {};
|
||||
if (user) {
|
||||
params["user"] = user;
|
||||
if (device) {
|
||||
params["device"] = device;
|
||||
}
|
||||
}
|
||||
const response = await fetchApi("/api/0/last", params);
|
||||
const json = await response.json();
|
||||
return json;
|
||||
};
|
||||
|
||||
export const getUserDeviceLocationHistory = async (
|
||||
user,
|
||||
device,
|
||||
start,
|
||||
end
|
||||
) => {
|
||||
const startDate = start.toISOString().split("T")[0];
|
||||
const endDate = end.toISOString().split("T")[0];
|
||||
const response = await fetchApi("/api/0/locations", {
|
||||
from: `${startDate}T00:00:00`,
|
||||
to: `${endDate}T23:59:59`,
|
||||
user,
|
||||
device,
|
||||
format: "json",
|
||||
});
|
||||
const json = await response.json();
|
||||
return json.data;
|
||||
};
|
||||
|
||||
export const getLocationHistory = async (devices, start, end) => {
|
||||
const locationHistory = {};
|
||||
await Promise.all(
|
||||
Object.keys(devices).map(async user => {
|
||||
locationHistory[user] = {};
|
||||
await Promise.all(
|
||||
devices[user].map(async device => {
|
||||
locationHistory[user][device] = await getUserDeviceLocationHistory(
|
||||
user,
|
||||
device,
|
||||
start,
|
||||
end
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
return locationHistory;
|
||||
};
|
||||
|
||||
export const connectWebsocket = async callback => {
|
||||
let url = getApiUrl("/ws/last");
|
||||
url.protocol = url.protocol.replace("http", "ws");
|
||||
url = url.href;
|
||||
const ws = new WebSocket(url);
|
||||
console.info(`[WS] Connecting to ${url}...`);
|
||||
ws.onopen = () => {
|
||||
console.info("[WS] Connected");
|
||||
ws.send("LAST");
|
||||
};
|
||||
ws.onclose = () => {
|
||||
console.info("[WS] Disconnected. Reconnecting in one second...");
|
||||
setTimeout(connectWebsocket, 1000);
|
||||
};
|
||||
ws.onmessage = async msg => {
|
||||
if (msg.data) {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data._type === "location") {
|
||||
console.info("[WS] Location update received");
|
||||
callback && (await callback());
|
||||
}
|
||||
} catch (err) {
|
||||
if (msg.data !== "LAST") {
|
||||
console.exception(err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.info("[WS] Ping");
|
||||
}
|
||||
};
|
||||
};
|
||||
232
src/components/AppHeader.vue
Normal file
232
src/components/AppHeader.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-outline"
|
||||
title="Automatically center the map view and zoom in to relevant data"
|
||||
@click="$root.$emit('fitView')"
|
||||
>
|
||||
Fit View
|
||||
</button>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<LayersIcon size="1x" />
|
||||
<div class="dropdown">
|
||||
<button class="dropdown-button button" title="Show/hide layers">
|
||||
Layer Settings
|
||||
</button>
|
||||
<div class="dropdown-body">
|
||||
<label tabindex="0">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="map.layers.last"
|
||||
@change="
|
||||
setMapLayerVisibility({
|
||||
layer: 'last',
|
||||
visibility: $event.target.checked,
|
||||
})
|
||||
"
|
||||
/>
|
||||
Show last known locations
|
||||
</label>
|
||||
<label tabindex="0">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="map.layers.line"
|
||||
@change="
|
||||
setMapLayerVisibility({
|
||||
layer: 'line',
|
||||
visibility: $event.target.checked,
|
||||
})
|
||||
"
|
||||
/>
|
||||
Show location history (line)
|
||||
</label>
|
||||
<label tabindex="0">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="map.layers.points"
|
||||
@change="
|
||||
setMapLayerVisibility({
|
||||
layer: 'points',
|
||||
visibility: $event.target.checked,
|
||||
})
|
||||
"
|
||||
/>
|
||||
Show location history (points)
|
||||
</label>
|
||||
<label tabindex="0">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="map.layers.heatmap"
|
||||
@change="
|
||||
setMapLayerVisibility({
|
||||
layer: 'heatmap',
|
||||
visibility: $event.target.checked,
|
||||
})
|
||||
"
|
||||
/>
|
||||
Show location heatmap
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<CalendarIcon size="1x" />
|
||||
<Datepicker
|
||||
v-model="startDate"
|
||||
:use-utc="true"
|
||||
:disabled-dates="startDateDisabledDates"
|
||||
title="Select start date"
|
||||
/>
|
||||
to
|
||||
<Datepicker
|
||||
v-model="endDate"
|
||||
:use-utc="true"
|
||||
:disabled-dates="endDateDisabledDates"
|
||||
title="Select end date"
|
||||
/>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<UserIcon size="1x" />
|
||||
<select
|
||||
v-model="selectedUser"
|
||||
class="dropdown-button button"
|
||||
title="Select user"
|
||||
>
|
||||
<option :value="null">
|
||||
Show All
|
||||
</option>
|
||||
<option v-for="user in users" :value="user" :key="user">
|
||||
{{ user }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedUser" class="nav-item">
|
||||
<SmartphoneIcon size="1x" />
|
||||
<select
|
||||
v-model="selectedDevice"
|
||||
class="dropdown-button button"
|
||||
title="Select device"
|
||||
>
|
||||
<option :value="null">
|
||||
Show All
|
||||
</option>
|
||||
<option
|
||||
v-for="device in devices[selectedUser]"
|
||||
:value="device"
|
||||
:key="`${selectedUser}-${device}`"
|
||||
>
|
||||
{{ device }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="nav-shrink">
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
title="Download raw data"
|
||||
@click="
|
||||
setModalVisibility({
|
||||
modal: 'download',
|
||||
visibility: true,
|
||||
})
|
||||
"
|
||||
>
|
||||
<DownloadIcon size="1x" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
title="Information"
|
||||
@click="
|
||||
setModalVisibility({
|
||||
modal: 'information',
|
||||
visibility: true,
|
||||
})
|
||||
"
|
||||
>
|
||||
<InfoIcon size="1x" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
|
||||
import {
|
||||
CalendarIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
} from "vue-feather-icons";
|
||||
import Datepicker from "vuejs-datepicker";
|
||||
|
||||
import * as types from "@/store/mutation-types";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CalendarIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
Datepicker,
|
||||
},
|
||||
computed: {
|
||||
...mapState(["users", "devices", "map"]),
|
||||
...mapGetters(["startDateDisabledDates", "endDateDisabledDates"]),
|
||||
selectedUser: {
|
||||
get() {
|
||||
return this.$store.state.selectedUser;
|
||||
},
|
||||
set(value) {
|
||||
this.setSelectedUser(value);
|
||||
},
|
||||
},
|
||||
selectedDevice: {
|
||||
get() {
|
||||
return this.$store.state.selectedDevice;
|
||||
},
|
||||
set(value) {
|
||||
this.setSelectedDevice(value);
|
||||
},
|
||||
},
|
||||
startDate: {
|
||||
get() {
|
||||
return this.$store.state.startDate;
|
||||
},
|
||||
set(value) {
|
||||
this.setStartDate(value);
|
||||
},
|
||||
},
|
||||
endDate: {
|
||||
get() {
|
||||
return this.$store.state.endDate;
|
||||
},
|
||||
set(value) {
|
||||
this.setEndDate(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setMapLayerVisibility: types.SET_MAP_LAYER_VISIBILITY,
|
||||
setModalVisibility: types.SET_MODAL_VISIBILITY,
|
||||
}),
|
||||
...mapActions([
|
||||
"setSelectedUser",
|
||||
"setSelectedDevice",
|
||||
"setStartDate",
|
||||
"setEndDate",
|
||||
]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
89
src/components/LDeviceLocationPopup.vue
Normal file
89
src/components/LDeviceLocationPopup.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<LPopup>
|
||||
<img v-if="face" class="location-popup-face" :src="faceImageDataURI" />
|
||||
<b v-if="name">{{ name }}</b>
|
||||
<b v-else>{{ user }}/{{ device }}</b>
|
||||
<div class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-calendar-clock"></span>
|
||||
{{ new Date(timestamp * 1000).toLocaleString() }}
|
||||
</div>
|
||||
<div class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-crosshairs-gps"></span>
|
||||
{{ lat }}, {{ lon }}, {{ alt }}m
|
||||
</div>
|
||||
<div v-if="address" class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-map-marker"></span>
|
||||
{{ address }}
|
||||
</div>
|
||||
<div v-if="typeof battery === 'number'" class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-battery"></span>
|
||||
{{ battery }} %
|
||||
</div>
|
||||
<div v-if="typeof speed === 'number'" class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-speedometer"></span>
|
||||
{{ speed }} km/h
|
||||
</div>
|
||||
</LPopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { LPopup } from "vue2-leaflet";
|
||||
|
||||
export default {
|
||||
name: "LDeviceLocationPopup",
|
||||
components: {
|
||||
LPopup,
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
device: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
face: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
timestamp: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
lat: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
lon: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
alt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
battery: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
speed: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
faceImageDataURI() {
|
||||
return `data:image/png;base64,${this.face}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
39
src/components/Modal.vue
Normal file
39
src/components/Modal.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="modal" v-show="modals[name]" @click.self="close">
|
||||
<div class="modal-container">
|
||||
<button class="modal-close-button" title="Close" @click="close">
|
||||
×
|
||||
</button>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapMutations, mapState } from "vuex";
|
||||
|
||||
import * as types from "@/store/mutation-types";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(["modals"]),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setModalVisibility: types.SET_MODAL_VISIBILITY,
|
||||
}),
|
||||
close() {
|
||||
this.setModalVisibility({
|
||||
modal: this.name,
|
||||
visibility: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
103
src/config.js
Normal file
103
src/config.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import deepmerge from "deepmerge";
|
||||
|
||||
const endDate = new Date();
|
||||
endDate.setUTCHours(0);
|
||||
endDate.setUTCMinutes(0);
|
||||
endDate.setUTCSeconds(0);
|
||||
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setUTCMonth(startDate.getMonth() - 1);
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
api: {
|
||||
// API base URL, defaults to the same domain. Keep CORS in mind.
|
||||
baseUrl: `${window.location.protocol}//${window.location.host}`,
|
||||
},
|
||||
accentColor: "#478db2",
|
||||
// Initial start and end date. Doesn't have to be hardcoded, see
|
||||
// above. Defaults to one month ago - today.
|
||||
startDate,
|
||||
endDate,
|
||||
// User and device selected by default. Set to null to show all by default.
|
||||
selectedUser: null,
|
||||
selectedDevice: null,
|
||||
map: {
|
||||
// Initial map center position
|
||||
center: { lat: 0, lng: 0 },
|
||||
// Initial map zoom
|
||||
zoom: 19,
|
||||
// This is being used to fetch tiles in different resolutions -
|
||||
// set to the highest value the configured tileserver supports.
|
||||
maxNativeZoom: 19,
|
||||
// Allow zooming closer than the tile server supports, which will
|
||||
// result in (slightly) blurry tiles on higher zoom levels. Set
|
||||
// to the same value as `maxNativeZoom` to disable.
|
||||
maxZoom: 21,
|
||||
// Tile server URL. See Leaflet documentation for more info.
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution:
|
||||
'© <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
|
||||
// Leaflet map controls. Options should be self-explanatory.
|
||||
controls: {
|
||||
zoom: {
|
||||
display: true,
|
||||
position: "topleft",
|
||||
},
|
||||
scale: {
|
||||
display: true,
|
||||
position: "bottomleft",
|
||||
maxWidth: 200,
|
||||
metric: true,
|
||||
imperial: true,
|
||||
},
|
||||
},
|
||||
// `color` and `fillColor` default to `accentColor` when null.
|
||||
polyline: {
|
||||
color: null,
|
||||
fillColor: "transparent",
|
||||
},
|
||||
circle: {
|
||||
color: null,
|
||||
fillColor: null,
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
circleMarker: {
|
||||
color: null,
|
||||
fillColor: "#fff",
|
||||
fillOpacity: 1,
|
||||
radius: 4,
|
||||
},
|
||||
// Configuration for the heatmap (simpleheat). See
|
||||
// https://github.com/mourner/simpleheat for more info.
|
||||
heatmap: {
|
||||
max: 20,
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
// Uses simpleheat's default gradient when null. See
|
||||
// https://github.com/mourner/simpleheat/blob/c1998c36fa2f9a31350371fd42ee30eafcc78f9c/simpleheat.js#L22-L28
|
||||
gradient: null,
|
||||
},
|
||||
// Which layers to show by default. The source of truth at runtime
|
||||
// is the Vuex store, which is initialised from these values and
|
||||
// the query parameters, in that order.
|
||||
layers: {
|
||||
last: true,
|
||||
line: true,
|
||||
points: false,
|
||||
heatmap: false,
|
||||
},
|
||||
// Maximum distance (in meters) between points for them to be part
|
||||
// of the the same line. This avoids straight lines going across
|
||||
// the map when there's a large distance between two points (which
|
||||
// usually indicates that they're not related). Set to Infinity to
|
||||
// disable splitting into separate lines.
|
||||
maxPointDistance: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
// Use deepmerge to combine the default and user-defined configuration.
|
||||
// This enables the user to use a fairly small config object which only
|
||||
// needs to contain actual changes, not all default values - and these
|
||||
// stay up-to-date automatically.
|
||||
// There might not be a user-defined config, default to an empty object.
|
||||
export default deepmerge(DEFAULT_CONFIG, (window.owntracks || {}).config || {});
|
||||
9
src/constants.js
Normal file
9
src/constants.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Regular expression for an ISO 8601 YYYY-MM-DD date.
|
||||
// Used to validate dates from URL query parameters.
|
||||
export const ISO_DATE_REGEXP = new RegExp(
|
||||
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
|
||||
);
|
||||
|
||||
// https://en.wikipedia.org/wiki/Earth_radius
|
||||
// Used to calculate the distance between two coordinates.
|
||||
export const EARTH_RADIUS_IN_KM = 6371;
|
||||
12
src/main.js
Normal file
12
src/main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import Vue from "vue";
|
||||
import App from "@/App.vue";
|
||||
import router from "@/router";
|
||||
import store from "@/store";
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
render: h => h(App),
|
||||
}).$mount("#app");
|
||||
17
src/router.js
Normal file
17
src/router.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Vue from "vue";
|
||||
import Router from "vue-router";
|
||||
import Map from "./views/Map.vue";
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
export default new Router({
|
||||
mode: "history",
|
||||
base: process.env.BASE_URL,
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "map",
|
||||
component: Map,
|
||||
},
|
||||
],
|
||||
});
|
||||
184
src/store/actions.js
Normal file
184
src/store/actions.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import * as types from "@/store/mutation-types";
|
||||
import * as api from "@/api";
|
||||
import { isIsoDate } from "@/util";
|
||||
|
||||
/**
|
||||
* Populate the state from URL query parameters.
|
||||
*
|
||||
* @param {Object} query URL query parameters
|
||||
*/
|
||||
const populateStateFromQuery = ({ state, commit }, query) => {
|
||||
if (query.lat && !isNaN(parseFloat(query.lat))) {
|
||||
commit(types.SET_MAP_CENTER, {
|
||||
lat: query.lat,
|
||||
lng: parseFloat(state.map.center.lng),
|
||||
});
|
||||
}
|
||||
if (query.lng && !isNaN(parseFloat(query.lng))) {
|
||||
commit(types.SET_MAP_CENTER, {
|
||||
lat: parseFloat(state.map.center.lat),
|
||||
lng: query.lng,
|
||||
});
|
||||
}
|
||||
if (query.zoom && !isNaN(parseInt(query.zoom))) {
|
||||
commit(types.SET_MAP_ZOOM, parseInt(query.zoom));
|
||||
}
|
||||
if (query.start && isIsoDate(query.start)) {
|
||||
commit(types.SET_START_DATE, new Date(query.start));
|
||||
}
|
||||
if (query.end && isIsoDate(query.end)) {
|
||||
commit(types.SET_END_DATE, new Date(query.end));
|
||||
}
|
||||
if (query.user) {
|
||||
commit(types.SET_SELECTED_USER, query.user);
|
||||
}
|
||||
if (query.device) {
|
||||
commit(types.SET_SELECTED_DEVICE, query.device);
|
||||
}
|
||||
if (query.layers) {
|
||||
const activeLayers = query.layers.split(",");
|
||||
Object.keys(state.map.layers).forEach(layer => {
|
||||
const visibility = activeLayers.includes(layer);
|
||||
if (state.map.layers[layer] !== visibility) {
|
||||
commit(types.SET_MAP_LAYER_VISIBILITY, { layer, visibility });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger loading of all required data: users, devices, last locations,
|
||||
* location history, version and initiate WebSocket connection.
|
||||
*/
|
||||
const loadData = async ({ dispatch }) => {
|
||||
await dispatch("getUsers");
|
||||
await dispatch("getDevices");
|
||||
await dispatch("getLastLocations");
|
||||
await dispatch("getLocationHistory");
|
||||
await dispatch("getRecorderVersion");
|
||||
await dispatch("connectWebsocket");
|
||||
};
|
||||
|
||||
/**
|
||||
* Connect to WebSocket to receive live location updates. When an update is
|
||||
* received, reload last locations and location history.
|
||||
*/
|
||||
const connectWebsocket = async ({ dispatch }) => {
|
||||
api.connectWebsocket(async () => {
|
||||
await dispatch("getLastLocations");
|
||||
// Reloading the complete location history is necessary because the
|
||||
// last locations do lack some of the detailed information.
|
||||
// TODO: make this optional via config.
|
||||
await dispatch("getLocationHistory");
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Load user names.
|
||||
*/
|
||||
const getUsers = async ({ commit }) => {
|
||||
commit(types.SET_USERS, await api.getUsers());
|
||||
};
|
||||
|
||||
/**
|
||||
* Load devices names of all users.
|
||||
*/
|
||||
const getDevices = async ({ commit, state }) => {
|
||||
commit(types.SET_DEVICES, await api.getDevices(state.users));
|
||||
};
|
||||
|
||||
/**
|
||||
* Load last location of the selected user/device.
|
||||
*/
|
||||
const getLastLocations = async ({ commit, state }) => {
|
||||
commit(
|
||||
types.SET_LAST_LOCATIONS,
|
||||
await api.getLastLocations(state.selectedUser, state.selectedDevice)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load location history of all devices, in the selected date range.
|
||||
*/
|
||||
const getLocationHistory = async ({ commit, state }) => {
|
||||
let devices;
|
||||
if (state.selectedUser) {
|
||||
if (state.selectedDevice) {
|
||||
devices = { [state.selectedUser]: [state.selectedDevice] };
|
||||
} else {
|
||||
devices = { [state.selectedUser]: state.devices[state.selectedUser] };
|
||||
}
|
||||
} else {
|
||||
devices = state.devices;
|
||||
}
|
||||
commit(
|
||||
types.SET_LOCATION_HISTORY,
|
||||
await api.getLocationHistory(devices, state.startDate, state.endDate)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the OwnTracks recorder version.
|
||||
*/
|
||||
const getRecorderVersion = async ({ commit }) => {
|
||||
commit(types.SET_RECORDER_VERSION, await api.getVersion());
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected user and reload the location history.
|
||||
*
|
||||
* @param {string} user Name of the new selected user
|
||||
*/
|
||||
const setSelectedUser = async ({ commit, dispatch }, user) => {
|
||||
commit(types.SET_SELECTED_USER, user);
|
||||
await dispatch("getLocationHistory");
|
||||
await dispatch("getLastLocations");
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected device and reload the location history.
|
||||
*
|
||||
* @param {string} device Name of the new selected device
|
||||
*/
|
||||
const setSelectedDevice = async ({ commit, dispatch }, device) => {
|
||||
commit(types.SET_SELECTED_DEVICE, device);
|
||||
await dispatch("getLocationHistory");
|
||||
await dispatch("getLastLocations");
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the start date for loading data and reload the location history.
|
||||
*
|
||||
* @param {Date} startDate Start date for loading data
|
||||
*/
|
||||
const setStartDate = async ({ commit, dispatch }, startDate) => {
|
||||
commit(types.SET_START_DATE, startDate);
|
||||
await dispatch("getLocationHistory");
|
||||
await dispatch("getLastLocations");
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the end date for loading data and reload the location history.
|
||||
*
|
||||
* @param {Date} endDate End date for loading data
|
||||
*/
|
||||
const setEndDate = async ({ commit, dispatch }, endDate) => {
|
||||
commit(types.SET_END_DATE, endDate);
|
||||
await dispatch("getLocationHistory");
|
||||
await dispatch("getLastLocations");
|
||||
};
|
||||
|
||||
export default {
|
||||
populateStateFromQuery,
|
||||
loadData,
|
||||
connectWebsocket,
|
||||
getUsers,
|
||||
getDevices,
|
||||
getLastLocations,
|
||||
getLocationHistory,
|
||||
getRecorderVersion,
|
||||
setSelectedUser,
|
||||
setSelectedDevice,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
};
|
||||
93
src/store/getters.js
Normal file
93
src/store/getters.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import L from "leaflet";
|
||||
|
||||
import config from "@/config";
|
||||
import { distanceBetweenCoordinates } from "@/util";
|
||||
|
||||
/**
|
||||
* From the selected users' and devices' location histories, create an
|
||||
* array of all coordinates.
|
||||
*
|
||||
* @param {Object} state.locationHistory Location history of selected users and devices
|
||||
* @return {Array.<L.LatLng>} All coordinates
|
||||
*/
|
||||
const locationHistoryLatLngs = state => {
|
||||
const latLngs = [];
|
||||
Object.keys(state.locationHistory).forEach(user => {
|
||||
Object.keys(state.locationHistory[user]).forEach(device => {
|
||||
state.locationHistory[user][device].forEach(coordinate => {
|
||||
latLngs.push(L.latLng(coordinate.lat, coordinate.lon));
|
||||
});
|
||||
});
|
||||
});
|
||||
return latLngs;
|
||||
};
|
||||
|
||||
/**
|
||||
* From the selected users' and devices' location histories, create an
|
||||
* array of coordinate groups where the distance between two subsequent
|
||||
* coordinates does not exceed `config.map.maxPointDistance`.
|
||||
*
|
||||
* @param {Object} state.locationHistory Location history of selected users and devices
|
||||
* @return {Array.<Array.<L.LatLng>>} Groups of coherent coordinates
|
||||
*/
|
||||
const locationHistoryLatLngGroups = state => {
|
||||
const groups = [];
|
||||
Object.keys(state.locationHistory).forEach(user => {
|
||||
Object.keys(state.locationHistory[user]).forEach(device => {
|
||||
let latLngs = [];
|
||||
state.locationHistory[user][device].forEach(coordinate => {
|
||||
const latLng = L.latLng(coordinate.lat, coordinate.lon);
|
||||
// Skip if group splitting is disabled or this is the first
|
||||
// coordinate in the current group
|
||||
if (config.map.maxPointDistance !== null && latLngs.length > 0) {
|
||||
const lastLatLng = latLngs.slice(-1)[0];
|
||||
if (
|
||||
distanceBetweenCoordinates(lastLatLng, latLng) >
|
||||
config.map.maxPointDistance
|
||||
) {
|
||||
// Distance is too far, start new group of coordinate
|
||||
groups.push(latLngs);
|
||||
latLngs = [];
|
||||
}
|
||||
}
|
||||
// Add coordinate to current active group
|
||||
latLngs.push(latLng);
|
||||
});
|
||||
groups.push(latLngs);
|
||||
});
|
||||
});
|
||||
return groups;
|
||||
};
|
||||
|
||||
/**
|
||||
* For the start date selector, disable all dates above the end date
|
||||
* or current date.
|
||||
*
|
||||
* @param {Date} state.endDate End date
|
||||
* @return {Object} Configuration for the `disabled-dates` prop of the `vuejs-datepicker` component
|
||||
*/
|
||||
const startDateDisabledDates = state => {
|
||||
return {
|
||||
customPredictor: date => date > state.endDate || date > new Date(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* For the end date selector, disable all dates below the start date
|
||||
* or above the current date.
|
||||
*
|
||||
* @param {Date} state.startDate Start date
|
||||
* @return {Object} Configuration for the `disabled-dates` prop of the `vuejs-datepicker` component
|
||||
*/
|
||||
const endDateDisabledDates = state => {
|
||||
return {
|
||||
customPredictor: date => date < state.startDate || date > new Date(),
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
locationHistoryLatLngs,
|
||||
locationHistoryLatLngGroups,
|
||||
startDateDisabledDates,
|
||||
endDateDisabledDates,
|
||||
};
|
||||
36
src/store/index.js
Normal file
36
src/store/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import Vue from "vue";
|
||||
import Vuex from "vuex";
|
||||
|
||||
import getters from "@/store/getters";
|
||||
import mutations from "@/store/mutations";
|
||||
import actions from "@/store/actions";
|
||||
import config from "@/config";
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
frontendVersion: process.env.PACKAGE_VERSION,
|
||||
recorderVersion: "",
|
||||
users: [],
|
||||
devices: {},
|
||||
lastLocations: [],
|
||||
locationHistory: {},
|
||||
selectedUser: config.selectedUser,
|
||||
selectedDevice: config.selectedDevice,
|
||||
startDate: config.startDate,
|
||||
endDate: config.endDate,
|
||||
map: {
|
||||
center: config.map.center,
|
||||
zoom: config.map.zoom,
|
||||
layers: config.map.layers,
|
||||
},
|
||||
modals: {
|
||||
download: false,
|
||||
information: false,
|
||||
},
|
||||
},
|
||||
getters,
|
||||
mutations,
|
||||
actions,
|
||||
});
|
||||
13
src/store/mutation-types.js
Normal file
13
src/store/mutation-types.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export const SET_RECORDER_VERSION = "SET_RECORDER_VERSION";
|
||||
export const SET_USERS = "SET_USERS";
|
||||
export const SET_DEVICES = "SET_DEVICES";
|
||||
export const SET_LAST_LOCATIONS = "SET_LAST_LOCATIONS";
|
||||
export const SET_LOCATION_HISTORY = "SET_LOCATION_HISTORY";
|
||||
export const SET_SELECTED_USER = "SET_SELECTED_USER";
|
||||
export const SET_SELECTED_DEVICE = "SET_SELECTED_DEVICE";
|
||||
export const SET_START_DATE = "SET_START_DATE";
|
||||
export const SET_END_DATE = "SET_END_DATE";
|
||||
export const SET_MAP_CENTER = "SET_MAP_CENTER";
|
||||
export const SET_MAP_ZOOM = "SET_MAP_ZOOM";
|
||||
export const SET_MAP_LAYER_VISIBILITY = "SET_MAP_LAYER_VISIBILITY";
|
||||
export const SET_MODAL_VISIBILITY = "SET_MODAL_VISIBILITY";
|
||||
43
src/store/mutations.js
Normal file
43
src/store/mutations.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as types from "@/store/mutation-types";
|
||||
|
||||
export default {
|
||||
[types.SET_RECORDER_VERSION](state, version) {
|
||||
state.recorderVersion = version;
|
||||
},
|
||||
[types.SET_USERS](state, users) {
|
||||
state.users = users;
|
||||
},
|
||||
[types.SET_DEVICES](state, devices) {
|
||||
state.devices = devices;
|
||||
},
|
||||
[types.SET_LAST_LOCATIONS](state, lastLocations) {
|
||||
state.lastLocations = lastLocations;
|
||||
},
|
||||
[types.SET_LOCATION_HISTORY](state, locationHistory) {
|
||||
state.locationHistory = locationHistory;
|
||||
},
|
||||
[types.SET_SELECTED_USER](state, selectedUser) {
|
||||
state.selectedUser = selectedUser;
|
||||
},
|
||||
[types.SET_SELECTED_DEVICE](state, selectedDevice) {
|
||||
state.selectedDevice = selectedDevice;
|
||||
},
|
||||
[types.SET_START_DATE](state, startDate) {
|
||||
state.startDate = startDate;
|
||||
},
|
||||
[types.SET_END_DATE](state, endDate) {
|
||||
state.endDate = endDate;
|
||||
},
|
||||
[types.SET_MAP_CENTER](state, center) {
|
||||
state.map.center = center;
|
||||
},
|
||||
[types.SET_MAP_ZOOM](state, zoom) {
|
||||
state.map.zoom = zoom;
|
||||
},
|
||||
[types.SET_MAP_LAYER_VISIBILITY](state, { layer, visibility }) {
|
||||
state.map.layers[layer] = visibility;
|
||||
},
|
||||
[types.SET_MODAL_VISIBILITY](state, { modal, visibility }) {
|
||||
state.modals[modal] = visibility;
|
||||
},
|
||||
};
|
||||
268
src/styles/_base.scss
Normal file
268
src/styles/_base.scss
Normal file
@@ -0,0 +1,268 @@
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-text: #333;
|
||||
--color-background: #fff;
|
||||
--color-accent: #478db2;
|
||||
--color-accent-text: #fff;
|
||||
--drop-shadow: drop-shadow(0 10px 10px rgb(0, 0, 0, 0.2));
|
||||
--dropdown-arrow: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2225%22%20height%3D%2210%22%3E%3Cpath%20fill%3D%22%23333%22%20fill-opacity%3D%221%22%20stroke%3D%22none%22%20d%3D%22M0%2C0%20L0%2C0%20L1%2C0%20L1%2C6%20L7%2C6%20L7%2C7%20L0%2C7%20z%22%20transform%3D%22rotate(-45%205%200)%22%20%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: inside;
|
||||
}
|
||||
|
||||
.vue2leaflet-map {
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#app > header {
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
z-index: 1;
|
||||
color: var(--color-accent-text);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
#app > header > nav {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#app > header > nav:not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
#app > header > nav.nav-shrink {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
#app > header > nav .nav-item {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#app > header > nav .nav-item:not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
#app > main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button,
|
||||
.vdp-datepicker input {
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border: 1px solid var(--color-background);
|
||||
color: var(--color-accent-text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button-flat {
|
||||
color: var(--color-accent-text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-button,
|
||||
.vdp-datepicker input {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: var(--dropdown-arrow);
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: right;
|
||||
background-position-y: center;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.dropdown-body {
|
||||
display: none;
|
||||
position: absolute;
|
||||
margin-top: 12px;
|
||||
padding: 8px 0;
|
||||
border-radius: 3px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
filter: var(--drop-shadow);
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.dropdown-body::before,
|
||||
.vdp-datepicker .vdp-datepicker__calendar::before {
|
||||
content: "";
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
border-top: 10px solid transparent;
|
||||
border-bottom: 10px solid var(--color-background);
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.dropdown:focus-within .dropdown-body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-body label {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.dropdown-body label:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-body label input[type=checkbox] {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
filter: var(--drop-shadow);
|
||||
z-index: 4000;
|
||||
}
|
||||
|
||||
.modal .modal-container {
|
||||
min-width: 300px;
|
||||
padding: 20px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.modal .modal-container .modal-close-button {
|
||||
display: block;
|
||||
border: none;
|
||||
float: right;
|
||||
font-size: 24px;
|
||||
line-height: 16px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-popup-face {
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-background);
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.location-popup-detail {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup {
|
||||
filter: var(--drop-shadow);
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup .leaflet-popup-content-wrapper {
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup a.leaflet-popup-close-button {
|
||||
padding: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip-container .leaflet-popup-tip {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vdp-datepicker {
|
||||
position: static !important;
|
||||
display: inline-block;
|
||||
white-space: initial;
|
||||
overflow: initial;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.vdp-datepicker input {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.vdp-datepicker .vdp-datepicker__calendar {
|
||||
color: var(--color-text);
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
z-index: 4000;
|
||||
margin-top: 12px;
|
||||
filter: var(--drop-shadow);
|
||||
}
|
||||
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).day:hover,
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).month:hover,
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).year:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell.selected,
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell.selected:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-text);
|
||||
}
|
||||
|
||||
header .feather {
|
||||
font-size: 20px;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.button-icon .feather {
|
||||
margin: 0;
|
||||
}
|
||||
1
src/styles/main.scss
Normal file
1
src/styles/main.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "base";
|
||||
27
src/util.js
Normal file
27
src/util.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import config from "@/config";
|
||||
import { ISO_DATE_REGEXP, EARTH_RADIUS_IN_KM } from "@/constants";
|
||||
|
||||
export const getApiUrl = path => new URL(`${config.api.baseUrl}${path}`);
|
||||
export const isIsoDate = s => ISO_DATE_REGEXP.test(s);
|
||||
export const degreesToRadians = degrees => (degrees * Math.PI) / 180;
|
||||
|
||||
// https://stackoverflow.com/a/365853/5952681
|
||||
export const distanceBetweenCoordinates = (c1, c2) => {
|
||||
const latDistanceInRad = degreesToRadians(c1.lat - c2.lat);
|
||||
const lngDistanceInRad = degreesToRadians(c1.lng - c2.lng);
|
||||
const lat1InRad = degreesToRadians(c1.lat);
|
||||
const lat2InRad = degreesToRadians(c2.lat);
|
||||
const a =
|
||||
Math.sin(latDistanceInRad / 2) * Math.sin(latDistanceInRad / 2) +
|
||||
Math.sin(lngDistanceInRad / 2) *
|
||||
Math.sin(lngDistanceInRad / 2) *
|
||||
Math.cos(lat1InRad) *
|
||||
Math.cos(lat2InRad);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
// Return distance in meters
|
||||
return EARTH_RADIUS_IN_KM * c * 1000;
|
||||
};
|
||||
|
||||
export const capitalizeFirstLetter = string => {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
};
|
||||
210
src/views/Map.vue
Normal file
210
src/views/Map.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<LMap
|
||||
ref="map"
|
||||
:center="map.center"
|
||||
:zoom="map.zoom"
|
||||
@update:center="setMapCenter"
|
||||
@update:zoom="setMapZoom"
|
||||
>
|
||||
<LControlZoom
|
||||
v-if="controls.zoom.display"
|
||||
:position="controls.zoom.position"
|
||||
/>
|
||||
<LControlScale
|
||||
v-if="controls.scale.display"
|
||||
:position="controls.scale.position"
|
||||
:maxWidth="controls.scale.maxWidth"
|
||||
:metric="controls.scale.metric"
|
||||
:imperial="controls.scale.imperial"
|
||||
/>
|
||||
<LTileLayer
|
||||
:url="url"
|
||||
:attribution="attribution"
|
||||
:options="{ maxNativeZoom, maxZoom }"
|
||||
/>
|
||||
|
||||
<template v-if="map.layers.last">
|
||||
<LCircle
|
||||
v-for="l in lastLocations"
|
||||
:key="`${l.topic}-circle`"
|
||||
:lat-lng="{ lat: l.lat, lng: l.lon }"
|
||||
:radius="l.acc"
|
||||
:color="circle.color"
|
||||
:fill-color="circle.fillColor"
|
||||
:fill-opacity="circle.fillOpacity"
|
||||
/>
|
||||
|
||||
<LMarker
|
||||
v-for="l in lastLocations"
|
||||
:key="`${l.topic}-marker`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
>
|
||||
<LDeviceLocationPopup
|
||||
:user="l.username"
|
||||
:device="l.device"
|
||||
:name="l.name"
|
||||
:face="l.face"
|
||||
:timestamp="l.tst"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
/>
|
||||
</LMarker>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.line">
|
||||
<LPolyline
|
||||
v-for="(group, i) in locationHistoryLatLngGroups"
|
||||
:key="i"
|
||||
:lat-lngs="group"
|
||||
:color="polyline.color"
|
||||
:fill-color="polyline.fillColor"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.points">
|
||||
<template v-for="(userDevices, user) in locationHistory">
|
||||
<template v-for="(deviceLocations, device) in userDevices">
|
||||
<LCircleMarker
|
||||
v-for="(l, n) in deviceLocations"
|
||||
:key="`${user}-${device}-${n}`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
:radius="circleMarker.radius"
|
||||
:color="circleMarker.color"
|
||||
:fill-color="circleMarker.fillColor"
|
||||
:fill-opacity="circleMarker.fillOpacity"
|
||||
>
|
||||
<LDeviceLocationPopup
|
||||
:user="user"
|
||||
:device="device"
|
||||
:timestamp="l.tst"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
></LDeviceLocationPopup>
|
||||
</LCircleMarker>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.heatmap">
|
||||
<LHeatmap
|
||||
v-if="locationHistoryLatLngs.length"
|
||||
:lat-lng="locationHistoryLatLngs"
|
||||
:max="heatmap.max"
|
||||
:radius="heatmap.radius"
|
||||
:blur="heatmap.blur"
|
||||
:gradient="heatmap.gradient"
|
||||
/>
|
||||
</template>
|
||||
</LMap>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapState, mapMutations } from "vuex";
|
||||
import L from "leaflet";
|
||||
import "leaflet.heat";
|
||||
import {
|
||||
LMap,
|
||||
LTileLayer,
|
||||
LControlScale,
|
||||
LControlZoom,
|
||||
LMarker,
|
||||
LCircleMarker,
|
||||
LCircle,
|
||||
LPolyline,
|
||||
} from "vue2-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import markerIcon from "leaflet/dist/images/marker-icon.png";
|
||||
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
|
||||
import markerShadow from "leaflet/dist/images/marker-shadow.png";
|
||||
import LHeatmap from "vue2-leaflet-heatmap";
|
||||
|
||||
import * as types from "@/store/mutation-types";
|
||||
import config from "@/config";
|
||||
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup";
|
||||
|
||||
// See https://github.com/KoRiGaN/Vue2Leaflet/issues/28#issuecomment-299038157
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: markerIcon,
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
shadowUrl: markerShadow,
|
||||
});
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LMap,
|
||||
LTileLayer,
|
||||
LControlScale,
|
||||
LControlZoom,
|
||||
LMarker,
|
||||
LCircleMarker,
|
||||
LCircle,
|
||||
LPolyline,
|
||||
LDeviceLocationPopup,
|
||||
LHeatmap,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
center: this.$store.state.map.center,
|
||||
zoom: this.$store.state.map.zoom,
|
||||
maxNativeZoom: config.map.maxNativeZoom,
|
||||
maxZoom: config.map.maxZoom,
|
||||
url: config.map.url,
|
||||
attribution: config.map.attribution,
|
||||
controls: config.map.controls,
|
||||
polyline: {
|
||||
...config.map.polyline,
|
||||
color: config.map.polyline.color || config.accentColor,
|
||||
},
|
||||
circle: {
|
||||
...config.map.circle,
|
||||
color: config.map.circle.color || config.accentColor,
|
||||
fillColor: config.map.circle.fillColor || config.accentColor,
|
||||
},
|
||||
circleMarker: {
|
||||
...config.map.circleMarker,
|
||||
color: config.map.circleMarker.color || config.accentColor,
|
||||
},
|
||||
heatmap: config.map.heatmap,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on("fitView", () => {
|
||||
this.fitView();
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["locationHistoryLatLngs", "locationHistoryLatLngGroups"]),
|
||||
...mapState(["lastLocations", "locationHistory", "map"]),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setMapCenter: types.SET_MAP_CENTER,
|
||||
setMapZoom: types.SET_MAP_ZOOM,
|
||||
}),
|
||||
fitView() {
|
||||
if (
|
||||
(this.map.layers.line ||
|
||||
this.map.layers.loints ||
|
||||
this.map.layers.heatmap) &&
|
||||
this.locationHistoryLatLngs.length > 0
|
||||
) {
|
||||
this.$refs.map.mapObject.fitBounds(
|
||||
new L.LatLngBounds(this.locationHistoryLatLngs)
|
||||
);
|
||||
} else if (this.map.layers.last && this.lastLocations.length > 0) {
|
||||
const locations = this.lastLocations.map(l => L.latLng(l.lat, l.lon));
|
||||
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {
|
||||
maxZoom: this.maxNativeZoom,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user