diff --git a/index.js b/index.js index 1c95378..ae6797d 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,12 @@ api.get('/', (req, res) => { ipAddr: anonymizeip(clientIP), } + // For the initial page load on the main route, the server will attempt to + // geolocate the user based on their IP address. If the geolocation is + // successful, the server will then call the weather API with the coordinates + // and timezone obtained from the geolocation API. If the geolocation fails, + // the server will default to Toronto, Ontario, Canada, and call the weather + // API with the coordinates and timezone for Toronto. const weatherResp = geolocateFromIP(clientIP).then((coords) => { // If the geolocation is successful, format the name of the returned location, // then call the weather API with the coordinates and timezone. diff --git a/public/js/client.js b/public/js/client.js index 9c9060d..7670fb0 100644 --- a/public/js/client.js +++ b/public/js/client.js @@ -1,52 +1,133 @@ +/** Take a decimal coordinate (representing either latitude or longitude) and + * return an anonymized version of it, as this app exists to be used for demos, + * rather than for production purposes. + * @returns {String} The anonymized coordinate, truncated to two decimal places. + * @param {Number} coord The coordinate value to anonymize. + */ function anonymizeCoordinate(coord) { + + // We want to randomly add or substract a small value to the coordinate to + // anonymize it. To decide whether to add or subtract, we generate a random + // number, and if it is greater than 0.5, we add; otherwise, we subtract. const addOrSubtract = Math.random() > 0.5 ? 1 : -1 + + // The actual random variation is a value between 0 and 0.5, which will be + // multiplied by the `addOrSubtract` value to determine the direction of the + // variation. const randomVariation = Math.random() * 0.5 * addOrSubtract - return (parseFloat(coord) + randomVariation).toFixed(2) + + // Return the anonymized coordinate as a string with two decimal places, + // which reduces the specificity of the coordinate and adds to the + // anonymization effect. + const anonymizedCoord = (parseFloat(coord) + randomVariation).toFixed(2) + return anonymizedCoord } +/** Utility function to get the local timezone of the user's browser. + * @returns {String} The local timezone of the user's browser. + * */ function getLocalTimezone() { return new Intl.DateTimeFormat().resolvedOptions().timeZone } +/** + * Fetch the user's geolocation based on their IP address. + * + * This function calls the `/geolocate` endpoint on the server, which will + * call a third-party API to get the user's geolocation based on their IP address. + * @returns {Object} An object containing the user's geolocation data. + */ async function getGeolocationFromIP() { - const response = await fetch('/geolocate') - return response.json() + try { + const response = await fetch('/geolocate') + return response.json() + } catch (e) { + console.error('Error getting geolocation from IP:', e) + } } +/** Update the text on the page to show the user's location in a human-readable + * format. + * + * This is done by updating the inner HTML of the `#coords` element on the page. + * @param {string} lat - The latitude of the user's location. + * @param {string} lon - The longitude of the user's location. + * */ function updateLocationText(lat, lon) { - const latString = `${Math.abs(lat)}° ${lat > 0 ? 'N' : 'S'}`; - const lonString = `${Math.abs(lon)}° ${lon > 0 ? 'E' : 'W'}`; + + // Format the latitude and longitude values to two decimal places, and add + // the appropriate compass direction (N, S, E, W) based on the sign of the + // coordinate. + const latString = `${parseFloat(Math.abs(lat)).toFixed(2)}° ${lat > 0 ? 'N' : 'S'}`; + const lonString = `${parseFloat(Math.abs(lon)).toFixed(2)}° ${lon > 0 ? 'E' : 'W'}`; + + // Update the text on the page to show the user's location in a human-readable + // format, with compass directions and degree symbols. document.querySelector('#coords').innerHTML = `Location: ${latString}, ${lonString}` } -// function updateWeatherData - +/** + * Attempt to geolocate the user using the browser's geolocation API after they + * click the "Geolocate" button on the page. If the browser does not support + * geolocation, or if the user denies the request, the function will fall back + * to getting the location from the user's IP address. + */ function geolocate() { - console.log(`Timezone: ${localTimezone}`) + // Create a constant to reference the `#coords` element on the page, which + // will be updated to display the user's location. const coordsOutput = document.querySelector('#coords') + /** + * Callback function for when the browser successfully retrieves the user's + * geolocation. + * @param {GeolocationPosition} position - The geolocation data returned by the browser. + */ function success(position) { - console.log('Device geolocation successful') + console.log('Device geolocation successful!') const lat = anonymizeCoordinate(position.coords.latitude) const lon = anonymizeCoordinate(position.coords.longitude) updateLocationText(lat, lon) } + /** + * Callback function for when the browser fails to retrieve the user's + * geolocation. This function will attempt to get the user's location from + * their IP address instead. + */ function deviceGeolocationUnavailable() { - console.log('Device geolocation failed') + console.log('Device geolocation failed; getting location from IP address.') getGeolocationFromIP().then((coords) => { + console.log('Geolocation from IP address successful!') updateLocationText(anonymizeCoordinate(coords.lat), anonymizeCoordinate(coords.lon)) }) } + // First, attempt to get the user's location using the browser's geolocation + // API. If the browser does not support geolocation, or if the user denies + // the request, the `deviceGeolocationUnavailable` function will be called + // instead to retrieve an approximate location based on the user's IP address. if (navigator.geolocation) { coordsOutput.innerHTML = 'Retrieving your location...' + + // If device geolocation is successful, the `success` function will be + // called with the `GeolocationPosition` object returned by + // `getCurrentPosition`. If the device geolocation fails, then call + // `deviceGeolocationUnavailable` instead, which takes no arguments. navigator.geolocation.getCurrentPosition(success, deviceGeolocationUnavailable); } else { + // If the browser simply does not support geolocation capabilities, don't + // even attempt to use it. Instead, get the location from the IP address. deviceGeolocationUnavailable() } } +// Get the local timezone of the user's browser, and store it as a global variable +// for use in later calls to the weather API, which require a timezone for accurately +// dating the forecast data. const localTimezone = getLocalTimezone() + +// The script will run when the page is loaded, and will set up the event +// listener for the geolocation button, starting here. The geolocate button +// is a default button in the index.pug template. document.querySelector('#geolocate').addEventListener('click', geolocate) diff --git a/src/weatherAPI.js b/src/weatherAPI.js index afdad95..d3477b6 100644 --- a/src/weatherAPI.js +++ b/src/weatherAPI.js @@ -161,6 +161,53 @@ function generateCurrentConditions(weatherData) { return currentConditions } +/** + * Parse the raw weather data retrieved from the Open-Meteo API into a format + * that can be used more easily by the client-side code. + * @param {Object} rawData - The raw weather data retrieved from the Open-Meteo API. + * @param {Number} rawData.latitude - The latitude of the location. + * @param {Number} rawData.longitude - The longitude of the location. + * @param {String} rawData.timezone - The timezone of the location. + * @param {Object} rawData.current - The current weather conditions. + * @param {Object} rawData.current_units - The units for the current weather conditions. + * @param {Object} rawData.daily - The daily weather forecast. + * @param {Object} rawData.daily_units - The units for the daily weather forecast. + * @param {string[]} rawData.daily.time - The timestamps for the daily forecast. + * @param {number[]} rawData.daily.weather_code - The weather codes for the daily forecast. + * @param {number[]} rawData.daily.temperature_2m_max - The maximum daily temperature. + * @param {number[]} rawData.daily.temperature_2m_min - The minimum daily temperature. + * @param {number[]} rawData.daily.precipitation_sum - The daily precipitation amount. + * @param {number[]} rawData.daily.precipitation_probability_max - The maximum daily precipitation probability. + */ +function parseWeatherResponse(rawData) { + console.log("Weather API raw response: ", rawData) + + // TODO: Throw an error if the `rawData` object + // is missing the expected keys (i.e. `current`, `daily`) + + rawData.current.weather = weatherCodes[rawData.current['weather_code']] + rawData.daily.weather = [] + rawData.daily.weather_code.forEach(day => { + rawData.daily.weather.push(weatherCodes[day]) + }) + + const parsedWeatherData = { + current: generateCurrentConditions(rawData), + daily_forecast: generateForecast(rawData) + } + + return parsedWeatherData +} + +/** + * Fetch weather data from the Open-Meteo API based on the provided latitude, + * longitude, and timezone. Returns a Promise that resolves with the parsed + * weather data. + * @returns {Promise} A Promise that resolves with the parsed weather data. + * @param {string} lat - The latitude of the location. + * @param {string} lon - The longitude of the location. + * @param {string} timezone - The timezone of the location. + */ function getWeather(lat, lon, timezone) { const queryParams = Object.assign({}, defaultQueryParams, { @@ -169,46 +216,35 @@ function getWeather(lat, lon, timezone) { timezone: timezone }) + // Construct the query string from the `queryParams` object, after merging + // the submitted values with the default values. const queryString = Object.keys(queryParams).map(key => key + '=' + queryParams[key]).join('&') const url = `http://api.open-meteo.com/v1/forecast?${queryString}` return new Promise((resolve, reject) => { let weatherResponse = {} + + // Make the request to the Open-Meteo API to get the weather data. const weatherReq = http.get(url, (res) => { res.setEncoding('utf8') - + // If the response status code is not 200, reject the Promise with an error. if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode} ${res.statusMessage}`)) } + // Create a variable to store the response data chunks as they come in. let responseData = '' res.on('data', (chunk) => { // console.log(`BODY: ${JSON.stringify(chunk)}`) responseData += chunk }) + // When the response has finished, parse the JSON data and resolve the Promise. res.on('end', () => { try { - weatherResponse = JSON.parse(responseData) - console.log("Weather API raw response: ", weatherResponse) - - // TODO: Throw an error if the `weatherResponse` object - // is missing the expected keys (i.e. `current`, `daily`) - - weatherResponse.current.weather = weatherCodes[weatherResponse.current['weather_code']] - weatherResponse.daily.weather = [] - weatherResponse.daily.weather_code.forEach(day => { - weatherResponse.daily.weather.push(weatherCodes[day]) - }) - - const parsedWeatherData = { - current: generateCurrentConditions(weatherResponse), - daily_forecast: generateForecast(weatherResponse) - } - - resolve(parsedWeatherData) + resolve(parseWeatherResponse(JSON.parse(responseData))) } catch (e) { console.error(e.message) reject(e)