From 5f597647b4e465efc55b59528faccb1841289e45 Mon Sep 17 00:00:00 2001 From: Franzz Date: Sat, 12 Aug 2023 23:35:40 +0200 Subject: [PATCH] Support geo located images & videos --- files/db/update_v20_to_v21.sql | 3 ++ inc/Media.php | 56 ++++++++++++++++++++++--- inc/Spot.php | 29 +++++++------ masks/project.html | 76 +++++++++++++++++++++++++++++++--- 4 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 files/db/update_v20_to_v21.sql diff --git a/files/db/update_v20_to_v21.sql b/files/db/update_v20_to_v21.sql new file mode 100644 index 0000000..0790f91 --- /dev/null +++ b/files/db/update_v20_to_v21.sql @@ -0,0 +1,3 @@ +ALTER TABLE medias ADD latitude DECIMAL(7,5) AFTER timezone; +ALTER TABLE medias ADD longitude DECIMAL(8,5) AFTER latitude; +ALTER TABLE medias ADD altitude SMALLINT AFTER longitude; \ No newline at end of file diff --git a/inc/Media.php b/inc/Media.php index 11b31d1..5db0b06 100644 --- a/inc/Media.php +++ b/inc/Media.php @@ -76,7 +76,7 @@ class Media extends PhpObject { if($bOwnMedia && empty($this->asMedia) || !$bOwnMedia && empty($this->asMedias) || $bConstraintArray) { if($this->oProject->getProjectId()) { $asParams = array( - 'select' => array(Db::getId(self::MEDIA_TABLE), 'filename', 'taken_on', 'posted_on', 'timezone', 'width', 'height', 'rotate', 'type AS subtype', 'comment'), + 'select' => array(Db::getId(self::MEDIA_TABLE), 'filename', 'taken_on', 'posted_on', 'timezone', 'latitude', 'longitude', 'width', 'height', 'rotate', 'type AS subtype', 'comment'), 'from' => self::MEDIA_TABLE, 'constraint'=> array(Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId()) ); @@ -128,6 +128,9 @@ class Media extends PhpObject { 'taken_on' => date(Db::TIMESTAMP_FORMAT, ($asMediaInfo['taken_ts'] > 0)?$asMediaInfo['taken_ts']:$asMediaInfo['file_ts']), 'posted_on' => date(Db::TIMESTAMP_FORMAT, $asMediaInfo['file_ts']), 'timezone' => $asMediaInfo['timezone'], + 'latitude' => $asMediaInfo['latitude'], + 'longitude' => $asMediaInfo['longitude'], + 'altitude' => $asMediaInfo['altitude'], 'width' => $asMediaInfo['width'], 'height' => $asMediaInfo['height'], 'rotate' => $asMediaInfo['rotate'], @@ -151,21 +154,23 @@ class Media extends PhpObject { { $sMediaPath = self::getMediaPath($sMediaName); $sType = self::getMediaType($sMediaName); - $iPostedOn = filemtime($sMediaPath); $sTimeZone = date_default_timezone_get(); $iWidth = 0; $iHeight = 0; $sRotate = '0'; $sTakenOn = ''; + $fLat = 0; + $fLng = 0; + $iAlt = 0; switch($sType) { case 'video': $asResult = array(); $sParams = implode(' ', array( '-loglevel error', //Remove comments '-select_streams v:0', //First video channel - '-show_entries '. //filter tags : Width, Height, Creation Time & Rotation - 'format_tags=creation_time,com.apple.quicktime.creationdate:'. + '-show_entries '. //filter tags : Width, Height, Creation Time, Location & Rotation + 'format_tags=creation_time,com.apple.quicktime.creationdate,com.apple.quicktime.location.ISO6709:'. 'stream_tags=rotate,creation_time:'. 'stream=width,height', '-print_format json', //output format: json @@ -181,6 +186,11 @@ class Media extends PhpObject { } else $sTakenOn = $asExif['format']['tags']['creation_time'] ?? $asExif['streams'][0]['tags']['creation_time']; + //Location + if(isset($asExif['format']['tags']['com.apple.quicktime.location.ISO6709'])) { + list($fLat, $fLng, $iAlt) = self::getLatLngAltFromISO6709($asExif['format']['tags']['com.apple.quicktime.location.ISO6709']); + } + //Width & Height $iWidth = $asExif['streams'][0]['width']; $iHeight = $asExif['streams'][0]['height']; @@ -209,6 +219,14 @@ class Media extends PhpObject { $sTimeZone = $asExif['EXIF']['OffsetTimeOriginal'] ?? $asExif['EXIF']['UndefinedTag:0x9011'] ?? Spot::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone; } + //Location + if(array_key_exists('GPS', $asExif)) { + $asGps = $asExif['GPS']; + $fLat = self::getLatLngFromExif($asGps['GPSLatitudeRef'] ?? $asGps['UndefinedTag:0x0001'], $asGps['GPSLatitude'] ?? $asGps['UndefinedTag:0x0002']); + $fLng = self::getLatLngFromExif($asGps['GPSLongitudeRef'] ?? $asGps['UndefinedTag:0x0003'], $asGps['GPSLongitude'] ?? $$asGps['UndefinedTag:0x0004']); + $iAlt = (($asGps['GPSAltitudeRef'] ?? $asGps['UndefinedTag:0x0005'] ?? '0') == '1'?-1:1) * ($asGps['GPSAltitude'] ?? $asGps['UndefinedTag:0x0006'] ?? 0); + } + //Orientation if(array_key_exists('IFD0', $asExif) && array_key_exists('Orientation', $asExif['IFD0'])) { switch($asExif['IFD0']['Orientation']) @@ -232,6 +250,9 @@ class Media extends PhpObject { return array( 'timezone' => $sTimeZone, + 'latitude' => $fLat, + 'longitude' => $fLng, + 'altitude' => $iAlt, 'taken_ts' => $iTakenOn, 'file_ts' => $iPostedOn, 'width' => $iWidth, @@ -290,4 +311,29 @@ class Media extends PhpObject { return $sType; } -} + + private static function getLatLngFromExif($sDirection, $asCoords) { + $fCoord = 0; + foreach($asCoords as $iIndex=>$sCoord) { + $fValue = 0; + $asCoordParts = explode('/', $sCoord); + switch(count($asCoordParts)) { + case 1: + $fValue = $asCoordParts[0]; + break; + case 2: + $fValue = floatval($asCoordParts[0]) / floatval($asCoordParts[1]); + break; + } + + $fCoord += $fValue / pow(60, $iIndex); + } + + return $fCoord * (($sDirection == 'W' || $sDirection == 'S')?-1:1); + } + + private static function getLatLngAltFromISO6709($sIso6709) { + preg_match('/^(?P[\+\-][0,1]?\d{2}\.\d+)(?P[\+\-][0,1]?\d{2}\.\d+)(?P[\+\-]\d+)?/', $sIso6709, $asMatches); + return array(floatval($asMatches['lat']), floatval($asMatches['lng']), floatval($asMatches['alt'] ?? 0)); + } +} \ No newline at end of file diff --git a/inc/Spot.php b/inc/Spot.php index c347648..61b195a 100755 --- a/inc/Spot.php +++ b/inc/Spot.php @@ -87,7 +87,7 @@ class Spot extends Main Feed::SPOT_TABLE => array('ref_spot_id', 'name', 'model'), Project::PROJ_TABLE => array('name', 'codename', 'active_from', 'active_to'), self::POST_TABLE => array(Db::getId(Project::PROJ_TABLE), Db::getId(User::USER_TABLE), 'name', 'content', 'site_time', 'timezone'), - Media::MEDIA_TABLE => array(Db::getId(Project::PROJ_TABLE), 'filename', 'type', 'taken_on', 'posted_on', 'timezone', 'width', 'height', 'rotate', 'comment'), + Media::MEDIA_TABLE => array(Db::getId(Project::PROJ_TABLE), 'filename', 'type', 'taken_on', 'posted_on', 'timezone', 'latitude', 'longitude', 'altitude', 'width', 'height', 'rotate', 'comment'), User::USER_TABLE => array('name', 'email', 'gravatar', 'language', 'timezone', 'active', 'clearance'), Map::MAP_TABLE => array('codename', 'pattern', 'token', 'tile_size', 'min_zoom', 'max_zoom', 'attribution'), Map::MAPPING_TABLE => array(Db::getId(Map::MAP_TABLE) , Db::getId(Project::PROJ_TABLE)) @@ -110,6 +110,7 @@ class Spot extends Main 'last_update' => "TIMESTAMP DEFAULT 0", 'latitude' => "DECIMAL(7,5)", 'longitude' => "DECIMAL(8,5)", + 'altitude' => "SMALLINT", 'model' => "VARCHAR(20)", 'name' => "VARCHAR(100)", 'pattern' => "VARCHAR(200) NOT NULL", @@ -121,7 +122,7 @@ class Spot extends Main 'site_time' => "TIMESTAMP DEFAULT 0", //DEFAULT 0 removes auto-set to current time 'status' => "VARCHAR(10)", 'taken_on' => "TIMESTAMP DEFAULT 0", - 'timezone' => "CHAR(64) NOT NULL", //see mysql.time_zone_name + 'timezone' => "CHAR(64) NOT NULL", //see mysql.time_zone_name 'token' => "VARCHAR(4096)", 'type' => "VARCHAR(20)", 'unix_time' => "INT", @@ -269,23 +270,24 @@ class Spot extends Main public function getMarkers($asMessageIds=array(), $asMediaIds=array(), $bInternal=false) { $asMessages = $this->getSpotMessages($asMessageIds); + $asGeoMedias = array(); usort($asMessages, function($a, $b){return $a['unix_time'] > $b['unix_time'];}); + $bHasMsg = !empty($asMessages); //Add medias - if(!empty($asMessages)) { - $asMedias = $this->getMedias('taken_on', $asMediaIds); - usort($asMedias, function($a, $b){return $a['unix_time'] > $b['unix_time'];}); + $asMedias = $this->getMedias('taken_on', $asMediaIds); + usort($asMedias, function($a, $b){return $a['unix_time'] > $b['unix_time'];}); - //Assign medias to closest message - $iIndex = 0; - $iMaxIndex = count($asMessages) - 1; - foreach($asMedias as $asMedia) { - while($iIndex <= $iMaxIndex && $asMedia['unix_time'] > $asMessages[$iIndex]['unix_time']) { - $iIndex++; - } + //Assign medias to closest message + $iIndex = 0; + $iMaxIndex = count($asMessages) - 1; + foreach($asMedias as $asMedia) { + if($asMedia['latitude']!='' && $asMedia['longitude']!='') $asGeoMedias[] = $asMedia; + elseif($bHasMsg) { + while($iIndex <= $iMaxIndex && $asMedia['unix_time'] > $asMessages[$iIndex]['unix_time']) $iIndex++; //All medias before first message or after last message are assigned to first/last message respectively - if($iIndex == 0) $iMsgIndex = $iIndex; + if($iIndex == 0) $iMsgIndex = $iIndex; elseif($iIndex > $iMaxIndex) $iMsgIndex = $iMaxIndex; else { $iHalfWayPoint = ($asMessages[$iIndex - 1]['unix_time'] + $asMessages[$iIndex]['unix_time'])/2; @@ -302,6 +304,7 @@ class Spot extends Main $asResult = array( 'messages' => $asMessages, + 'medias' => $asGeoMedias, 'maps' => $this->oMap->getProjectMaps($this->oProject->getProjectId()), 'last_update' => $asLastUpdate ); diff --git a/masks/project.html b/masks/project.html index b55861b..1ade6bd 100644 --- a/masks/project.html +++ b/masks/project.html @@ -282,7 +282,7 @@ function initProject(sProjectCodeName, oFocusPost){ ).done(function(aoMessages, aoTracks) { var asData = aoMessages[0]['data']; setMapLayers(asData['maps']); - initSpotMessages(asData['messages'], aoTracks[0]); + initSpotMessages(asData['messages'], aoTracks[0], asData['medias']); updateSettingsPanel(asData['last_update']); }); @@ -420,7 +420,7 @@ function setMapLayers(asLayers) { }); } -function initSpotMessages(aoMessages, aoTracks) { +function initSpotMessages(aoMessages, aoTracks, aoMedias) { var bIsMobile = isMobile(); //Map @@ -656,7 +656,10 @@ function initSpotMessages(aoMessages, aoTracks) { } //Add Spot messages - addSpotMessages(aoMessages); + addSpotMarkers(aoMessages); + + //Add Medias + addMediaMarkers(aoMedias) //Open tooltip on latest message in mobile mode if( @@ -672,7 +675,7 @@ function initSpotMessages(aoMessages, aoTracks) { */ } -function addSpotMessages(aoMessages) { +function addSpotMarkers(aoMessages) { //Spot Messages var iWorkSpaceMinWidth = isMobile()?self.tmp('$Projects').width():(self.tmp('$Projects').width() - self.tmp('$Feed').outerWidth(true) - self.tmp('$Settings').outerWidth(true)); @@ -689,7 +692,7 @@ function addSpotMessages(aoMessages) { $Tooltip = $('
', {'class':'info-window'}) .append($('

') .addIcon('fa-message fa-lg', true) - .append($('').text('Message '+oSpot.lang('counter', oMsg.displayed_id))) + .append($('').text(oSpot.lang('post_message')+' '+oSpot.lang('counter', oMsg.displayed_id))) .append($('', {'class':'message-type'}).text('('+oMsg.type+')')) ) .append($('
', {'class':'separator'})) @@ -746,6 +749,67 @@ function addSpotMessages(aoMessages) { }); } +function addMediaMarkers(aoMedias) { + var iWorkSpaceMinWidth = isMobile()?self.tmp('$Projects').width():(self.tmp('$Projects').width() - self.tmp('$Feed').outerWidth(true) - self.tmp('$Settings').outerWidth(true)); + + $.each(aoMedias, function(iKey, oMedia){ + var oMarker = L.marker(L.latLng(oMedia.latitude, oMedia.longitude), { + id: oMedia.id_media, + riseOnHover: true, + icon: getDivIcon(oMedia.subtype+' fa-rotate-270') + }).addTo(self.tmp('map')); + + //Tooltip + $Tooltip = $('
', {'class':'info-window'}) + .append($('

') + .addIcon('fa-'+oMedia.subtype+' fa-lg', true) + .append($('').text(oSpot.lang(oMedia.subtype)+' '+oSpot.lang('counter', oMedia.displayed_id || oMedia.id_media))) + ) + .append($('
', {'class':'separator'})) + .append($('

', {'class':'time'}) + .addIcon('fa-time fa-fw fa-lg', true) + .append(oMedia.formatted_time+(self.vars(['project', 'mode'])==self.consts.modes.blog?' ('+oMedia.relative_time+')':''))); + + //Tooltip: Time Zone + if(oMedia.formatted_time_local != oMedia.formatted_time) { + $Tooltip.append($('

', {'class':'timezone'}) + .addIcon('fa-timezone fa-fw fa-lg', true) + .append(oSpot.lang('local_time', getRelativeTime(oMedia.formatted_time_local, oMedia.day_offset)))); + } + + $Tooltip.data('medias', [oMedia]); + oSpot.tmp(['marker-media-tooltips', oMedia.id_media], $Tooltip); + + oMarker.bindPopup( + function(e) { + let $Tooltip = oSpot.tmp(['marker-media-tooltips', e.options.id]); + + $Tooltip.on('mouseout', function(){e.closePopup();}); + + //Tooltip: Medias: Set on the fly to avoid resource load + let oMedias = $Tooltip.data('medias'); + let $Medias = $Tooltip.find('.medias'); + if(oMedias && $Medias.length == 0) { + $Medias = $('

', {'class':'medias'}); + $.each(oMedias, function(iKey, asMedia) { + $Medias.append(getMediaLink(asMedia, 'marker')); + }); + $Tooltip.append($Medias); + } + return $Tooltip[0]; + }, + { + maxWidth: iWorkSpaceMinWidth, + autoPan: false, + closeOnClick: true, + offset: new L.Point(0, -30) + } + ); + + oMarker.on('mouseover', function(e) {this.openPopup();}); + }); +} + function toggleSoftPopup(e, sMode) { switch(sMode) { case 'open': @@ -806,7 +870,7 @@ function checkNewFeed() { self.tmp('$PostList').prepend($Posts.children()); //Markers - addSpotMessages(asData.messages); + addSpotMarkers(asData.messages); //Message Last Update updateSettingsPanel(asData.last_update);