@geogirafe/lib-geoportal
Version:
GeoGirafe is a flexible application to build online geoportals.
501 lines (500 loc) • 27.1 kB
JavaScript
import { html as uHtml } from 'uhtml';
// SPDX-License-Identifier: Apache-2.0
import Geolocation from 'ol/Geolocation.js';
import Feature from 'ol/Feature.js';
import Point from 'ol/geom/Point.js';
import VectorLayer from 'ol/layer/Vector.js';
import VectorSource from 'ol/source/Vector.js';
import CircleStyle from 'ol/style/Circle.js';
import Fill from 'ol/style/Fill.js';
import Stroke from 'ol/style/Stroke.js';
import Style from 'ol/style/Style.js';
import { RegularShape } from 'ol/style.js';
import GirafeHTMLElement from '../../base/GirafeHTMLElement.js';
import locationDisabledIcon from '../../assets/icons/location_disabled.svg';
import locationSearchingIcon from '../../assets/icons/location_searching.svg';
import locationHeadingIcon from '../../assets/icons/location_heading.svg';
import myLocationIcon from '../../assets/icons/my_location.svg';
export default class GeolocationMobile extends GirafeHTMLElement {
templateUrl = null;
styleUrls = null;
template = () => {
return uHtml `<style>
.hidden{display:none!important}.gg-rotate90{transform:rotate(90deg)}.gg-rotate180{transform:rotate(180deg)}.gg-rotate270{transform:rotate(270deg)}img{filter:var(--svg-filter)}img.legend-image{filter:var(--svg-map-filter);background:var(--svg-legend-bkg)}div{scrollbar-width:thin}a,a:visited{color:var(--link-color)} spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}} spin-wait{0%{transform:rotate(0)}7%{transform:rotate(360deg)}to{transform:rotate(360deg)}}.gg-spin{animation-name:spin;animation-duration:2s;animation-timing-function:linear;animation-iteration-count:infinite}.gg-spin-wait{animation-name:spin-wait;animation-duration:10s;animation-timing-function:linear;animation-iteration-count:infinite}::-webkit-scrollbar{width:5px}::-webkit-scrollbar-thumb{background:#999}button,input,select,textarea{font:inherit}.gg-button,.gg-select,.gg-input,.gg-textarea{background-color:var(--bkg-color);color:var(--text-color);border:var(--app-standard-border);box-sizing:border-box;cursor:pointer;border-radius:3px;outline:0;margin:0;padding:0 0 0 .5rem;display:inline-block}.gg-label{background-color:var(--bkg-color);color:var(--text-color);border:none;align-items:center;margin:0;padding:0;display:flex}.gg-button,.gg-select,.gg-input,.gg-label{min-height:calc(var(--app-standard-height) / 1.5)}.gg-textarea{max-height:initial;resize:vertical;height:6rem;padding:.5rem;line-height:1.3rem}.gg-input{cursor:text}.gg-checkbox{accent-color:var(--text-color);width:1.2rem}.gg-range{accent-color:var(--text-color)}.gg-button{padding:0 .5rem}.gg-button.active{border:solid 1px var(--text-color-grad1);background-color:var(--bkg-color-grad2)}.gg-button:disabled{background-color:var(--bkg-color-grad1);color:var(--text-color-grad2);cursor:not-allowed;border:none}.gg-button:disabled img{filter:opacity(.6)}.gg-input:disabled,.gg-select:disabled,.gg-textarea:disabled{background-color:var(--bkg-color-grad1);color:var(--text-color-grad2);cursor:not-allowed}.gg-button>img{vertical-align:middle}.gg-icon-button{color:var(--text-color);cursor:pointer;background-color:#0000;border:none;flex-direction:column;justify-content:center;align-items:center;padding:0;display:flex}.gg-icon{justify-content:center;align-items:center;display:flex}.gg-big,.gg-big-withtext{min-width:var(--app-standard-height);min-height:var(--app-standard-height);max-height:var(--app-standard-height)}.gg-big img,.gg-big-withtext img{width:calc(var(--app-standard-height) - 1.5rem);margin:0}.gg-big-withtext span{font-variant:small-caps;padding:0 1rem;font-size:.9rem}.gg-medium,.gg-medium-withtext{min-width:calc(var(--app-standard-height) / 1.2);min-height:calc(var(--app-standard-height) / 1.2);max-height:calc(var(--app-standard-height) / 1.2);flex-direction:row}.gg-medium img{width:calc(var(--app-standard-height) / 2.4);margin:0}.gg-medium-withtext img{width:calc(var(--app-standard-height) / 2.4);margin-left:.5rem}.gg-medium-withtext span{padding:0 1rem 0 .5rem;font-size:.9rem}.gg-small,.gg-small-withtext{min-width:calc(var(--app-standard-height) / 2);min-height:calc(var(--app-standard-height) / 2);max-height:calc(var(--app-standard-height) / 2);flex-direction:row}.gg-small img{width:calc(var(--app-standard-height) / 3);margin:0}.gg-small-withtext img{width:calc(var(--app-standard-height) / 3);margin-left:.5rem}.gg-small-withtext span{padding:0 .5rem 0 .3rem;font-size:.9rem}.gg-button:hover:not(:disabled),.gg-select:hover:not(:disabled),.gg-input:hover:not(:disabled),.gg-textarea:hover:not(:disabled),.gg-icon-button:hover:not(:disabled){background-color:var(--bkg-color-grad1)}.gg-opacity{opacity:.5}.gg-opacity:hover{opacity:1;background-color:#0000}.gg-tabs{cursor:pointer;grid-auto-flow:column;padding-bottom:1rem;font-size:1rem;display:grid}.gg-tab{border:none;border-bottom:var(--app-standard-border);cursor:pointer;color:var(--text-color);background:0 0;padding:.5rem}.gg-tab.active{border-bottom:solid 1px var(--text-color)}.girafe-button-big,.girafe-button-large,.girafe-button-small,.girafe-button-tiny{color:var(--text-color);background-color:#0000;border:none;flex-direction:column;display:flex}.girafe-button-big:hover,.girafe-button-large:hover,.girafe-button-small:hover,.girafe-button-tiny:hover{background-color:var(--bkg-color-grad1);cursor:pointer}.girafe-button-big.dark,.girafe-button-large.dark,.girafe-button-small.dark,.girafe-button-tiny.dark{background-color:var(--bkg-color);filter:invert()}.girafe-button-big{width:var(--app-standard-height);height:var(--app-standard-height);align-items:center;padding:1rem}.girafe-button-big img{overflow:hidden}.girafe-button-large{flex-direction:row}.girafe-button-large img{height:2rem;margin:.3rem}.girafe-button-large span{height:2rem;margin:.3rem;line-height:2rem}.girafe-button-small{min-width:calc(var(--app-standard-height) / 2);height:calc(var(--app-standard-height) / 2);align-items:center;padding:.5rem}.girafe-button-small img{overflow:hidden}.girafe-button-small span{text-align:left;text-overflow:ellipsis;width:100%;overflow:hidden}.girafe-button-tiny{align-items:center;width:1rem;height:1rem;padding:0}.girafe-button-tiny img{overflow:hidden}.girafe-onboarding-theme{background-color:var(--bkg-color)!important;color:var(--text-color)!important}.girafe-onboarding-theme button{background-color:var(--bkg-color)!important;color:var(--text-color)!important;text-shadow:none!important}.girafe-onboarding-theme button.driver-popover-close-btn{z-index:10000}.tippy-box{background-color:var(--bkg-color)!important;border:var(--app-standard-border)!important}.tippy-content{color:var(--text-color)!important}.tippy-arrow{color:var(--bkg-color)!important}.tippy-box[data-theme~=error]{background-color:var(--error-color);white-space:pre-line}div.tippy-box[data-theme=image-popup]{border:var(--app-standard-border)!important;& div.tippy-content{background-color:var(--bkg-color)!important;& img{max-width:20rem;max-height:20rem}}& div.tippy-arrow{color:var(--bkg-color)!important}}
</style><style>
#geolocation-button{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#geolocation-button:focus{outline:none}.black-to-red{filter:invert(27%)sepia(50%)saturate(3000%)brightness(95%)contrast()}.alpha{opacity:.4}.blink{-webkit-animation:2s infinite both blink;animation:2s infinite both blink}@-webkit-keyframes blink{0%,50%,to{opacity:1}25%,75%{opacity:.1}} blink{0%,50%,to{opacity:1}25%,75%{opacity:.1}}.black-to-blue{filter:brightness(0)saturate()invert(54%)sepia(97%)saturate(2988%)hue-rotate(187deg)brightness(123%)contrast(101%)}#clock{z-index:2;opacity:.5;pointer-events:none;background:0 0;border-radius:100%;width:200%;height:200%;margin:auto;position:absolute;inset:0 0 0 -50%}
</style>
<style>${this.customStyle}</style>
<link rel="stylesheet" href="styles/common.mobile.css"><button id="geolocation-button" class="girafe-button-big" tip="Theme selection" onpointerup="${(e) => this.pointerUp(e)}" onpointerdown="${(e) => this.pointerDown(e)}" onpointerleave="${(e) => this.pointerUp(e)}" oncontextmenu="${(e) => this.preventContextMenu(e)}"><div id="clock"></div><img alt="menu-icon" class="${this.getCssClassesForStatus()}" src="${this.getIconForStatus()}"></button>
${this.htmlUnsafe(this.feedbackTemplateHtml ?? '')}`;
};
geolocation = null;
status = 'off';
statusBeforeError = 'off';
positionFeature;
accuracyFeature;
headingFeature;
vectorLayer;
shouldCenter = false;
isTrackingOutsideView = false;
lastPositionTimestamp = 0;
isPressed = false;
hasLongpressed = false;
constructor() {
super('geolocation-mobile');
this.positionFeature = new Feature();
this.positionFeature.setProperties({ order: 1 });
this.positionFeature.setStyle(new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({
color: 'rgb(38, 197, 255)'
}),
stroke: new Stroke({
color: '#fff',
width: 2
})
})
}));
this.headingFeature = new Feature();
this.headingFeature.setProperties({ order: 2 });
this.headingFeature.setStyle(new Style({
image: new RegularShape({
points: 3, // Triangle has 3 points
radius: 8,
displacement: [0, 15],
fill: new Fill({
color: 'rgb(38, 197, 255)'
}),
stroke: new Stroke({
color: '#fff',
width: 2
}),
rotation: 0
})
}));
this.accuracyFeature = new Feature();
this.accuracyFeature.setProperties({ order: 3 });
this.accuracyFeature.setStyle(new Style({
stroke: new Stroke({
color: 'rgba(38, 197, 255, 0.5)',
width: 1,
lineDash: [5, 5]
}),
fill: new Fill({
color: 'rgba(38, 197, 255, 0.1)'
})
}));
this.vectorLayer = new VectorLayer({
source: new VectorSource({
features: [this.accuracyFeature, this.headingFeature, this.positionFeature]
}),
renderOrder: (fA, fB) => {
const orderA = fA.getProperties().order || 0;
const orderB = fB.getProperties().order || 0;
return orderA - orderB;
}
});
}
connectedCallback() {
super.connectedCallback();
this.registerInteractionListener('map.contextmenu', false);
const map = this.context.mapManager.getMap();
const onMapInteract = () => {
if (this.status === 'headlock') {
this.disableHeadlock();
}
};
// When the map is dragged, it disables the heading lock
map.on('pointerdrag', onMapInteract);
map.once('postrender', () => {
const view = map.getView();
map.addLayer(this.vectorLayer);
this.geolocation = new Geolocation({
trackingOptions: {
enableHighAccuracy: true
},
projection: view.getProjection()
});
this.geolocation.on('error', (_err) => {
this.statusBeforeError = this.status;
this.pauseTracking();
this.status = 'error';
this.render();
// If an error occurs, there is a timeout of 15 seconds until going back to "off" mode,
// unless a new location was acquired in the meantime
const errorTimestamp = Date.now();
setTimeout(() => {
if (this.lastPositionTimestamp < errorTimestamp) {
this.stopTracking();
this.render();
}
}, 15 * 1000);
});
this.geolocation.on('change:tracking', () => {
if (!this.geolocation) {
return;
}
if (this.geolocation.getTracking()) {
this.vectorLayer.setVisible(true);
}
else {
this.pauseTracking();
this.status = 'off';
}
this.render();
});
this.geolocation.on('change:position', () => {
if (!this.geolocation) {
return;
}
const coordinates = this.geolocation.getPosition();
this.positionFeature.setGeometry(coordinates ? new Point(coordinates) : undefined);
if (!coordinates) {
return;
}
this.vectorLayer.setVisible(true);
switch (this.status) {
case 'off':
case 'searching':
this.status = 'tracking';
this.render();
break;
case 'error':
if (this.statusBeforeError === 'tracking' || this.statusBeforeError === 'headlock') {
this.vectorLayer.changed();
this.status = this.statusBeforeError;
this.render();
}
break;
default:
}
// Update the button icon if tracked position moved outside of view
this.lastPositionTimestamp = Date.now();
this.updateIconIfTrackingOutside();
if (this.status === 'headlock') {
view.setCenter(coordinates);
}
// Center on the geolocation if not in the view at the moment of switching geolocation ON
if (this.shouldCenter) {
if (!this.isGeolocationInView()) {
this.centerToView();
}
this.shouldCenter = false;
}
});
this.geolocation.on('change:accuracy', () => {
if (!this.geolocation) {
return;
}
});
this.geolocation.on('change:accuracyGeometry', () => {
if (!this.geolocation) {
return;
}
this.accuracyFeature.setGeometry(this.geolocation.getAccuracyGeometry() ?? undefined);
});
this.geolocation.on('change:heading', () => {
if (!this.geolocation) {
return;
}
const heading = this.geolocation.getHeading();
if (heading === undefined) {
this.headingFeature.setGeometry(undefined);
return;
}
const coordinates = this.geolocation.getPosition();
this.headingFeature.setGeometry(coordinates ? new Point(coordinates) : undefined);
// When the headlock is enabled, the map rotates to align with the heading
// but when the headlock is disabled, the arrow icon on the geolocation marker rotates
if (this.status === 'headlock') {
this.headingFeature.getStyle().getImage().setRotation(0);
map.getView().setRotation(-heading);
}
else {
this.headingFeature.getStyle().getImage().setRotation(heading + map.getView().getRotation());
}
});
view.on('change', () => {
this.updateIconIfTrackingOutside();
});
this.render();
});
}
pauseTracking() {
this.isTrackingOutsideView = false;
this.accuracyFeature.setGeometry(undefined);
this.headingFeature.setGeometry(undefined);
this.positionFeature.setGeometry(undefined);
this.vectorLayer.setVisible(false);
}
updateIconIfTrackingOutside() {
const previous = this.isTrackingOutsideView;
this.isTrackingOutsideView = this.isTracking() && !this.isGeolocationInView();
if (this.isTrackingOutsideView !== previous) {
this.render();
}
}
getIconForStatus() {
// The geolocation object must be instantiated,
// which happens only after the OL Map is ready
if (!this.geolocation) {
return;
}
switch (this.status) {
case 'searching':
return locationSearchingIcon;
case 'tracking':
return myLocationIcon;
case 'off':
return locationDisabledIcon;
case 'error':
return locationDisabledIcon;
case 'headlock':
return locationHeadingIcon;
default:
return locationDisabledIcon;
}
}
pointerUp(e) {
e.stopPropagation();
this.isPressed = false;
if (this.hasLongpressed) {
this.hasLongpressed = false;
const clockDiv = this.shadow.getElementById('clock');
clockDiv.style.setProperty('display', 'none');
this.stopTracking();
return;
}
if (e.type === 'pointerleave') {
return;
}
switch (this.status) {
case 'searching':
return this.stopTracking();
case 'tracking':
if (this.isTracking()) {
// if the geolocation is already tracking,
// it checks if the location is in the view. If not, it centers it,
// if yes, it also centers it but n addition enables the head lock
if (this.isGeolocationInView()) {
this.enableHeadLock();
}
this.centerToView();
}
break;
case 'headlock':
return this.disableHeadlock();
case 'off':
this.shouldCenter = true;
return this.startTracking();
case 'error':
return this.stopTracking();
default:
return;
}
}
pointerDown(e) {
e.stopImmediatePropagation();
e.stopPropagation();
e.preventDefault();
if (!(this.status === 'headlock' || this.status === 'tracking')) {
return;
}
this.isPressed = true;
let timeStampStartAnimation = 0;
const animationDuration = 1000; // milliseconds
const clockDiv = this.shadow.getElementById('clock');
const radialGradient = (ratio) => {
const angleDeg = 360 * ratio;
if (ratio < 0.01) {
clockDiv.style.setProperty('display', 'none');
return;
}
clockDiv.style.setProperty('display', 'inherit');
clockDiv.style.setProperty('background', `conic-gradient(red 0deg, red ${angleDeg}deg, transparent ${angleDeg}deg, transparent 360deg)`);
};
const animate = () => {
const now = performance.now();
const progressMillisec = now - timeStampStartAnimation;
const progressRatio = progressMillisec / animationDuration;
if (!this.isPressed) {
clockDiv.style.setProperty('display', 'none');
return;
}
if (progressRatio >= 0.99) {
this.hasLongpressed = true;
}
radialGradient(progressRatio);
requestAnimationFrame(animate);
};
setTimeout(() => {
if (this.isPressed) {
timeStampStartAnimation = performance.now();
animate();
}
}, 200);
}
preventContextMenu(e) {
e.stopPropagation();
e.preventDefault();
return false;
}
getCssClassesForStatus() {
switch (this.status) {
case 'searching':
return 'blink';
case 'tracking':
return this.isTrackingOutsideView ? 'black-to-blue blink' : 'black-to-blue';
case 'off':
return 'alpha';
case 'headlock':
return 'black-to-blue';
case 'error':
return 'black-to-red blink';
default:
return 'alpha';
}
}
stopTracking() {
if (!this.geolocation) {
return;
}
this.geolocation.setTracking(false);
this.status = 'off';
this.render();
}
startTracking() {
if (!this.geolocation) {
return;
}
// Already tracking
if (this.geolocation.getTracking()) {
return;
}
// When the geolocalization is activated, a toast message is displayed to
// inform how to disable it
const infoMessageId = crypto.randomUUID();
this.context.stateManager.state.infobox.elements.push({
id: infoMessageId,
text: this.context.i18nManager.getTranslation('Longpress disable geolocation'),
type: 'info'
});
// The message is shown for a few seconds and then is removed without the need
// for the user to close it
setTimeout(() => {
for (let i = 0; i < this.context.stateManager.state.infobox.elements.length; i += 1) {
const infoMessage = this.context.stateManager.state.infobox.elements[i];
if (infoMessage.id === infoMessageId) {
this.context.stateManager.state.infobox.elements.splice(i, 1);
break;
}
}
}, 3 * 1000);
// Until the tracking is treiggered (caught by event above), the status is set to "searching"
this.status = 'searching';
this.render();
this.geolocation.setTracking(true);
}
isTracking() {
if (!this.geolocation) {
return false;
}
return !!this.geolocation.getTracking();
}
centerToView(duration = 300) {
const tracking = this.isTracking();
if (!tracking) {
return;
}
const coordinates = this.geolocation?.getPosition();
if (!coordinates) {
return;
}
this.context.mapManager.getMap().getView().animate({
center: coordinates,
duration
});
}
isGeolocationInView() {
const tracking = this.isTracking();
if (!tracking) {
return false;
}
const coordinates = this.geolocation?.getPosition();
if (!coordinates) {
return false;
}
const map = this.context.mapManager.getMap();
// Get the pixel for the geolocation coordinates
const pixel = map.getPixelFromCoordinate(coordinates);
// Check if the pixel is within the map's viewport size.
// This is a bit costlier than checking if the geolocaiton coordinates
// are within the extent of the view, but this is more robust as the view extent
// is much wider than the visible part of the map when the heading of the map is not pointng north
const mapSize = map.getSize();
if (!pixel || !mapSize) {
return false;
}
return pixel[0] >= 0 && pixel[0] <= mapSize[0] && pixel[1] >= 0 && pixel[1] <= mapSize[1];
}
enableHeadLock() {
if (this.isTracking() && this.status === 'tracking' && this.geolocation?.getHeading() !== undefined) {
const start = performance.now();
const animationDuration = 300;
const arrowRotationStart = this.headingFeature.getStyle().getImage().getRotation();
const arrowRotationTarget = 0;
const mapRotationStart = this.context.mapManager.getMap().getView().getRotation();
const mapRotationTarget = -this.geolocation?.getHeading();
const mapCenterStart = this.context.mapManager.getMap().getView().getCenter();
const mapCenterTarget = this.geolocation.getPosition();
// Finding the shortest rotation between CW and CCW
const mapRotationTargetShortest = Math.abs(mapRotationTarget - mapRotationStart) > Math.abs(mapRotationTarget + Math.PI * 2 - mapRotationStart)
? mapRotationTarget + Math.PI * 2
: mapRotationTarget;
const arrowRotationTargetShortest = Math.abs(arrowRotationTarget - arrowRotationStart) >
Math.abs(arrowRotationTarget + Math.PI * 2 - arrowRotationStart)
? arrowRotationTarget + Math.PI * 2
: arrowRotationTarget;
// To animate the different components, we cannot use this.context.mapManager.getMap().getView().animate() because
// it appears that it's locking non-map view update (such as the arrow icon) during the animation.
// Due to this limitation, the animation is done with a lower level
const updateHeadingArrow = () => {
// Progress and easing
const now = performance.now();
const progress = (now - start) / animationDuration;
const easedOutProgress = progress;
// Animating the rotation of the heading arrow to make it point up (viewport space)
this.headingFeature.getStyle().getImage().setRotation(arrowRotationTargetShortest * easedOutProgress + arrowRotationStart * (1 - easedOutProgress));
// Animating the map view to rotate it to alight with geolocation heading
this.context.mapManager
.getMap()
.getView()
.setRotation(mapRotationTargetShortest * easedOutProgress + mapRotationStart * (1 - easedOutProgress));
// animating the center offset
const intermediateCoord = [
mapCenterTarget[0] * easedOutProgress + mapCenterStart[0] * (1 - easedOutProgress),
mapCenterTarget[1] * easedOutProgress + mapCenterStart[1] * (1 - easedOutProgress)
];
this.context.mapManager.getMap().getView().setCenter(intermediateCoord);
if (progress < 1) {
requestAnimationFrame(updateHeadingArrow);
}
};
updateHeadingArrow();
setTimeout(() => {
this.status = 'headlock';
this.render();
}, animationDuration);
}
}
disableHeadlock() {
if (this.isTracking()) {
this.status = 'tracking';
}
else {
this.status = 'off';
}
this.render();
}
}