Proyecto 3: Aplicación del Clima con API Real
Objetivos de aprendizajeAl finalizar este proyecto serás capaz de:
- Consumir una API REST real para obtener datos meteorológicos
- Manejar estados de carga, éxito y error en una interfaz
- Implementar geolocalización del navegador con JavaScript
- Usar async/await y Fetch API en un proyecto completo
- Crear una interfaz dinámica que se actualiza con datos en tiempo real
Descripción del proyecto
Vamos a crear una aplicación del clima que use la API gratuita de Open-Meteo (no requiere API key) para mostrar el clima actual y el pronóstico de cualquier ciudad.
Funcionalidades
- Búsqueda por ciudad con autocomplete
- Geolocalización para detectar la ubicación actual
- Clima actual: temperatura, humedad, viento, condición
- Pronóstico de 7 días
- Clima por hora (próximas 24 horas)
- Ciudades favoritas guardadas en localStorage
- Cambio de unidades (Celsius/Fahrenheit)
- Fondo dinámico según la condición del clima
API que vamos a usar
Usamos Open-Meteo porque es gratuita, no requiere API key y tiene excelentes datos:
// Geocoding (buscar ciudades)
// GET https://geocoding-api.open-meteo.com/v1/search?name=Buenos+Aires&count=5&language=es
// Clima actual + pronóstico
// GET https://api.open-meteo.com/v1/forecast?latitude=-34.61&longitude=-58.38¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto
Código JavaScript completo
// ============================================
// WEATHER APP - Aplicación del clima
// ============================================
// Configuración
const CONFIG = {
geoAPI: "https://geocoding-api.open-meteo.com/v1/search",
weatherAPI: "https://api.open-meteo.com/v1/forecast",
unidad: localStorage.getItem("unidad") || "celsius"
};
// Códigos de clima de WMO a español
const WEATHER_CODES = {
0: { desc: "Cielo despejado", icon: "clear", bg: "sunny" },
1: { desc: "Mayormente despejado", icon: "clear", bg: "sunny" },
2: { desc: "Parcialmente nublado", icon: "cloudy", bg: "cloudy" },
3: { desc: "Nublado", icon: "overcast", bg: "cloudy" },
45: { desc: "Niebla", icon: "fog", bg: "fog" },
48: { desc: "Niebla con escarcha", icon: "fog", bg: "fog" },
51: { desc: "Llovizna ligera", icon: "drizzle", bg: "rainy" },
53: { desc: "Llovizna moderada", icon: "drizzle", bg: "rainy" },
55: { desc: "Llovizna intensa", icon: "drizzle", bg: "rainy" },
61: { desc: "Lluvia ligera", icon: "rain", bg: "rainy" },
63: { desc: "Lluvia moderada", icon: "rain", bg: "rainy" },
65: { desc: "Lluvia intensa", icon: "rain", bg: "rainy" },
71: { desc: "Nieve ligera", icon: "snow", bg: "snow" },
73: { desc: "Nieve moderada", icon: "snow", bg: "snow" },
75: { desc: "Nieve intensa", icon: "snow", bg: "snow" },
80: { desc: "Chubascos ligeros", icon: "rain", bg: "rainy" },
81: { desc: "Chubascos moderados", icon: "rain", bg: "rainy" },
82: { desc: "Chubascos intensos", icon: "rain", bg: "storm" },
95: { desc: "Tormenta eléctrica", icon: "storm", bg: "storm" },
96: { desc: "Tormenta con granizo", icon: "storm", bg: "storm" },
99: { desc: "Tormenta fuerte con granizo", icon: "storm", bg: "storm" }
};
// Estado
const estado = {
ciudadActual: null,
clima: null,
favoritos: JSON.parse(localStorage.getItem("favoritos") || "[]"),
cargando: false
};
// ===== API Functions =====
async function buscarCiudades(nombre) {
if (nombre.length < 2) return [];
try {
const resp = await fetch(
`${CONFIG.geoAPI}?name=${encodeURIComponent(nombre)}&count=5&language=es`
);
const data = await resp.json();
return data.results || [];
} catch (error) {
console.error("Error buscando ciudades:", error);
return [];
}
}
async function obtenerClima(lat, lon) {
const params = new URLSearchParams({
latitude: lat,
longitude: lon,
current: [
"temperature_2m", "relative_humidity_2m", "apparent_temperature",
"weather_code", "wind_speed_10m", "wind_direction_10m"
].join(","),
daily: [
"weather_code", "temperature_2m_max", "temperature_2m_min",
"precipitation_probability_max"
].join(","),
hourly: "temperature_2m,weather_code",
timezone: "auto",
forecast_days: 7,
forecast_hours: 24
});
if (CONFIG.unidad === "fahrenheit") {
params.append("temperature_unit", "fahrenheit");
}
try {
const resp = await fetch(`${CONFIG.weatherAPI}?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return await resp.json();
} catch (error) {
console.error("Error obteniendo clima:", error);
throw error;
}
}
async function obtenerUbicacionActual() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error("Geolocalización no disponible"));
return;
}
navigator.geolocation.getCurrentPosition(
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
err => reject(new Error("Permiso de ubicación denegado")),
{ timeout: 10000 }
);
});
}
// ===== Funciones de renderizado =====
function renderizarClimaActual(clima, ciudad) {
const current = clima.current;
const weatherInfo = WEATHER_CODES[current.weather_code] || WEATHER_CODES[0];
const unidadSimbolo = CONFIG.unidad === "celsius" ? "C" : "F";
document.getElementById("climaActual").innerHTML = `
<div class="current-weather ${weatherInfo.bg}">
<div class="location">
<h2>${ciudad.name}</h2>
<p>${ciudad.admin1 || ""}, ${ciudad.country || ""}</p>
</div>
<div class="temperature">
<span class="temp-value">${Math.round(current.temperature_2m)}</span>
<span class="temp-unit">°${unidadSimbolo}</span>
</div>
<div class="condition">
<p class="weather-desc">${weatherInfo.desc}</p>
<p>Sensacion: ${Math.round(current.apparent_temperature)}°${unidadSimbolo}</p>
</div>
<div class="details">
<div class="detail">
<span class="label">Humedad</span>
<span class="value">${current.relative_humidity_2m}%</span>
</div>
<div class="detail">
<span class="label">Viento</span>
<span class="value">${Math.round(current.wind_speed_10m)} km/h</span>
</div>
</div>
</div>
`;
document.body.className = `bg-${weatherInfo.bg}`;
}
function renderizarPronostico(clima) {
const daily = clima.daily;
const unidadSimbolo = CONFIG.unidad === "celsius" ? "C" : "F";
const dias = ["Dom", "Lun", "Mar", "Mie", "Jue", "Vie", "Sab"];
document.getElementById("pronostico").innerHTML = `
<h3>Pronostico 7 dias</h3>
<div class="forecast-grid">
${daily.time.map((fecha, i) => {
const d = new Date(fecha + "T12:00:00");
const weatherInfo = WEATHER_CODES[daily.weather_code[i]] || WEATHER_CODES[0];
const esHoy = i === 0;
return `
<div class="forecast-day ${esHoy ? 'today' : ''}">
<span class="day-name">${esHoy ? "Hoy" : dias[d.getDay()]}</span>
<span class="day-icon">${weatherInfo.desc.substring(0, 10)}</span>
<div class="day-temps">
<span class="temp-max">${Math.round(daily.temperature_2m_max[i])}°</span>
<span class="temp-min">${Math.round(daily.temperature_2m_min[i])}°</span>
</div>
${daily.precipitation_probability_max[i] > 0 ?
`<span class="rain-chance">${daily.precipitation_probability_max[i]}% lluvia</span>` : ''}
</div>
`;
}).join("")}
</div>
`;
}
function renderizarClimaHora(clima) {
const hourly = clima.hourly;
const unidadSimbolo = CONFIG.unidad === "celsius" ? "C" : "F";
document.getElementById("porHora").innerHTML = `
<h3>Proximas 24 horas</h3>
<div class="hourly-scroll">
${hourly.time.slice(0, 24).map((hora, i) => {
const h = new Date(hora);
const weatherInfo = WEATHER_CODES[hourly.weather_code[i]] || WEATHER_CODES[0];
return `
<div class="hour-item">
<span class="hour-time">${h.getHours().toString().padStart(2, '0')}:00</span>
<span class="hour-icon" title="${weatherInfo.desc}">${weatherInfo.icon === 'clear' ? '*' : weatherInfo.icon === 'rain' ? '~' : '.'}</span>
<span class="hour-temp">${Math.round(hourly.temperature_2m[i])}°</span>
</div>
`;
}).join("")}
</div>
`;
}
// ===== Flujo principal =====
async function cargarClima(ciudad) {
estado.cargando = true;
mostrarCargando(true);
try {
const clima = await obtenerClima(ciudad.latitude, ciudad.longitude);
estado.clima = clima;
estado.ciudadActual = ciudad;
renderizarClimaActual(clima, ciudad);
renderizarPronostico(clima);
renderizarClimaHora(clima);
mostrarCargando(false);
} catch (error) {
mostrarError(`No se pudo obtener el clima: ${error.message}`);
} finally {
estado.cargando = false;
}
}
function mostrarCargando(visible) {
document.getElementById("loading").style.display = visible ? "flex" : "none";
document.getElementById("contenido").style.display = visible ? "none" : "block";
}
function mostrarError(mensaje) {
document.getElementById("loading").style.display = "none";
document.getElementById("contenido").innerHTML = `
<div class="error-state">
<h3>Ocurrio un error</h3>
<p>${mensaje}</p>
<button onclick="location.reload()" class="btn-retry">Reintentar</button>
</div>
`;
document.getElementById("contenido").style.display = "block";
}
// ===== Event Listeners =====
// Búsqueda con debounce
const inputBusqueda = document.getElementById("searchInput");
const sugerencias = document.getElementById("sugerencias");
let timerBusqueda;
inputBusqueda.addEventListener("input", (e) => {
clearTimeout(timerBusqueda);
const valor = e.target.value.trim();
if (valor.length < 2) {
sugerencias.innerHTML = "";
sugerencias.style.display = "none";
return;
}
timerBusqueda = setTimeout(async () => {
const ciudades = await buscarCiudades(valor);
if (ciudades.length === 0) {
sugerencias.innerHTML = '<div class="sugerencia">No se encontraron resultados</div>';
} else {
sugerencias.innerHTML = ciudades.map(c => `
<div class="sugerencia" data-lat="${c.latitude}" data-lon="${c.longitude}"
data-name="${c.name}" data-country="${c.country || ''}" data-admin="${c.admin1 || ''}">
${c.name}, ${c.admin1 || ''} - ${c.country || ''}
</div>
`).join("");
}
sugerencias.style.display = "block";
}, 300);
});
// Click en sugerencia
sugerencias.addEventListener("click", (e) => {
const sug = e.target.closest(".sugerencia");
if (!sug || !sug.dataset.lat) return;
const ciudad = {
name: sug.dataset.name,
country: sug.dataset.country,
admin1: sug.dataset.admin,
latitude: parseFloat(sug.dataset.lat),
longitude: parseFloat(sug.dataset.lon)
};
inputBusqueda.value = ciudad.name;
sugerencias.style.display = "none";
cargarClima(ciudad);
});
// Botón de ubicación actual
document.getElementById("btnUbicacion").addEventListener("click", async () => {
try {
const pos = await obtenerUbicacionActual();
// Reverse geocoding con Open-Meteo
const ciudades = await buscarCiudades(`${pos.lat},${pos.lon}`);
const ciudad = {
name: "Tu ubicacion",
country: "",
admin1: "",
latitude: pos.lat,
longitude: pos.lon
};
cargarClima(ciudad);
} catch (error) {
mostrarError(error.message);
}
});
// Toggle unidad
document.getElementById("btnUnidad").addEventListener("click", () => {
CONFIG.unidad = CONFIG.unidad === "celsius" ? "fahrenheit" : "celsius";
localStorage.setItem("unidad", CONFIG.unidad);
document.getElementById("btnUnidad").textContent =
CONFIG.unidad === "celsius" ? "C / F" : "F / C";
if (estado.ciudadActual) {
cargarClima(estado.ciudadActual);
}
});
// Favoritos
document.getElementById("btnFavorito").addEventListener("click", () => {
if (!estado.ciudadActual) return;
const existe = estado.favoritos.find(f =>
f.latitude === estado.ciudadActual.latitude &&
f.longitude === estado.ciudadActual.longitude
);
if (!existe) {
estado.favoritos.push(estado.ciudadActual);
localStorage.setItem("favoritos", JSON.stringify(estado.favoritos));
}
renderizarFavoritos();
});
function renderizarFavoritos() {
const container = document.getElementById("favoritos");
if (estado.favoritos.length === 0) {
container.innerHTML = "<p class='hint'>Sin favoritos</p>";
return;
}
container.innerHTML = estado.favoritos.map(f => `
<button class="fav-btn" data-lat="${f.latitude}" data-lon="${f.longitude}"
data-name="${f.name}" data-country="${f.country}" data-admin="${f.admin1}">
${f.name}
</button>
`).join("");
}
document.getElementById("favoritos").addEventListener("click", (e) => {
const btn = e.target.closest(".fav-btn");
if (!btn) return;
cargarClima({
name: btn.dataset.name,
country: btn.dataset.country,
admin1: btn.dataset.admin,
latitude: parseFloat(btn.dataset.lat),
longitude: parseFloat(btn.dataset.lon)
});
});
// Cerrar sugerencias al hacer click afuera
document.addEventListener("click", (e) => {
if (!e.target.closest(".search-container")) {
sugerencias.style.display = "none";
}
});
// Inicialización
renderizarFavoritos();
// Cargar Buenos Aires por defecto
cargarClima({
name: "Buenos Aires",
country: "Argentina",
admin1: "Buenos Aires",
latitude: -34.6131,
longitude: -58.3772
});
console.log("Weather App cargada");
Conceptos aplicados
| Concepto | Implementacion |
|---|---|
| Fetch API | Llamadas a Open-Meteo geocoding y weather |
| async/await | Todas las funciones de datos |
| Promise | Geolocalización envuelta en Promise |
| try/catch | Manejo de errores en cada petición |
| Event delegation | Sugerencias y favoritos |
| Debounce | Búsqueda de ciudades |
| localStorage | Favoritos y preferencia de unidad |
| Template literals | Todo el renderizado dinámico |
| Destructuring | Acceso a datos del API |
| URLSearchParams | Construcción de queries |
| Geolocation API | Ubicación del usuario |
| DOM manipulation | Actualización de toda la interfaz |
Desafíos extra
- Gráfico de temperaturas: Usá Canvas o una librería como Chart.js para graficar las temperaturas de los próximos 7 días
- Notificaciones: Alertar si va a llover en las próximas horas
- Mapa: Integrar un mapa (Leaflet.js) mostrando la ubicación
- Comparar ciudades: Mostrar el clima de dos ciudades lado a lado
- PWA: Convertirlo en app instalable con service worker y cache offline
Revisemos los puntos más importantes de esta lección antes de continuar.
Quiz de autoevaluación
1. ¿Por qué usamos Open-Meteo en vez de OpenWeatherMap?
a) Es más rápido
b) No requiere API key, facilitando el desarrollo y aprendizaje
c) Tiene mejor diseño
d) Es la única API de clima
2. ¿Por qué envolvemos navigator.geolocation en una Promise?
a) Porque es obligatorio
b) Porque la API de geolocalización usa callbacks, y queremos usar async/await
c) Porque Promises son más rápidas
d) Porque fetch lo requiere
3. ¿Qué ventaja tiene URLSearchParams sobre concatenar strings para la URL?
a) Es más corto
b) Maneja automáticamente encoding de caracteres especiales y formato correcto
c) Es más rápido
d) No hay ventaja
Respuestas: 1-b, 2-b, 3-b
- Comprendo el concepto principal y puedo explicarlo con mis palabras
- Entiendo cómo aplicarlo en mi situación específica
- Necesito repasar algunas partes antes de continuar
- Quiero ver más ejemplos prácticos del tema