Pick project from globe

This commit is contained in:
2026-05-13 23:28:36 +02:00
parent c3835f45c5
commit 49f37465bd
9 changed files with 151 additions and 78 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE projects ADD latitude DECIMAL(8,6) AFTER name;
ALTER TABLE projects ADD longitude DECIMAL(9,6) AFTER latitude;

View File

@@ -29,7 +29,10 @@ class Converter extends PhpObject {
$oGeoJson->sortOffTracks(); $oGeoJson->sortOffTracks();
$oGeoJson->saveFile(); $oGeoJson->saveFile();
return $oGpx->getLog().'<br />'.$oGeoJson->getLog(); return [
'logs' => $oGpx->getLog().'<br />'.$oGeoJson->getLog(),
'center' => $oGeoJson->getCenter()
];
} }
public static function isGeoJsonValid($sCodeName) { public static function isGeoJsonValid($sCodeName) {

View File

@@ -103,7 +103,6 @@ class GeoJson extends Geo {
if($bSimplify) $this->addNotice('Total: '.$iGlobalInvalidPointCount.'/'.$iGlobalPointCount.' points removed ('.round($iGlobalInvalidPointCount / $iGlobalPointCount * 100, 1).'%)'); if($bSimplify) $this->addNotice('Total: '.$iGlobalInvalidPointCount.'/'.$iGlobalPointCount.' points removed ('.round($iGlobalInvalidPointCount / $iGlobalPointCount * 100, 1).'%)');
} }
public function sortOffTracks() { public function sortOffTracks() {
$this->addNotice('Sorting off-tracks'); $this->addNotice('Sorting off-tracks');
@@ -155,6 +154,18 @@ class GeoJson extends Geo {
$this->asTracks = array_values($asTracks); $this->asTracks = array_values($asTracks);
} }
public function getCenter() {
$asCoords = array();
$asMainTracks = array_filter($this->asTracks, function ($astrack) {return $astrack['properties']['type'] == 'main';});
foreach($asMainTracks as $asMainTrack) {
foreach($asMainTrack['geometry']['coordinates'] as $aiCoords) {
$asCoords[] = $aiCoords;
}
}
return $asCoords[(int) floor(count($asCoords) / 2)];
}
private function parseOptions($sComment) { private function parseOptions($sComment) {
$sComment = strip_tags(html_entity_decode($sComment)); $sComment = strip_tags(html_entity_decode($sComment));
$asOptions = array(self::OPT_SIMPLE=>''); $asOptions = array(self::OPT_SIMPLE=>'');

View File

@@ -126,6 +126,8 @@ class Project extends PhpObject {
Db::getId(self::PROJ_TABLE)." AS id", Db::getId(self::PROJ_TABLE)." AS id",
'codename', 'codename',
'name', 'name',
'latitude',
'longitude',
'active_from', 'active_from',
'active_to', 'active_to',
"IF(NOW() BETWEEN active_from AND active_to, 1, IF(NOW() < active_from, 0, 2)) AS mode" "IF(NOW() BETWEEN active_from AND active_to, 1, IF(NOW() < active_from, 0, 2)) AS mode"
@@ -147,12 +149,16 @@ class Project extends PhpObject {
$asProject['gpxfilepath'] = Spot::addTimestampToFilePath(Gpx::getDistFilePath($sCodeName)); $asProject['gpxfilepath'] = Spot::addTimestampToFilePath(Gpx::getDistFilePath($sCodeName));
$asProject['codename'] = $sCodeName; $asProject['codename'] = $sCodeName;
$asProject['default'] = ($sCodeName == $sDefaultProjectCodeName); $asProject['default'] = ($sCodeName == $sDefaultProjectCodeName);
//$asProject['center'] = [$asProject['latitude'], $asProject['longitude']];
} }
return $bSpecificProj?$asProject:$asProjects; return $bSpecificProj?$asProject:$asProjects;
} }
public function getGeoJson() { public function getGeoJson() {
if($this->sCodeName != '' && !Converter::isGeoJsonValid($this->sCodeName)) Converter::convertToGeoJson($this->sCodeName); if($this->sCodeName != '' && !Converter::isGeoJsonValid($this->sCodeName)){
$aiCenter = Converter::convertToGeoJson($this->sCodeName)['center'];
$this->oDb->updateRow(self::PROJ_TABLE, $this->iProjectId, ['latitude' => $aiCenter[1], 'longitude' => $aiCenter[0]]);
}
return json_decode(file_get_contents(GeoJson::getDistFilePath($this->sCodeName)), true); return json_decode(file_get_contents(GeoJson::getDistFilePath($this->sCodeName)), true);
} }

View File

@@ -179,6 +179,7 @@ class Spot extends Main
'modes' => Project::MODES, 'modes' => Project::MODES,
'clearances' => User::CLEARANCES, 'clearances' => User::CLEARANCES,
'default_timezone' => Settings::TIMEZONE, 'default_timezone' => Settings::TIMEZONE,
'default_maps' => $this->oMap->getProjectMaps(-1),
'chunk_size' => self::FEED_CHUNK_SIZE, 'chunk_size' => self::FEED_CHUNK_SIZE,
'hash_sep' => '-', 'hash_sep' => '-',
'title' => 'Spotty', 'title' => 'Spotty',
@@ -823,7 +824,7 @@ class Spot extends Main
} }
public function buildGeoJSON($sCodeName) { public function buildGeoJSON($sCodeName) {
return Converter::convertToGeoJson($sCodeName); return Converter::convertToGeoJson($sCodeName)['logs'];
} }
public static function decToDms($dValue, $sType) { public static function decToDms($dValue, $sType) {

View File

@@ -29,12 +29,13 @@ export default {
track: null, track: null,
markers: [], markers: [],
markerProps: { markerProps: {
project: {mainClasses: 'project', iconMain: 'marker', iconSub: 'project'},
image: {mainClasses: 'media', iconMain: 'marker', iconSub: 'image'}, image: {mainClasses: 'media', iconMain: 'marker', iconSub: 'image'},
video: {mainClasses: 'media', iconMain: 'marker', iconSub: 'video'}, video: {mainClasses: 'media', iconMain: 'marker', iconSub: 'video'},
message: {mainClasses: 'message', iconMain: 'marker', iconSub: 'footprint', iconSubTransform: 'rotate-270'} message: {mainClasses: 'message', iconMain: 'marker', iconSub: 'footprint', iconSubTransform: 'rotate-270'}
}, },
currProject: {}, currProject: null,
modeHisto: false, modeHisto: null,
posts: [], posts: [],
baseMaps: {}, baseMaps: {},
baseMap: null, baseMap: null,
@@ -63,8 +64,8 @@ export default {
} }
}, },
'hash.items.0'(newProjectCodename, oldProjectCodename) { 'hash.items.0'(newProjectCodename, oldProjectCodename) {
if(newProjectCodename && newProjectCodename != oldProjectCodename) { if(newProjectCodename != oldProjectCodename) {
this.hash.items = [newProjectCodename]; this.hash.items = newProjectCodename?[newProjectCodename]:[];
this.toggleSettingsPanel(false, 'none'); this.toggleSettingsPanel(false, 'none');
this.init(); this.init();
} }
@@ -82,9 +83,6 @@ export default {
}; };
}, },
inject: ['api', 'lang', 'hash', 'projects', 'user', 'consts', 'isMobile'], inject: ['api', 'lang', 'hash', 'projects', 'user', 'consts', 'isMobile'],
beforeMount() {
if(this.hash.items.length == 0) this.hash.items[0] = this.projects.getDefaultCodeName();
},
mounted() { mounted() {
this.init(); this.init();
}, },
@@ -93,35 +91,47 @@ export default {
}, },
methods: { methods: {
async init() { async init() {
this.initProject();
this.initLightbox(); this.initLightbox();
await Promise.all([
this.initFeed(),
this.initMap()
]);
//Direct link post action
if(this.hash.items.length == 3) await this.findPost(this.hash.items[1], this.hash.items[2]);
},
quit() {
this.lightbox.end();
this.$refs.feedSimpleBar.scrollElement.removeEventListener('scroll', this.onFeedScroll);
this.setFeedUpdateTimer(-1);
this.map.remove();
},
initProject() {
this.currProject = this.projects[this.hash.items[0]];
this.modeHisto = (this.currProject.mode == this.consts.modes.histo);
this.feed = {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true};
this.posts = [];
//this.baseMap = null;
this.baseMaps = {};
this.hikes.colors = { this.hikes.colors = {
'main': this.getStyleProperty('--track-main'), 'main': this.getStyleProperty('--track-main'),
'off-track': this.getStyleProperty('--track-off-track'), 'off-track': this.getStyleProperty('--track-off-track'),
'hitchhiking': this.getStyleProperty('--track-hitchhiking') 'hitchhiking': this.getStyleProperty('--track-hitchhiking')
}; };
if(!this.hash.items[0] || !this.projects[this.hash.items[0]]) await this.initProjectOverview();
else await this.initProject();
},
quit() {
this.lightbox.end();
this.$refs.feedSimpleBar?.scrollElement.removeEventListener('scroll', this.onFeedScroll);
this.setFeedUpdateTimer(-1);
this.map.remove();
},
async initProjectOverview() {
this.setFeedUpdateTimer(-1);
this.currProject = null;
this.modeHisto = null;
this.feed = {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true};
this.posts = [];
this.baseMaps = this.consts.default_maps;
await this.initMapOverview();
},
async initProject() {
this.setFeedUpdateTimer(-1);
this.currProject = this.projects[this.hash.items[0]];
this.modeHisto = (this.currProject.mode == this.consts.modes.histo);
this.feed = {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true};
this.posts = [];
this.baseMaps = {};
await Promise.all([
this.initFeed(),
this.initMapProject()
]);
//Direct link post action
if(this.hash.items.length == 3) await this.findPost(this.hash.items[1], this.hash.items[2]);
}, },
initLightbox() { initLightbox() {
if(!this.lightbox) { if(!this.lightbox) {
@@ -149,7 +159,10 @@ export default {
} }
}, },
async initFeed() { async initFeed() {
await this.$nextTick();
//Simplebar event //Simplebar event
this.$refs.feedSimpleBar?.scrollElement.removeEventListener('scroll', this.onFeedScroll);
this.$refs.feedSimpleBar?.scrollElement.addEventListener('scroll', this.onFeedScroll); this.$refs.feedSimpleBar?.scrollElement.addEventListener('scroll', this.onFeedScroll);
//Mobile Touchscreen Events //Mobile Touchscreen Events
@@ -162,12 +175,12 @@ export default {
//Get first posts batch //Get first posts batch
await this.getNextFeed(); await this.getNextFeed();
this.$refs.feedSimpleBar.scrollElement.scrollTop = 0; if(this.$refs.feedSimpleBar) this.$refs.feedSimpleBar.scrollElement.scrollTop = 0;
//Start auto-update //Start auto-update
if(!this.modeHisto) this.setFeedUpdateTimer(this.refreshRate); if(!this.modeHisto) this.setFeedUpdateTimer(this.refreshRate);
}, },
async initMap() { async initMapProject() {
//Start async calls //Start async calls
[ [
{ {
@@ -181,7 +194,51 @@ export default {
this.api.get('geojson', {id_project: this.currProject.id}) this.api.get('geojson', {id_project: this.currProject.id})
]); ]);
//Build Map await this.initMapBase({
setCamera: () => {
this.map.fitBounds(this.getInitialMapBounds(), {
padding: 20,
animate: false,
maxZoom: 15
});
},
addMarkers: () => {
this.addTrack(this.track);
this.markers.forEach(oMarker => this.addMarker(oMarker));
}
});
},
async initMapOverview() {
await this.initMapBase({
setCamera: () => {
//Center on default project
const oDefaultProject = this.projects.getDefaultProject();
//Adapt zoom to see whole planet
const $Canvas = this.map.getCanvas();
const iTargetRadius = Math.min($Canvas.clientWidth, $Canvas.clientHeight) / 2;
const iWorldSize = iTargetRadius * 2 * Math.PI * Math.cos(oDefaultProject.latitude * Math.PI / 180);
this.map.jumpTo({
center: new LngLat(oDefaultProject.longitude, oDefaultProject.latitude),
zoom: Math.log2(iWorldSize / this.map.transform.tileSize)
});
},
addMarkers: () => {
for(const asProject of Object.values(this.projects)) {
this.addMarker({
subtype: 'project',
longitude: asProject.longitude,
latitude: asProject.latitude,
opacityWhenCovered: 0.3
}, () => {
this.hash.items = [asProject.codename];
});
}
}
});
},
async initMapBase({setCamera, addMarkers}) {
if(this.map) this.map.remove(); if(this.map) this.map.remove();
this.map = new Map({ this.map = new Map({
container: 'map', container: 'map',
@@ -200,16 +257,10 @@ export default {
}, },
attributionControl: false attributionControl: false
}); });
this.updateMapPadding();
this.map.fitBounds(this.getInitialMapBounds(), {
padding: 20,
animate: false,
maxZoom: 15
});
this.map.addControl(new ScaleControl({unit: 'metric'}), 'bottom-right');
//Get default basemap this.updateMapPadding();
this.baseMap = this.baseMaps.find((asBM) => asBM.default_map)?.codename ?? null; setCamera();
this.map.addControl(new ScaleControl({unit: 'metric'}), 'bottom-right');
//Force wait for load event //Force wait for load event
await new Promise((resolve) => { await new Promise((resolve) => {
@@ -218,6 +269,7 @@ export default {
}); });
//Base maps (raster tiles) //Base maps (raster tiles)
this.baseMap = this.baseMaps.find((asBM) => asBM.default_map)?.codename ?? null;
for(const asBaseMap of this.baseMaps) { for(const asBaseMap of this.baseMaps) {
this.map.addSource(asBaseMap.codename, { this.map.addSource(asBaseMap.codename, {
type: 'raster', type: 'raster',
@@ -234,13 +286,7 @@ export default {
}); });
} }
//Add track addMarkers();
this.addTrack(this.track);
//Add Markers
this.markers.forEach(oMarker => this.addMarker(oMarker));
//Force wait for idle event
await new Promise((resolve) => { await new Promise((resolve) => {
if(this.map.loaded() && this.map.areTilesLoaded()) resolve(); if(this.map.loaded() && this.map.areTilesLoaded()) resolve();
else this.map.once('idle', resolve); else this.map.once('idle', resolve);
@@ -300,18 +346,19 @@ export default {
options: this.projects.getTrackInfo(oEvent.features[0], this.track, this.lang), options: this.projects.getTrackInfo(oEvent.features[0], this.track, this.lang),
}); });
}, },
addMarker(oMarker) { addMarker(oMarker, fClickCallback=null) {
const $Marker = document.createElement('div'); const $Marker = document.createElement('div');
createApp(SpotIconStack, this.markerProps[oMarker.subtype]).mount($Marker); createApp(SpotIconStack, this.markerProps[oMarker.subtype]).mount($Marker);
new Marker({element: $Marker, anchor: 'bottom', opacityWhenCovered: 0}) new Marker({element: $Marker, anchor: 'bottom', opacityWhenCovered: oMarker.opacityWhenCovered ?? 0})
.setLngLat([oMarker.longitude, oMarker.latitude]) .setLngLat([oMarker.longitude, oMarker.latitude])
.addTo(this.map) .addTo(this.map)
.getElement() .getElement()
.addEventListener('click', (oEvent) => { .addEventListener('click', (oEvent) => {
oEvent.preventDefault(); oEvent.preventDefault();
oEvent.stopPropagation(); oEvent.stopPropagation();
this.openMarkerPopup(oMarker.id, oMarker.type); if(fClickCallback) fClickCallback(oEvent, oMarker);
else this.openMarkerPopup(oMarker.id, oMarker.type);
}); });
}, },
openMarkerPopup(iMarkerId, sMarkerType) { openMarkerPopup(iMarkerId, sMarkerType) {
@@ -574,7 +621,7 @@ export default {
<div id="settings-panel" class="map-panel"> <div id="settings-panel" class="map-panel">
<div class="settings-header"> <div class="settings-header">
<div class="logo"><img width="289" height="72" src="images/logo_black.png" alt="Spotty" /></div> <div class="logo"><img width="289" height="72" src="images/logo_black.png" alt="Spotty" /></div>
<div id="last_update" v-if="this.currProject.mode == this.consts.modes.blog && lastUpdate.unix_time > 0"> <div id="last_update" v-if="this.currProject && this.currProject.mode == this.consts.modes.blog && lastUpdate.unix_time > 0">
<p><span><img src="images/spot-logo-only.svg" alt="" /></span><abbr :title="lastUpdate.formatted_time">{{ lang.get('feed.last_update')+' '+lastUpdate.relative_time }}</abbr></p> <p><span><img src="images/spot-logo-only.svg" alt="" /></span><abbr :title="lastUpdate.formatted_time">{{ lang.get('feed.last_update')+' '+lastUpdate.relative_time }}</abbr></p>
</div> </div>
</div> </div>
@@ -625,18 +672,18 @@ export default {
<div :class="'map-control map-control-icon settings-control map-control-'+(isMobile()?'bottom':'top')" @click="toggleSettingsPanel"> <div :class="'map-control map-control-icon settings-control map-control-'+(isMobile()?'bottom':'top')" @click="toggleSettingsPanel">
<SpotIcon :icon="settingsPanelOpen?'prev':'menu'" /> <SpotIcon :icon="settingsPanelOpen?'prev':'menu'" />
</div> </div>
<div v-if="!isMobile()" id="legend" class="map-control settings-control map-control-bottom"> <div v-if="currProject && !isMobile()" id="legend" class="map-control settings-control map-control-bottom">
<div v-for="(color, hikeType) in hikes.colors" class="track"> <div v-for="(color, hikeType) in hikes.colors" class="track">
<span class="line" :style="'background-color:'+color+'; height:'+hikes.width+'px;'"></span> <span class="line" :style="'background-color:'+color+'; height:'+hikes.width+'px;'"></span>
<span class="desc">{{ lang.get('track.'+hikeType) }}</span> <span class="desc">{{ lang.get('track.'+hikeType) }}</span>
</div> </div>
</div> </div>
<div id="title" :class="'map-control settings-control map-control-'+(isMobile()?'bottom':'top')"> <div v-if="currProject" id="title" :class="'map-control settings-control map-control-'+(isMobile()?'bottom':'top')">
<span>{{ currProject.name }}</span> <span>{{ currProject.name }}</span>
</div> </div>
</div> </div>
<div id="feed" class="map-container map-container-right" ref="feed"> <div id="feed" class="map-container map-container-right" ref="feed">
<Simplebar id="feed-panel" class="map-panel" ref="feedSimpleBar"> <Simplebar v-if="currProject" id="feed-panel" class="map-panel" ref="feedSimpleBar">
<div id="feed-header"> <div id="feed-header">
<ProjectPost v-if="modeHisto" :options="{type: 'archived', headerless: true}" /> <ProjectPost v-if="modeHisto" :options="{type: 'archived', headerless: true}" />
<ProjectPost v-else :options="{type: 'poster', relative_time: lang.get('post.new_message')}" /> <ProjectPost v-else :options="{type: 'poster', relative_time: lang.get('post.new_message')}" />
@@ -648,7 +695,7 @@ export default {
<ProjectPost :options="{type: 'loading', headerless: true}" /> <ProjectPost :options="{type: 'loading', headerless: true}" />
</div> </div>
</Simplebar> </Simplebar>
<div :class="'map-control map-control-icon feed-control map-control-'+(isMobile()?'bottom':'top')" @click="toggleFeedPanel"> <div v-if="currProject" :class="'map-control map-control-icon feed-control map-control-'+(isMobile()?'bottom':'top')" @click="toggleFeedPanel">
<SpotIcon :icon="feedPanelOpen?'next':'post'" /> <SpotIcon :icon="feedPanelOpen?'next':'post'" />
</div> </div>
</div> </div>

View File

@@ -13,7 +13,6 @@ export default class Projects {
} }
getDefaultProject() { getDefaultProject() {
const sCodeName = this.getDefaultCodeName();
return this[this.getDefaultCodeName()]; return this[this.getDefaultCodeName()];
} }

View File

@@ -56,7 +56,7 @@
filter: drop-shadow(0px 1px 1px color.$over-img-shadow); filter: drop-shadow(0px 1px 1px color.$over-img-shadow);
} }
&.message { &.message, &.project {
.main { .main {
color: color.$message-flashy; color: color.$message-flashy;
} }

View File

@@ -105,6 +105,10 @@
margin-left: var.$text-spacing; margin-left: var.$text-spacing;
@extend .clickable; @extend .clickable;
@include common.no-text-overflow(); @include common.no-text-overflow();
&:hover {
color: color.$default-hover;
}
} }
.download { .download {