Topic:
ProjectBuild the Mapty app with geolocation, Leaflet maps, and OOP architecture.
- Project Overview
- How to Plan a Web Project
- Using the Geolocation API min
- Displaying a Map Using Leaflet Library
- Displaying a Map Marker
- Rendering Workout Input Form
- Project Architecture
- Refactoring for Project Architecture
- Managing Workout Data: Creating Classes
- Creating a New Workout
- Rendering Workouts
- Move to Marker On Click
- Working with local Storage
- Final Considerations
📝 What We're Building: Mapty is a workout tracking application that uses the browser's Geolocation API to pinpoint your location and the Leaflet library to display an interactive map. You can click anywhere on the map to log a running or cycling workout, and your data persists across page reloads using
localStorage.
Architecture Pattern: This project uses a class-based architecture with:
- App class: Central controller managing state and UI
- Workout classes:
RunningandCyclingextending a baseWorkoutclass - Geolocation API: Browser API for GPS coordinates
- Leaflet: Open-source library for interactive maps
- localStorage: Browser API for persistent client-side storage
🎯 Learning Goals: By building this app, you'll practice OOP inheritance, async geolocation, third-party library integration, and browser storage APIs.
🖼️ Visual: Mapty App OOP, Geolocation, External Libraries, and More!
🖼️ Visual: Mapty App OOP, Geolocation, External Libraries, and More!
🖼️ Visual: Mapty App OOP, Geolocation, External Libraries, and More!
🖼️ Visual: Mapty App OOP, Geolocation, External Libraries, and More!
📝 The Geolocation API: The browser exposes
navigator.geolocationto access the device's GPS.getCurrentPosition()takes two callbacks: a success handler that receives coordinates, and an error handler for when permission is denied or GPS is unavailable.
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
function (position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
console.log(`https://www.google.pt/maps/@${latitude},${longitude}`);
},
function () {
alert('Could not get your position');
}
);
}📝 Leaflet is the most popular open-source JavaScript library for interactive maps. It's lightweight, mobile-friendly, and easy to extend.
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
function (position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
const coords = [latitude, longitude];
const map = L.map('map').setView(coords, 15); // 'map' is the container ID
// Add OpenStreetMap tiles (the actual map imagery)
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
// Place a marker at user's location
L.marker(coords)
.addTo(map)
.bindPopup('A pretty CSS3 popup.<br> Easily customizable.')
.openPopup();
},
function () {
alert(`Couldn't get the Location`);
}
);
}📝 Handling Map Clicks: The map itself can listen for click events. When the user clicks anywhere, Leaflet provides a
mapEventobject containing the exact latitude and longitude of the click viamapEvent.latlng.
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
function (position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
const coords = [latitude, longitude];
const map = L.map('map').setView(coords, 15);
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
// Listen for clicks anywhere on the map
map.on('click', function (mapEvent) {
const { lat, lng } = mapEvent.latlng; // Destructure click coordinates
// Create a custom popup at the clicked location
L.marker([lat, lng])
.addTo(map)
.bindPopup(
L.popup({
maxWidth: 250,
minWidth: 100,
autoClose: false, // Popup stays open until manually closed
closeOnClick: false, // Clicking map doesn't close popup
className: 'running-popup',
})
)
.setPopupContent('Workout')
.openPopup();
});
},
function () {
alert(`Couldn't get the Location`);
}
);
}let map, mapEvent;
if (navigator.geolocation)
navigator.geolocation.getCurrentPosition(
function (position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
const coords = [latitude, longitude];
map = L.map('map').setView(coords, 15);
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
// Handling click on map
map.on('click', function (mapE) {
mapEvent = mapE;
form.classList.remove('hidden');
inputDistance.focus();
});
},
function () {
alert(`Couldn't get the Location`);
}
);
form.addEventListener('submit', function (e) {
e.preventDefault();
// Clear input fields
inputDistance.value =
inputDuration.value =
inputCadence.value =
inputElevation.value =
'';
// Display Marker
const { lat, lng } = mapEvent.latlng;
L.marker([lat, lng])
.addTo(map)
.bindPopup(
L.popup({
maxWidth: 250,
minWidth: 100,
autoClose: false,
closeOnClick: false,
className: 'running-popup',
})
)
.setPopupContent('Workout')
.openPopup();
});
inputType.addEventListener('change', function () {
inputElevation.closest('.form__row').classList.toggle('form__row--hidden');
inputCadence.closest('.form__row').classList.toggle('form__row--hidden');
});🖼️ Visual: Mapty App OOP, Geolocation, External Libraries, and More!
class App {
#map;
#mapEvent;
constructor() {
this._getPosition();
form.addEventListener('submit', this._newWorkout.bind(this));
inputType.addEventListener('change', this._toggleElevationField);
}
_getPosition() {
if (navigator.geolocation)
navigator.geolocation.getCurrentPosition(
this._loadMap.bind(this),
function () {
alert(`Couldn't get the Location`);
}
);
}
_loadMap(position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
const coords = [latitude, longitude];
this.#map = L.map('map').setView(coords, 15);
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(this.#map);
// Handling click on map
this.#map.on('click', this._showForm.bind(this));
}
_showForm(mapE) {
this.#mapEvent = mapE;
form.classList.remove('hidden');
inputDistance.focus();
}
_toggleElevationField() {
inputElevation.closest('.form__row').classList.toggle('form__row--hidden');
inputCadence.closest('.form__row').classList.toggle('form__row--hidden');
}
_newWorkout(e) {
e.preventDefault();
// Clear input fields
inputDistance.value =
inputDuration.value =
inputCadence.value =
inputElevation.value =
'';
// Display Marker
const { lat, lng } = this.#mapEvent.latlng;
L.marker([lat, lng])
.addTo(this.#map)
.bindPopup(
L.popup({
maxWidth: 250,
minWidth: 100,
autoClose: false,
closeOnClick: false,
className: 'running-popup',
})
)
.setPopupContent('Workout')
.openPopup();
}
}
const app = new App();class Workout {
date = new Date();
id = (Date.now() + '').slice(-10);
constructor(coords, distance, duration) {
this.coords = coords; // [lat, lng]
this.distance = distance; // in Km
this.duration = duration; // in min
}
}
class Running extends Workout {
constructor(coords, distance, duration, cadence) {
super(coords, distance, duration);
this.cadence = cadence;
this.clacPace();
}
clacPace() {
// Min/km
this.pace = this.duration / this.distance;
return this.pace;
}
}
class Cycling extends Workout {
constructor(coords, distance, duration, elevationGain) {
super(coords, distance, duration);
this.elevationGain = elevationGain;
this.calcSpeed();
}
calcSpeed() {
// Km/hr
this.speed = this.distance / this.duration / 60;
return this.speed;
}
}
console.log(run1, cycling1);
class App {
#map;
#mapEvent;
constructor() {
this._getPosition();
form.addEventListener('submit', this._newWorkout.bind(this));
inputType.addEventListener('change', this._toggleElevationField);
}
_getPosition() {
if (navigator.geolocation)
navigator.geolocation.getCurrentPosition(
this._loadMap.bind(this),
function () {
alert(`Couldn't get the Location`);
}
);
}
_loadMap(position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
const coords = [latitude, longitude];
this.#map = L.map('map').setView(coords, 15);
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(this.#map);
// Handling click on map
this.#map.on('click', this._showForm.bind(this));
}
_showForm(mapE) {
this.#mapEvent = mapE;
form.classList.remove('hidden');
inputDistance.focus();
}
_toggleElevationField() {
inputElevation.closest('.form__row').classList.toggle('form__row--hidden');
inputCadence.closest('.form__row').classList.toggle('form__row--hidden');
}
_newWorkout(e) {
e.preventDefault();
// Clear input fields
inputDistance.value =
inputDuration.value =
inputCadence.value =
inputElevation.value =
'';
// Display Marker
const { lat, lng } = this.#mapEvent.latlng;
L.marker([lat, lng])
.addTo(this.#map)
.bindPopup(
L.popup({
maxWidth: 250,
minWidth: 100,
autoClose: false,
closeOnClick: false,
className: 'running-popup',
})
)
.setPopupContent('Workout')
.openPopup();
}
}
const app = new App();class Workout {
date = new Date();
id = (Date.now() + '').slice(-10);
constructor(coords, distance, duration) {
this.coords = coords; // [lat, lng]
this.distance = distance; // in Km
this.duration = duration; // in min
}
}
class Running extends Workout {
type = 'running';
constructor(coords, distance, duration, cadence) {
super(coords, distance, duration);
this.cadence = cadence;
this.clacPace();
}
clacPace() {
// Min/km
this.pace = this.duration / this.distance;
return this.pace;
}
}
class Cycling extends Workout {
type = 'cycling';
constructor(coords, distance, duration, elevationGain) {
super(coords, distance, duration);
this.elevationGain = elevationGain;
this.calcSpeed();
}
calcSpeed() {
// Km/hr
this.speed = this.distance / this.duration / 60;
return this.speed;
}
}
///////////////////////////////////////
// APPLICATION ARCHITECTURE
const form = document.querySelector('.form');
const containerWorkouts = document.querySelector('.workouts');
const inputType = document.querySelector('.form__input--type');
const inputDistance = document.querySelector('.form__input--distance');
const inputDuration = document.querySelector('.form__input--duration');
const inputCadence = document.querySelector('.form__input--cadence');
const inputElevation = document.querySelector('.form__input--elevation');
class App {
#map;
#mapEvent;
#workouts = [];
constructor() {
this._getPosition();
form.addEventListener('submit', this._newWorkout.bind(this));
inputType.addEventListener('change', this._toggleElevationField);
}
_getPosition() {
if (navigator.geolocation)
navigator.geolocation.getCurrentPosition(
this._loadMap.bind(this),
function () {
alert(`Couldn't get the Location`);
}
);
}
_loadMap(position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
const coords = [latitude, longitude];
this.#map = L.map('map').setView(coords, 15);
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(this.#map);
// Handling click on map
this.#map.on('click', this._showForm.bind(this));
}
_showForm(mapE) {
this.#mapEvent = mapE;
form.classList.remove('hidden');
inputDistance.focus();
}
_toggleElevationField() {
inputElevation.closest('.form__row').classList.toggle('form__row--hidden');
inputCadence.closest('.form__row').classList.toggle('form__row--hidden');
}
_newWorkout(e) {
e.preventDefault();
const validInputs = (...inputs) =>
inputs.every(inp => Number.isFinite(inp));
const allPositives = (...inputs) => inputs.every(inp => inp > 0);
// Get the data from the form
const type = inputType.value;
// + sign is to convert string into Number
const distance = +inputDistance.value;
const duration = +inputDuration.value;
const { lat, lng } = this.#mapEvent.latlng;
let workout;
// If workout running, creacte running object
if (type == 'running') {
const cadence = +inputCadence.value;
console.log(cadence, distance, duration);
// Check if the data is valid
if (
// !Number.isFinite(distance) ||
// !Number.isFinite(duration) ||
// !Number.isFinite(cadence)
!validInputs(distance, duration, cadence) ||
!allPositives(distance, duration, cadence)
)
return alert('Input have to be positive numbers');
workout = new Running([lat, lng], distance, duration, cadence);
}
// If workout cycling, creacte cycling object
if (type == 'cycling') {
const elevation = +inputElevation.value;
if (
!validInputs(distance, duration, elevation) ||
!allPositives(distance, duration)
)
return alert('Input have to be positive numbers');
workout = new Cycling([lat, lng], distance, duration, elevation);
}
// Add new object to workout array
this.#workouts.push(workout);
console.log(workout);
// Render workout on map as marker
this.renderWorkoutMarker(workout);
// Clear input fields
inputDistance.value =
inputDuration.value =
inputCadence.value =
inputElevation.value =
'';
}
renderWorkoutMarker(workout) {
L.marker(workout.coords)
.addTo(this.#map)
.bindPopup(
L.popup({
maxWidth: 250,
minWidth: 100,
autoClose: false,
closeOnClick: false,
className: `${workout.type}-popup`,
})
)
.setPopupContent('workout')
.openPopup();
}
}
const app = new App();'use strict';
class Workout {
date = new Date();
id = (new Date() + '').slice(-10);
constructor(coords, distance, duration) {
this.coords = coords; // [lat, lng]
this.distance = distance; // in Km
this.duration = duration; // in min
}
_setDescription() {
// Prettier-ignore
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
this.description = `${this.type[0].toUpperCase()}${this.type.slice(1)} on ${
months[this.date.getMonth()]
} ${this.date.getDate()}`;
}
}
class Running extends Workout {
type = 'running';
constructor(coords, distance, duration, cadence) {
super(coords, distance, duration);
this.cadence = cadence;
this.clacPace();
this._setDescription();
}
clacPace() {
// Min/km
this.pace = this.duration / this.distance;
return this.pace;
}
}
class Cycling extends Workout {
type = 'cycling';
constructor(coords, distance, duration, elevationGain) {
super(coords, distance, duration);
this.elevationGain = elevationGain;
this.calcSpeed();
this._setDescription();
}
calcSpeed() {
// Km/hr
this.speed = this.distance / this.duration / 60;
return this.speed;
}
}
///////////////////////////////////////
// APPLICATION ARCHITECTURE
const form = document.querySelector('.form');
const containerWorkouts = document.querySelector('.workouts');
const inputType = document.querySelector('.form__input--type');
const inputDistance = document.querySelector('.form__input--distance');
const inputDuration = document.querySelector('.form__input--duration');
const inputCadence = document.querySelector('.form__input--cadence');
const inputElevation = document.querySelector('.form__input--elevation');
class App {
#map;
#mapEvent;
#workouts = [];
constructor() {
this._getPosition();
form.addEventListener('submit', this._newWorkout.bind(this));
inputType.addEventListener('change', this._toggleElevationField);
}
_getPosition() {
if (navigator.geolocation)
navigator.geolocation.getCurrentPosition(
this._loadMap.bind(this),
function () {
alert(`Couldn't get the Location`);
}
);
}
_loadMap(position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
const coords = [latitude, longitude];
this.#map = L.map('map').setView(coords, 15);
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(this.#map);
// Handling click on map
this.#map.on('click', this._showForm.bind(this));
}
_showForm(mapE) {
this.#mapEvent = mapE;
form.classList.remove('hidden');
inputDistance.focus();
}
_hideForm() {
// Empty inputs
inputDistance.value =
inputDuration.value =
inputCadence.value =
inputElevation.value =
'';
form.style.display = 'none';
form.classList.add('hidden');
setTimeout(() => (form.style.display = 'grid'), 1000);
}
_toggleElevationField() {
inputElevation.closest('.form__row').classList.toggle('form__row--hidden');
inputCadence.closest('.form__row').classList.toggle('form__row--hidden');
}
_newWorkout(e) {
e.preventDefault();
const validInputs = (...inputs) =>
inputs.every(inp => Number.isFinite(inp));
const allPositives = (...inputs) => inputs.every(inp => inp > 0);
// Get the data from the form
const type = inputType.value;
// + sign is to convert string into Number
const distance = +inputDistance.value;
const duration = +inputDuration.value;
const { lat, lng } = this.#mapEvent.latlng;
let workout;
// If workout running, creacte running object
if (type == 'running') {
const cadence = +inputCadence.value;
console.log(cadence, distance, duration);
// Check if the data is valid
if (
// !Number.isFinite(distance) ||
// !Number.isFinite(duration) ||
// !Number.isFinite(cadence)
!validInputs(distance, duration, cadence) ||
!allPositives(distance, duration, cadence)
)
return alert('Input have to be positive numbers');
workout = new Running([lat, lng], distance, duration, cadence);
}
// If workout cycling, creacte cycling object
if (type == 'cycling') {
const elevation = +inputElevation.value;
if (
!validInputs(distance, duration, elevation) ||
!allPositives(distance, duration)
)
return alert('Input have to be positive numbers');
workout = new Cycling([lat, lng], distance, duration, elevation);
}
// Add new object to workout array
this.#workouts.push(workout);
console.log(workout);
// Render workout on map as marker
this._renderWorkoutMarker(workout);
// Render workout on list
this._renderWorkout(workout);
// Clear input fields
this._hideForm();
}
_renderWorkoutMarker(workout) {
L.marker(workout.coords)
.addTo(this.#map)
.bindPopup(
L.popup({
maxWidth: 250,
minWidth: 100,
autoClose: false,
closeOnClick: false,
className: `${workout.type}-popup`,
})
)
.setPopupContent(
`${workout.type === 'running' ? '🏃♂️' : '🚴🏻♀️'} ${workout.description}`
)
.openPopup();
}
_renderWorkout(workout) {
let html = `
<li class="workout workout--${workout.type}" data-id="${workout.id}">
<h2 class="workout__title">${workout.description}</h2>
<div class="workout__details">
<span class="workout__icon">${
workout.type === 'running' ? '🏃♂️' : '🚴🏻♀️'
}</span>
<span class="workout__value">${workout.distance}</span>
<span class="workout__unit">km</span>
</div>
<div class="workout__details">
<span class="workout__icon">⏱</span>
<span class="workout__value">${workout.duration}</span>
<span class="workout__unit">min</span>
</div>`;
if (workout.type === 'running')
html += `
<div class="workout__details">
<span class="workout__icon">⚡️</span>
<span class="workout__value">${workout.pace.toFixed(1)}</span>
<span class="workout__unit">min/km</span>
</div>
<div class="workout__details">
<span class="workout__icon">🦶🏼</span>
<span class="workout__value">${workout.cadence}</span>
<span class="workout__unit">spm</span>
</div>
</li>`;
if (workout.type === 'cycling')
html += `
<div class="workout__details">
<span class="workout__icon">⚡️</span>
<span class="workout__value">${workout.speed.toFixed(1)}</span>
<span class="workout__unit">km/h</span>
</div>
<div class="workout__details">
<span class="workout__icon">⛰</span>
<span class="workout__value">${workout.elevationGain}</span>
<span class="workout__unit">m</span>
</div>
`;
form.insertAdjacentHTML('afterend', html);
}
}
const app = new App();'use strict';
class Workout {
date = new Date();
id = (Date.now() + '').slice(-10);
clicks = 0;
constructor(coords, distance, duration) {
this.coords = coords; // [lat, lng]
this.distance = distance; // in Km
this.duration = duration; // in min
}
_setDescription() {
// Prettier-ignore
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
this.description = `${this.type[0].toUpperCase()}${this.type.slice(1)} on ${
months[this.date.getMonth()]
} ${this.date.getDate()}`;
}
click() {
this.clicks++;
}
}
class Running extends Workout {
type = 'running';
constructor(coords, distance, duration, cadence) {
super(coords, distance, duration);
this.cadence = cadence;
this.clacPace();
this._setDescription();
}
clacPace() {
// Min/km
this.pace = this.duration / this.distance;
return this.pace;
}
}
class Cycling extends Workout {
type = 'cycling';
constructor(coords, distance, duration, elevationGain) {
super(coords, distance, duration);
this.elevationGain = elevationGain;
this.calcSpeed();
this._setDescription();
}
calcSpeed() {
// Km/hr
this.speed = this.distance / this.duration / 60;
return this.speed;
}
}
///////////////////////////////////////
// APPLICATION ARCHITECTURE
const form = document.querySelector('.form');
const containerWorkouts = document.querySelector('.workouts');
const inputType = document.querySelector('.form__input--type');
const inputDistance = document.querySelector('.form__input--distance');
const inputDuration = document.querySelector('.form__input--duration');
const inputCadence = document.querySelector('.form__input--cadence');
const inputElevation = document.querySelector('.form__input--elevation');
class App {
#map;
#mapZoomLevel = 13;
#mapEvent;
#workouts = [];
constructor() {
this._getPosition();
form.addEventListener('submit', this._newWorkout.bind(this));
inputType.addEventListener('change', this._toggleElevationField);
containerWorkouts.addEventListener('click', this._moveToPopup.bind(this));
}
_getPosition() {
if (navigator.geolocation)
navigator.geolocation.getCurrentPosition(
this._loadMap.bind(this),
function () {
alert(`Couldn't get the Location`);
}
);
}
_loadMap(position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
const coords = [latitude, longitude];
this.#map = L.map('map').setView(coords, this.#mapZoomLevel);
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(this.#map);
// Handling click on map
this.#map.on('click', this._showForm.bind(this));
}
_showForm(mapE) {
this.#mapEvent = mapE;
form.classList.remove('hidden');
inputDistance.focus();
}
_hideForm() {
// Empty inputs
inputDistance.value =
inputDuration.value =
inputCadence.value =
inputElevation.value =
'';
form.style.display = 'none';
form.classList.add('hidden');
setTimeout(() => (form.style.display = 'grid'), 1000);
}
_toggleElevationField() {
inputElevation.closest('.form__row').classList.toggle('form__row--hidden');
inputCadence.closest('.form__row').classList.toggle('form__row--hidden');
}
_newWorkout(e) {
e.preventDefault();
const validInputs = (...inputs) =>
inputs.every(inp => Number.isFinite(inp));
const allPositives = (...inputs) => inputs.every(inp => inp > 0);
// Get the data from the form
const type = inputType.value;
// + sign is to convert string into Number
const distance = +inputDistance.value;
const duration = +inputDuration.value;
const { lat, lng } = this.#mapEvent.latlng;
let workout;
// If workout running, creacte running object
if (type == 'running') {
const cadence = +inputCadence.value;
console.log(cadence, distance, duration);
// Check if the data is valid
if (
// !Number.isFinite(distance) ||
// !Number.isFinite(duration) ||
// !Number.isFinite(cadence)
!validInputs(distance, duration, cadence) ||
!allPositives(distance, duration, cadence)
)
return alert('Input have to be positive numbers');
workout = new Running([lat, lng], distance, duration, cadence);
}
// If workout cycling, creacte cycling object
if (type == 'cycling') {
const elevation = +inputElevation.value;
if (
!validInputs(distance, duration, elevation) ||
!allPositives(distance, duration)
)
return alert('Input have to be positive numbers');
workout = new Cycling([lat, lng], distance, duration, elevation);
}
// Add new object to workout array
this.#workouts.push(workout);
console.log(workout);
// Render workout on map as marker
this._renderWorkoutMarker(workout);
// Render workout on list
this._renderWorkout(workout);
// Clear input fields
this._hideForm();
}
_renderWorkoutMarker(workout) {
L.marker(workout.coords)
.addTo(this.#map)
.bindPopup(
L.popup({
maxWidth: 250,
minWidth: 100,
autoClose: false,
closeOnClick: false,
className: `${workout.type}-popup`,
})
)
.setPopupContent(
`${workout.type === 'running' ? '🏃♂️' : '🚴🏻♀️'} ${workout.description}`
)
.openPopup();
}
_renderWorkout(workout) {
let html = `
<li class="workout workout--${workout.type}" data-id="${workout.id}">
<h2 class="workout__title">${workout.description}</h2>
<div class="workout__details">
<span class="workout__icon">${
workout.type === 'running' ? '🏃♂️' : '🚴🏻♀️'
}</span>
<span class="workout__value">${workout.distance}</span>
<span class="workout__unit">km</span>
</div>
<div class="workout__details">
<span class="workout__icon">⏱</span>
<span class="workout__value">${workout.duration}</span>
<span class="workout__unit">min</span>
</div>`;
if (workout.type === 'running')
html += `
<div class="workout__details">
<span class="workout__icon">⚡️</span>
<span class="workout__value">${workout.pace.toFixed(1)}</span>
<span class="workout__unit">min/km</span>
</div>
<div class="workout__details">
<span class="workout__icon">🦶🏼</span>
<span class="workout__value">${workout.cadence}</span>
<span class="workout__unit">spm</span>
</div>
</li>`;
if (workout.type === 'cycling')
html += `
<div class="workout__details">
<span class="workout__icon">⚡️</span>
<span class="workout__value">${workout.speed.toFixed(1)}</span>
<span class="workout__unit">km/h</span>
</div>
<div class="workout__details">
<span class="workout__icon">⛰</span>
<span class="workout__value">${workout.elevationGain}</span>
<span class="workout__unit">m</span>
</div>
`;
form.insertAdjacentHTML('afterend', html);
}
_moveToPopup(e) {
const workoutEl = e.target.closest('.workout');
if (!workoutEl) return;
const workout = this.#workouts.find(
work => work.id === workoutEl.dataset.id
);
this.#map.setView(workout.coords, this.#mapZoomLevel, {
animate: true,
pan: {
duration: 1,
},
});
// Using the public interface
workout.click();
}
}
const app = new App();'use strict';
class Workout {
date = new Date();
id = (Date.now() + '').slice(-10);
clicks = 0;
constructor(coords, distance, duration) {
// This.date = ...
// This.id = ...
this.coords = coords; // [lat, lng]
this.distance = distance; // in km
this.duration = duration; // in min
}
_setDescription() {
// Prettier-ignore
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
this.description = `${this.type[0].toUpperCase()}${this.type.slice(1)} on ${
months[this.date.getMonth()]
} ${this.date.getDate()}`;
}
click() {
this.clicks++;
}
}
class Running extends Workout {
type = 'running';
constructor(coords, distance, duration, cadence) {
super(coords, distance, duration);
this.cadence = cadence;
this.calcPace();
this._setDescription();
}
calcPace() {
// Min/km
this.pace = this.duration / this.distance;
return this.pace;
}
}
class Cycling extends Workout {
type = 'cycling';
constructor(coords, distance, duration, elevationGain) {
super(coords, distance, duration);
this.elevationGain = elevationGain;
// This.type = 'cycling';
this.calcSpeed();
this._setDescription();
}
calcSpeed() {
// Km/h
this.speed = this.distance / (this.duration / 60);
return this.speed;
}
}
// Const run1 = new Running([39, -12], 5.2, 24, 178);
// Const cycling1 = new Cycling([39, -12], 27, 95, 523);
// console.log(run1, cycling1);
///////////////////////////////////////
// APPLICATION ARCHITECTURE
const form = document.querySelector('.form');
const containerWorkouts = document.querySelector('.workouts');
const inputType = document.querySelector('.form__input--type');
const inputDistance = document.querySelector('.form__input--distance');
const inputDuration = document.querySelector('.form__input--duration');
const inputCadence = document.querySelector('.form__input--cadence');
const inputElevation = document.querySelector('.form__input--elevation');
class App {
#map;
#mapZoomLevel = 13;
#mapEvent;
#workouts = [];
constructor() {
// Get user's position
this._getPosition();
// Get data from local storage
this._getLocalStorage();
// Attach event handlers
form.addEventListener('submit', this._newWorkout.bind(this));
inputType.addEventListener('change', this._toggleElevationField);
containerWorkouts.addEventListener('click', this._moveToPopup.bind(this));
}
_getPosition() {
if (navigator.geolocation)
navigator.geolocation.getCurrentPosition(
this._loadMap.bind(this),
function () {
alert('Could not get your position');
}
);
}
_loadMap(position) {
const { latitude } = position.coords;
const { longitude } = position.coords;
// console.log(`https://www.google.pt/maps/@${latitude},${longitude}`);
const coords = [latitude, longitude];
this.#map = L.map('map').setView(coords, this.#mapZoomLevel);
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(this.#map);
// Handling clicks on map
this.#map.on('click', this._showForm.bind(this));
}
_showForm(mapE) {
this.#mapEvent = mapE;
form.classList.remove('hidden');
inputDistance.focus();
}
_hideForm() {
// Empty inputs
inputDistance.value = inputDuration.value = inputCadence.value = inputElevation.value =
'';
form.style.display = 'none';
form.classList.add('hidden');
setTimeout(() => (form.style.display = 'grid'), 1000);
}
_toggleElevationField() {
inputElevation.closest('.form__row').classList.toggle('form__row--hidden');
inputCadence.closest('.form__row').classList.toggle('form__row--hidden');
}
_newWorkout(e) {
const validInputs = (...inputs) =>
inputs.every(inp => Number.isFinite(inp));
const allPositive = (...inputs) => inputs.every(inp => inp > 0);
e.preventDefault();
// Get data from form
const type = inputType.value;
const distance = +inputDistance.value;
const duration = +inputDuration.value;
const { lat, lng } = this.#mapEvent.latlng;
let workout;
// If workout running, create running object
if (type === 'running') {
const cadence = +inputCadence.value;
// Check if data is valid
if (
// !Number.isFinite(distance) ||
// !Number.isFinite(duration) ||
// !Number.isFinite(cadence)
!validInputs(distance, duration, cadence) ||
!allPositive(distance, duration, cadence)
)
return alert('Inputs have to be positive numbers!');
workout = new Running([lat, lng], distance, duration, cadence);
}
// If workout cycling, create cycling object
if (type === 'cycling') {
const elevation = +inputElevation.value;
if (
!validInputs(distance, duration, elevation) ||
!allPositive(distance, duration)
)
return alert('Inputs have to be positive numbers!');
workout = new Cycling([lat, lng], distance, duration, elevation);
}
// Add new object to workout array
this.#workouts.push(workout);
// Render workout on map as marker
this._renderWorkoutMarker(workout);
// Render workout on list
this._renderWorkout(workout);
// Hide form + clear input fields
this._hideForm();
// Set local storage to all workouts
this._setLocalStorage();
}
_renderWorkoutMarker(workout) {
L.marker(workout.coords)
.addTo(this.#map)
.bindPopup(
L.popup({
maxWidth: 250,
minWidth: 100,
autoClose: false,
closeOnClick: false,
className: `${workout.type}-popup`,
})
)
.setPopupContent(
`${workout.type === 'running' ? '🏃♂️' : '🚴♀️'} ${workout.description}`
)
.openPopup();
}
_renderWorkout(workout) {
let html = `
<li class="workout workout--${workout.type}" data-id="${workout.id}">
<h2 class="workout__title">${workout.description}</h2>
<div class="workout__details">
<span class="workout__icon">${
workout.type === 'running' ? '🏃♂️' : '🚴♀️'
}</span>
<span class="workout__value">${workout.distance}</span>
<span class="workout__unit">km</span>
</div>
<div class="workout__details">
<span class="workout__icon">⏱</span>
<span class="workout__value">${workout.duration}</span>
<span class="workout__unit">min</span>
</div>
`;
if (workout.type === 'running')
html += `
<div class="workout__details">
<span class="workout__icon">⚡️</span>
<span class="workout__value">${workout.pace.toFixed(1)}</span>
<span class="workout__unit">min/km</span>
</div>
<div class="workout__details">
<span class="workout__icon">🦶🏼</span>
<span class="workout__value">${workout.cadence}</span>
<span class="workout__unit">spm</span>
</div>
</li>
`;
if (workout.type === 'cycling')
html += `
<div class="workout__details">
<span class="workout__icon">⚡️</span>
<span class="workout__value">${workout.speed.toFixed(1)}</span>
<span class="workout__unit">km/h</span>
</div>
<div class="workout__details">
<span class="workout__icon">⛰</span>
<span class="workout__value">${workout.elevationGain}</span>
<span class="workout__unit">m</span>
</div>
</li>
`;
form.insertAdjacentHTML('afterend', html);
}
_moveToPopup(e) {
// BUGFIX: When we click on a workout before the map has loaded, we get an error. But there is an easy fix:
if (!this.#map) return;
const workoutEl = e.target.closest('.workout');
if (!workoutEl) return;
const workout = this.#workouts.find(
work => work.id === workoutEl.dataset.id
);
this.#map.setView(workout.coords, this.#mapZoomLevel, {
animate: true,
pan: {
duration: 1,
},
});
// Using the public interface
// Workout.click();
}
_setLocalStorage() {
localStorage.setItem('workouts', JSON.stringify(this.#workouts));
}
_getLocalStorage() {
const data = JSON.parse(localStorage.getItem('workouts'));
if (!data) return;
this.#workouts = data;
this.#workouts.forEach(work => {
this._renderWorkout(work);
});
}
reset() {
localStorage.removeItem('workouts');
location.reload();
}
}
const app = new App();- 10 Additional Features Ideas: Challenges
- Ability to edit a workout;
- Ability to delete a workout;
- Ability to delete all workouts;
- Ability to sort workouts by a certain field (e.g. distance);
- Re-build Running and Cycling objects coming from Local Storage; More realistic error and confirmation messages;
- Ability to position the map to show all workouts [very hard];
- Ability to draw lines and shapes instead of just points [very hard];
- Geocode location from coordinates ("Run in Faro, Portugal") [only after asynchronous JavaScript section];
- Display weather data for workout time and place [only after asynchronous JavaScript section].
| Task | Approach |
|---|---|
| Get user location | navigator.geolocation.getCurrentPosition(success, error) |
| Display map | Leaflet: L.map('map').setView(coords, zoom) |
| Add marker | L.marker(coords).addTo(map).bindPopup('text') |
| Store data | localStorage.setItem('workouts', JSON.stringify(data)) |
| Retrieve data | JSON.parse(localStorage.getItem('workouts')) |
| Architecture pattern | Class-based OOP with App controller |
💡 Best Practice: Always guard geolocation with
if (navigator.geolocation)for browsers that don't support it.
← 📄 13. Object-Oriented Programming (O0P) With JavaScript | 🏠 Home | End of Course →
Happy Coding! 🚀