KITKIT 19 ago 2026Aug 19, 2026
Mapa de viajes: marca los lugares que conoces Travel map: mark the places you know
No necesitas una app de terceros ni una llave de API para llevar tu mapa de viajes. Con un SVG, un poco de JavaScript y localStorage puedes tener uno tuyo que funciona sin conexión. You don't need a third-party app or API key to keep your travel map. With an SVG, a little JavaScript and localStorage you can have your own that works offline.
Los promptsThe prompts
Archivos de texto. Ábrelos, cópialos y pégalos en el asistente que uses. Plain text files. Open them, copy and paste into whichever assistant you use.
Llevar un mapa de viajes no debería depender de que una app siga viva, de que una API no suba de precio o de que le cuentes a un tercero a dónde viajas. Esta receta te da un mapamundi tuyo: un SVG embebido, una lista de lugares y unas estadísticas simples. Funciona con doble clic, sin conexión y sin llaves.
Dos decisiones técnicas
1. Mapa en SVG propio, cero dependencias
No usamos Leaflet, Google Maps ni azulejos de servidor ajeno. Usamos un mapamundi vectorial donde cada país es un <path> con su código ISO (data-iso="MX", data-iso="JP", etc.). Hacer clic en un país lo colorea y lo marca como visitado.
Ventajas reales:
- Funciona sin conexión.
- No caduca cuando una API cambia de precio o términos.
- No mandas a dónde viajas a ningún tercero.
- El archivo entero se abre con doble clic.
¿De dónde sacar el SVG? Hay dos caminos. Bajar uno hecho —Wikimedia Commons y Natural Earth tienen mapamundis de dominio público o con licencias abiertas— y revisar la licencia del que elijas, porque no todos permiten uso comercial o redistribución. O generarlo tú desde los datos de Natural Earth, que son de dominio público y no piden atribución: el prompt 4 de esta receta escribe ese generador y saca 174 países con su código ISO, en proyección Robinson y ~120 KB.
Lo que no funciona es pedirle el mapa a un modelo sin darle datos. Lo dibuja de memoria y salen rectángulos grises que no se parecen a ningún país. La geometría de un mapamundi se toma de una fuente.
2. Mapa + lista, no uno u otro
El mapa da la vista de conjunto: a qué países has ido. La lista da el detalle: qué ciudades, cuántas veces, cuándo. Las dos piezas leen el mismo modelo de datos; cuando haces clic en el mapa, se actualiza la lista, y viceversa.
Modelo de datos mínimo
{
"MX": {
"status": "visited",
"cities": [
{"name": "Ciudad de México", "times": 3, "last": "2024-11"},
{"name": "Oaxaca", "times": 1, "last": "2023-02"}
]
},
"JP": {
"status": "bucketlist",
"cities": [
{"name": "Tokio", "horizon": "mediano"}
]
}
}json
status puede ser visited, bucketlist o none. El mapa pinta el path según el status. La lista filtra y agrupa según el mismo campo.
Seis trampas comunes (explicadas al menos cuatro)
1. Math.max() sobre un arreglo vacío devuelve -Infinity
Si generas IDs así:
const newId = Math.max(...destinations.map(d => d.id)) + 1;js
y la lista está vacía, obtienes -Infinity. El siguiente destino da -Infinity + 1, que sigue siendo -Infinity. Todos los destinos acaban con el mismo ID, y hacer clic en cualquier tarjeta cambia siempre la primera. No lanza error; simplemente falla en silencio.
Arreglo: destinations.length ? Math.max(...destinations.map(d => d.id)) + 1 : 1, o usa crypto.randomUUID().
2. localStorage es por navegador y por dispositivo
Si abres la página en el móvil y en la laptop, cada una tiene su propio localStorage. Si dos personas quieren marcar sus lugares desde la misma página, acaban con dos listas que nunca se ven. localStorage sirve para “mi copia local”, no para “compartir con otros”.
Arreglo: si quieres compartir estado, necesitas un backend con una base de datos o al menos un archivo JSON sincronizado.
3. innerHTML con texto del usuario rompe todo
Si construyes tarjetas así:
card.innerHTML = `<h3>${city}</h3>`;js
y alguien pone "<script>" o incluso solo "<" en el nombre de una ciudad, rompes la tarjeta. En una versión compartida es una puerta de inyección HTML.
Arreglo: usa textContent para insertar texto, o escapa los caracteres HTML antes de interpolar.
4. División entre cero en estadísticas
const pct = (n / visited.length) * 100;js
Si visited.length es cero, obtienes NaN. El ancho de la barra de progreso sale inválido y el layout se rompe.
Arreglo: defensivamente,
const pct = visited.length ? (n / visited.length) * 100 : 0;js
5. Depender del objeto global event
function switchTab() {
event.target.classList.add('active');
}js
Esto funciona en Chrome por herencia de un event global, pero está obsoleto y falla si llamas a la función desde otro sitio.
Arreglo: pasa el elemento o el evento como parámetro:
function switchTab(tabEl) { tabEl.classList.add('active'); }js
6. La región por defecto que miente
const region = regionMap[country] || 'eu';js
Si no encuentras la región de un país, meterlo en 'eu' es un sesgo silencioso. Cualquier país desconocido acaba contado como Europa hasta que las estadísticas mienten.
Arreglo: usa 'unknown' o 'otros' como default, y muestra esos casos aparte para que los corrijas.
Sincronización entre mapa y lista
La única fuente de verdad es el objeto de datos. Cuando haces clic en un país del SVG:
- Lees
data-isodel path. - Buscas o creas la entrada en el modelo.
- Cambias el status al siguiente valor del ciclo (
none → visited → bucketlist → none). - Vuelves a pintar el path.
- Vuelves a renderizar la lista.
Cuando haces clic en una tarjeta de la lista, haces lo mismo: actualizas el modelo y renderizas ambas vistas.
Cinco mejoras de usabilidad que la receta enseña
1. Selector de país, no emoji a mano
Pedirle al usuario que ponga la bandera como emoji no funciona: nadie sabe teclear 🇰🇭. En su lugar, usa una tabla de países con nombre, código ISO, bandera y región. El usuario elige de la lista y esos cuatro datos se llenan solos.
La bandera se puede derivar del código ISO de dos letras convirtiendo cada letra a su símbolo indicador regional. Así no mantienes una tabla de emojis a mano.
De paso, una tabla ISO completa elimina el sesgo del regionMap de 25 países y el default 'eu'. Con todos los países mapeados, no hay caída silenciosa a Europa.
2. El mapa es la forma principal de marcar
Hacer clic en un país del mapa debe ser el gesto de todos los días. Sin formulario, sin diálogo. El formulario se reserva para cuando quieres añadir detalle: ciudad, fecha, número de visitas. La receta debe dejar claro cuál es el gesto rápido y cuál el ocasional.
3. Un clic no puede destruir un dato
Si tocar una tarjeta cambia su estado de “visitado” a “lista de deseos” sin avisar, y solo querías mirarla, ya modificaste el dato sin forma de deshacer. La regla general es: la acción más fácil de hacer por accidente no debe ser la destructiva.
Hay dos formas de resolverlo:
- Separar ver de cambiar: un clic abre o selecciona; cambiar el estado es un botón explícito.
- Conservar el clic rápido, pero añadir un aviso con botón de “deshacer” durante unos segundos.
Cualquiera de las dos vale; lo importante es que el usuario pueda corregir un error.
4. Editar y borrar
Permitir solo añadir deja los errores para siempre. Un dedazo en el nombre de la ciudad se queda eternamente. La interfaz debe permitir editar ciudades y eliminarlas, con confirmación solo en el borrado.
5. Exportar e importar
Los datos viven en localStorage, que es de un navegador y un dispositivo. Si limpias el perfil o cambias de teléfono, pierdes todo. Por eso la página debe tener un botón que descargue todo como JSON y otro que lo lea de vuelta.
La razón dicha en voz alta: si no puedes exportar tus datos, no son tuyos, son del navegador. De paso, exportar/importar permite que dos personas comparen mapas sin montar un servidor: cada quien exporta y se manda el archivo.
Cuando el viaje ya se registró en caliente
Si además llevas las cuentas del viaje con la receta de gastos de viaje en grupo, el cierre de ese viaje siembra este mapa: los lugares con estrellas llegan agrupados por ciudad con su mes; el mapa preselecciona el país por nombre, empata a tus acompañantes y deja corregir cualquier ciudad antes de incorporar la visita. El registro alimenta el recuerdo.
Qué puede salir mal
- El SVG no tiene códigos ISO. Si los paths no llevan
data-iso, no puedes enlazar un clic con tu modelo. Revisa el SVG antes de usarlo. - El mapa lo dibujó el modelo, no los datos. Si los países parecen cajas o no reconoces los continentes, nadie leyó una fuente geográfica. Genera el SVG con el prompt 4 y compruébalo a ojo.
- El SVG es enorme. Un mapamundi detallado puede pesar varios megabytes. Para web, busca uno simplificado (Natural Earth 1:110m es buen punto de partida).
- Pierdes los datos al limpiar el navegador.
localStoragese borra si el usuario limpia datos. Exporta/importa JSON periódicamente. - Quieres compartirlo. Con
localStorageno se puede. Necesitas un backend o un archivo JSON compartido. - Licencia del mapa. No todos los SVG de mapamundis son de dominio público. Revisa antes de publicar.
Límite honesto: si quieres geolocalización, rutas o imágenes de satélite, esta receta no alcanza. Es un mapa de países visitados, no un GPS.
Keeping a travel map shouldn’t depend on an app staying alive, an API raising prices, or telling a third party where you travel. This recipe gives you your own world map: an embedded SVG, a list of places and simple statistics. It works with a double click, offline and without keys.
Two technical decisions
1. SVG map, zero dependencies
We don’t use Leaflet, Google Maps or third-party map tiles. We use a vector world map where each country is a <path> with its ISO code (data-iso="MX", data-iso="JP", etc.). Clicking a country colors it and marks it as visited.
Real advantages:
- Works offline.
- Doesn’t expire when an API changes pricing or terms.
- You don’t send where you travel to any third party.
- The whole file opens with a double click.
Where to get the SVG? Two ways. Download a ready-made one —Wikimedia Commons and Natural Earth have public-domain or openly licensed world maps— and check the license of the one you choose, since not all allow commercial use or redistribution. Or generate it yourself from Natural Earth data, which is public domain and requires no attribution: prompt 4 in this recipe writes that generator and produces 174 countries with their ISO code, in Robinson projection and ~120 KB.
What doesn’t work is asking a model for the map without giving it data. It draws from memory and you get grey rectangles that look like no country on earth. World-map geometry comes from a source.
2. Map + list, not one or the other
The map gives the overview: which countries you’ve been to. The list gives the detail: which cities, how many times, when. Both pieces read the same data model; when you click on the map, the list updates, and vice versa.
Minimal data model
{
"MX": {
"status": "visited",
"cities": [
{"name": "Mexico City", "times": 3, "last": "2024-11"},
{"name": "Oaxaca", "times": 1, "last": "2023-02"}
]
},
"JP": {
"status": "bucketlist",
"cities": [
{"name": "Tokyo", "horizon": "medium"}
]
}
}json
status can be visited, bucketlist or none. The map paints the path by status. The list filters and groups by the same field.
Six common traps (at least four explained)
1. Math.max() on an empty array returns -Infinity
If you generate IDs like this:
const newId = Math.max(...destinations.map(d => d.id)) + 1;js
and the list is empty, you get -Infinity. The next destination gives -Infinity + 1, which is still -Infinity. All destinations end up with the same ID, and clicking any card always changes the first one. It throws no error; it just silently fails.
Fix: destinations.length ? Math.max(...destinations.map(d => d.id)) + 1 : 1, or use crypto.randomUUID().
2. localStorage is per browser and per device
If you open the page on mobile and laptop, each has its own localStorage. If two people want to mark their places from the same page, they end up with two lists that never see each other. localStorage is for “my local copy”, not “share with others”.
Fix: if you want shared state, you need a backend with a database or at least a synchronized JSON file.
3. innerHTML with user text breaks everything
If you build cards like this:
card.innerHTML = `<h3>${city}</h3>`;js
and someone puts "<script>" or even just "<" in a city name, you break the card. In a shared version it’s an HTML injection door.
Fix: use textContent to insert text, or escape HTML characters before interpolating.
4. Division by zero in statistics
const pct = (n / visited.length) * 100;js
If visited.length is zero, you get NaN. The progress bar width becomes invalid and the layout breaks.
Fix: defensively,
const pct = visited.length ? (n / visited.length) * 100 : 0;js
5. Depending on the global event object
function switchTab() {
event.target.classList.add('active');
}js
This works in Chrome because of a legacy global event, but it’s obsolete and breaks if you call the function from elsewhere.
Fix: pass the element or event as a parameter:
function switchTab(tabEl) { tabEl.classList.add('active'); }js
6. The default region that lies
const region = regionMap[country] || 'eu';js
If you don’t find a country’s region, putting it in 'eu' is silent bias. Any unknown country ends up counted as Europe until the statistics lie.
Fix: use 'unknown' or 'others' as the default, and show those cases separately so you can correct them.
Syncing map and list
The single source of truth is the data object. When you click a country in the SVG:
- Read the path’s
data-iso. - Find or create the entry in the model.
- Change status to the next value in the cycle (
none → visited → bucketlist → none). - Repaint the path.
- Re-render the list.
When you click a card in the list, do the same: update the model and render both views.
Five usability improvements the recipe teaches
1. Country selector, not hand-typed emoji
Asking the user to input a flag as an emoji doesn’t work: nobody knows how to type 🇰🇭. Instead, use a country table with name, ISO code, flag and region. The user picks from the list and those four fields fill themselves.
The flag can be derived from the two-letter ISO code by converting each letter to its regional indicator symbol. That way you don’t maintain a table of emojis by hand.
As a bonus, a complete ISO table removes the bias of a 25-country regionMap and the 'eu' default. With all countries mapped, there is no silent fall-back to Europe.
2. The map is the main way to mark
Clicking a country on the map should be the everyday gesture. No form, no dialog. The form is reserved for when you want to add detail: city, date, number of visits. The recipe should make clear which is the quick gesture and which is the occasional one.
3. One click must not destroy data
If tapping a card changes its status from “visited” to “bucketlist” without warning, and you only wanted to look at it, you’ve already modified the data with no way to undo. The general rule is: the easiest action to perform by accident must not be the destructive one.
Two ways to fix it:
- Separate view from change: a click opens or selects; changing status is an explicit button.
- Keep the quick click, but add a toast with an “undo” button for a few seconds.
Either works; the point is that the user can correct a mistake.
4. Edit and delete
Only allowing additions leaves errors forever. A typo in a city name stays forever. The UI must allow editing cities and deleting them, with confirmation only for deletion.
5. Export and import
Data lives in localStorage, which belongs to one browser and one device. If you clear your profile or switch phones, you lose everything. So the page must have a button to download everything as JSON and another to read it back.
The reason stated out loud: if you can’t export your data, they’re not yours, they’re the browser’s. As a side effect, export/import lets two people compare maps without a server: each exports and sends the file.
When the trip was logged as it happened
If you also manage the trip’s expenses with the group trip expenses recipe, closing that trip seeds this map: starred places arrive grouped by city with their month; the map preselects the country by name, matches your travel companions, and lets you edit any city name before merging. The trip log feeds the memory.
What can go wrong
- The SVG has no ISO codes. If the paths don’t have
data-iso, you can’t link a click to your model. Check the SVG before using it. - The map was drawn by the model, not by data. If countries look like boxes or you can’t recognise the continents, nobody read a geographic source. Generate the SVG with prompt 4 and check it by eye.
- The SVG is huge. A detailed world map can weigh several megabytes. For the web, use a simplified one (Natural Earth 1:110m is a good starting point).
- You lose data when clearing the browser.
localStorageis wiped when the user clears data. Export/import JSON periodically. - You want to share it. With
localStorageyou can’t. You need a backend or shared JSON file. - Map license. Not all world-map SVGs are public domain. Check before publishing.
Honest limit: if you want geolocation, routes or satellite imagery, this recipe isn’t enough. It’s a countries-visited map, not a GPS.