diff --git a/css/main.css b/css/main.css
index ce8e626..8df8f28 100644
--- a/css/main.css
+++ b/css/main.css
@@ -289,6 +289,17 @@ label[for=user] {
color: #f0f8ff;
}
+#pfooter div:first-child {
+ width: 40%;
+ float: left;
+}
+
+#pfooter div:last-child {
+ width: 40%;
+ float: right;
+ text-align: right;
+}
+
#bottom {
position: absolute;
z-index: 10000;
diff --git a/helpers/position.php b/helpers/position.php
index 1305a5e..185cf56 100644
--- a/helpers/position.php
+++ b/helpers/position.php
@@ -71,7 +71,7 @@ require_once(ROOT_DIR . "/helpers/upload.php");
FROM " . self::db()->table('positions') . " p
LEFT JOIN " . self::db()->table('users') . " u ON (p.user_id = u.id)
LEFT JOIN " . self::db()->table('tracks') . " t ON (p.track_id = t.id)
- WHERE id = ? LIMIT 1";
+ WHERE p.id = ? LIMIT 1";
$params = [ $positionId ];
try {
$this->loadWithQuery($query, $params);
@@ -91,6 +91,15 @@ require_once(ROOT_DIR . "/helpers/upload.php");
return uDb::getInstance();
}
+ /**
+ * Has image
+ *
+ * @return bool True if has image
+ */
+ public function hasImage() {
+ return !empty($this->image);
+ }
+
/**
* Add position
*
@@ -135,6 +144,70 @@ require_once(ROOT_DIR . "/helpers/upload.php");
return $positionId;
}
+ /**
+ * Save position to database
+ *
+ * @return bool True if success, false otherwise
+ */
+ public function update() {
+ $ret = false;
+ if ($this->isValid) {
+ try {
+ $query = "UPDATE " . self::db()->table('positions') . " SET
+ time = " . self::db()->from_unixtime('?') . ", user_id = ?, track_id = ?, latitude = ?, longitude = ?, altitude = ?,
+ speed = ?, bearing = ?, accuracy = ?, provider = ?, comment = ?, image = ? WHERE id = ?";
+ $stmt = self::db()->prepare($query);
+ $params = [
+ $this->timestamp,
+ $this->userId,
+ $this->trackId,
+ $this->latitude,
+ $this->longitude,
+ $this->altitude,
+ $this->speed,
+ $this->bearing,
+ $this->accuracy,
+ $this->provider,
+ $this->comment,
+ $this->image,
+ $this->id
+ ];
+ $stmt->execute($params);
+ $ret = true;
+ } catch (PDOException $e) {
+ // TODO: handle exception
+ syslog(LOG_ERR, $e->getMessage());
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Delete positions
+ *
+ * @return bool True if success, false otherwise
+ */
+ public function delete() {
+ $ret = false;
+ if ($this->isValid) {
+ try {
+ $query = "DELETE FROM " . self::db()->table('positions') . " WHERE id = ?";
+ $stmt = self::db()->prepare($query);
+ $stmt->execute([ $this->id ]);
+ if ($this->hasImage()) {
+ uUpload::delete($this->image);
+ }
+ $ret = true;
+ $this->id = NULL;
+ $this->isValid = false;
+ } catch (PDOException $e) {
+ // TODO: handle exception
+ syslog(LOG_ERR, $e->getMessage());
+ }
+ }
+ return $ret;
+ }
+
/**
* Delete all user's positions, optionally limit to given track
*
diff --git a/js/src/mapapi/api_openlayers.js b/js/src/mapapi/api_openlayers.js
index 543cfff..e53389d 100644
--- a/js/src/mapapi/api_openlayers.js
+++ b/js/src/mapapi/api_openlayers.js
@@ -309,9 +309,11 @@ export default class OpenLayersApi {
* Close popup
*/
popupClose() {
- // eslint-disable-next-line no-undefined
- this.popup.setPosition(undefined);
- this.popup.getElement().firstElementChild.innerHTML = '';
+ if (this.popup) {
+ // eslint-disable-next-line no-undefined
+ this.popup.setPosition(undefined);
+ this.popup.getElement().firstElementChild.innerHTML = '';
+ }
this.viewModel.model.markerSelect = null;
}
@@ -492,6 +494,7 @@ export default class OpenLayersApi {
* Clear map
*/
clearMap() {
+ this.popupClose();
if (this.layerTrack) {
this.layerTrack.getSource().clear();
}
diff --git a/js/src/mapviewmodel.js b/js/src/mapviewmodel.js
index 42e9a16..50b68bb 100644
--- a/js/src/mapviewmodel.js
+++ b/js/src/mapviewmodel.js
@@ -17,9 +17,10 @@
* along with this program; if not, see .
*/
-import { lang as $, config } from './initializer.js';
+import { lang as $, auth, config } from './initializer.js';
import GoogleMapsApi from './mapapi/api_gmaps.js';
import OpenLayersApi from './mapapi/api_openlayers.js';
+import PositionDialogModel from './positiondialogmodel.js';
import ViewModel from './viewmodel.js';
import uDialog from './dialog.js';
import uObserve from './observe.js';
@@ -138,12 +139,14 @@ export default class MapViewModel extends ViewModel {
/**
* Get popup html
- * @param {number} id Position ID
+ * @param {number} id Position index
* @returns {HTMLDivElement}
*/
getPopupElement(id) {
const pos = this.state.currentTrack.positions[id];
const count = this.state.currentTrack.length;
+ const user = this.state.currentTrack.user;
+ const isEditable = auth.user && (auth.isAdmin || auth.user === user);
let date = '–––';
let time = '–––';
if (pos.timestamp > 0) {
@@ -157,6 +160,10 @@ export default class MapViewModel extends ViewModel {
} else if (pos.provider === 'network') {
provider = ` `;
}
+ let editLink = '';
+ if (isEditable) {
+ editLink = ``;
+ }
let stats = '';
if (!this.state.showLatest) {
stats =
@@ -182,7 +189,7 @@ export default class MapViewModel extends ViewModel {
${(pos.altitude !== null) ? `${$.getLocaleAltitude(pos.altitude, true)}
` : ''}
${(pos.accuracy !== null) ? `${$.getLocaleAccuracy(pos.accuracy, true)}${provider}
` : ''}
${stats}
-
`;
+ `;
const node = document.createElement('div');
node.setAttribute('id', 'popup');
node.innerHTML = html;
@@ -196,6 +203,13 @@ export default class MapViewModel extends ViewModel {
modal.show();
}
}
+ if (isEditable) {
+ const edit = node.querySelector('#editposition');
+ edit.onclick = () => {
+ const vm = new PositionDialogModel(this.state, id);
+ vm.init();
+ }
+ }
return node;
}
diff --git a/js/src/observe.js b/js/src/observe.js
index f766af9..b13bed9 100644
--- a/js/src/observe.js
+++ b/js/src/observe.js
@@ -61,6 +61,17 @@ export default class uObserve {
}
}
+ /**
+ * Trigger notify of property observers
+ * @param {Object} obj
+ * @param {string} property
+ */
+ static forceUpdate(obj, property) {
+ const value = obj._values[property];
+ const observers = obj._observers[property];
+ this.notify(observers, value);
+ }
+
/**
* Check if object property is observed;
* Optionally check if it is observed by given observer
diff --git a/js/src/position.js b/js/src/position.js
index a52b725..ce467c0 100644
--- a/js/src/position.js
+++ b/js/src/position.js
@@ -17,6 +17,7 @@
* along with this program; if not, see .
*/
+import uAjax from './ajax.js';
import uUtils from './utils.js';
/**
@@ -91,4 +92,60 @@ export default class uPosition {
get totalSpeed() {
return this.totalSeconds ? this.totalMeters / this.totalSeconds : 0;
}
+
+ /**
+ * @return {Promise}
+ */
+ delete() {
+ return uPosition.update({
+ action: 'delete',
+ posid: this.id
+ });
+ }
+
+ /**
+ * @return {Promise}
+ */
+ save() {
+ return uPosition.update({
+ action: 'update',
+ posid: this.id,
+ comment: this.comment
+ });
+ }
+
+ /**
+ * Save track data
+ * @param {Object} data
+ * @return {Promise}
+ */
+ static update(data) {
+ return uAjax.post('utils/handleposition.php', data);
+ }
+
+ /**
+ * Calculate distance to target point using haversine formula
+ * @param {uPosition} target
+ * @return {number} Distance in meters
+ */
+ distanceTo(target) {
+ const lat1 = uUtils.deg2rad(this.latitude);
+ const lon1 = uUtils.deg2rad(this.longitude);
+ const lat2 = uUtils.deg2rad(target.latitude);
+ const lon2 = uUtils.deg2rad(target.longitude);
+ const latD = lat2 - lat1;
+ const lonD = lon2 - lon1;
+ const bearing = 2 * Math.asin(Math.sqrt((Math.sin(latD / 2) ** 2) + Math.cos(lat1) * Math.cos(lat2) * (Math.sin(lonD / 2) ** 2)));
+ return bearing * 6371000;
+ }
+
+ /**
+ * Calculate time elapsed since target point
+ * @param {uPosition} target
+ * @return {number} Number of seconds
+ */
+ secondsTo(target) {
+ return this.timestamp - target.timestamp;
+ }
+
}
diff --git a/js/src/positiondialogmodel.js b/js/src/positiondialogmodel.js
new file mode 100644
index 0000000..8fcd7c6
--- /dev/null
+++ b/js/src/positiondialogmodel.js
@@ -0,0 +1,114 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2020 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+import { lang as $ } from './initializer.js';
+import ViewModel from './viewmodel.js';
+import uDialog from './dialog.js';
+import uObserve from './observe.js';
+import uUtils from './utils.js';
+
+/**
+ * @class PositionDialogModel
+ */
+export default class PositionDialogModel extends ViewModel {
+
+ /**
+ * @param {uState} state
+ * @param {number} positionIndex
+ */
+ constructor(state, positionIndex) {
+ super({
+ onPositionDelete: null,
+ onPositionUpdate: null,
+ onCancel: null,
+ comment: ''
+ });
+ this.state = state;
+ this.positionIndex = positionIndex;
+ this.position = this.state.currentTrack.positions[positionIndex];
+ this.model.onPositionDelete = () => this.onPositionDelete();
+ this.model.onPositionUpdate = () => this.onPositionUpdate();
+ this.model.onCancel = () => this.onCancel();
+ }
+
+ init() {
+ const html = this.getHtml();
+ this.dialog = new uDialog(html);
+ this.dialog.show();
+ this.bindAll(this.dialog.element);
+ }
+
+ /**
+ * @return {string}
+ */
+ getHtml() {
+ return `${$._('editingposition', this.positionIndex + 1, `${uUtils.htmlEncode(this.position.trackname)}`)}
+
+
+ `;
+ }
+
+ onPositionDelete() {
+ if (uDialog.isConfirmed($._('positiondelwarn', this.positionIndex + 1, uUtils.htmlEncode(this.position.trackname)))) {
+ this.position.delete()
+ .then(() => {
+ const track = this.state.currentTrack;
+ this.state.currentTrack = null;
+ track.positions.splice(this.positionIndex, 1);
+ track.recalculatePositions();
+ this.state.currentTrack = track;
+ this.dialog.destroy();
+ }).catch((e) => { uUtils.error(e, `${$._('actionfailure')}\n${e.message}`); });
+ }
+ }
+
+ onPositionUpdate() {
+ if (this.validate()) {
+ this.position.comment = this.model.comment;
+ this.position.save()
+ .then(() => {
+ uObserve.forceUpdate(this.state, 'currentTrack');
+ this.dialog.destroy()
+ })
+ .catch((e) => { uUtils.error(e, `${$._('actionfailure')}\n${e.message}`); });
+ }
+ }
+
+ onCancel() {
+ this.dialog.destroy();
+ }
+
+ /**
+ * Validate form
+ * @return {boolean} True if valid
+ */
+ validate() {
+ if (this.model.comment === this.position.comment) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/js/src/track.js b/js/src/track.js
index 96f2971..1b20830 100644
--- a/js/src/track.js
+++ b/js/src/track.js
@@ -49,6 +49,8 @@ export default class uTrack extends uPositionSet {
this.user = user;
this.plotData = [];
this.maxId = 0;
+ this.totalMeters = 0;
+ this.totalSeconds = 0;
this.listItem(id, name);
}
@@ -59,8 +61,14 @@ export default class uTrack extends uPositionSet {
clear() {
super.clear();
+ this.clearTrackCounters();
+ }
+
+ clearTrackCounters() {
this.maxId = 0;
this.plotData.length = 0;
+ this.totalMeters = 0;
+ this.totalSeconds = 0;
}
/**
@@ -84,30 +92,16 @@ export default class uTrack extends uPositionSet {
* @param {boolean=} isUpdate If true append to old data
*/
fromJson(posArr, isUpdate = false) {
- let totalMeters = 0;
- let totalSeconds = 0;
let positions = [];
if (isUpdate && this.hasPositions) {
positions = this.positions;
- const last = positions[this.length - 1];
- totalMeters = last.totalMeters;
- totalSeconds = last.totalSeconds;
} else {
this.clear();
}
for (const pos of posArr) {
const position = uPosition.fromJson(pos);
- totalMeters += position.meters;
- totalSeconds += position.seconds;
- position.totalMeters = totalMeters;
- position.totalSeconds = totalSeconds;
+ this.calculatePosition(position);
positions.push(position);
- if (position.altitude != null) {
- this.plotData.push({ x: position.totalMeters, y: position.altitude });
- }
- if (position.id > this.maxId) {
- this.maxId = position.id;
- }
}
// update at the end to avoid observers update invidual points
this.positions = positions;
@@ -243,4 +237,31 @@ export default class uTrack extends uPositionSet {
return uAjax.post('utils/handletrack.php', data);
}
+ recalculatePositions() {
+ this.clearTrackCounters();
+ let previous = null;
+ for (const position of this.positions) {
+ position.meters = previous ? position.distanceTo(previous) : 0;
+ position.seconds = previous ? position.secondsTo(previous) : 0;
+ this.calculatePosition(position);
+ previous = position;
+ }
+ }
+
+ /**
+ * Calculate position total counters and plot data
+ * @param {uPosition} position
+ */
+ calculatePosition(position) {
+ this.totalMeters += position.meters;
+ this.totalSeconds += position.seconds;
+ position.totalMeters = this.totalMeters;
+ position.totalSeconds = this.totalSeconds;
+ if (position.altitude != null) {
+ this.plotData.push({ x: position.totalMeters, y: position.altitude });
+ }
+ if (position.id > this.maxId) {
+ this.maxId = position.id;
+ }
+ }
}
diff --git a/js/src/utils.js b/js/src/utils.js
index cc03fa1..cf6b497 100644
--- a/js/src/utils.js
+++ b/js/src/utils.js
@@ -317,4 +317,13 @@ export default class uUtils {
console.error(details);
alert(message);
}
+
+ /**
+ * Degrees to radians
+ * @param {number} degrees
+ * @return {number}
+ */
+ static deg2rad(degrees) {
+ return degrees * Math.PI / 180;
+ }
}
diff --git a/js/src/viewmodel.js b/js/src/viewmodel.js
index 9c2cc02..41e2629 100644
--- a/js/src/viewmodel.js
+++ b/js/src/viewmodel.js
@@ -65,9 +65,12 @@ export default class ViewModel {
observers.forEach(/** @param {HTMLElement} element */ (element) => {
const name = element.dataset[dataProp];
if (name === key) {
- if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) {
+ if (element instanceof HTMLInputElement ||
+ element instanceof HTMLSelectElement ||
+ element instanceof HTMLTextAreaElement) {
this.onChangeBind(element, key);
- } else if (element instanceof HTMLAnchorElement || element instanceof HTMLButtonElement) {
+ } else if (element instanceof HTMLAnchorElement ||
+ element instanceof HTMLButtonElement) {
this.onClickBind(element, key);
} else {
this.viewUpdateBind(element, key);
diff --git a/lang/en.php b/lang/en.php
index d6e2ec4..1f7adcc 100644
--- a/lang/en.php
+++ b/lang/en.php
@@ -111,6 +111,11 @@ $lang["editingtrack"] = "You are editing track %s"; // substitutes track name
$lang["deltrack"] = "Remove track";
$lang["trackname"] = "Track name";
$lang["edittrack"] = "Edit track";
+$lang["positiondelwarn"] = "Warning!\n\nYou are going to permanently delete position %d of track %s.\n\nAre you sure?"; // substitutes position index and track name
+$lang["editingposition"] = "You are editing position #%d of track %s"; // substitutes position index and track name
+$lang["delposition"] = "Remove position";
+$lang["comment"] = "Comment";
+$lang["editposition"] = "Edit position";
$lang["passlenmin"] = "Password must be at least %d characters"; // substitutes password minimum length
$lang["passrules_1"] = "It should contain at least one lower case letter, one upper case letter";
$lang["passrules_2"] = "It should contain at least one lower case letter, one upper case letter and one digit";
diff --git a/utils/handleposition.php b/utils/handleposition.php
new file mode 100644
index 0000000..19bbdae
--- /dev/null
+++ b/utils/handleposition.php
@@ -0,0 +1,65 @@
+.
+ */
+
+ require_once(dirname(__DIR__) . "/helpers/auth.php");
+ require_once(ROOT_DIR . "/helpers/lang.php");
+ require_once(ROOT_DIR . "/helpers/track.php");
+ require_once(ROOT_DIR . "/helpers/utils.php");
+ require_once(ROOT_DIR . "/helpers/config.php");
+
+ $auth = new uAuth();
+
+ $action = uUtils::postString('action');
+ $positionId = uUtils::postInt('posid');
+ $comment = uUtils::postString('comment');
+
+ $lang = (new uLang(uConfig::$lang))->getStrings();
+
+ if (empty($action) || empty($positionId)) {
+ uUtils::exitWithError($lang["servererror"]);
+ }
+ $position = new uPosition($positionId);
+ if (!$position->isValid ||
+ (!$auth->isAuthenticated() || (!$auth->isAdmin() && $auth->user->id !== $position->userId))) {
+ uUtils::exitWithError($lang["servererror"]);
+ }
+
+ switch ($action) {
+
+ case 'update':
+ $position->comment = $comment;
+ if ($position->update() === false) {
+ uUtils::exitWithError($lang["servererror"]);
+ }
+ break;
+
+ case 'delete':
+ if ($position->delete() === false) {
+ uUtils::exitWithError($lang["servererror"]);
+ }
+ break;
+
+ default:
+ uUtils::exitWithError($lang["servererror"]);
+ break;
+ }
+
+ uUtils::exitWithSuccess();
+
+?>
\ No newline at end of file