From 9417d75632510400232ab84f9996a7bc8f701733 Mon Sep 17 00:00:00 2001 From: Bartek Fabiszewski Date: Mon, 13 Jan 2020 22:57:13 +0100 Subject: [PATCH] Add basic position editing --- css/main.css | 11 +++ helpers/position.php | 75 ++++++++++++++++++++- js/src/mapapi/api_openlayers.js | 9 ++- js/src/mapviewmodel.js | 20 +++++- js/src/observe.js | 11 +++ js/src/position.js | 57 ++++++++++++++++ js/src/positiondialogmodel.js | 114 ++++++++++++++++++++++++++++++++ js/src/track.js | 51 +++++++++----- js/src/utils.js | 9 +++ js/src/viewmodel.js | 7 +- lang/en.php | 5 ++ utils/handleposition.php | 65 ++++++++++++++++++ 12 files changed, 410 insertions(+), 24 deletions(-) create mode 100644 js/src/positiondialogmodel.js create mode 100644 utils/handleposition.php 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 = ` ${$._('network')}`; } + let editLink = ''; + if (isEditable) { + editLink = `${$._('editposition')}`; + } let stats = ''; if (!this.state.showLatest) { stats = @@ -182,7 +189,7 @@ export default class MapViewModel extends ViewModel { ${(pos.altitude !== null) ? `${$._('altitude')}${$.getLocaleAltitude(pos.altitude, true)}
` : ''} ${(pos.accuracy !== null) ? `${$._('accuracy')}${$.getLocaleAccuracy(pos.accuracy, true)}${provider}
` : ''} ${stats} -
${$._('pointof', id + 1, count)}
`; +
${$._('pointof', id + 1, count)}
${editLink}
`; 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