Route progress
vanilla.html
react.html
vue.html
common.css
common.ts
variables.ts
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/@turf/turf@7"></script>
<!-- To make the map appear, you must add your apikey -->
<script src="https://js.api.mappable.world/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
<script
data-plugins="transform-modules-umd"
data-presets="typescript"
type="text/babel"
src="./common.ts"
></script>
<script
data-plugins="transform-modules-umd"
data-presets="typescript"
type="text/babel"
src="../variables.ts"
></script>
<script data-plugins="transform-modules-umd" data-presets="typescript" type="text/babel">
import type {LngLat} from '@mappable-world/mappable-types';
import {
ANIMATE_DURATION_MS,
DriverAnimation,
angleFromCoordinate,
animate,
fetchRoute,
splitLineString
} from './common';
import {
INITIAL_DRIVER_SPEED,
LOCATION,
MARKER_IMAGE_PATH,
MAX_DRIVER_SPEED,
MIN_DRIVER_SPEED,
PASSED_ROUTE_STYLE,
ROUTE,
ROUTE_STYLE
} from '../variables';
window.map = null;
main();
async function main() {
// Waiting for all api elements to be loaded
await mappable.ready;
const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapFeature, MMapMarker, MMapControls, MMapControl} =
mappable;
// Import the package to add a default marker
const {MMapDefaultMarker} = await mappable.import('@mappable-world/mappable-default-ui-theme');
// Initialize the map
map = new MMap(
// Pass the link to the HTMLElement of the container
document.getElementById('app'),
// Pass the map initialization parameters
{location: LOCATION, showScaleInCopyrights: true},
[
// Add a map scheme layer
new MMapDefaultSchemeLayer({}),
// Add a layer of geo objects to display the markers
new MMapDefaultFeaturesLayer({})
]
);
class ResetButton extends mappable.MMapComplexEntity<{onClick: () => void}> {
private _element!: HTMLButtonElement;
// Method for create a DOM control element
_createElement() {
// Create a root element
const button = document.createElement('button');
button.classList.add('button');
button.innerText = 'Restart';
return button;
}
// Method for attaching the control to the map
_onAttach() {
this._element = this._createElement();
this._element.addEventListener('click', this._onClick);
const control = new MMapControl({}, this._element);
this.addChild(control);
}
// Method for detaching control from the map
_onDetach() {
this._element.removeEventListener('click', this._onClick);
}
_onClick = () => {
this._props.onClick();
};
}
type SpeedRangeProps = {
onChange: (value: number) => void;
initialValue: number;
min: number;
max: number;
};
class SpeedRange extends mappable.MMapComplexEntity<SpeedRangeProps> {
private _element!: HTMLDivElement;
private _input!: HTMLInputElement;
// Method for create a DOM control element
_createElement() {
// Create a root element
const container = document.createElement('div');
container.classList.add('container');
const text = document.createElement('div');
text.classList.add('text');
text.innerText = 'speed';
this._input = document.createElement('input');
this._input.id = 'range';
this._input.type = 'range';
this._input.min = this._props.min.toString();
this._input.max = this._props.max.toString();
this._input.step = '1';
this._input.value = this._props.initialValue.toString();
this._input.classList.add('slider');
const percent = this.__getPercent(this._props.initialValue);
this._input.style.background = `linear-gradient(to right, #122DB2 ${percent}%, #F5F6F7 ${percent}%)`;
this._input.addEventListener('input', this._onInput);
container.appendChild(text);
container.appendChild(this._input);
return container;
}
__getPercent(value: number) {
return ((value - this._props.min) / (this._props.max - this._props.min)) * 100;
}
// Method for attaching the control to the map
_onAttach() {
this._element = this._createElement();
const control = new MMapControl({transparent: true}, this._element);
this.addChild(control);
}
// Method for detaching control from the map
_onDetach() {
this._input.removeEventListener('input', this._onInput);
}
_onInput = () => {
const value = Number(this._input.value);
this._props.onChange(value);
const percent = this.__getPercent(value);
this._input.style.background = `linear-gradient(to right, #122DB2 ${percent}%, #F5F6F7 ${percent}%)`;
};
}
let animation: DriverAnimation;
let driverSpeed = INITIAL_DRIVER_SPEED;
let prevCoordinates: LngLat;
const routeProgress = (initDistance: number) => {
let passedDistance = initDistance;
let passedTime = 0;
animation = animate((progress) => {
const timeS = (progress * ANIMATE_DURATION_MS) / 1000;
const length = passedDistance + driverSpeed * (timeS - passedTime);
const nextCoordinates = turf.along(route.geometry, length, {units: 'meters'}).geometry
.coordinates as LngLat;
marker.update({coordinates: nextCoordinates});
if (prevCoordinates && !turf.booleanEqual(turf.point(prevCoordinates), turf.point(nextCoordinates))) {
const angle = angleFromCoordinate(prevCoordinates, nextCoordinates);
const markerElement = document.getElementById('marker');
markerElement.style.transform = `rotate(${angle}deg)`;
}
const [newLineStingFirstPart, newLineStringSecondPart] = splitLineString(route, nextCoordinates);
lineStringFirstPart.update({geometry: newLineStingFirstPart});
lineStringSecondPart.update({geometry: newLineStringSecondPart});
prevCoordinates = nextCoordinates;
passedTime = timeS;
passedDistance = length;
if (progress === 1 && routeLength > length) {
routeProgress(length);
}
});
};
const lineStringSecondPart = new MMapFeature({
geometry: {coordinates: [], type: 'LineString'},
style: PASSED_ROUTE_STYLE
});
const lineStringFirstPart = new MMapFeature({
geometry: {coordinates: [], type: 'LineString'},
style: ROUTE_STYLE
});
map.addChild(new MMapDefaultMarker(ROUTE.start));
map.addChild(new MMapDefaultMarker(ROUTE.end));
const markerElement = document.createElement('div');
markerElement.classList.add('marker_container');
const markerElementImg = document.createElement('img');
markerElementImg.src = MARKER_IMAGE_PATH;
markerElementImg.alt = 'marker';
markerElementImg.id = 'marker';
markerElement.appendChild(markerElementImg);
const marker = new MMapMarker(
{
coordinates: ROUTE.start.coordinates,
disableRoundCoordinates: true
},
markerElement
);
map.addChild(marker);
map.addChild(
new MMapControls({position: 'bottom'}, [
new ResetButton({
onClick: () => {
const animationId = animation.getAnimationId();
cancelAnimationFrame(animationId);
marker.update({coordinates: ROUTE.start.coordinates});
routeProgress(0);
}
})
])
).addChild(
new MMapControls({position: 'top right'}, [
new SpeedRange({
initialValue: INITIAL_DRIVER_SPEED,
min: MIN_DRIVER_SPEED,
max: MAX_DRIVER_SPEED,
onChange: (value) => {
driverSpeed = value;
}
})
])
);
const route = await fetchRoute(ROUTE.start.coordinates, ROUTE.end.coordinates);
const routeLength = turf.length(turf.lineString(route.geometry.coordinates), {units: 'meters'});
lineStringFirstPart.update({geometry: route.geometry});
map.addChild(lineStringFirstPart);
map.addChild(lineStringSecondPart);
routeProgress(0);
}
</script>
<!-- prettier-ignore -->
<style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; } </style>
<link rel="stylesheet" href="./common.css" />
</head>
<body>
<div id="app"></div>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<script crossorigin src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/@turf/turf@7"></script>
<!-- To make the map appear, you must add your apikey -->
<script src="https://js.api.mappable.world/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
<script
data-plugins="transform-modules-umd"
data-presets="react, typescript"
type="text/babel"
src="./common.ts"
></script>
<script
data-plugins="transform-modules-umd"
data-presets="react, typescript"
type="text/babel"
src="../variables.ts"
></script>
<script data-plugins="transform-modules-umd" data-presets="react, typescript" type="text/babel">
import type {LineStringGeometry, LngLat, RouteFeature} from '@mappable-world/mappable-types';
import {
ANIMATE_DURATION_MS,
DriverAnimation,
angleFromCoordinate,
animate,
fetchRoute,
splitLineString
} from './common';
import {
INITIAL_DRIVER_SPEED,
LOCATION,
MARKER_IMAGE_PATH,
MAX_DRIVER_SPEED,
MIN_DRIVER_SPEED,
PASSED_ROUTE_STYLE,
ROUTE,
ROUTE_STYLE
} from '../variables';
import type {ChangeEvent, CSSProperties} from 'react';
window.map = null;
main();
async function main() {
// For each object in the JS API, there is a React counterpart
// To use the React version of the API, include the module @mappable-world/mappable-reactify
const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]);
const reactify = mappableReact.reactify.bindTo(React, ReactDOM);
const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapMarker, MMapFeature, MMapControls, MMapControl} =
reactify.module(mappable);
// Import the package to add a default marker
const {MMapDefaultMarker} = await reactify.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
const {useState, useMemo, useEffect, useRef, useCallback} = React;
const getPercent = (value: number) => {
return ((value - MIN_DRIVER_SPEED) / (MAX_DRIVER_SPEED - MIN_DRIVER_SPEED)) * 100;
};
function App() {
const animation = useRef<DriverAnimation>();
const route = useRef<RouteFeature>();
const routeLength = useRef<number>(0);
const prevCoordinates = useRef<LngLat>();
const driverSpeedRef = useRef(INITIAL_DRIVER_SPEED);
const [coordinates, setCoordinates] = useState(ROUTE.start.coordinates);
const [angle, setAngle] = useState(0);
const [lineStringSecondPart, setLineStringSecondPart] = useState<LineStringGeometry>({
type: 'LineString',
coordinates: []
});
const [lineStringFirstPart, setLineStringFirstPart] = useState<LineStringGeometry>({
type: 'LineString',
coordinates: []
});
const [sliderStyle, setSliderStyle] = useState<CSSProperties>({
background: `linear-gradient(to right, #122DB2 ${getPercent(driverSpeedRef.current)}%, #F5F6F7 ${getPercent(
driverSpeedRef.current
)}%)`
});
const onRestartClick = useCallback(() => {
const animationId = animation.current.getAnimationId();
cancelAnimationFrame(animationId);
setCoordinates(ROUTE.start.coordinates);
routeProgress(0);
}, []);
const onSliderChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value);
driverSpeedRef.current = value;
setSliderStyle({
background: `linear-gradient(to right, #122DB2 ${getPercent(value)}%, #F5F6F7 ${getPercent(value)}%)`
});
}, []);
useEffect(() => {
fetchRoute(ROUTE.start.coordinates, ROUTE.end.coordinates).then((routeRes) => {
route.current = routeRes;
routeLength.current = turf.length(turf.lineString(route.current.geometry.coordinates), {
units: 'meters'
});
setLineStringFirstPart(route.current.geometry);
routeProgress(0);
});
}, []);
const routeProgress = (initDistance: number) => {
let passedDistance = initDistance;
let passedTime = 0;
animation.current = animate((progress) => {
const timeS = (progress * ANIMATE_DURATION_MS) / 1000;
const length = passedDistance + driverSpeedRef.current * (timeS - passedTime);
const nextCoordinates = turf.along(route.current.geometry, length, {units: 'meters'}).geometry
.coordinates as LngLat;
setCoordinates(nextCoordinates);
if (
prevCoordinates.current &&
!turf.booleanEqual(turf.point(prevCoordinates.current), turf.point(nextCoordinates))
) {
setAngle(angleFromCoordinate(prevCoordinates.current, nextCoordinates));
}
const [newLineStingFirstPart, newLineStringSecondPart] = splitLineString(
route.current,
nextCoordinates
);
setLineStringFirstPart(newLineStingFirstPart);
setLineStringSecondPart(newLineStringSecondPart);
prevCoordinates.current = nextCoordinates;
passedTime = timeS;
passedDistance = length;
if (progress === 1 && routeLength.current > length) {
routeProgress(length);
}
});
};
return (
// Initialize the map and pass initialization parameters
<MMap location={LOCATION} showScaleInCopyrights={true} ref={(x) => (map = x)}>
{/* Add a map scheme layer */}
<MMapDefaultSchemeLayer />
{/* Add a layer of geo objects to display the markers */}
<MMapDefaultFeaturesLayer />
<MMapDefaultMarker {...ROUTE.start} />
<MMapDefaultMarker {...ROUTE.end} />
<MMapFeature geometry={lineStringFirstPart} style={ROUTE_STYLE} />
<MMapFeature geometry={lineStringSecondPart} style={PASSED_ROUTE_STYLE} />
<MMapMarker disableRoundCoordinates coordinates={coordinates}>
<div className="marker_container">
<img src={MARKER_IMAGE_PATH} alt="marker" style={{transform: `rotate(${angle}deg)`}} />
</div>
</MMapMarker>
<MMapControls position="bottom">
<MMapControl>
<button onClick={onRestartClick} className="button">
Restart
</button>
</MMapControl>
</MMapControls>
<MMapControls position="top right">
<MMapControl transparent={true}>
<div className="container">
<div className="text">speed</div>
<input
style={sliderStyle}
type="range"
defaultValue={INITIAL_DRIVER_SPEED}
min={MIN_DRIVER_SPEED}
onChange={onSliderChange}
max={MAX_DRIVER_SPEED}
step="1"
className="slider"
/>
</div>
</MMapControl>
</MMapControls>
</MMap>
);
}
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('app')
);
}
</script>
<!-- prettier-ignore -->
<style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; } </style>
<link rel="stylesheet" href="./common.css" />
</head>
<body>
<div id="app"></div>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<script crossorigin src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/@turf/turf@7"></script>
<script src="https://js.api.mappable.world/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
<script
data-plugins="transform-modules-umd"
data-presets="typescript"
type="text/babel"
src="./common.ts"
></script>
<script
data-plugins="transform-modules-umd"
data-presets="typescript"
type="text/babel"
src="../variables.ts"
></script>
<script data-plugins="transform-modules-umd" data-presets="typescript" type="text/babel"></script>
<!-- prettier-ignore -->
<style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; } </style>
<link rel="stylesheet" href="./common.css" />
</head>
<body>
<div id="app"></div>
</body>
</html>
.marker_container {
position: absolute;
transform: translate(-50%, -50%) scale(0.5);
}
.container {
display: flex;
align-items: center;
width: 210px;
padding: 16px;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 12px 0 rgba(95, 105, 131, 0.1), 0 4px 24px 0 rgba(95, 105, 131, 0.04);
gap: 12px;
}
.text {
font-size: 14px;
font-style: normal;
line-height: 16px;
color: #050d33;
}
.button {
width: 120px;
height: 40px;
margin: 0 auto;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-align: center;
border: none;
border-radius: 12px;
background-color: #fff;
}
input[type='range'] {
width: 100%;
height: 2px;
cursor: pointer;
outline: none;
background: linear-gradient(to right, #122db2 50%, #f5f6f7 50%);
-webkit-appearance: none;
appearance: none;
}
input[type='range']::-webkit-slider-thumb {
width: 16px;
height: 16px;
cursor: pointer;
border: 2px solid #122db2;
border-radius: 50%;
background-color: #fff;
-webkit-appearance: none;
appearance: none;
}
import type {LineStringGeometry, LngLat, RouteFeature} from '@mappable-world/mappable-types';
// Wait for the api to load to access the map configuration
mappable.ready.then(() => {
mappable.import.registerCdn(
'https://cdn.jsdelivr.net/npm/{package}',
'@mappable-world/mappable-default-ui-theme@0.0'
);
});
export async function fetchRoute(startCoordinates: LngLat, endCoordinates: LngLat) {
// Request a route from the Router API with the specified parameters.
const routes = await mappable.route({
points: [startCoordinates, endCoordinates], // Start and end points of the route LngLat[]
type: 'driving', // Type of the route
bounds: true // Flag indicating whether to include route boundaries in the response
});
// Check if a route was found
if (!routes[0]) return;
// Convert the received route to a RouteFeature object.
const route = routes[0].toRoute();
// Check if a route has coordinates
if (route.geometry.coordinates.length == 0) return;
return route;
}
export const ANIMATE_DURATION_MS = 4000;
export type DriverAnimation = {
getAnimationId: () => number;
};
export function animate(cb: (progress: number) => void): DriverAnimation {
let animationId = 0;
const startTime = Date.now();
function tick() {
const progress = (Date.now() - startTime) / ANIMATE_DURATION_MS;
if (progress >= 1) {
cb(1);
return;
}
cb(progress);
animationId = requestAnimationFrame(tick);
}
animationId = requestAnimationFrame(tick);
return {
getAnimationId: () => animationId
};
}
export function angleFromCoordinate(lngLat1: LngLat, lngLat2: LngLat) {
const toRadians = (degrees: number) => degrees * (Math.PI / 180);
const toDegrees = (radians: number) => radians * (180 / Math.PI);
const dLon = toRadians(lngLat2[0] - lngLat1[0]);
const y = Math.sin(dLon) * Math.cos(toRadians(lngLat2[1]));
const x =
Math.cos(toRadians(lngLat1[1])) * Math.sin(toRadians(lngLat2[1])) -
Math.sin(toRadians(lngLat1[1])) * Math.cos(toRadians(lngLat2[1])) * Math.cos(dLon);
let deg = Math.atan2(y, x);
deg = toDegrees(deg);
deg = (deg + 360) % 360;
return deg;
}
export function splitLineString(route: RouteFeature, coordinates: LngLat) {
if (!route || !coordinates) {
return [];
}
const firstPart = turf.lineSlice(
coordinates,
route.geometry.coordinates[route.geometry.coordinates.length - 1],
route.geometry
);
const secondPart = turf.lineSlice(route.geometry.coordinates[0], coordinates, route.geometry);
return [firstPart.geometry as LineStringGeometry, secondPart.geometry as LineStringGeometry];
}
import type {DrawingStyle, MMapLocationRequest} from '@mappable-world/mappable-types';
import type {MMapDefaultMarkerProps} from '@mappable-world/mappable-default-ui-theme';
export const LOCATION: MMapLocationRequest = {
center: [55.4355, 25.3461], // starting position [lng, lat]
zoom: 14.0 // starting zoom
};
export const INITIAL_DRIVER_SPEED = 210;
export const MIN_DRIVER_SPEED = 20;
export const MAX_DRIVER_SPEED = 400;
export const ROUTE: {start: Partial<MMapDefaultMarkerProps>; end: Partial<MMapDefaultMarkerProps>} = {
start: {
zIndex: 1000,
size: 'small',
title: 'Home',
iconName: 'building',
coordinates: [55.4609, 25.3398]
},
end: {
zIndex: 1000,
size: 'normal',
title: 'Al Hisn Fort',
iconName: 'fallback',
coordinates: [55.3862, 25.3587]
}
};
export const ROUTE_STYLE: DrawingStyle = {
simplificationRate: 0,
stroke: [
{color: '#34D9AD', width: 7},
{color: '#050D33', opacity: 0.4, width: 9}
]
};
export const PASSED_ROUTE_STYLE: DrawingStyle = {
simplificationRate: 0,
stroke: [{color: '#050D33', opacity: 0.4, width: 9}]
};
export const MARKER_IMAGE_PATH = '../marker-black.png';