Files
spot/src/components/project.vue
Franzz 034d02f042
All checks were successful
Deploy Spot / deploy (push) Successful in 34s
Restructure project folders and remove obsolete files
2026-05-30 01:32:20 +02:00

584 lines
16 KiB
Vue

<script>
import 'maplibre-gl/dist/maplibre-gl.css';
import { Map, Marker, LngLatBounds, LngLat, Popup, ScaleControl } from 'maplibre-gl';
import { createApp } from 'vue';
import Lightbox from '@scripts/lightbox';
import SpotIcon from '@components/spotIcon';
import SpotIconStack from '@components/spotIconStack';
import ProjectPopup from '@components/projectPopup';
import ProjectFeed from '@components/projectFeed';
import ProjectSettings from '@components/projectSettings';
export default {
components: {
SpotIcon,
ProjectFeed,
ProjectSettings
},
data() {
return {
panels: {
leftOpen: false,
rightOpen: false
},
feed: null,
settings: null,
track: null,
markers: [],
markerProps: {
project: {mainClasses: 'project', iconMain: 'marker', iconSub: 'project'},
image: {mainClasses: 'media', iconMain: 'marker', iconSub: 'image'},
video: {mainClasses: 'media', iconMain: 'marker', iconSub: 'video'},
message: {mainClasses: 'message', iconMain: 'marker', iconSub: 'footprint', iconSubTransform: 'rotate-270'}
},
project: null,
modeHisto: null,
baseMaps: [],
baseMap: null,
map: null,
mapInitializing: false,
markerHeight: 32, //FIXME
mapPadding: 16 + 32, //1rem + marker height
lightbox: null,
hikes: {
colors: {},
width: 4
},
popup: {content: null, element: null},
overview: {id: 0, codename:'overview', name: this.lang.get('project.overview')},
};
},
computed: {
projectOptions() {
return [
this.overview,
...Object.values(this.projects)
];
}
},
watch: {
baseMap(sNewBaseMap, sOldBaseMap) {
if(this.map?.isStyleLoaded()) {
if(sOldBaseMap && this.map.getLayer(sOldBaseMap)) this.map.setLayoutProperty(sOldBaseMap, 'visibility', 'none');
if(sNewBaseMap && this.map.getLayer(sNewBaseMap)) this.map.setLayoutProperty(sNewBaseMap, 'visibility', 'visible');
}
},
'hash.items.0'(newProjectCodename, oldProjectCodename) { //hash.items.0 = Project Code Name
if(newProjectCodename != oldProjectCodename) {
this.hash.items = [newProjectCodename]; //Force removal of direct link
this.settings.toggle(false, 0);
this.init();
}
}
},
provide() {
return {
map: {
panToBetweenPanels: this.panToBetweenPanels,
openMarkerPopup: this.openMarkerPopup,
closePopup: this.closePopup,
isMarkerVisible: this.isMarkerVisible
},
project: this
};
},
inject: ['api', 'lang', 'hash', 'projects', 'user', 'consts', 'isMobile'],
mounted() {
//Starts default project init() through watcher
if(this.hash.items.length == 0) {
this.hash.items[0] = (this.projects.getDefaultProject().mode == this.consts.modes.blog)?this.projects.getDefaultCodeName():this.overview.codename;
}
else this.init();
},
beforeUnmount() {
this.quit();
},
methods: {
async init() {
this.initLightbox();
this.hikes.colors = {
'main': this.getStyleProperty('--track-main'),
'off-track': this.getStyleProperty('--track-off-track'),
'hitchhiking': this.getStyleProperty('--track-hitchhiking')
};
//Reset values
this.track = null;
this.project = null;
this.removeMapContent();
//Build Map
this.mapInitializing = true;
if(this.hash.items[0] && this.projects[this.hash.items[0]]) await this.initProject(this.hash.items[0]);
else await this.initOverview();
this.mapInitializing = false;
},
quit() {
this.lightbox.end(true);
this.lightbox = null;
this.removeMap();
},
async initOverview() {
this.modeHisto = true;
this.hash.items = [this.overview.codename];
this.feed.toggle(false, 0);
await this.initOverviewMap();
},
async initProject(sProjectCodeName) {
this.project = this.projects[sProjectCodeName];
this.modeHisto = (this.project.mode == this.consts.modes.histo);
await this.$nextTick();
await Promise.all([
this.feed.init(),
this.initProjectMap()
]);
//Direct link post action
if(this.hash.items.length == 3) await this.feed.findPost(this.hash.items[1], this.hash.items[2]);
},
initLightbox() {
if(!this.lightbox) {
this.lightbox = new Lightbox({
alwaysShowNavOnTouchDevices: true,
albumLabel: 'Media %1 / %2',
fadeDuration: 300,
imageFadeDuration: 400,
positionFromTop: 0,
resizeDuration: 400,
hasVideo: true,
onMediaChange: async (oMedia) => {
this.hash.items = [this.project.codename, 'media', oMedia.id];
if(oMedia.set == 'post-medias') {
(await this.feed.goToPost('media', oMedia.id))?.panMapToMarker();
if(!this.lightbox.hasMediaAfterCurrent()) {
await this.feed.getNextFeed();
await this.$nextTick();
this.lightbox.refreshAlbum();
}
}
},
onClosing: () => {this.hash.items = [this.hash.items[0]];}
});
}
},
async initProjectMap() {
[
{maps: this.baseMaps, markers: this.markers},
this.track
] = await Promise.all([
this.api.get('markers', {id_project: this.project.id}),
this.api.get('geojson', {id_project: this.project.id})
]);
await this.initMap({
setCamera: () => {
this.map.fitBounds(this.getInitialMapBounds(), {
padding: this.mapPadding,
animate: false,
maxZoom: 15
});
}
});
},
async initOverviewMap() {
this.baseMaps = this.consts.default_maps;
this.markers = Object.values(this.projects).map((asProject) => ({
type: 'project',
subtype: 'project',
...asProject,
opacityWhenCovered: 0.3
}));
await this.initMap({
setCamera: () => {
//Center on default project
const oDefaultProject = this.projects.getDefaultProject();
//Get Map / Canvas size
const $Canvas = this.map.getCanvas();
const oMapBounds = this.map.getContainer().getBoundingClientRect();
//Adapt zoom to see whole planet
const iTargetRadius = Math.max(1, Math.min(oMapBounds.width || $Canvas.clientWidth, oMapBounds.height || $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)
});
}
});
},
async initMap({setCamera}) {
//Build map
if(!this.map) this.addMap();
this.updateMapPadding();
//Force wait for load event
await new Promise((resolve) => {
if(this.map.isStyleLoaded()) resolve();
else this.map.once('load', resolve);
});
this.map.resize();
setCamera();
//Add content: Base Maps, Tracks, Markers
this.addMapContent();
await new Promise((resolve) => {
if(this.map.loaded() && this.map.areTilesLoaded()) resolve();
else this.map.once('idle', resolve);
});
},
addMap() {
this.map = new Map({
container: 'map',
aroundCenter: true,
style: {
version: 8,
projection: {type: 'globe'},
sky: {
'sky-color': this.getStyleProperty('--space'),
'horizon-color': this.getStyleProperty('--horizon'),
'sky-horizon-blend': 0.35,
'atmosphere-blend': 0.8
},
sources: {},
layers: []
},
attributionControl: false
});
this.map.addControl(new ScaleControl({unit: 'metric'}), 'bottom-right');
},
removeMap() {
this.removeMapContent();
this.map?.remove();
this.map = null;
},
addMapContent() {
this.baseMaps.forEach(this.addBaseMap);
this.addTrack();
this.markers.forEach(this.addMarker);
},
removeMapContent() {
if(!this.map) return;
this.closePopup();
this.removeTrack();
this.markers.forEach(this.removeMarker);
this.baseMaps.forEach(this.removeBaseMap);
},
addBaseMap(asBaseMap) {
if(asBaseMap.default_map) this.baseMap = asBaseMap.codename;
if(this.map.getSource(asBaseMap.codename) && this.map.getLayer(asBaseMap.codename)) return;
this.map.addSource(asBaseMap.codename, {
type: 'raster',
tiles: [asBaseMap.pattern],
tileSize: asBaseMap.tile_size
});
this.map.addLayer({
id: asBaseMap.codename,
type: 'raster',
source: asBaseMap.codename,
'layout': {'visibility': asBaseMap.default_map?'visible':'none'},
minZoom: asBaseMap.min_zoom,
maxZoom: asBaseMap.max_zoom
});
},
removeBaseMap(asBaseMap) {
if(this.map.getLayer(asBaseMap.codename)) this.map.removeLayer(asBaseMap.codename);
if(this.map.getSource(asBaseMap.codename)) this.map.removeSource(asBaseMap.codename);
},
addTrack() {
if(!this.track) return;
this.track.features.forEach((oFeature, iFeatureId) => {
oFeature.properties.track_id = iFeatureId;
});
this.map.addSource('track', {
'type': 'geojson',
'data': this.track
});
//Color mapping
let asColorMapping = ['match', ['get', 'type']];
for(const [sHikeType, sColor] of Object.entries(this.hikes.colors)) {
asColorMapping.push(sHikeType);
asColorMapping.push(sColor);
}
asColorMapping.push('black'); //fallback value
//Track layer
this.map.addLayer({
'id': 'track',
'type': 'line',
'source': 'track',
'layout': {
'line-join': 'round',
'line-cap': 'round'
},
'paint': {
'line-color': asColorMapping,
'line-width': this.hikes.width
}
});
//Enlarged track (click hit box)
this.map.addLayer({
'id': 'track-hitbox',
'type': 'line',
'source': 'track',
'paint': {
'line-opacity': 0,
'line-width': this.hikes.width + this.mapPadding
}
});
this.map.on('click', 'track-hitbox', this.openTrackPopup);
this.map.on('mouseenter', 'track-hitbox', this.onTrackHover);
this.map.on('mouseleave', 'track-hitbox', this.onTrackHover);
},
removeTrack() {
//Over clickable track
if(this.map.getLayer('track-hitbox')) {
this.map.off('click', 'track-hitbox', this.openTrackPopup);
this.map.off('mouseenter', 'track-hitbox', this.onTrackHover);
this.map.off('mouseleave', 'track-hitbox', this.onTrackHover);
this.map.removeLayer('track-hitbox');
}
//Actual track
if(this.map.getLayer('track')) this.map.removeLayer('track');
//Track source
if(this.map.getSource('track')) this.map.removeSource('track');
},
addMarker(oMarker) {
const $Marker = document.createElement('div');
oMarker.app = createApp(SpotIconStack, this.markerProps[oMarker.subtype]);
oMarker.app.mount($Marker);
oMarker.marker = new Marker({element: $Marker, anchor: 'bottom', opacityWhenCovered: oMarker.opacityWhenCovered ?? 0})
.setLngLat([oMarker.longitude, oMarker.latitude])
.addTo(this.map);
const $MarkerElement = oMarker.marker.getElement();
$MarkerElement.addEventListener('click', (oEvent) => {this.onMarkerClick(oEvent, oMarker);});
$MarkerElement.addEventListener('mouseenter', (oEvent) => {this.onMarkerHover(oEvent, oMarker);});
$MarkerElement.addEventListener('mouseleave', (oEvent) => {this.onMarkerHover(oEvent, oMarker);});
},
removeMarker(oMarker) {
if(oMarker.app) {
oMarker.app.unmount();
delete oMarker.app;
}
if(oMarker.marker) {
oMarker.marker.remove();
delete oMarker.marker;
}
},
onTrackHover(oEvent) {
this.map.getCanvas().style.cursor = (oEvent.type == 'mouseenter')?'pointer':'';
},
onMarkerClick(oEvent, oMarker) {
oEvent.preventDefault();
oEvent.stopPropagation();
switch (oMarker.type) {
case 'project':
this.hash.items = [oMarker.codename];
break;
default:
this.openMarkerPopup(oMarker.id, oMarker.type);
}
},
onMarkerHover(oEvent, oMarker) {
switch (oMarker.type) {
case 'project':
if(oEvent.type == 'mouseenter') this.openProjectPopup(oMarker);
else this.closePopup();
break;
}
},
openProjectPopup(oProject) {
this.openPopup({
lnglat: [oProject.longitude, oProject.latitude],
options: oProject,
offset: [0, -1 * this.markerHeight]
});
},
openMarkerPopup(iMarkerId, sMarkerType) {
let oMarker = this.markers.find((oCandidate) => oCandidate.id == iMarkerId && oCandidate.type == sMarkerType);
this.openPopup({
lnglat: [oMarker.longitude, oMarker.latitude],
options: oMarker,
offset: [0, -1 * this.markerHeight]
});
},
openTrackPopup(oEvent) {
this.openPopup({
lnglat: oEvent.lngLat,
options: this.projects.getTrackInfo(oEvent.features[0], this.track, this.lang),
});
},
openPopup({lnglat, options={}, offset=[0, 0]} = {}) {
this.closePopup();
const $Popup = document.createElement('div');
this.popup.element = new Popup({
anchor: 'bottom',
offset: offset,
closeButton: false
})
.setDOMContent($Popup)
.setLngLat(lnglat)
.addTo(this.map);
this.popup.content = createApp(ProjectPopup, {
options: options,
project: this.project
});
this.popup.content
.provide('lang', this.lang)
.provide('consts', this.consts)
.provide('isMobile', this.isMobile)
.mount($Popup);
},
closePopup() {
if(this.popup.content) {
this.popup.content.unmount();
this.popup.content = null;
}
if(this.popup.element) {
this.popup.element.remove();
this.popup.element = null;
}
},
getInitialMapBounds() {
let oBounds = new LngLatBounds();
let oHashMarker;
if(this.hash.items.length == 3) {
oHashMarker = this.markers.find((oMarker) => (
oMarker.type == this.hash.items[1] &&
oMarker.id == this.hash.items[2] &&
oMarker.longitude != null &&
oMarker.latitude != null
)) || null;
}
if(oHashMarker) { //Direct link to marker
oBounds.extend(new LngLat(oHashMarker.longitude, oHashMarker.latitude));
}
else if( //Blog Mode: Fit to last message
this.project.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) => bounds.extend(coord),
oBounds
);
}
}
return oBounds;
},
addNewMarkers(aoMarkers) { //FIXME Use its own marker update API
this.markers.push(...aoMarkers);
aoMarkers.forEach(this.addMarker);
},
panToBetweenPanels(oLngLat, iZoom, iAnimDuration=500) {
return new Promise((resolve) => {
if(!this.map) {
resolve();
return;
}
this.map.once('moveend', resolve);
this.map.easeTo({
center: oLngLat,
zoom: iZoom,
padding: this.getMapPadding(),
duration: iAnimDuration
});
});
},
getMapPadding() {
let bIsMobile = this.isMobile();
return {
top: this.mapPadding,
bottom: this.mapPadding,
left: this.mapPadding + ((!bIsMobile && this.panels.leftOpen && this.settings)?this.settings.getWidth():0),
right: this.mapPadding + ((!bIsMobile && this.panels.rightOpen && this.feed)?this.feed.getWidth():0)
};
},
updateMapPadding(iDuration=0) {
const asPadding = this.getMapPadding();
if(iDuration > 0) this.map.easeTo({padding: asPadding, duration: iDuration});
else this.map.jumpTo({padding: asPadding});
},
getStyleProperty(sProperty) {
return getComputedStyle(this.$el).getPropertyValue(sProperty).trim();
},
isMarkerVisible(oLngLat){
return !!this.map && this.map.getBounds().contains(oLngLat);
},
onPanelToggle(sPanel, bNewValue, iAnimDuration=500) {
const sPanelKey = sPanel + 'Open';
let bOldValue = this.panels[sPanelKey];
this.panels[sPanelKey] = bNewValue;
if(bOldValue != bNewValue) {
//Adjust map center
if(!this.isMobile() && this.map) this.updateMapPadding(iAnimDuration);
//Open Close panels
this.$el.classList.toggle('with-'+sPanel+'-panel');
}
},
setFeed(vPanel) {
this.feed = vPanel;
},
setSettings(vPanel) {
this.settings = vPanel;
}
}
}
</script>
<template>
<div class="projects">
<div id="space"></div>
<div id="submap">
<div class="loader">
<SpotIcon :icon="'map'" :classes="'flicker'" width="fixed" />
</div>
</div>
<div id="map"></div>
<ProjectSettings
:ref="setSettings"
:projects="projectOptions"
v-model:project-code-name="hash.items[0]"
:base-maps="baseMaps"
v-model:base-map="baseMap"
:map-initializing="mapInitializing"
:hikes="hikes"
@toggle="(bIsOpen, iAnimDuration) => onPanelToggle('left', bIsOpen, iAnimDuration)"
/>
<ProjectFeed
:ref="setFeed"
:project="project"
:mode-histo="modeHisto"
@request-last-update="settings?.setLastUpdate"
@new-markers="addNewMarkers"
@toggle="(bIsOpen, iAnimDuration) => onPanelToggle('right', bIsOpen, iAnimDuration)"
/>
</div>
</template>