Convert Map & Lightbox to vue components
This commit is contained in:
@@ -0,0 +1,585 @@
|
|||||||
|
<script>
|
||||||
|
import AppIcon from '@components/AppIcon';
|
||||||
|
import { getStyleProperty } from '@scripts/common';
|
||||||
|
|
||||||
|
/* lightbox (https://github.com/lokesh/lightbox2) converted to a vue component and improved to support videos */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppIcon
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
alwaysShowNavOnTouchDevices: {type: Boolean, default: false},
|
||||||
|
positionFromTop: {type: Number, default: 50},
|
||||||
|
wrapAround: {type: Boolean, default: false},
|
||||||
|
disableScrolling: {type: Boolean, default: false},
|
||||||
|
sanitizeTitle: {type: Boolean, default: false},
|
||||||
|
hasVideo: {type: Boolean, default: true},
|
||||||
|
maxWidth: {type: Number, default: null},
|
||||||
|
maxHeight: {type: Number, default: null}
|
||||||
|
},
|
||||||
|
emits: ['media-change', 'closing'],
|
||||||
|
data() {
|
||||||
|
/*
|
||||||
|
fadeDuration/imageFadeDuration/resizeDuration read --trans-quick/--trans-slow
|
||||||
|
directly rather than being passed down as props from Project.vue: they're
|
||||||
|
declared on :root (_common.scss), so they're available immediately.
|
||||||
|
No need to wait on Project.vue's own $el to mount first, and no risk of
|
||||||
|
a parent-to-child prop update lagging behind the first time this opens.
|
||||||
|
*/
|
||||||
|
const fadeDuration = parseFloat(getStyleProperty('--trans-quick'));
|
||||||
|
const resizeDuration = parseFloat(getStyleProperty('--trans-slow'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
album: [],
|
||||||
|
currentImageIndex: 0,
|
||||||
|
gMouseDownOffsetX: 0,
|
||||||
|
gMouseDownOffsetY: 0,
|
||||||
|
resizeTimer: null,
|
||||||
|
containerPadding: null,
|
||||||
|
imageBorderWidth: null,
|
||||||
|
videoBorderWidth: null,
|
||||||
|
fadeDuration,
|
||||||
|
imageFadeDuration: fadeDuration,
|
||||||
|
resizeDuration
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setVisible(this.$refs.overlay, false);
|
||||||
|
this.setVisible(this.$refs.lightboxEl, false);
|
||||||
|
|
||||||
|
this.containerPadding = this.getBoxMetrics(this.$refs.container, 'padding');
|
||||||
|
this.imageBorderWidth = this.getBoxMetrics(this.$refs.image, 'border');
|
||||||
|
this.videoBorderWidth = this.getBoxMetrics(this.$refs.video, 'border');
|
||||||
|
|
||||||
|
this.$refs.nav.addEventListener('wheel', this.onWheel, {passive: false});
|
||||||
|
this.$refs.nav.addEventListener('mousedown', this.onDragStart);
|
||||||
|
window.addEventListener('mouseup', this.onDragEnd);
|
||||||
|
|
||||||
|
this.enable();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.disable();
|
||||||
|
if(this.resizeTimer) clearTimeout(this.resizeTimer);
|
||||||
|
window.removeEventListener('mouseup', this.onDragEnd);
|
||||||
|
window.removeEventListener('resize', this.sizeOverlay);
|
||||||
|
window.removeEventListener('mousemove', this.onDragMove);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
enable() {
|
||||||
|
document.body.addEventListener('click', this.onBodyClick);
|
||||||
|
},
|
||||||
|
disable() {
|
||||||
|
document.body.removeEventListener('click', this.onBodyClick);
|
||||||
|
},
|
||||||
|
onBodyClick(event) {
|
||||||
|
const link = event.target.closest('a[data-lightbox], area[data-lightbox]');
|
||||||
|
if(!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.start(link);
|
||||||
|
},
|
||||||
|
start(link) {
|
||||||
|
this.sizeOverlay();
|
||||||
|
this.album = [];
|
||||||
|
let imageNumber = 0;
|
||||||
|
const setName = link.getAttribute('data-lightbox');
|
||||||
|
|
||||||
|
const links = [...document.querySelectorAll(`${link.tagName}[data-lightbox="${CSS.escape(setName)}"]`)];
|
||||||
|
links.forEach((item, index) => {
|
||||||
|
this.addToAlbum(item);
|
||||||
|
if(item === link) imageNumber = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fade(this.$refs.overlay, true, this.fadeDuration);
|
||||||
|
this.fade(this.$refs.lightboxEl, true, this.fadeDuration);
|
||||||
|
|
||||||
|
if(this.disableScrolling) document.body.classList.add('lb-disable-scrolling');
|
||||||
|
|
||||||
|
window.addEventListener('resize', this.sizeOverlay);
|
||||||
|
this.changeImage(imageNumber);
|
||||||
|
},
|
||||||
|
end(dispose = false) {
|
||||||
|
this.disableKeyboardNav();
|
||||||
|
this.$refs.video?.pause();
|
||||||
|
this.$refs.video?.removeAttribute('src');
|
||||||
|
this.$refs.container?.classList.remove('lb-video-nav', 'moveable', 'moving');
|
||||||
|
window.removeEventListener('resize', this.sizeOverlay);
|
||||||
|
window.removeEventListener('mousemove', this.onDragMove);
|
||||||
|
|
||||||
|
if(dispose) {
|
||||||
|
this.album = [];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.fade(this.$refs.lightboxEl, false, this.fadeDuration);
|
||||||
|
this.fade(this.$refs.overlay, false, this.fadeDuration);
|
||||||
|
this.$emit('closing');
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.disableScrolling) document.body.classList.remove('lb-disable-scrolling');
|
||||||
|
},
|
||||||
|
addToAlbum(link) {
|
||||||
|
const img = link.querySelector('img');
|
||||||
|
this.album.push({
|
||||||
|
alt: link.getAttribute('data-alt') || '',
|
||||||
|
link: link.getAttribute('href'),
|
||||||
|
title: link.getAttribute('data-title') || link.getAttribute('title') || '',
|
||||||
|
orientation: parseInt(link.getAttribute('data-orientation') || '0', 10),
|
||||||
|
type: link.getAttribute('data-type') || 'image',
|
||||||
|
id: link.getAttribute('data-id'),
|
||||||
|
width: parseInt(img?.getAttribute('width') || '0', 10),
|
||||||
|
height: parseInt(img?.getAttribute('height') || '0', 10),
|
||||||
|
set: link.getAttribute('data-lightbox') || ''
|
||||||
|
});
|
||||||
|
},
|
||||||
|
hasMediaAfterCurrent() {
|
||||||
|
return this.currentImageIndex < this.album.length - 1;
|
||||||
|
},
|
||||||
|
refreshAlbum() {
|
||||||
|
const current = this.album[this.currentImageIndex];
|
||||||
|
if(!current?.set) return;
|
||||||
|
|
||||||
|
const links = [...document.querySelectorAll(`a[data-lightbox="${CSS.escape(current.set)}"], area[data-lightbox="${CSS.escape(current.set)}"]`)];
|
||||||
|
if(!links.length) return;
|
||||||
|
|
||||||
|
const existingKeys = new Set(this.album.map((media) => this.getMediaKey(media)));
|
||||||
|
links.forEach((link) => {
|
||||||
|
const key = this.getLinkMediaKey(link);
|
||||||
|
if(existingKeys.has(key)) return;
|
||||||
|
|
||||||
|
this.addToAlbum(link);
|
||||||
|
existingKeys.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateNav();
|
||||||
|
},
|
||||||
|
getMediaKey(media) {
|
||||||
|
return `${media.set}:${media.id}`;
|
||||||
|
},
|
||||||
|
getLinkMediaKey(link) {
|
||||||
|
return `${link.getAttribute('data-lightbox') || ''}:${link.getAttribute('data-id')}`;
|
||||||
|
},
|
||||||
|
getMaxSizes(mediaType) {
|
||||||
|
let maxWidth = window.innerWidth - this.containerPadding.left - this.containerPadding.right;
|
||||||
|
let maxHeight = window.innerHeight - this.containerPadding.top - this.containerPadding.bottom - this.positionFromTop;
|
||||||
|
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
|
||||||
|
maxWidth -= border.left + border.right;
|
||||||
|
maxHeight -= border.top + border.bottom;
|
||||||
|
maxHeight -= this.getDataContainerHeight(maxWidth + this.containerPadding.left + this.containerPadding.right + border.left + border.right);
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxWidth: Math.max(maxWidth, 1),
|
||||||
|
maxHeight: Math.max(maxHeight, 1)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getDataContainerHeight(width = null) {
|
||||||
|
if(!this.$refs.dataContainer) return 0;
|
||||||
|
|
||||||
|
const currentWidth = this.$refs.dataContainer.style.width;
|
||||||
|
if(width !== null) this.$refs.dataContainer.style.width = `${width}px`;
|
||||||
|
const height = Math.ceil(this.$refs.dataContainer.getBoundingClientRect().height || this.$refs.dataContainer.offsetHeight || 0);
|
||||||
|
this.$refs.dataContainer.style.width = currentWidth;
|
||||||
|
|
||||||
|
return height;
|
||||||
|
},
|
||||||
|
getMediaSize(media, maxWidth, maxHeight) {
|
||||||
|
if(media.width <= maxWidth && media.height <= maxHeight) {
|
||||||
|
return {
|
||||||
|
width: media.width,
|
||||||
|
height: media.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthRatio = media.width / maxWidth;
|
||||||
|
const heightRatio = media.height / maxHeight;
|
||||||
|
|
||||||
|
if(widthRatio > heightRatio) {
|
||||||
|
return {
|
||||||
|
width: maxWidth,
|
||||||
|
height: Math.round(media.height / widthRatio)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.round(media.width / heightRatio),
|
||||||
|
height: maxHeight
|
||||||
|
};
|
||||||
|
},
|
||||||
|
fitSizeWithDataContainer(size, mediaType) {
|
||||||
|
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
|
||||||
|
const maxOuterHeight = Math.max(window.innerHeight - this.positionFromTop, 1);
|
||||||
|
let fittedSize = size;
|
||||||
|
|
||||||
|
for(let i = 0; i < 5; i++) {
|
||||||
|
const containerWidth = fittedSize.width + this.containerPadding.left + this.containerPadding.right + border.left + border.right;
|
||||||
|
const containerHeight = fittedSize.height + this.containerPadding.top + this.containerPadding.bottom + border.top + border.bottom;
|
||||||
|
const dataHeight = this.getDataContainerHeight(containerWidth);
|
||||||
|
const overflow = Math.ceil(containerHeight + dataHeight - maxOuterHeight);
|
||||||
|
if(overflow <= 0 || fittedSize.height <= 1) break;
|
||||||
|
|
||||||
|
const height = Math.max(fittedSize.height - overflow, 1);
|
||||||
|
fittedSize = {
|
||||||
|
width: Math.max(Math.round(fittedSize.width * (height / fittedSize.height)), 1),
|
||||||
|
height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return fittedSize;
|
||||||
|
},
|
||||||
|
updateSize(index) {
|
||||||
|
const media = this.album[index];
|
||||||
|
const maxSizes = this.getMaxSizes(media.type);
|
||||||
|
const maxWidth = this.maxWidth ? Math.min(this.maxWidth, maxSizes.maxWidth) : maxSizes.maxWidth;
|
||||||
|
const maxHeight = this.maxHeight ? Math.min(this.maxHeight, maxSizes.maxHeight) : maxSizes.maxHeight;
|
||||||
|
const size = this.fitSizeWithDataContainer(this.getMediaSize(media, maxWidth, maxHeight), media.type);
|
||||||
|
|
||||||
|
const target = media.type === 'video' ? this.$refs.video : this.$refs.image;
|
||||||
|
target.width = size.width;
|
||||||
|
target.height = size.height;
|
||||||
|
this.sizeContainer(size.width, size.height, media.type);
|
||||||
|
},
|
||||||
|
changeImage(index) {
|
||||||
|
const media = this.album[index];
|
||||||
|
if(!media) return;
|
||||||
|
|
||||||
|
this.updateDetails(media, false);
|
||||||
|
this.hideElements([this.$refs.dataContainer]);
|
||||||
|
this.disableKeyboardNav();
|
||||||
|
this.fade(this.$refs.overlay, true, this.fadeDuration);
|
||||||
|
this.fade(this.$refs.loader, true, 200);
|
||||||
|
this.hideElements([this.$refs.image, this.$refs.video, this.$refs.nav, this.$refs.prev, this.$refs.next]);
|
||||||
|
this.resetImageTransform();
|
||||||
|
this.$refs.outerContainer.classList.add('animating');
|
||||||
|
this.$refs.container.classList.remove('moveable', 'moving', 'lb-video-nav');
|
||||||
|
this.currentImageIndex = index;
|
||||||
|
|
||||||
|
this.$emit('media-change', media);
|
||||||
|
|
||||||
|
if(media.type === 'video') {
|
||||||
|
this.$refs.image.removeAttribute('src');
|
||||||
|
this.$refs.container.classList.add('lb-video-nav');
|
||||||
|
this.$refs.video.onloadedmetadata = () => {
|
||||||
|
media.width = this.$refs.video.videoWidth;
|
||||||
|
media.height = this.$refs.video.videoHeight;
|
||||||
|
this.$refs.video.onloadedmetadata = null;
|
||||||
|
this.updateSize(index);
|
||||||
|
};
|
||||||
|
this.$refs.video.src = media.link;
|
||||||
|
} else {
|
||||||
|
this.$refs.video.pause();
|
||||||
|
this.$refs.video.removeAttribute('src');
|
||||||
|
this.$refs.image.onload = () => {
|
||||||
|
this.$refs.image.alt = media.alt;
|
||||||
|
let width = this.$refs.image.naturalWidth;
|
||||||
|
let height = this.$refs.image.naturalHeight;
|
||||||
|
if(Math.abs(media.orientation) === 90 && width > height) {
|
||||||
|
const tmp = width;
|
||||||
|
width = height;
|
||||||
|
height = tmp;
|
||||||
|
}
|
||||||
|
media.width = width;
|
||||||
|
media.height = height;
|
||||||
|
this.$refs.image.onload = null;
|
||||||
|
this.updateSize(index);
|
||||||
|
};
|
||||||
|
this.$refs.image.src = media.link;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sizeOverlay() {
|
||||||
|
if(this.resizeTimer) clearTimeout(this.resizeTimer);
|
||||||
|
if(!this.album.length) return;
|
||||||
|
|
||||||
|
this.resizeTimer = window.setTimeout(() => {
|
||||||
|
const current = this.album[this.currentImageIndex];
|
||||||
|
if(!current) return;
|
||||||
|
if(current.type === 'image') this.changeImage(this.currentImageIndex);
|
||||||
|
else this.updateSize(this.currentImageIndex);
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
sizeContainer(width, height, mediaType = 'image') {
|
||||||
|
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
|
||||||
|
const newWidth = width + this.containerPadding.left + this.containerPadding.right + border.left + border.right;
|
||||||
|
const newHeight = height + this.containerPadding.top + this.containerPadding.bottom + border.top + border.bottom;
|
||||||
|
const dataHeight = this.getDataContainerHeight(newWidth);
|
||||||
|
|
||||||
|
this.$refs.outerContainer.style.transition = `width ${this.resizeDuration}ms, height ${this.resizeDuration}ms`;
|
||||||
|
this.$refs.outerContainer.style.width = `${newWidth}px`;
|
||||||
|
this.$refs.outerContainer.style.height = `${newHeight + dataHeight}px`;
|
||||||
|
this.$refs.container.style.height = `${newHeight}px`;
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.$refs.overlay.focus();
|
||||||
|
this.showImage();
|
||||||
|
this.$refs.outerContainer.style.transition = '';
|
||||||
|
}, this.resizeDuration);
|
||||||
|
},
|
||||||
|
showImage() {
|
||||||
|
this.fade(this.$refs.loader, false, 0);
|
||||||
|
if(this.hasVideo && this.album[this.currentImageIndex].type === 'video') this.fade(this.$refs.video, true, this.imageFadeDuration);
|
||||||
|
else this.fade(this.$refs.image, true, this.imageFadeDuration);
|
||||||
|
|
||||||
|
this.updateNav();
|
||||||
|
this.updateDetails();
|
||||||
|
this.preloadNeighboringImages();
|
||||||
|
this.enableKeyboardNav();
|
||||||
|
},
|
||||||
|
updateNav() {
|
||||||
|
this.setVisible(this.$refs.nav, true);
|
||||||
|
this.setVisible(this.$refs.prev, false);
|
||||||
|
this.setVisible(this.$refs.next, false);
|
||||||
|
|
||||||
|
const alwaysShowNav = ('ontouchstart' in window) && this.alwaysShowNavOnTouchDevices;
|
||||||
|
if(this.album.length <= 1) return;
|
||||||
|
|
||||||
|
if(this.wrapAround) {
|
||||||
|
this.setVisible(this.$refs.prev, true);
|
||||||
|
this.setVisible(this.$refs.next, true);
|
||||||
|
} else {
|
||||||
|
if(this.currentImageIndex > 0) this.setVisible(this.$refs.prev, true);
|
||||||
|
if(this.currentImageIndex < this.album.length - 1) this.setVisible(this.$refs.next, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(alwaysShowNav) {
|
||||||
|
this.$refs.prev.style.opacity = '1';
|
||||||
|
this.$refs.next.style.opacity = '1';
|
||||||
|
} else {
|
||||||
|
this.$refs.prev.style.opacity = '';
|
||||||
|
this.$refs.next.style.opacity = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateDetails(media = this.album[this.currentImageIndex], show = true) {
|
||||||
|
if(!media) return;
|
||||||
|
|
||||||
|
if(media.title) {
|
||||||
|
if(this.sanitizeTitle) this.$refs.caption.textContent = media.title;
|
||||||
|
else this.$refs.caption.innerHTML = media.title;
|
||||||
|
if(show) this.fade(this.$refs.caption, true, 200);
|
||||||
|
else this.setVisible(this.$refs.caption, true);
|
||||||
|
} else {
|
||||||
|
this.$refs.caption.textContent = '';
|
||||||
|
this.setVisible(this.$refs.caption, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(show) {
|
||||||
|
this.fade(this.$refs.closeButton, true, 200);
|
||||||
|
this.$refs.outerContainer.classList.remove('animating');
|
||||||
|
this.fade(this.$refs.dataContainer, true, this.resizeDuration);
|
||||||
|
} else {
|
||||||
|
this.setVisible(this.$refs.closeButton, true);
|
||||||
|
this.setVisible(this.$refs.dataContainer, false);
|
||||||
|
this.$refs.dataContainer.style.transition = '';
|
||||||
|
this.$refs.dataContainer.style.opacity = '0';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preloadNeighboringImages() {
|
||||||
|
const next = this.album[this.currentImageIndex + 1];
|
||||||
|
const prev = this.album[this.currentImageIndex - 1];
|
||||||
|
if(next && next.type === 'image') {
|
||||||
|
const preloadNext = new Image();
|
||||||
|
preloadNext.src = next.link;
|
||||||
|
}
|
||||||
|
if(prev && prev.type === 'image') {
|
||||||
|
const preloadPrev = new Image();
|
||||||
|
preloadPrev.src = prev.link;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enableKeyboardNav() {
|
||||||
|
this.disableKeyboardNav();
|
||||||
|
this.$refs.lightboxEl.addEventListener('keyup', this.keyboardAction);
|
||||||
|
this.$refs.overlay.addEventListener('keyup', this.keyboardAction);
|
||||||
|
},
|
||||||
|
disableKeyboardNav() {
|
||||||
|
this.$refs.lightboxEl?.removeEventListener('keyup', this.keyboardAction);
|
||||||
|
this.$refs.overlay?.removeEventListener('keyup', this.keyboardAction);
|
||||||
|
},
|
||||||
|
keyboardAction(event) {
|
||||||
|
switch(event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
event.stopPropagation();
|
||||||
|
this.end();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if(this.currentImageIndex !== 0) this.changeImage(this.currentImageIndex - 1);
|
||||||
|
else if(this.wrapAround && this.album.length > 1) this.changeImage(this.album.length - 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
if(this.currentImageIndex !== this.album.length - 1) this.changeImage(this.currentImageIndex + 1);
|
||||||
|
else if(this.wrapAround && this.album.length > 1) this.changeImage(0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCloseKeyup(event) {
|
||||||
|
if(event.key === 'Enter' || event.key === ' ') this.end();
|
||||||
|
},
|
||||||
|
onPrevClick() {
|
||||||
|
if(this.currentImageIndex === 0) this.changeImage(this.album.length - 1);
|
||||||
|
else this.changeImage(this.currentImageIndex - 1);
|
||||||
|
},
|
||||||
|
onNextClick() {
|
||||||
|
if(this.currentImageIndex === this.album.length - 1) this.changeImage(0);
|
||||||
|
else this.changeImage(this.currentImageIndex + 1);
|
||||||
|
},
|
||||||
|
onOuterContainerClick(event) {
|
||||||
|
if(event.target === this.$refs.outerContainer) this.end();
|
||||||
|
},
|
||||||
|
onLightboxClick(event) {
|
||||||
|
if(event.target === this.$refs.lightboxEl) this.end();
|
||||||
|
},
|
||||||
|
onWheel(event) {
|
||||||
|
const media = this.album[this.currentImageIndex];
|
||||||
|
if(!media || media.type === 'video') return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const rect = this.$refs.image.getBoundingClientRect();
|
||||||
|
const oldTransform = this.getImageTransform();
|
||||||
|
const oldZoom = oldTransform.scale;
|
||||||
|
const maxZoom = Math.max(media.width / Math.max(this.$refs.image.width, 1), media.height / Math.max(this.$refs.image.height, 1), 1);
|
||||||
|
const newZoom = Math.min(Math.max(oldZoom + (-Math.sign(event.deltaY) / 10), 1), maxZoom);
|
||||||
|
|
||||||
|
const imageCenterX = rect.left + rect.width / 2 - oldTransform.translateX;
|
||||||
|
const imageCenterY = rect.top + rect.height / 2 - oldTransform.translateY;
|
||||||
|
const cursorX = event.clientX - imageCenterX;
|
||||||
|
const cursorY = event.clientY - imageCenterY;
|
||||||
|
const zoomRatio = newZoom / oldZoom;
|
||||||
|
const transform = this.clampImageTransform({
|
||||||
|
scale: newZoom,
|
||||||
|
translateX: cursorX - zoomRatio * (cursorX - oldTransform.translateX),
|
||||||
|
translateY: cursorY - zoomRatio * (cursorY - oldTransform.translateY)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$refs.container.classList.toggle('moveable', newZoom > 1);
|
||||||
|
this.setImageTransform(transform);
|
||||||
|
},
|
||||||
|
onDragStart(event) {
|
||||||
|
const scale = parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1');
|
||||||
|
if(scale <= 1) return;
|
||||||
|
|
||||||
|
this.gMouseDownOffsetX = event.clientX - parseFloat(this.$refs.image.style.getPropertyValue('--translate-x') || '0');
|
||||||
|
this.gMouseDownOffsetY = event.clientY - parseFloat(this.$refs.image.style.getPropertyValue('--translate-y') || '0');
|
||||||
|
this.$refs.container.classList.add('moving');
|
||||||
|
window.addEventListener('mousemove', this.onDragMove);
|
||||||
|
},
|
||||||
|
onDragMove(event) {
|
||||||
|
const zoom = parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1');
|
||||||
|
const transform = this.clampImageTransform({
|
||||||
|
scale: zoom,
|
||||||
|
translateX: event.clientX - this.gMouseDownOffsetX,
|
||||||
|
translateY: event.clientY - this.gMouseDownOffsetY
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setImageTransform(transform);
|
||||||
|
},
|
||||||
|
onDragEnd() {
|
||||||
|
window.removeEventListener('mousemove', this.onDragMove);
|
||||||
|
this.$refs.container?.classList.remove('moving');
|
||||||
|
},
|
||||||
|
getBoxMetrics(element, type) {
|
||||||
|
const styles = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
top: parseInt(styles[`${type}-top-width`], 10) || 0,
|
||||||
|
right: parseInt(styles[`${type}-right-width`], 10) || 0,
|
||||||
|
bottom: parseInt(styles[`${type}-bottom-width`], 10) || 0,
|
||||||
|
left: parseInt(styles[`${type}-left-width`], 10) || 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
resetImageTransform() {
|
||||||
|
this.setImageTransform({scale: 1, translateX: 0, translateY: 0});
|
||||||
|
},
|
||||||
|
getImageTransform() {
|
||||||
|
return {
|
||||||
|
scale: parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1'),
|
||||||
|
translateX: parseFloat(this.$refs.image.style.getPropertyValue('--translate-x') || '0'),
|
||||||
|
translateY: parseFloat(this.$refs.image.style.getPropertyValue('--translate-y') || '0')
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clampImageTransform(transform) {
|
||||||
|
const maxTranslateX = (transform.scale - 1) * this.$refs.image.width / 2;
|
||||||
|
const maxTranslateY = (transform.scale - 1) * this.$refs.image.height / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scale: transform.scale,
|
||||||
|
translateX: Math.max(Math.min(transform.translateX, maxTranslateX), -maxTranslateX),
|
||||||
|
translateY: Math.max(Math.min(transform.translateY, maxTranslateY), -maxTranslateY)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setImageTransform(transform) {
|
||||||
|
if(!this.$refs.image) return;
|
||||||
|
this.$refs.image.style.setProperty('--scale', String(transform.scale));
|
||||||
|
this.$refs.image.style.setProperty('--translate-x', `${transform.translateX}px`);
|
||||||
|
this.$refs.image.style.setProperty('--translate-y', `${transform.translateY}px`);
|
||||||
|
},
|
||||||
|
hideElements(elements) {
|
||||||
|
elements.forEach((element) => {
|
||||||
|
this.setVisible(element, false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setVisible(element, visible) {
|
||||||
|
if(!element) return;
|
||||||
|
element.style.visibility = visible ? 'visible' : 'hidden';
|
||||||
|
element.style.pointerEvents = visible ? '' : 'none';
|
||||||
|
},
|
||||||
|
fade(element, show, duration, done) {
|
||||||
|
if(!element) return;
|
||||||
|
|
||||||
|
const safeDuration = duration || 0;
|
||||||
|
element.style.transition = `opacity ${safeDuration}ms`;
|
||||||
|
if(show) {
|
||||||
|
this.setVisible(element, true);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
element.style.opacity = element === this.$refs.overlay ? '0.8' : '1';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
element.style.opacity = '0';
|
||||||
|
element.style.pointerEvents = 'none';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.setVisible(element, false);
|
||||||
|
}, safeDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typeof done === 'function') {
|
||||||
|
window.setTimeout(done, safeDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div id="lightboxOverlay" ref="overlay" tabindex="-1" class="lightboxOverlay" @click="end()"></div>
|
||||||
|
<div id="lightbox" ref="lightboxEl" tabindex="-1" class="lightbox" @click="onLightboxClick">
|
||||||
|
<div class="lb-outerContainer" ref="outerContainer" @click.stop="onOuterContainerClick">
|
||||||
|
<div class="lb-container" ref="container">
|
||||||
|
<img class="lb-image" ref="image" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" alt="" />
|
||||||
|
<video class="lb-video" ref="video" controls autoplay></video>
|
||||||
|
<div class="lb-nav" ref="nav">
|
||||||
|
<div class="lb-prev-area">
|
||||||
|
<a class="lb-prev" ref="prev" aria-label="Previous image" href="" role="button" @click.prevent="onPrevClick">
|
||||||
|
<AppIcon :icon="'prev'" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="lb-next-area">
|
||||||
|
<a class="lb-next" ref="next" aria-label="Next image" href="" role="button" @click.prevent="onNextClick"><AppIcon :icon="'next'" /></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lb-loader" ref="loader" @click.prevent="end()">
|
||||||
|
<a class="lb-cancel" ref="cancel" href="#">
|
||||||
|
<AppIcon :icon="'cancel'" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lb-dataContainer desktop" ref="dataContainer" @click="end()">
|
||||||
|
<div class="lb-data">
|
||||||
|
<div class="lb-details">
|
||||||
|
<span class="lb-caption" ref="caption"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lb-closeContainer">
|
||||||
|
<a class="lb-close" ref="closeButton" href="#" role="button" @click.prevent.stop="end()" @keyup="onCloseKeyup">
|
||||||
|
<AppIcon :icon="'close'" :classes="'fa-lg'" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
+74
-532
@@ -1,47 +1,19 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Map, Marker, LngLatBounds, LngLat, Popup, ScaleControl, NavigationControl, setWorkerUrl } from 'maplibre-gl';
|
import Lightbox from '@components/Lightbox';
|
||||||
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
|
import ProjectMap, { BASE_MAP_PADDING } from '@components/ProjectMap';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import { getStyleProperty } from '@scripts/common';
|
||||||
|
|
||||||
import { createApp } from 'vue';
|
|
||||||
|
|
||||||
import Lightbox from '@scripts/lightbox';
|
|
||||||
|
|
||||||
import AppIcon from '@components/AppIcon';
|
import AppIcon from '@components/AppIcon';
|
||||||
import AppIconStack from '@components/AppIconStack';
|
|
||||||
import ProjectPopup from '@components/ProjectPopup';
|
|
||||||
import ProjectFeed from '@components/ProjectFeed';
|
import ProjectFeed from '@components/ProjectFeed';
|
||||||
import ProjectSettings from '@components/ProjectSettings';
|
import ProjectSettings from '@components/ProjectSettings';
|
||||||
|
|
||||||
setWorkerUrl(maplibreWorkerUrl);
|
|
||||||
|
|
||||||
class GroupedScaleControl {
|
|
||||||
constructor(options) {
|
|
||||||
this.scale = new ScaleControl(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
onAdd(map) {
|
|
||||||
this.container = document.createElement('div');
|
|
||||||
this.container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
|
|
||||||
|
|
||||||
const scaleElement = this.scale.onAdd(map);
|
|
||||||
scaleElement.classList.remove('maplibregl-ctrl');
|
|
||||||
this.container.appendChild(scaleElement);
|
|
||||||
|
|
||||||
return this.container;
|
|
||||||
}
|
|
||||||
|
|
||||||
onRemove() {
|
|
||||||
this.scale.onRemove();
|
|
||||||
this.container.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
AppIcon,
|
AppIcon,
|
||||||
ProjectFeed,
|
ProjectFeed,
|
||||||
ProjectSettings
|
ProjectSettings,
|
||||||
|
ProjectMap,
|
||||||
|
Lightbox
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -51,26 +23,12 @@ export default {
|
|||||||
},
|
},
|
||||||
feed: null,
|
feed: null,
|
||||||
settings: 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,
|
project: null,
|
||||||
modeHisto: null,
|
modeHisto: null,
|
||||||
baseMaps: [],
|
baseMaps: [],
|
||||||
baseMap: null,
|
baseMap: null,
|
||||||
terrainEnabled: false,
|
terrainEnabled: false,
|
||||||
map: null,
|
|
||||||
mapInitializing: false,
|
mapInitializing: false,
|
||||||
markerHeight: 32, //FIXME
|
|
||||||
mapPadding: 16 + 32, //1rem + marker height
|
|
||||||
maxZoom: 15,
|
|
||||||
initialPitch: 45,
|
|
||||||
lightbox: null,
|
|
||||||
hikes: {
|
hikes: {
|
||||||
colors: {},
|
colors: {},
|
||||||
width: null,
|
width: null,
|
||||||
@@ -78,8 +36,11 @@ export default {
|
|||||||
lineWidthTransition: {duration:null}
|
lineWidthTransition: {duration:null}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
popup: {content: null, element: null},
|
overview: {
|
||||||
overview: {id: 0, codename:'overview', name: this.lang.get('project.overview')},
|
id: 0,
|
||||||
|
codename: 'overview',
|
||||||
|
name: this.lang.get('project.overview')
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -91,17 +52,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
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');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
terrainEnabled(bEnabled) {
|
|
||||||
if(!this.map?.isStyleLoaded()) return;
|
|
||||||
if(bEnabled) this.addTerrain();
|
|
||||||
else this.removeTerrain();
|
|
||||||
},
|
|
||||||
'hash.items.0'(newProjectCodename, oldProjectCodename) { //hash.items.0 = Project Code Name
|
'hash.items.0'(newProjectCodename, oldProjectCodename) { //hash.items.0 = Project Code Name
|
||||||
if(newProjectCodename != oldProjectCodename) {
|
if(newProjectCodename != oldProjectCodename) {
|
||||||
this.hash.items = [newProjectCodename]; //Force removal of direct link
|
this.hash.items = [newProjectCodename]; //Force removal of direct link
|
||||||
@@ -114,9 +64,10 @@ export default {
|
|||||||
return {
|
return {
|
||||||
map: {
|
map: {
|
||||||
panToBetweenPanels: this.panToBetweenPanels,
|
panToBetweenPanels: this.panToBetweenPanels,
|
||||||
openMarkerPopup: this.openMarkerPopup,
|
openMarkerPopup: (iMarkerId, sMarkerType) => this.$refs.mapCtrl.openMarkerPopup(iMarkerId, sMarkerType),
|
||||||
closePopup: this.closePopup,
|
closePopup: () => this.$refs.mapCtrl.closePopup(),
|
||||||
isMarkerVisible: this.isMarkerVisible
|
isMarkerVisible: (oLngLat) => this.$refs.mapCtrl.isMarkerVisible(oLngLat),
|
||||||
|
findMarkerByMediaId: (iMediaId) => this.$refs.mapCtrl.findMarkerByMediaId(iMediaId)
|
||||||
},
|
},
|
||||||
project: this
|
project: this
|
||||||
};
|
};
|
||||||
@@ -130,23 +81,22 @@ export default {
|
|||||||
else this.init();
|
else this.init();
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.quit();
|
this.$refs.lightbox.end(true);
|
||||||
|
this.$refs.mapCtrl.destroy();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async init() {
|
async init() {
|
||||||
this.initLightbox();
|
|
||||||
this.hikes.colors = {
|
this.hikes.colors = {
|
||||||
'main': this.getStyleProperty('--track-main'),
|
'main': getStyleProperty('--track-main'),
|
||||||
'off-track': this.getStyleProperty('--track-off-track'),
|
'off-track': getStyleProperty('--track-off-track'),
|
||||||
'hitchhiking': this.getStyleProperty('--track-hitchhiking')
|
'hitchhiking': getStyleProperty('--track-hitchhiking')
|
||||||
};
|
};
|
||||||
this.hikes.width = parseFloat(this.getStyleProperty('--track-width'));
|
this.hikes.width = parseFloat(getStyleProperty('--track-width'));
|
||||||
this.hikes.transitions.lineWidthTransition.duration = parseFloat(this.getStyleProperty('--trans-quick'));
|
this.hikes.transitions.lineWidthTransition.duration = parseFloat(getStyleProperty('--trans-quick'));
|
||||||
|
|
||||||
//Reset values
|
//Reset values
|
||||||
this.track = null;
|
|
||||||
this.project = null;
|
this.project = null;
|
||||||
this.removeMapContent();
|
this.$refs.mapCtrl.removeMapContent();
|
||||||
|
|
||||||
//Build Map
|
//Build Map
|
||||||
this.mapInitializing = true;
|
this.mapInitializing = true;
|
||||||
@@ -154,11 +104,6 @@ export default {
|
|||||||
else await this.initOverview();
|
else await this.initOverview();
|
||||||
this.mapInitializing = false;
|
this.mapInitializing = false;
|
||||||
},
|
},
|
||||||
quit() {
|
|
||||||
this.lightbox.end(true);
|
|
||||||
this.lightbox = null;
|
|
||||||
this.removeMap();
|
|
||||||
},
|
|
||||||
async initOverview() {
|
async initOverview() {
|
||||||
this.modeHisto = true;
|
this.modeHisto = true;
|
||||||
this.hash.items = [this.overview.codename];
|
this.hash.items = [this.overview.codename];
|
||||||
@@ -174,485 +119,70 @@ export default {
|
|||||||
const pMapReady = this.initProjectMap();
|
const pMapReady = this.initProjectMap();
|
||||||
await this.feed.init(pMapReady);
|
await this.feed.init(pMapReady);
|
||||||
},
|
},
|
||||||
initLightbox() {
|
async onLightboxMediaChange(oMedia) {
|
||||||
if(!this.lightbox) {
|
this.hash.items = [this.project.codename, 'media', oMedia.id];
|
||||||
this.lightbox = new Lightbox({
|
if(oMedia.set == 'post-medias') {
|
||||||
alwaysShowNavOnTouchDevices: true,
|
(await this.feed.findPost('media', oMedia.id))?.panMapToMarker();
|
||||||
fadeDuration: parseFloat(this.getStyleProperty('--trans-quick')),
|
if(!this.$refs.lightbox.hasMediaAfterCurrent()) {
|
||||||
imageFadeDuration: parseFloat(this.getStyleProperty('--trans-quick')),
|
await this.feed.getNextFeed();
|
||||||
positionFromTop: 0,
|
this.$refs.lightbox.refreshAlbum();
|
||||||
resizeDuration: parseFloat(this.getStyleProperty('--trans-slow')),
|
}
|
||||||
hasVideo: true,
|
|
||||||
onMediaChange: async(oMedia) => {
|
|
||||||
this.hash.items = [this.project.codename, 'media', oMedia.id];
|
|
||||||
if(oMedia.set == 'post-medias') {
|
|
||||||
(await this.feed.findPost('media', oMedia.id))?.panMapToMarker();
|
|
||||||
if(!this.lightbox.hasMediaAfterCurrent()) {
|
|
||||||
await this.feed.getNextFeed();
|
|
||||||
this.lightbox.refreshAlbum();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClosing: () => {this.hash.items = [this.hash.items[0]];}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onLightboxClosing() {
|
||||||
|
this.hash.items = [this.hash.items[0]];
|
||||||
|
},
|
||||||
async initProjectMap() {
|
async initProjectMap() {
|
||||||
[
|
const [{maps: baseMaps, markers}, track] = await Promise.all([
|
||||||
{maps: this.baseMaps, markers: this.markers},
|
|
||||||
this.track
|
|
||||||
] = await Promise.all([
|
|
||||||
this.api.get('markers', {id_project: this.project.id}),
|
this.api.get('markers', {id_project: this.project.id}),
|
||||||
this.api.getAsset(this.project.geofilepath)
|
this.api.getAsset(this.project.geofilepath)
|
||||||
]);
|
]);
|
||||||
|
this.baseMaps = baseMaps;
|
||||||
|
|
||||||
await this.initMap();
|
await this.$refs.mapCtrl.initMap({
|
||||||
|
project: this.project,
|
||||||
|
hikes: this.hikes,
|
||||||
|
baseMaps,
|
||||||
|
markers,
|
||||||
|
track,
|
||||||
|
padding: this.getMapPadding()
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async initOverviewMap() {
|
async initOverviewMap() {
|
||||||
this.baseMaps = this.consts.default_maps;
|
this.baseMaps = this.consts.default_maps;
|
||||||
this.markers = Object.values(this.projects).map((asProject) => ({
|
const markers = Object.values(this.projects).map((asProject) => ({
|
||||||
type: 'project',
|
type: 'project',
|
||||||
subtype: 'project',
|
subtype: 'project',
|
||||||
...asProject,
|
...asProject,
|
||||||
opacityWhenCovered: 0.3
|
opacityWhenCovered: 0.3
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await this.initMap();
|
await this.$refs.mapCtrl.initMap({
|
||||||
},
|
project: null,
|
||||||
async initMap() {
|
hikes: this.hikes,
|
||||||
//Build map
|
baseMaps: this.baseMaps,
|
||||||
if(!this.map) this.addMap();
|
markers,
|
||||||
this.updateMapPadding();
|
track: null,
|
||||||
|
padding: this.getMapPadding()
|
||||||
//Force wait for load event
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
if(this.map.isStyleLoaded()) resolve();
|
|
||||||
else this.map.once('load', resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.map.resize();
|
|
||||||
this.setInitialProjectCamera();
|
|
||||||
|
|
||||||
//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() {
|
addNewMarkers(aoMarkers) {
|
||||||
this.map = new Map({
|
this.$refs.mapCtrl.addNewMarkers(aoMarkers);
|
||||||
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 GroupedScaleControl({unit: 'metric'}), 'bottom-right');
|
|
||||||
this.map.addControl(new NavigationControl({showZoom: false, visualizePitch: true}), 'bottom-right');
|
|
||||||
},
|
|
||||||
removeMap() {
|
|
||||||
this.removeMapContent();
|
|
||||||
this.map?.remove();
|
|
||||||
this.map = null;
|
|
||||||
},
|
|
||||||
addMapContent() {
|
|
||||||
this.baseMaps.forEach(this.addBaseMap);
|
|
||||||
if(this.terrainEnabled) this.addTerrain();
|
|
||||||
this.addTrack();
|
|
||||||
this.markers.forEach(this.addMarker);
|
|
||||||
},
|
|
||||||
removeMapContent() {
|
|
||||||
if(!this.map) return;
|
|
||||||
|
|
||||||
this.closePopup();
|
|
||||||
this.removeTrack();
|
|
||||||
this.markers.forEach(this.removeMarker);
|
|
||||||
this.removeTerrain();
|
|
||||||
this.baseMaps.forEach(this.removeBaseMap);
|
|
||||||
},
|
|
||||||
addTerrain() {
|
|
||||||
// MapLibre terrain's fog matrix is only implemented for Mercator.
|
|
||||||
this.map.setProjection({type: 'mercator'});
|
|
||||||
if(!this.map.getSource('terrain-dem')) {
|
|
||||||
this.map.addSource('terrain-dem', {
|
|
||||||
type: 'raster-dem',
|
|
||||||
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
|
||||||
tileSize: 256,
|
|
||||||
maxzoom: 15,
|
|
||||||
encoding: 'terrarium'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if(!this.map.getSource('hillshade-dem')) {
|
|
||||||
this.map.addSource('hillshade-dem', {
|
|
||||||
type: 'raster-dem',
|
|
||||||
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
|
||||||
tileSize: 256,
|
|
||||||
maxzoom: 13,
|
|
||||||
encoding: 'terrarium'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if(!this.map.getLayer('terrain-hillshade')) {
|
|
||||||
this.map.addLayer({
|
|
||||||
id: 'terrain-hillshade',
|
|
||||||
type: 'hillshade',
|
|
||||||
source: 'hillshade-dem',
|
|
||||||
paint: {
|
|
||||||
'hillshade-exaggeration': 0.35,
|
|
||||||
'hillshade-shadow-color': '#2d342b',
|
|
||||||
'hillshade-highlight-color': '#ffffff'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.map.setTerrain({source: 'terrain-dem', exaggeration: 1.25});
|
|
||||||
},
|
|
||||||
removeTerrain() {
|
|
||||||
if(!this.map) return;
|
|
||||||
if(this.map.getTerrain()) this.map.setTerrain(null);
|
|
||||||
if(this.map.getLayer('terrain-hillshade')) this.map.removeLayer('terrain-hillshade');
|
|
||||||
if(this.map.getSource('hillshade-dem')) this.map.removeSource('hillshade-dem');
|
|
||||||
if(this.map.getSource('terrain-dem')) this.map.removeSource('terrain-dem');
|
|
||||||
this.map.setProjection({type: 'globe'});
|
|
||||||
},
|
|
||||||
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(AppIconStack, 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 * this.getStyleProperty('--zoom-scale')]
|
|
||||||
});
|
|
||||||
},
|
|
||||||
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 * (this.isMobile?this.getStyleProperty('--zoom-scale'):1)]
|
|
||||||
});
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
hikes: this.hikes
|
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setInitialProjectCamera() {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let oLastMarker = this.markers.at(-1);
|
|
||||||
|
|
||||||
//Overview map: Center on default project
|
|
||||||
if(!this.project) {
|
|
||||||
//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 / 512),
|
|
||||||
pitch: 0,
|
|
||||||
bearing: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
//Direct link to marker
|
|
||||||
else if(oHashMarker) {
|
|
||||||
this.map.jumpTo({
|
|
||||||
center: new LngLat(oHashMarker.longitude, oHashMarker.latitude),
|
|
||||||
zoom: 13,
|
|
||||||
pitch: this.initialPitch
|
|
||||||
});
|
|
||||||
}
|
|
||||||
//Blog Mode: Fit to last marker
|
|
||||||
else if(this.project.mode == this.consts.modes.blog && oLastMarker) {
|
|
||||||
this.map.jumpTo({
|
|
||||||
center: new LngLat(oLastMarker.longitude, oLastMarker.latitude),
|
|
||||||
zoom: this.maxZoom,
|
|
||||||
pitch: this.initialPitch,
|
|
||||||
bearing: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
//Pre Mode, Histo Mode, Blog Mode without markers or missing direct link marker: Fit to track
|
|
||||||
else {
|
|
||||||
let oBounds = new LngLatBounds();
|
|
||||||
const aoTrackCoordinates = [];
|
|
||||||
for(const iFeatureId in this.track.features) {
|
|
||||||
oBounds = this.track.features[iFeatureId].geometry.coordinates.reduce(
|
|
||||||
(bounds, coord) => {
|
|
||||||
aoTrackCoordinates.push(coord);
|
|
||||||
return bounds.extend(coord);
|
|
||||||
},
|
|
||||||
oBounds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.map.fitBounds(oBounds, {
|
|
||||||
padding: this.mapPadding,
|
|
||||||
animate: false,
|
|
||||||
maxZoom: this.maxZoom,
|
|
||||||
pitch: this.initialPitch,
|
|
||||||
bearing: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fixPitchedCameraCenter(aoTrackCoordinates);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fixPitchedCameraCenter(aoTrackCoordinates) {
|
|
||||||
//Project min/max coords (lat, lng) onto map rectangle corner points (x, y)
|
|
||||||
const oScreenBounds = aoTrackCoordinates.reduce((oBounds, coord) => {
|
|
||||||
const oPoint = this.map.project(coord);
|
|
||||||
return {
|
|
||||||
minX: Math.min(oBounds.minX, oPoint.x),
|
|
||||||
minY: Math.min(oBounds.minY, oPoint.y),
|
|
||||||
maxX: Math.max(oBounds.maxX, oPoint.x),
|
|
||||||
maxY: Math.max(oBounds.maxY, oPoint.y)
|
|
||||||
};
|
|
||||||
}, {
|
|
||||||
minX: Infinity,
|
|
||||||
minY: Infinity,
|
|
||||||
maxX: -Infinity,
|
|
||||||
maxY: -Infinity
|
|
||||||
});
|
|
||||||
|
|
||||||
//Current Rectangle center
|
|
||||||
const oTrackCenter = {
|
|
||||||
x: (oScreenBounds.minX + oScreenBounds.maxX) / 2,
|
|
||||||
y: (oScreenBounds.minY + oScreenBounds.maxY) / 2
|
|
||||||
};
|
|
||||||
|
|
||||||
//Convert back center point (x, y) to coords and Move map to the track center
|
|
||||||
this.map.jumpTo({
|
|
||||||
center: this.map.unproject([
|
|
||||||
oTrackCenter.x,
|
|
||||||
oTrackCenter.y
|
|
||||||
])
|
|
||||||
});
|
|
||||||
},
|
|
||||||
addNewMarkers(aoMarkers) { //FIXME Use its own marker update API
|
|
||||||
this.markers.push(...aoMarkers);
|
|
||||||
aoMarkers.forEach(this.addMarker);
|
|
||||||
},
|
},
|
||||||
panToBetweenPanels(oLngLat, iZoom, iAnimDuration=500) {
|
panToBetweenPanels(oLngLat, iZoom, iAnimDuration=500) {
|
||||||
return new Promise((resolve) => {
|
return this.$refs.mapCtrl.panTo(oLngLat, iZoom, this.getMapPadding(), iAnimDuration);
|
||||||
if(!this.map) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.map.once('moveend', resolve);
|
|
||||||
this.map.easeTo({
|
|
||||||
center: oLngLat,
|
|
||||||
zoom: iZoom,
|
|
||||||
padding: this.getMapPadding(),
|
|
||||||
duration: iAnimDuration
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
getMapPadding() {
|
getMapPadding() {
|
||||||
let bIsMobile = this.isMobile();
|
let bIsMobile = this.isMobile();
|
||||||
return {
|
return {
|
||||||
top: this.mapPadding,
|
top: BASE_MAP_PADDING,
|
||||||
bottom: this.mapPadding,
|
bottom: BASE_MAP_PADDING,
|
||||||
left: this.mapPadding + ((!bIsMobile && this.panels.leftOpen && this.settings)?this.settings.getWidth():0),
|
left: BASE_MAP_PADDING + ((!bIsMobile && this.panels.leftOpen && this.settings)?this.settings.getWidth():0),
|
||||||
right: this.mapPadding + ((!bIsMobile && this.panels.rightOpen && this.feed)?this.feed.getWidth():0)
|
right: BASE_MAP_PADDING + ((!bIsMobile && this.panels.rightOpen && this.feed)?this.feed.getWidth():0)
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
updateMapPadding(iDuration=0) {
|
updateMapPadding(iDuration=0) {
|
||||||
const asPadding = this.getMapPadding();
|
this.$refs.mapCtrl.setPadding(this.getMapPadding(), iDuration);
|
||||||
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) {
|
onPanelToggle(sPanel, bNewValue, iAnimDuration=500) {
|
||||||
const sPanelKey = sPanel + 'Open';
|
const sPanelKey = sPanel + 'Open';
|
||||||
@@ -661,7 +191,7 @@ export default {
|
|||||||
|
|
||||||
if(bOldValue != bNewValue) {
|
if(bOldValue != bNewValue) {
|
||||||
//Adjust map center
|
//Adjust map center
|
||||||
if(!this.isMobile() && this.map) this.updateMapPadding(iAnimDuration);
|
if(!this.isMobile() && this.$refs.mapCtrl.map) this.updateMapPadding(iAnimDuration);
|
||||||
|
|
||||||
//Open Close panels
|
//Open Close panels
|
||||||
this.$el.classList.toggle('with-'+sPanel+'-panel');
|
this.$el.classList.toggle('with-'+sPanel+'-panel');
|
||||||
@@ -685,7 +215,19 @@ export default {
|
|||||||
<AppIcon :icon="'map'" :classes="'flicker'" width="fixed" />
|
<AppIcon :icon="'map'" :classes="'flicker'" width="fixed" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="map"></div>
|
<ProjectMap
|
||||||
|
ref="mapCtrl"
|
||||||
|
v-model:base-map="baseMap"
|
||||||
|
:terrain-enabled="terrainEnabled"
|
||||||
|
/>
|
||||||
|
<Lightbox
|
||||||
|
ref="lightbox"
|
||||||
|
:always-show-nav-on-touch-devices="true"
|
||||||
|
:position-from-top="0"
|
||||||
|
:has-video="true"
|
||||||
|
@media-change="onLightboxMediaChange"
|
||||||
|
@closing="onLightboxClosing"
|
||||||
|
/>
|
||||||
<ProjectSettings
|
<ProjectSettings
|
||||||
:ref="setSettings"
|
:ref="setSettings"
|
||||||
:projects="projectOptions"
|
:projects="projectOptions"
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
<script>
|
||||||
|
import { Map, Marker, LngLatBounds, LngLat, Popup, ScaleControl, NavigationControl, setWorkerUrl } from 'maplibre-gl';
|
||||||
|
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
|
||||||
|
import { getStyleProperty } from '@scripts/common';
|
||||||
|
import ProjectPopup from '@components/ProjectPopup';
|
||||||
|
import AppIconStack from '@components/AppIconStack';
|
||||||
|
|
||||||
|
setWorkerUrl(maplibreWorkerUrl);
|
||||||
|
|
||||||
|
//Padding shared with Project.vue's own panel-width padding calculation: 1rem + marker height
|
||||||
|
export const BASE_MAP_PADDING = 16 + 32;
|
||||||
|
|
||||||
|
const MARKER_PROPS = {
|
||||||
|
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'}
|
||||||
|
};
|
||||||
|
|
||||||
|
class GroupedScaleControl {
|
||||||
|
constructor(options) {
|
||||||
|
this.scale = new ScaleControl(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd(map) {
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
|
||||||
|
|
||||||
|
const scaleElement = this.scale.onAdd(map);
|
||||||
|
scaleElement.classList.remove('maplibregl-ctrl');
|
||||||
|
this.container.appendChild(scaleElement);
|
||||||
|
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemove() {
|
||||||
|
this.scale.onRemove();
|
||||||
|
this.container.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Owns the MapLibre map instance and everything drawn on it (base maps,
|
||||||
|
//terrain, track, markers, popups). Project.vue keeps the reactive state
|
||||||
|
//its own template/props need (baseMaps, hikes, project...) and drives
|
||||||
|
//this component's imperative API through a template ref, the same way
|
||||||
|
//it would drive a plain class - most of what happens here is imperative
|
||||||
|
//MapLibre calls (addLayer, jumpTo) rather than declarative rendering, so
|
||||||
|
//the <template> is just the map container div.
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
terrainEnabled: Boolean,
|
||||||
|
baseMap: String
|
||||||
|
},
|
||||||
|
emits: ['update:base-map'],
|
||||||
|
inject: ['lang', 'consts', 'isMobile', 'projects', 'hash'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
map: null,
|
||||||
|
baseMaps: [],
|
||||||
|
markers: [],
|
||||||
|
track: null,
|
||||||
|
hikes: null,
|
||||||
|
project: null,
|
||||||
|
popup: {content: null, element: null},
|
||||||
|
markerHeight: 32, //FIXME
|
||||||
|
maxZoom: 15,
|
||||||
|
initialPitch: 45
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
baseMap(sNewBaseMap, sOldBaseMap) {
|
||||||
|
if(!this.map?.isStyleLoaded()) return;
|
||||||
|
if(sOldBaseMap && this.map.getLayer(sOldBaseMap)) this.map.setLayoutProperty(sOldBaseMap, 'visibility', 'none');
|
||||||
|
if(sNewBaseMap && this.map.getLayer(sNewBaseMap)) this.map.setLayoutProperty(sNewBaseMap, 'visibility', 'visible');
|
||||||
|
},
|
||||||
|
terrainEnabled(bEnabled) {
|
||||||
|
if(!this.map?.isStyleLoaded()) return;
|
||||||
|
if(bEnabled) this.addTerrain();
|
||||||
|
else this.removeTerrain();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initMap({project, hikes, baseMaps, markers, track, padding}) {
|
||||||
|
this.project = project;
|
||||||
|
this.hikes = hikes;
|
||||||
|
this.baseMaps = baseMaps;
|
||||||
|
this.markers = markers;
|
||||||
|
this.track = track;
|
||||||
|
|
||||||
|
if(!this.map) this.addMap();
|
||||||
|
this.setPadding(padding);
|
||||||
|
|
||||||
|
//Force wait for load event
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
if(this.map.isStyleLoaded()) resolve();
|
||||||
|
else this.map.once('load', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.resize();
|
||||||
|
this.setInitialProjectCamera();
|
||||||
|
|
||||||
|
//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: this.$el,
|
||||||
|
aroundCenter: true,
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
projection: {type: 'globe'},
|
||||||
|
sky: {
|
||||||
|
'sky-color': getStyleProperty('--space'),
|
||||||
|
'horizon-color': getStyleProperty('--horizon'),
|
||||||
|
'sky-horizon-blend': 0.35,
|
||||||
|
'atmosphere-blend': 0.8
|
||||||
|
},
|
||||||
|
sources: {},
|
||||||
|
layers: []
|
||||||
|
},
|
||||||
|
attributionControl: false
|
||||||
|
});
|
||||||
|
this.map.addControl(new GroupedScaleControl({unit: 'metric'}), 'bottom-right');
|
||||||
|
this.map.addControl(new NavigationControl({showZoom: false, visualizePitch: true}), 'bottom-right');
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
this.removeMapContent();
|
||||||
|
this.map?.remove();
|
||||||
|
this.map = null;
|
||||||
|
},
|
||||||
|
addMapContent() {
|
||||||
|
this.baseMaps.forEach(this.addBaseMap);
|
||||||
|
if(this.terrainEnabled) this.addTerrain();
|
||||||
|
this.addTrack();
|
||||||
|
this.markers.forEach(this.addMarker);
|
||||||
|
},
|
||||||
|
removeMapContent() {
|
||||||
|
if(!this.map) return;
|
||||||
|
|
||||||
|
this.closePopup();
|
||||||
|
this.removeTrack();
|
||||||
|
this.markers.forEach(this.removeMarker);
|
||||||
|
this.removeTerrain();
|
||||||
|
this.baseMaps.forEach(this.removeBaseMap);
|
||||||
|
},
|
||||||
|
addTerrain() {
|
||||||
|
// MapLibre terrain's fog matrix is only implemented for Mercator.
|
||||||
|
this.map.setProjection({type: 'mercator'});
|
||||||
|
if(!this.map.getSource('terrain-dem')) {
|
||||||
|
this.map.addSource('terrain-dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 15,
|
||||||
|
encoding: 'terrarium'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(!this.map.getSource('hillshade-dem')) {
|
||||||
|
this.map.addSource('hillshade-dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 13,
|
||||||
|
encoding: 'terrarium'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(!this.map.getLayer('terrain-hillshade')) {
|
||||||
|
this.map.addLayer({
|
||||||
|
id: 'terrain-hillshade',
|
||||||
|
type: 'hillshade',
|
||||||
|
source: 'hillshade-dem',
|
||||||
|
paint: {
|
||||||
|
'hillshade-exaggeration': 0.35,
|
||||||
|
'hillshade-shadow-color': '#2d342b',
|
||||||
|
'hillshade-highlight-color': '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.map.setTerrain({source: 'terrain-dem', exaggeration: 1.25});
|
||||||
|
},
|
||||||
|
removeTerrain() {
|
||||||
|
if(!this.map) return;
|
||||||
|
if(this.map.getTerrain()) this.map.setTerrain(null);
|
||||||
|
if(this.map.getLayer('terrain-hillshade')) this.map.removeLayer('terrain-hillshade');
|
||||||
|
if(this.map.getSource('hillshade-dem')) this.map.removeSource('hillshade-dem');
|
||||||
|
if(this.map.getSource('terrain-dem')) this.map.removeSource('terrain-dem');
|
||||||
|
this.map.setProjection({type: 'globe'});
|
||||||
|
},
|
||||||
|
addBaseMap(asBaseMap) {
|
||||||
|
if(asBaseMap.default_map) this.$emit('update:base-map', 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 + BASE_MAP_PADDING
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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(AppIconStack, MARKER_PROPS[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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addNewMarkers(aoMarkers) { //FIXME Use its own marker update API
|
||||||
|
this.markers.push(...aoMarkers);
|
||||||
|
aoMarkers.forEach(this.addMarker);
|
||||||
|
},
|
||||||
|
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 * getStyleProperty('--zoom-scale')]
|
||||||
|
});
|
||||||
|
},
|
||||||
|
findMarkerByMediaId(iMediaId) {
|
||||||
|
return this.markers.find((oMarker) => (oMarker.medias || []).some((oMedia) => oMedia.id_media == iMediaId)) || null;
|
||||||
|
},
|
||||||
|
openMarkerPopup(iMarkerId, sMarkerType) {
|
||||||
|
let oMarker = this.markers.find((oCandidate) => oCandidate.id == iMarkerId && oCandidate.type == sMarkerType);
|
||||||
|
this.openPopup({
|
||||||
|
lnglat: [oMarker.longitude, oMarker.latitude],
|
||||||
|
options: oMarker,
|
||||||
|
//NB: `this.isMobile` here is the injected function itself, not a call to it - always
|
||||||
|
//truthy, so this branch always applies. Pre-existing behavior, kept as-is by this split.
|
||||||
|
offset: [0, -1 * this.markerHeight * (this.isMobile?getStyleProperty('--zoom-scale'):1)]
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
hikes: this.hikes
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setInitialProjectCamera() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let oLastMarker = this.markers.at(-1);
|
||||||
|
|
||||||
|
//Overview map: Center on default project
|
||||||
|
if(!this.project) {
|
||||||
|
//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 / 512),
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//Direct link to marker
|
||||||
|
else if(oHashMarker) {
|
||||||
|
this.map.jumpTo({
|
||||||
|
center: new LngLat(oHashMarker.longitude, oHashMarker.latitude),
|
||||||
|
zoom: 13,
|
||||||
|
pitch: this.initialPitch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//Blog Mode: Fit to last marker
|
||||||
|
else if(this.project.mode == this.consts.modes.blog && oLastMarker) {
|
||||||
|
this.map.jumpTo({
|
||||||
|
center: new LngLat(oLastMarker.longitude, oLastMarker.latitude),
|
||||||
|
zoom: this.maxZoom,
|
||||||
|
pitch: this.initialPitch,
|
||||||
|
bearing: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//Pre Mode, Histo Mode, Blog Mode without markers or missing direct link marker: Fit to track
|
||||||
|
else {
|
||||||
|
let oBounds = new LngLatBounds();
|
||||||
|
const aoTrackCoordinates = [];
|
||||||
|
for(const iFeatureId in this.track.features) {
|
||||||
|
oBounds = this.track.features[iFeatureId].geometry.coordinates.reduce(
|
||||||
|
(bounds, coord) => {
|
||||||
|
aoTrackCoordinates.push(coord);
|
||||||
|
return bounds.extend(coord);
|
||||||
|
},
|
||||||
|
oBounds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.map.fitBounds(oBounds, {
|
||||||
|
padding: BASE_MAP_PADDING,
|
||||||
|
animate: false,
|
||||||
|
maxZoom: this.maxZoom,
|
||||||
|
pitch: this.initialPitch,
|
||||||
|
bearing: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fixPitchedCameraCenter(aoTrackCoordinates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fixPitchedCameraCenter(aoTrackCoordinates) {
|
||||||
|
//Project min/max coords (lat, lng) onto map rectangle corner points (x, y)
|
||||||
|
const oScreenBounds = aoTrackCoordinates.reduce((oBounds, coord) => {
|
||||||
|
const oPoint = this.map.project(coord);
|
||||||
|
return {
|
||||||
|
minX: Math.min(oBounds.minX, oPoint.x),
|
||||||
|
minY: Math.min(oBounds.minY, oPoint.y),
|
||||||
|
maxX: Math.max(oBounds.maxX, oPoint.x),
|
||||||
|
maxY: Math.max(oBounds.maxY, oPoint.y)
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
minX: Infinity,
|
||||||
|
minY: Infinity,
|
||||||
|
maxX: -Infinity,
|
||||||
|
maxY: -Infinity
|
||||||
|
});
|
||||||
|
|
||||||
|
//Current Rectangle center
|
||||||
|
const oTrackCenter = {
|
||||||
|
x: (oScreenBounds.minX + oScreenBounds.maxX) / 2,
|
||||||
|
y: (oScreenBounds.minY + oScreenBounds.maxY) / 2
|
||||||
|
};
|
||||||
|
|
||||||
|
//Convert back center point (x, y) to coords and Move map to the track center
|
||||||
|
this.map.jumpTo({
|
||||||
|
center: this.map.unproject([
|
||||||
|
oTrackCenter.x,
|
||||||
|
oTrackCenter.y
|
||||||
|
])
|
||||||
|
});
|
||||||
|
},
|
||||||
|
panTo(oLngLat, iZoom, padding, iAnimDuration=500) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if(!this.map) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.map.once('moveend', resolve);
|
||||||
|
this.map.easeTo({
|
||||||
|
center: oLngLat,
|
||||||
|
zoom: iZoom,
|
||||||
|
padding: padding,
|
||||||
|
duration: iAnimDuration
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setPadding(padding, iDuration=0) {
|
||||||
|
if(iDuration > 0) this.map.easeTo({padding, duration: iDuration});
|
||||||
|
else this.map.jumpTo({padding});
|
||||||
|
},
|
||||||
|
isMarkerVisible(oLngLat) {
|
||||||
|
return !!this.map && this.map.getBounds().contains(oLngLat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="map"></div>
|
||||||
|
</template>
|
||||||
@@ -71,11 +71,7 @@
|
|||||||
relatedMarker() {
|
relatedMarker() {
|
||||||
//Find corresponding marker
|
//Find corresponding marker
|
||||||
if(!this.options.longitude && !this.options.latitude && this.options.type == 'media') {
|
if(!this.options.longitude && !this.options.latitude && this.options.type == 'media') {
|
||||||
return this.project.markers.find((marker) => {
|
return this.map.findMarkerByMediaId(this.options.id_media);
|
||||||
return (marker.medias || []).some((media) => {
|
|
||||||
return media.id_media == this.options.id_media;
|
|
||||||
});
|
|
||||||
}) || null;
|
|
||||||
}
|
}
|
||||||
else if(
|
else if(
|
||||||
['message', 'media'].includes(this.options.type)
|
['message', 'media'].includes(this.options.type)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
/* Common Functions */
|
/* Common Functions */
|
||||||
|
|
||||||
|
//All the custom properties this reads (--space, --track-*, --trans-*, --zoom-scale...)
|
||||||
|
//are declared on :root (_common.scss), so there's no need to read them from any
|
||||||
|
//specific component's own element - document.documentElement always has them.
|
||||||
|
export function getStyleProperty(sProperty) {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(sProperty).trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function copyTextToClipboard(text) {
|
export function copyTextToClipboard(text) {
|
||||||
if(!navigator.clipboard) {
|
if(!navigator.clipboard) {
|
||||||
let textArea = document.createElement('textarea');
|
let textArea = document.createElement('textarea');
|
||||||
|
|||||||
@@ -1,650 +0,0 @@
|
|||||||
import { icon } from '@fortawesome/fontawesome-svg-core';
|
|
||||||
import { getIcon } from '@scripts/icons';
|
|
||||||
|
|
||||||
export default class Lightbox {
|
|
||||||
constructor(options = {}) {
|
|
||||||
this.album = [];
|
|
||||||
this.currentImageIndex = 0;
|
|
||||||
this.options = {
|
|
||||||
alwaysShowNavOnTouchDevices: false,
|
|
||||||
fadeDuration: 600,
|
|
||||||
imageFadeDuration: 600,
|
|
||||||
positionFromTop: 50,
|
|
||||||
resizeDuration: 700,
|
|
||||||
wrapAround: false,
|
|
||||||
disableScrolling: false,
|
|
||||||
sanitizeTitle: false,
|
|
||||||
hasVideo: true,
|
|
||||||
onMediaChange: () => {},
|
|
||||||
onClosing: () => {}
|
|
||||||
};
|
|
||||||
this.option(options);
|
|
||||||
this.gMouseDownOffsetX = 0;
|
|
||||||
this.gMouseDownOffsetY = 0;
|
|
||||||
this.resizeTimer = null;
|
|
||||||
this.boundOnBodyClick = this.onBodyClick.bind(this);
|
|
||||||
this.boundOnResize = this.sizeOverlay.bind(this);
|
|
||||||
this.boundOnKeyUp = this.keyboardAction.bind(this);
|
|
||||||
this.boundOnWheel = this.onWheel.bind(this);
|
|
||||||
this.boundOnDragStart = this.onDragStart.bind(this);
|
|
||||||
this.boundOnDragMove = this.onDragMove.bind(this);
|
|
||||||
this.boundOnDragEnd = this.onDragEnd.bind(this);
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
option(options = {}) {
|
|
||||||
Object.assign(this.options, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
if(document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
this.build();
|
|
||||||
this.enable();
|
|
||||||
}, { once: true });
|
|
||||||
} else {
|
|
||||||
this.build();
|
|
||||||
this.enable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enable() {
|
|
||||||
document.body.addEventListener('click', this.boundOnBodyClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
disable() {
|
|
||||||
document.body.removeEventListener('click', this.boundOnBodyClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
onBodyClick(event) {
|
|
||||||
const link = event.target.closest('a[data-lightbox], area[data-lightbox]');
|
|
||||||
if(!link) return;
|
|
||||||
event.preventDefault();
|
|
||||||
this.start(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderIcon(name, sClass=null) {
|
|
||||||
return icon(getIcon(name), {classes: ['app-icon', name, sClass]}).html;
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
if(!document.getElementById('lightbox')) {
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.innerHTML = `
|
|
||||||
<div id="lightboxOverlay" tabindex="-1" class="lightboxOverlay"></div>
|
|
||||||
<div id="lightbox" tabindex="-1" class="lightbox">
|
|
||||||
<div class="lb-outerContainer">
|
|
||||||
<div class="lb-container">
|
|
||||||
<img class="lb-image" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" alt="" />
|
|
||||||
<video class="lb-video" controls autoplay></video>
|
|
||||||
<div class="lb-nav">
|
|
||||||
<div class="lb-prev-area">
|
|
||||||
<a class="lb-prev" aria-label="Previous image" href="" role="button">${this.renderIcon('prev')}</a>
|
|
||||||
</div>
|
|
||||||
<div class="lb-next-area">
|
|
||||||
<a class="lb-next" aria-label="Next image" href="" role="button">${this.renderIcon('next')}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="lb-loader">
|
|
||||||
<a class="lb-cancel" href="#">${this.renderIcon('cancel')}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="lb-dataContainer desktop">
|
|
||||||
<div class="lb-data">
|
|
||||||
<div class="lb-details">
|
|
||||||
<span class="lb-caption"></span>
|
|
||||||
</div>
|
|
||||||
<div class="lb-closeContainer">
|
|
||||||
<a class="lb-close" href="#" role="button">${this.renderIcon('close', 'fa-lg')}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.append(...wrapper.children);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.overlay = document.getElementById('lightboxOverlay');
|
|
||||||
this.lightbox = document.getElementById('lightbox');
|
|
||||||
this.outerContainer = this.lightbox.querySelector('.lb-outerContainer');
|
|
||||||
this.container = this.lightbox.querySelector('.lb-container');
|
|
||||||
this.image = this.lightbox.querySelector('.lb-image');
|
|
||||||
this.nav = this.lightbox.querySelector('.lb-nav');
|
|
||||||
this.loader = this.lightbox.querySelector('.lb-loader');
|
|
||||||
this.caption = this.lightbox.querySelector('.lb-caption');
|
|
||||||
this.closeButton = this.lightbox.querySelector('.lb-close');
|
|
||||||
this.dataContainer = this.lightbox.querySelector('.lb-dataContainer');
|
|
||||||
this.prev = this.lightbox.querySelector('.lb-prev');
|
|
||||||
this.next = this.lightbox.querySelector('.lb-next');
|
|
||||||
this.video = this.lightbox.querySelector('.lb-video');
|
|
||||||
|
|
||||||
this.setVisible(this.overlay, false);
|
|
||||||
this.setVisible(this.lightbox, false);
|
|
||||||
|
|
||||||
this.containerPadding = this.getBoxMetrics(this.container, 'padding');
|
|
||||||
this.imageBorderWidth = this.getBoxMetrics(this.image, 'border');
|
|
||||||
this.videoBorderWidth = this.getBoxMetrics(this.video, 'border');
|
|
||||||
|
|
||||||
this.overlay.addEventListener('click', () => this.end());
|
|
||||||
this.dataContainer.addEventListener('click', () => this.end());
|
|
||||||
this.lightbox.addEventListener('click', (event) => {
|
|
||||||
if(event.target === this.lightbox) this.end();
|
|
||||||
});
|
|
||||||
this.outerContainer.addEventListener('click', (event) => {
|
|
||||||
if(event.target === this.outerContainer) this.end();
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.prev.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if(this.currentImageIndex === 0) this.changeImage(this.album.length - 1);
|
|
||||||
else this.changeImage(this.currentImageIndex - 1);
|
|
||||||
});
|
|
||||||
this.next.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if(this.currentImageIndex === this.album.length - 1) this.changeImage(0);
|
|
||||||
else this.changeImage(this.currentImageIndex + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.loader.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
this.end();
|
|
||||||
});
|
|
||||||
this.closeButton.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.end();
|
|
||||||
});
|
|
||||||
this.closeButton.addEventListener('keyup', (event) => {
|
|
||||||
if(event.key === 'Enter' || event.key === ' ') this.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.nav.addEventListener('wheel', this.boundOnWheel, { passive: false });
|
|
||||||
this.nav.addEventListener('mousedown', this.boundOnDragStart);
|
|
||||||
window.addEventListener('mouseup', this.boundOnDragEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
getBoxMetrics(element, type) {
|
|
||||||
const styles = getComputedStyle(element);
|
|
||||||
return {
|
|
||||||
top: parseInt(styles[`${type}-top-width`], 10) || 0,
|
|
||||||
right: parseInt(styles[`${type}-right-width`], 10) || 0,
|
|
||||||
bottom: parseInt(styles[`${type}-bottom-width`], 10) || 0,
|
|
||||||
left: parseInt(styles[`${type}-left-width`], 10) || 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
start(link) {
|
|
||||||
this.sizeOverlay();
|
|
||||||
this.album = [];
|
|
||||||
let imageNumber = 0;
|
|
||||||
const setName = link.getAttribute('data-lightbox');
|
|
||||||
|
|
||||||
const links = [...document.querySelectorAll(`${link.tagName}[data-lightbox="${CSS.escape(setName)}"]`)];
|
|
||||||
links.forEach((item, index) => {
|
|
||||||
this.addToAlbum(item);
|
|
||||||
if(item === link) imageNumber = index;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fade(this.overlay, true, this.options.fadeDuration);
|
|
||||||
this.fade(this.lightbox, true, this.options.fadeDuration);
|
|
||||||
|
|
||||||
if(this.options.disableScrolling) document.body.classList.add('lb-disable-scrolling');
|
|
||||||
|
|
||||||
window.addEventListener('resize', this.boundOnResize);
|
|
||||||
this.changeImage(imageNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
addToAlbum(link) {
|
|
||||||
const img = link.querySelector('img');
|
|
||||||
this.album.push({
|
|
||||||
alt: link.getAttribute('data-alt') || '',
|
|
||||||
link: link.getAttribute('href'),
|
|
||||||
title: link.getAttribute('data-title') || link.getAttribute('title') || '',
|
|
||||||
orientation: parseInt(link.getAttribute('data-orientation') || '0', 10),
|
|
||||||
type: link.getAttribute('data-type') || 'image',
|
|
||||||
id: link.getAttribute('data-id'),
|
|
||||||
width: parseInt(img?.getAttribute('width') || '0', 10),
|
|
||||||
height: parseInt(img?.getAttribute('height') || '0', 10),
|
|
||||||
set: link.getAttribute('data-lightbox') || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hasMediaAfterCurrent() {
|
|
||||||
return this.currentImageIndex < this.album.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshAlbum() {
|
|
||||||
const current = this.album[this.currentImageIndex];
|
|
||||||
if(!current?.set) return;
|
|
||||||
|
|
||||||
const links = [...document.querySelectorAll(`a[data-lightbox="${CSS.escape(current.set)}"], area[data-lightbox="${CSS.escape(current.set)}"]`)];
|
|
||||||
if(!links.length) return;
|
|
||||||
|
|
||||||
const existingKeys = new Set(this.album.map((media) => this.getMediaKey(media)));
|
|
||||||
links.forEach((link) => {
|
|
||||||
const key = this.getLinkMediaKey(link);
|
|
||||||
if(existingKeys.has(key)) return;
|
|
||||||
|
|
||||||
this.addToAlbum(link);
|
|
||||||
existingKeys.add(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateNav();
|
|
||||||
}
|
|
||||||
|
|
||||||
getMediaKey(media) {
|
|
||||||
return `${media.set}:${media.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getLinkMediaKey(link) {
|
|
||||||
return `${link.getAttribute('data-lightbox') || ''}:${link.getAttribute('data-id')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMaxSizes(mediaType) {
|
|
||||||
let maxWidth = window.innerWidth - this.containerPadding.left - this.containerPadding.right;
|
|
||||||
let maxHeight = window.innerHeight - this.containerPadding.top - this.containerPadding.bottom - this.options.positionFromTop;
|
|
||||||
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
|
|
||||||
maxWidth -= border.left + border.right;
|
|
||||||
maxHeight -= border.top + border.bottom;
|
|
||||||
maxHeight -= this.getDataContainerHeight(maxWidth + this.containerPadding.left + this.containerPadding.right + border.left + border.right);
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxWidth: Math.max(maxWidth, 1),
|
|
||||||
maxHeight: Math.max(maxHeight, 1)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getDataContainerHeight(width = null) {
|
|
||||||
if(!this.dataContainer) return 0;
|
|
||||||
|
|
||||||
const currentWidth = this.dataContainer.style.width;
|
|
||||||
if(width !== null) this.dataContainer.style.width = `${width}px`;
|
|
||||||
const height = Math.ceil(this.dataContainer.getBoundingClientRect().height || this.dataContainer.offsetHeight || 0);
|
|
||||||
this.dataContainer.style.width = currentWidth;
|
|
||||||
|
|
||||||
return height;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMediaSize(media, maxWidth, maxHeight) {
|
|
||||||
if(media.width <= maxWidth && media.height <= maxHeight) {
|
|
||||||
return {
|
|
||||||
width: media.width,
|
|
||||||
height: media.height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const widthRatio = media.width / maxWidth;
|
|
||||||
const heightRatio = media.height / maxHeight;
|
|
||||||
|
|
||||||
if(widthRatio > heightRatio) {
|
|
||||||
return {
|
|
||||||
width: maxWidth,
|
|
||||||
height: Math.round(media.height / widthRatio)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: Math.round(media.width / heightRatio),
|
|
||||||
height: maxHeight
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fitSizeWithDataContainer(size, mediaType) {
|
|
||||||
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
|
|
||||||
const maxOuterHeight = Math.max(window.innerHeight - this.options.positionFromTop, 1);
|
|
||||||
let fittedSize = size;
|
|
||||||
|
|
||||||
for(let i = 0; i < 5; i++) {
|
|
||||||
const containerWidth = fittedSize.width + this.containerPadding.left + this.containerPadding.right + border.left + border.right;
|
|
||||||
const containerHeight = fittedSize.height + this.containerPadding.top + this.containerPadding.bottom + border.top + border.bottom;
|
|
||||||
const dataHeight = this.getDataContainerHeight(containerWidth);
|
|
||||||
const overflow = Math.ceil(containerHeight + dataHeight - maxOuterHeight);
|
|
||||||
if(overflow <= 0 || fittedSize.height <= 1) break;
|
|
||||||
|
|
||||||
const height = Math.max(fittedSize.height - overflow, 1);
|
|
||||||
fittedSize = {
|
|
||||||
width: Math.max(Math.round(fittedSize.width * (height / fittedSize.height)), 1),
|
|
||||||
height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return fittedSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSize(index) {
|
|
||||||
const media = this.album[index];
|
|
||||||
const maxSizes = this.getMaxSizes(media.type);
|
|
||||||
const maxWidth = this.options.maxWidth ? Math.min(this.options.maxWidth, maxSizes.maxWidth) : maxSizes.maxWidth;
|
|
||||||
const maxHeight = this.options.maxHeight ? Math.min(this.options.maxHeight, maxSizes.maxHeight) : maxSizes.maxHeight;
|
|
||||||
const size = this.fitSizeWithDataContainer(this.getMediaSize(media, maxWidth, maxHeight), media.type);
|
|
||||||
|
|
||||||
const target = media.type === 'video' ? this.video : this.image;
|
|
||||||
target.width = size.width;
|
|
||||||
target.height = size.height;
|
|
||||||
this.sizeContainer(size.width, size.height, media.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
changeImage(index) {
|
|
||||||
const media = this.album[index];
|
|
||||||
if(!media) return;
|
|
||||||
|
|
||||||
this.updateDetails(media, false);
|
|
||||||
this.hideElements([this.dataContainer]);
|
|
||||||
this.disableKeyboardNav();
|
|
||||||
this.fade(this.overlay, true, this.options.fadeDuration);
|
|
||||||
this.fade(this.loader, true, 200);
|
|
||||||
this.hideElements([this.image, this.video, this.nav, this.prev, this.next]);
|
|
||||||
this.resetImageTransform();
|
|
||||||
this.outerContainer.classList.add('animating');
|
|
||||||
this.container.classList.remove('moveable', 'moving', 'lb-video-nav');
|
|
||||||
this.currentImageIndex = index;
|
|
||||||
|
|
||||||
this.options.onMediaChange(media);
|
|
||||||
|
|
||||||
if(media.type === 'video') {
|
|
||||||
this.image.removeAttribute('src');
|
|
||||||
this.container.classList.add('lb-video-nav');
|
|
||||||
this.video.onloadedmetadata = () => {
|
|
||||||
media.width = this.video.videoWidth;
|
|
||||||
media.height = this.video.videoHeight;
|
|
||||||
this.video.onloadedmetadata = null;
|
|
||||||
this.updateSize(index);
|
|
||||||
};
|
|
||||||
this.video.src = media.link;
|
|
||||||
} else {
|
|
||||||
this.video.pause();
|
|
||||||
this.video.removeAttribute('src');
|
|
||||||
this.image.onload = () => {
|
|
||||||
this.image.alt = media.alt;
|
|
||||||
let width = this.image.naturalWidth;
|
|
||||||
let height = this.image.naturalHeight;
|
|
||||||
if(Math.abs(media.orientation) === 90 && width > height) {
|
|
||||||
const tmp = width;
|
|
||||||
width = height;
|
|
||||||
height = tmp;
|
|
||||||
}
|
|
||||||
media.width = width;
|
|
||||||
media.height = height;
|
|
||||||
this.image.onload = null;
|
|
||||||
this.updateSize(index);
|
|
||||||
};
|
|
||||||
this.image.src = media.link;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sizeOverlay() {
|
|
||||||
if(this.resizeTimer) clearTimeout(this.resizeTimer);
|
|
||||||
if(!this.album.length) return;
|
|
||||||
|
|
||||||
this.resizeTimer = window.setTimeout(() => {
|
|
||||||
const current = this.album[this.currentImageIndex];
|
|
||||||
if(!current) return;
|
|
||||||
if(current.type === 'image') this.changeImage(this.currentImageIndex);
|
|
||||||
else this.updateSize(this.currentImageIndex);
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
sizeContainer(width, height, mediaType = 'image') {
|
|
||||||
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
|
|
||||||
const newWidth = width + this.containerPadding.left + this.containerPadding.right + border.left + border.right;
|
|
||||||
const newHeight = height + this.containerPadding.top + this.containerPadding.bottom + border.top + border.bottom;
|
|
||||||
const dataHeight = this.getDataContainerHeight(newWidth);
|
|
||||||
|
|
||||||
this.outerContainer.style.transition = `width ${this.options.resizeDuration}ms, height ${this.options.resizeDuration}ms`;
|
|
||||||
this.outerContainer.style.width = `${newWidth}px`;
|
|
||||||
this.outerContainer.style.height = `${newHeight + dataHeight}px`;
|
|
||||||
this.container.style.height = `${newHeight}px`;
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.overlay.focus();
|
|
||||||
this.showImage();
|
|
||||||
this.outerContainer.style.transition = '';
|
|
||||||
}, this.options.resizeDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
showImage() {
|
|
||||||
this.fade(this.loader, false, 0);
|
|
||||||
if(this.options.hasVideo && this.album[this.currentImageIndex].type === 'video') this.fade(this.video, true, this.options.imageFadeDuration);
|
|
||||||
else this.fade(this.image, true, this.options.imageFadeDuration);
|
|
||||||
|
|
||||||
this.updateNav();
|
|
||||||
this.updateDetails();
|
|
||||||
this.preloadNeighboringImages();
|
|
||||||
this.enableKeyboardNav();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateNav() {
|
|
||||||
this.setVisible(this.nav, true);
|
|
||||||
this.setVisible(this.prev, false);
|
|
||||||
this.setVisible(this.next, false);
|
|
||||||
|
|
||||||
const alwaysShowNav = ('ontouchstart' in window) && this.options.alwaysShowNavOnTouchDevices;
|
|
||||||
if(this.album.length <= 1) return;
|
|
||||||
|
|
||||||
if(this.options.wrapAround) {
|
|
||||||
this.setVisible(this.prev, true);
|
|
||||||
this.setVisible(this.next, true);
|
|
||||||
} else {
|
|
||||||
if(this.currentImageIndex > 0) this.setVisible(this.prev, true);
|
|
||||||
if(this.currentImageIndex < this.album.length - 1) this.setVisible(this.next, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(alwaysShowNav) {
|
|
||||||
this.prev.style.opacity = '1';
|
|
||||||
this.next.style.opacity = '1';
|
|
||||||
} else {
|
|
||||||
this.prev.style.opacity = '';
|
|
||||||
this.next.style.opacity = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDetails(media = this.album[this.currentImageIndex], show = true) {
|
|
||||||
if(!media) return;
|
|
||||||
|
|
||||||
if(media.title) {
|
|
||||||
if(this.options.sanitizeTitle) this.caption.textContent = media.title;
|
|
||||||
else this.caption.innerHTML = media.title;
|
|
||||||
if(show) this.fade(this.caption, true, 200);
|
|
||||||
else this.setVisible(this.caption, true);
|
|
||||||
} else {
|
|
||||||
this.caption.textContent = '';
|
|
||||||
this.setVisible(this.caption, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(show) {
|
|
||||||
this.fade(this.closeButton, true, 200);
|
|
||||||
this.outerContainer.classList.remove('animating');
|
|
||||||
this.fade(this.dataContainer, true, this.options.resizeDuration);
|
|
||||||
} else {
|
|
||||||
this.setVisible(this.closeButton, true);
|
|
||||||
this.setVisible(this.dataContainer, false);
|
|
||||||
this.dataContainer.style.transition = '';
|
|
||||||
this.dataContainer.style.opacity = '0';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
preloadNeighboringImages() {
|
|
||||||
const next = this.album[this.currentImageIndex + 1];
|
|
||||||
const prev = this.album[this.currentImageIndex - 1];
|
|
||||||
if(next && next.type === 'image') {
|
|
||||||
const preloadNext = new Image();
|
|
||||||
preloadNext.src = next.link;
|
|
||||||
}
|
|
||||||
if(prev && prev.type === 'image') {
|
|
||||||
const preloadPrev = new Image();
|
|
||||||
preloadPrev.src = prev.link;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enableKeyboardNav() {
|
|
||||||
this.disableKeyboardNav();
|
|
||||||
this.lightbox.addEventListener('keyup', this.boundOnKeyUp);
|
|
||||||
this.overlay.addEventListener('keyup', this.boundOnKeyUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
disableKeyboardNav() {
|
|
||||||
this.lightbox?.removeEventListener('keyup', this.boundOnKeyUp);
|
|
||||||
this.overlay?.removeEventListener('keyup', this.boundOnKeyUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
keyboardAction(event) {
|
|
||||||
switch(event.key) {
|
|
||||||
case 'Escape':
|
|
||||||
event.stopPropagation();
|
|
||||||
this.end();
|
|
||||||
break;
|
|
||||||
case 'ArrowLeft':
|
|
||||||
if(this.currentImageIndex !== 0) this.changeImage(this.currentImageIndex - 1);
|
|
||||||
else if(this.options.wrapAround && this.album.length > 1) this.changeImage(this.album.length - 1);
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
if(this.currentImageIndex !== this.album.length - 1) this.changeImage(this.currentImageIndex + 1);
|
|
||||||
else if(this.options.wrapAround && this.album.length > 1) this.changeImage(0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onWheel(event) {
|
|
||||||
const media = this.album[this.currentImageIndex];
|
|
||||||
if(!media || media.type === 'video') return;
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const rect = this.image.getBoundingClientRect();
|
|
||||||
const oldTransform = this.getImageTransform();
|
|
||||||
const oldZoom = oldTransform.scale;
|
|
||||||
const maxZoom = Math.max(media.width / Math.max(this.image.width, 1), media.height / Math.max(this.image.height, 1), 1);
|
|
||||||
const newZoom = Math.min(Math.max(oldZoom + (-Math.sign(event.deltaY) / 10), 1), maxZoom);
|
|
||||||
|
|
||||||
const imageCenterX = rect.left + rect.width / 2 - oldTransform.translateX;
|
|
||||||
const imageCenterY = rect.top + rect.height / 2 - oldTransform.translateY;
|
|
||||||
const cursorX = event.clientX - imageCenterX;
|
|
||||||
const cursorY = event.clientY - imageCenterY;
|
|
||||||
const zoomRatio = newZoom / oldZoom;
|
|
||||||
const transform = this.clampImageTransform({
|
|
||||||
scale: newZoom,
|
|
||||||
translateX: cursorX - zoomRatio * (cursorX - oldTransform.translateX),
|
|
||||||
translateY: cursorY - zoomRatio * (cursorY - oldTransform.translateY)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.container.classList.toggle('moveable', newZoom > 1);
|
|
||||||
this.setImageTransform(transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragStart(event) {
|
|
||||||
const scale = parseFloat(this.image.style.getPropertyValue('--scale') || '1');
|
|
||||||
if(scale <= 1) return;
|
|
||||||
|
|
||||||
this.gMouseDownOffsetX = event.clientX - parseFloat(this.image.style.getPropertyValue('--translate-x') || '0');
|
|
||||||
this.gMouseDownOffsetY = event.clientY - parseFloat(this.image.style.getPropertyValue('--translate-y') || '0');
|
|
||||||
this.container.classList.add('moving');
|
|
||||||
window.addEventListener('mousemove', this.boundOnDragMove);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragMove(event) {
|
|
||||||
const zoom = parseFloat(this.image.style.getPropertyValue('--scale') || '1');
|
|
||||||
const transform = this.clampImageTransform({
|
|
||||||
scale: zoom,
|
|
||||||
translateX: event.clientX - this.gMouseDownOffsetX,
|
|
||||||
translateY: event.clientY - this.gMouseDownOffsetY
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setImageTransform(transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragEnd() {
|
|
||||||
window.removeEventListener('mousemove', this.boundOnDragMove);
|
|
||||||
this.container?.classList.remove('moving');
|
|
||||||
}
|
|
||||||
|
|
||||||
resetImageTransform() {
|
|
||||||
this.setImageTransform({scale: 1, translateX: 0, translateY: 0});
|
|
||||||
}
|
|
||||||
|
|
||||||
getImageTransform() {
|
|
||||||
return {
|
|
||||||
scale: parseFloat(this.image.style.getPropertyValue('--scale') || '1'),
|
|
||||||
translateX: parseFloat(this.image.style.getPropertyValue('--translate-x') || '0'),
|
|
||||||
translateY: parseFloat(this.image.style.getPropertyValue('--translate-y') || '0')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
clampImageTransform(transform) {
|
|
||||||
const maxTranslateX = (transform.scale - 1) * this.image.width / 2;
|
|
||||||
const maxTranslateY = (transform.scale - 1) * this.image.height / 2;
|
|
||||||
|
|
||||||
return {
|
|
||||||
scale: transform.scale,
|
|
||||||
translateX: Math.max(Math.min(transform.translateX, maxTranslateX), -maxTranslateX),
|
|
||||||
translateY: Math.max(Math.min(transform.translateY, maxTranslateY), -maxTranslateY)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setImageTransform(transform) {
|
|
||||||
if(!this.image) return;
|
|
||||||
this.image.style.setProperty('--scale', String(transform.scale));
|
|
||||||
this.image.style.setProperty('--translate-x', `${transform.translateX}px`);
|
|
||||||
this.image.style.setProperty('--translate-y', `${transform.translateY}px`);
|
|
||||||
}
|
|
||||||
|
|
||||||
hideElements(elements) {
|
|
||||||
elements.forEach((element) => {
|
|
||||||
this.setVisible(element, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setVisible(element, visible) {
|
|
||||||
if(!element) return;
|
|
||||||
element.style.visibility = visible ? 'visible' : 'hidden';
|
|
||||||
element.style.pointerEvents = visible ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
fade(element, show, duration, done) {
|
|
||||||
if(!element) return;
|
|
||||||
|
|
||||||
const safeDuration = duration || 0;
|
|
||||||
element.style.transition = `opacity ${safeDuration}ms`;
|
|
||||||
if(show) {
|
|
||||||
this.setVisible(element, true);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
element.style.opacity = element === this.overlay ? '0.8' : '1';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
element.style.opacity = '0';
|
|
||||||
element.style.pointerEvents = 'none';
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.setVisible(element, false);
|
|
||||||
}, safeDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(typeof done === 'function') {
|
|
||||||
window.setTimeout(done, safeDuration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
end(dispose = false) {
|
|
||||||
this.disableKeyboardNav();
|
|
||||||
this.video?.pause();
|
|
||||||
this.video?.removeAttribute('src');
|
|
||||||
this.container?.classList.remove('lb-video-nav', 'moveable', 'moving');
|
|
||||||
window.removeEventListener('resize', this.boundOnResize);
|
|
||||||
window.removeEventListener('mousemove', this.boundOnDragMove);
|
|
||||||
|
|
||||||
if(dispose) {
|
|
||||||
this.disable();
|
|
||||||
if(this.resizeTimer) clearTimeout(this.resizeTimer);
|
|
||||||
window.removeEventListener('mouseup', this.boundOnDragEnd);
|
|
||||||
this.lightbox?.remove();
|
|
||||||
this.overlay?.remove();
|
|
||||||
this.album = [];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.fade(this.lightbox, false, this.options.fadeDuration);
|
|
||||||
this.fade(this.overlay, false, this.options.fadeDuration);
|
|
||||||
this.options.onClosing();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(this.options.disableScrolling) document.body.classList.remove('lb-disable-scrolling');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user