Harmonize marker / popup rendering

This commit is contained in:
2026-05-02 00:16:37 +02:00
parent 560b22c039
commit 95ebc96484
9 changed files with 169 additions and 232 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
/node_modules/
/log.html
/dist/
.codex

View File

@@ -82,7 +82,6 @@ module.exports = {
from: path.resolve(LIB, 'index.php'),
to: 'index.php'
},
{ from: 'src/images/footprint_mapbox.png', to: 'images' },
{ from: 'src/images/logo_black.png', to: 'images' },
{ from: 'src/images/spot-logo-only.svg', to: 'images' }
],

View File

@@ -280,6 +280,12 @@ class Spot extends Main
usort($asMessages, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
$bHasMsg = !empty($asMessages);
foreach($asMessages as &$asMessage) {
$asMessage['id'] = $asMessage[Db::getId(Feed::MSG_TABLE)];
$asMessage['type'] = 'message';
$asMessage['subtype'] = 'message';
}
//Add medias
$asMedias = $this->getMedias('taken_on', $asMediaIds);
usort($asMedias, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
@@ -289,11 +295,13 @@ class Spot extends Main
$iMaxIndex = count($asMessages) - 1;
foreach($asMedias as $asMedia) {
if($asMedia['latitude']!='' && $asMedia['longitude']!='') {
$asMedia['id'] = $asMedia[Db::getId(Media::MEDIA_TABLE)];
$asMedia['type'] = 'media';
$asMedia['lat_dms'] = self::decToDms($asMedia['latitude'], 'lat');
$asMedia['lon_dms'] = self::decToDms($asMedia['longitude'], 'lon');
$asGeoMedias[] = $asMedia;
}
elseif($bHasMsg) {
if($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
@@ -308,13 +316,15 @@ class Spot extends Main
}
}
$asMarkers = [...$asMessages, ...$asGeoMedias];
usort($asMarkers, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
//Spot Last Update
$asLastUpdate = array();
$this->addTimeStamp($asLastUpdate, $this->oProject->getLastUpdate());
$asResult = array(
'messages' => $asMessages,
'medias' => $asGeoMedias,
'markers' => $asMarkers,
'maps' => $this->oMap->getProjectMaps($this->oProject->getProjectId()),
'last_update' => $asLastUpdate
);

View File

@@ -28,8 +28,14 @@ export default {
lastUpdate: { unix_time: 0, relative_time: '', formatted_time: ''},
feedPanelOpen: false,
settingsPanelOpen: false,
markers: {messages: null, medias: null},
track: null,
markers: [],
markerSize: {width: 32, height: 32},
markerProps: {
image: {mainClasses: 'media', iconMain: 'marker', iconSub: 'image'},
video: {mainClasses: 'media', iconMain: 'marker', iconSub: 'video'},
message: {mainClasses: 'message', iconMain: 'marker', iconSub: 'footprint', iconSubTransform: 'rotate-270'}
},
currProject: {},
modeHisto: false,
posts: [],
@@ -38,6 +44,7 @@ export default {
baseMaps: {},
baseMap: null,
map: null,
mapReady: false,
hikes: {
colors:{'main':'#00ff78', 'off-track':'#0000ff', 'hitchhiking':'#FF7814'},
width: 4
@@ -49,7 +56,8 @@ export default {
projectClasses() {
return [
this.feedPanelOpen?'with-feed':'',
this.settingsPanelOpen?'with-settings':''
this.settingsPanelOpen?'with-settings':'',
this.mapReady?'map-ready':''
].filter(n => n).join(' ');
},
nlClasses() {
@@ -75,6 +83,7 @@ export default {
'hash.items.0'(newProjectCodename, oldProjectCodename) {
if(newProjectCodename && newProjectCodename != oldProjectCodename) {
this.hash.items = [newProjectCodename];
this.toggleSettingsPanel(false, 'none');
this.init();
}
}
@@ -122,8 +131,10 @@ export default {
this.initMap()
]);
//Direct link: Scroll to post
if(this.hash.items.length == 3) this.findPost({type: this.hash.items[1], id: this.hash.items[2]});
//Direct link or default project positioning
await this.setInitialMapPosition();
this.mapReady = true;
},
quit() {
lightbox.end();
@@ -152,6 +163,7 @@ export default {
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.mapReady = false;
//this.baseMap = null;
this.baseMaps = {};
},
@@ -166,7 +178,7 @@ export default {
hasVideo: true,
onMediaChange: (oMedia) => {
this.hash.items = [this.currProject.codename, 'media', oMedia.id];
if(oMedia.set == 'post-medias') this.goToPost({type: 'media', id: oMedia.id});
if(oMedia.set == 'post-medias') this.goToPost('media', oMedia.id);
},
onClosing: () => {this.hash.items = [this.hash.items[0]];}
});
@@ -178,6 +190,8 @@ export default {
//Mobile Touchscreen Events
//TODO
this.toggleFeedPanel(!this.isMobile(), 'none');
//Add post Event handling
//TODO
@@ -209,8 +223,7 @@ export default {
const aoMarkers = await oMarkersPromise;
this.baseMaps = aoMarkers.maps;
this.baseMap = this.baseMaps.find((asBM) => asBM.default_map)?.codename ?? null;
this.markers.messages = aoMarkers.messages;
this.markers.medias = aoMarkers.medias;
this.markers = aoMarkers.markers;
this.lastUpdate = aoMarkers.last_update;
//Force wait for load event
@@ -237,21 +250,19 @@ export default {
}
//Add track
const oTrack = await oTrackPromise;
this.addTrack(oTrack);
//Centering map
await this.positionMap(oTrack);
this.addTrack(await oTrackPromise);
//Add Markers
this.addMarkers();
this.markers.forEach(oMarker => this.addMarker(oMarker));
//Force wait for idle event
await new Promise((resolve) => {
this.map.once('idle', resolve);
if(this.map.loaded() && this.map.areTilesLoaded()) resolve();
else this.map.once('idle', resolve);
});
},
addTrack(oTrack) {
addTrack(oTrack, bCenter=false) {
this.track = oTrack;
this.map.addSource('track', {
'type': 'geojson',
'data': oTrack
@@ -280,167 +291,37 @@ export default {
}
});
},
async addMarkers() {
this.map.addImage('markerIcon', (await this.map.loadImage('images/footprint_mapbox.png')).data);
this.map.addSource('markers', {
type:'geojson',
data: {
type: 'FeatureCollection',
features: this.convertMsgToFeatures(this.markers.messages)
}
});
this.map.addLayer({
'id': 'markers',
'type': 'symbol',
'source': 'markers',
'layout': {'icon-image': 'markerIcon'}
});
this.map.on('click', 'markers', (e) => {
this.openMarkerPopup(e.features[0]);
});
/*
this.markers.messages.forEach(msg => {
const el = document.createElement('div');
const app = createApp(SpotIconStack, {iconMain: 'message', iconSub:'message-in', iconSubRotation: 270});
app.mount(el);
new Marker({element: el, anchor: 'bottom'})
.setLngLat([msg.longitude, msg.latitude])
.addTo(this.map);
});
*/
//Medias
//TODO Use same way of displaying markers (so that openMarkerPopup works on all markers)
this.markers.medias.forEach(msg => {
addMarker(oMarker) {
const $Marker = document.createElement('div');
createApp(SpotIconStack, {mainClasses: 'media', iconMain: 'marker', iconSub: msg.subtype}).mount($Marker);
createApp(SpotIconStack, this.markerProps[oMarker.subtype]).mount($Marker);
const marker = new Marker({element: $Marker, anchor: 'bottom'})
.setLngLat([msg.longitude, msg.latitude])
new Marker({element: $Marker, anchor: 'bottom'})
.setLngLat([oMarker.longitude, oMarker.latitude])
.addTo(this.map)
.getElement().addEventListener('click', (oEvent) => {
.getElement()
.addEventListener('click', (oEvent) => {
oEvent.preventDefault();
oEvent.stopPropagation();
this.openMediaPopup(msg);
});
this.openMarkerPopup(oMarker.id, oMarker.type);
});
},
async positionMap(oTrack) {
let bOpenFeedPanel = !this.isMobile();
let oBounds = new LngLatBounds();
if( //Blog Mode: Fit to last message
this.currProject.mode == this.consts.modes.blog &&
this.markers.messages.length > 0 &&
this.hash.items[2] != 'message'
) {
let oLastMsg = this.markers.messages[this.markers.messages.length - 1];
oBounds.extend(new LngLat(oLastMsg.longitude, oLastMsg.latitude));
}
else { //Pre/Histo Mode: Fit to track
for(const iFeatureId in oTrack.features) {
oBounds = oTrack.features[iFeatureId].geometry.coordinates.reduce(
(bounds, coord) => {
return bounds.extend(coord);
},
oBounds
);
}
}
const iFeedPanelPadding = bOpenFeedPanel?(getOuterWidth(this.$refs.feed)/2):0;
await this.map.fitBounds(
oBounds,
{
padding: {
top: 20,
bottom: 20,
left: (20 + iFeedPanelPadding),
right: (20 + iFeedPanelPadding)
},
animate: false,
maxZoom: 15
}
);
//Toggle only when map is ready, for the tilt effet
this.toggleFeedPanel(bOpenFeedPanel);
},
convertMsgToFeatures(oMsg) {
return oMsg.map(oMsg => ({
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [oMsg.longitude, oMsg.latitude]
},
'properties': {
...oMsg,
...{'description': ''}
}
}));
},
openMediaPopup(oMedia) {
openMarkerPopup(iMarkerId, sMarkerType) {
this.closeMarkerPopup();
let oMarker = this.markers.find((oCandidate) => oCandidate.id == iMarkerId && oCandidate.type == sMarkerType);
const $Popup = document.createElement('div');
this.popup.element = new Popup({
anchor: 'bottom',
offset: [0, this.markerSize.height * -1],
offset: [0, this.markerSize.height * -1], //FIXME
closeButton: false
})
.setDOMContent($Popup)
.setLngLat([oMedia.longitude, oMedia.latitude])
.setMaxWidth(300)
.setLngLat([oMarker.longitude, oMarker.latitude])
.addTo(this.map);
this.popup.content = createApp(ProjectPopup, {
type: 'media',
options: oMedia,
medias: [oMedia],
project: this.currProject
});
this.popup.content
.provide('spot', this.spot)
.provide('lang', this.lang)
.provide('consts', this.consts)
.mount($Popup);
},
openMarkerPopup(oFeature) {
this.closeMarkerPopup();
//Convert ID Message to feature
if(typeof oFeature == 'number') {
const oMatchingFeatures = this.map.querySourceFeatures('markers', {
filter: ['==', ['get', 'id_message'], oFeature]
});
if(!oMatchingFeatures.length) {
console.warn('Marker not found: ', oFeature);
return;
}
else oFeature = oMatchingFeatures[0];
}
const $Popup = document.createElement('div');
this.popup.element = new Popup({
anchor: 'bottom',
offset: [0, this.markerSize.height * -1],
closeButton: false
})
.setDOMContent($Popup)
.setLngLat(oFeature.geometry.coordinates)
.setMaxWidth(300)
.addTo(this.map);
this.popup.content = createApp(ProjectPopup, {
type: 'message',
options: oFeature.properties,
medias: JSON.parse(oFeature.properties.medias || '[]'),
options: oMarker,
project: this.currProject
});
this.popup.content
@@ -459,6 +340,75 @@ export default {
this.popup.element = null;
}
},
async setInitialMapPosition() {
if(this.hash.items.length == 3) {
await this.findPost(this.hash.items[1], this.hash.items[2]);
}
else {
let oBounds = new LngLatBounds();
if( //Blog Mode: Fit to last message
this.currProject.mode == this.consts.modes.blog &&
this.markers.length > 0
) {
let oLastMsg = this.markers.at(-1);
oBounds.extend(new LngLat(oLastMsg.longitude, oLastMsg.latitude));
}
else { //Pre/Histo Mode: Fit to track
for(const iFeatureId in this.track.features) {
oBounds = this.track.features[iFeatureId].geometry.coordinates.reduce(
(bounds, coord) => {
return bounds.extend(coord);
},
oBounds
);
}
}
const iFeedPanelPadding = this.feedPanelOpen?(getOuterWidth(this.$refs.feed)):0;
await this.map.fitBounds(
oBounds,
{
padding: {
top: 20,
bottom: 20,
left: 20,
right: 20 + iFeedPanelPadding
},
animate: false,
maxZoom: 15
}
);
}
},
async findPost(sPostType, iPostId) {
let oRef = this.goToPost(sPostType, iPostId);
if(oRef) {
await oRef.executeMainAction(0);
}
else if(!this.feed.outOfData) {
await this.getNextFeed();
await this.findPost(sPostType, iPostId);
}
else console.log('Missing element ID "'+iPostId+'" of type "'+sPostType+'"');
},
goToPost(sPostType, iPostId) {
let bFound = false;
let aoRefs = this.$refs.posts.filter((post) => {return post.postId == sPostType+'-'+iPostId;});
if(aoRefs.length > 0) {
let oRef = aoRefs[0];
this.$refs.feedSimpleBar.scrollElement.scrollTop += Math.round(
oRef.$el.getBoundingClientRect().top
+ window.pageYOffset
- parseFloat(getComputedStyle(this.$refs.feedSimpleBar.$el).paddingTop)
);
//this.hash.items = [this.hash.items[0]];
return oRef;
}
},
async getNextFeed() {
if(!this.feed.outOfData && !this.feed.loading) {
//Get next chunk
@@ -504,31 +454,28 @@ export default {
}
//Add new Markers
if(Object.keys(aoData.messages).length > 0) {
const oMarkerSource = this.map.getSource('markers');
oMarkerSource.setData({
type: 'FeatureCollection',
features: [...oMarkerSource._data.features, ...this.convertMsgToFeatures(aoData.messages)]
});
if(Object.keys(aoData.markers).length > 0) {
this.markers.push(...aoData.markers);
aoData.messages.forEach(oMarker => this.addMarker(oMarker));
}
//TODO medias
//Message Last Update
this.lastUpdate = aoData.last_update;
//Reschedule
this.setFeedUpdateTimer(this.refreshRate);
},
panToBetweenPanels(oLngLat, iZoom, fCallback) {
panToBetweenPanels(oLngLat, iZoom, iAnimDuration=500) {
const iXOffset = (this.settingsPanelOpen?getOuterWidth(this.$refs.settings):0) - (this.feedPanelOpen?getOuterWidth(this.$refs.feed):0);
this.map.once('moveend', fCallback);
return new Promise((resolve) => {
this.map.once('moveend', resolve);
this.map.easeTo({
center: oLngLat,
zoom: iZoom,
offset: [iXOffset / 2, 0],
duration: 500
duration: iAnimDuration
});
});
},
isMarkerVisible(oLngLat){
@@ -610,33 +557,6 @@ export default {
break;
}
}
},
async findPost(oPost) {
let oRef = this.goToPost(oPost);
if(oRef) {
oRef.executeMainAction();
}
else if(!this.feed.outOfData) {
await this.getNextFeed();
this.findPost(oPost);
}
else console.log('Missing element ID "'+oPost.id+'" of type "'+oPost.type+'"');
},
goToPost(oPost) {
let bFound = false;
let aoRefs = this.$refs.posts.filter((post) => {return post.postId == oPost.type+'-'+oPost.id;});
if(aoRefs.length == 1) {
let oRef = aoRefs[0];
this.$refs.feedSimpleBar.scrollElement.scrollTop += Math.round(
oRef.$el.getBoundingClientRect().top
+ window.pageYOffset
- parseFloat(getComputedStyle(this.$refs.feedSimpleBar.$el).paddingTop)
);
//this.hash.items = [this.hash.items[0]];
return oRef;
}
}
}
}

View File

@@ -11,6 +11,7 @@ export default {
options: Object,
type: String
},
emits: ['opening-lightbox'],
data() {
return {
title:''
@@ -41,6 +42,7 @@ export default {
:data-id="options.id_media"
:data-title="title"
:data-orientation="options.rotate"
@click="$emit('opening-lightbox', $event)"
ref="link"
>
<img

View File

@@ -13,23 +13,24 @@ export default {
projectRelTime
},
props: {
type: String,
options: Object,
medias: Array,
project: Object
},
inject: ['lang', 'consts'],
computed: {
timeIcon() {
return (this.type == 'media')?'image-shot':'time';
return (this.options.type == 'media')?'image-shot':'time';
},
medias() {
return (this.options.type == 'media')?[this.options]:this.options?.medias;
}
}
}
</script>
<template>
<div :class="type">
<div class="header" v-if="type=='message'">
<div :class="options.type">
<div class="header" v-if="options.type=='message'">
<h1>
<spotIcon :icon="'message'" size="lg" :text="lang.get('feed.counter', options.displayed_id)" width="auto" />
<span class="message-type">({{ options.type }})</span>
@@ -50,8 +51,8 @@ export default {
<div class="section weather" v-if="options.weather_icon && options.weather_icon!='unknown'" :title="options.weather_cond==''?'':lang.get('weather.'+options.weather_icon)">
<spotIcon :icon="options.weather_icon" fixed-width size="lg" :text="options.weather_temp+'°C'" />
</div>
<div v-if="medias.length > 0" class="section medias">
<spotIcon v-if="type=='message'" icon="media" fixed-width size="lg" :text="lang.get('media.nearby')" />
<div v-if="medias" class="section medias">
<spotIcon v-if="options.type=='message'" icon="media" fixed-width size="lg" :text="lang.get('media.nearby')" />
<div class="medias-list">
<projectMediaLink v-for="media in medias" :options="media" :type="'marker'" />
</div>

View File

@@ -84,19 +84,19 @@
this.anchorIcon = 'link';
}, 5000);
},
panMapToMessage() {
panMapToMarker(iAnimDuration=500) {
this.popupRequested = true;
if(this.isMobile()) this.project.toggleFeedPanel(false, 'panToInstant');
this.map.panToBetweenPanels(
this.lngLat,
this.focusZoomLevel,
() => {this.map.openMarkerPopup(this.options.id_message);}
);
this.hash.items = [this.hash.items[0], this.options.type, this.options.id_message];
this.hash.items = [this.hash.items[0], this.options.type, this.options.id];
return this.map.panToBetweenPanels(this.lngLat, this.focusZoomLevel, iAnimDuration).then(() => {
this.openMarkerPopup();
});
},
openMarkerPopup() {
this.mouseOverDrill = true;
if(this.map.isMarkerVisible(this.lngLat)) this.map.openMarkerPopup(this.options.id_message);
if(this.map.isMarkerVisible(this.lngLat)) this.map.openMarkerPopup(this.options.id, this.options.type);
},
closeMarkerPopup() {
this.mouseOverDrill = false;
@@ -127,12 +127,12 @@
executeMainAction() {
switch(this.options.type) {
case 'message':
this.panMapToMessage();
break;
return this.panMapToMarker(0);
case 'media':
this.$refs.medialink.openMedia();
if(this.lngLat) this.map.panToBetweenPanels(this.lngLat, this.focusZoomLevel);
break;
if(this.lngLat) return this.panMapToMarker(0);
default:
return Promise.resolve();
}
}
},
@@ -161,12 +161,12 @@
</div>
<div class="body">
<div v-if="options.type == 'message'" class="body-box">
<div class="drill" @click.prevent="executeMainAction" @mouseenter="openMarkerPopup" @mouseleave="closeMarkerPopup">
<div class="drill" @click.prevent="() => {this.panMapToMarker();}" @mouseenter="openMarkerPopup" @mouseleave="closeMarkerPopup">
<span v-if="options.weather_icon && options.weather_icon!='unknown'" class="weather clickable" :title="lang.get('weather.'+options.weather_icon)">
<spotIcon :icon="options.weather_icon" :text="Math.round(options.weather_temp)+'°C'" text-classes="temperature" />
</span>
<img class="staticmap clickable" :title="lang.get('media.click_zoom')" :src="options.static_img_url" />
<spotIconStack :mainClasses="'message drill-icon'" :iconMain="drillMainIcon" :iconSub="'footprint'" :icon-sub-transform="'rotate-270'" />
<spotIconStack :mainClasses="'message drill-icon'" :iconMain="drillMainIcon" iconSub="footprint" :icon-sub-transform="'rotate-270'" />
<div class="comment">
<p>
<spotIcon :icon="'coords'" margin="right" size="lg" />
@@ -179,7 +179,7 @@
</div>
</div>
<div v-else-if="options.type == 'media'" class="body-box">
<projectMediaLink :options="options" :type="'post'" ref="medialink" />
<projectMediaLink :options="options" :type="'post'" ref="medialink" @opening-lightbox="() => {if(this.lngLat) return this.panMapToMarker();}" />
</div>
<div v-else-if="options.type == 'post'">
<p class="message">{{ options.content }}</p>

View File

@@ -88,7 +88,6 @@ const ICONS = {
map: faMapLocationDot,
marker: faLocationPin,
footprint: faShoePrints,
'message-in': faShoePrints,
'track-off-track': faPersonHiking,
'track-main': faPersonHiking,
'track-hitchhiking': faCarSide,

View File

@@ -10,6 +10,11 @@ $thumbnail-max-size: 60px;
top: 0;
bottom: 0;
width: 100%;
visibility: hidden;
.map-ready & {
visibility: visible;
}
.maplibregl-popup {
max-width: 300px;