Hi everyone π I'm Andrea, a full-stack developer from Italy, living in Tenerife. Yes, that one, the island with the big Teide, a volcano, which is maybe why I ended up building this.
I have been building Vue apps in production for about four years: a blockchain explorer, a marketplace, editors, dashboards, data visualisation pages. What I had never built is a map. No GIS, no projections, no OpenLayers.
So I gave myself a small side project: a world map of natural events happening right now (storms, wildfires, volcanoes, icebergs), with a list next to it and a details card. Click a dot, see what it is.
It is called EarthPulse. It is not a big app. But a new library and a new kind of data are a good way to find out which of your habits still work and which do not. This post is about the mistakes I made on the way, in the order I made them. Some are OpenLayers things I did not know. Some are shortcuts I took because "it is just a prototype", and then paid for later on.
- Live app: https://andrearaccagni.github.io/earth-pulse/
- Code: https://github.com/AndreaRaccagni/earth-pulse
What it does
- It loads open events from NASA EONET (Earth Observatory Natural Event Tracker).
- It draws them on an OpenLayers map.
- It shows the same events in a list, with a date for each one.
- Click a dot or a row: the map flies there, the dot gets highlighted, and the card shows category, longitude and latitude.
- You can filter by category. The list and the map always show the same events.
Stack: Vue 3 with and TypeScript, OpenLayers 10, Vite, axios, pnpm. Deployed on GitHub Pages.
A few decisions before the code
- OpenLayers, not Leaflet. Leaflet is simpler for markers on a map. I wanted projections and vector styling built in, not added with plugins, and I wanted to learn the library that does more.
- No backend. EONET is a public API that the browser can call directly. The trade-off: no caching, and if NASA is down, my app shows an error. For a side project that is fine. In production I would put a small proxy with a cache in front of it.
- No Pinia. Three components and one owner of the state. Props down and events up are enough. I use Pinia at work, but adding a store here would only move the same code into another file.
OpenLayers in four words
If you know Vue but never used OpenLayers, these four words are the whole thing:
-
Map: the engine. It draws into a DOM element (the
target). - View: the camera. Center, zoom, projection.
- Source: where the data comes from.
- Layer: how the data is drawn. Layers are stacked like sheets.
The background map is a TileLayer with an OSM source (OpenStreetMap pictures). My events are a VectorLayer with a VectorSource.
Two things that are not obvious if you come from normal frontend work:
GeoJSON coordinates are [longitude, latitude]. X first, then Y. Most apps show latitude first. GeoJSON does the opposite.
The map does not work in degrees. NASA sends degrees (EPSG:4326). The OpenStreetMap tiles and the default view use metres (EPSG:3857). If you give degrees to a map that expects metres, all your points end up near [0, 0], in the ocean next to Africa. The fix is one option when you read the data:
new GeoJSON().readFeatures(collection, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
dataProjection is "what my data is in". featureProjection is "what the map wants".
And two places where normal Vue habits clash with OpenLayers:
-
The map needs a real DOM element.
new Map({ target: 'map' })goes inonMounted, because the#mapdiv does not exist during setup. Same as any library that draws into the DOM. -
Do not put OpenLayers objects in a normal
ref(). In app code,ref()is the default for everything. Butref()wraps objects in a deep Proxy, and OpenLayers objects are full of internal listeners that do not like being proxied. I useshallowReffor the map (Vue only tracks when the instance is replaced), and a plain variable for the vector source. Vue owns my data. OpenLayers owns its own objects.
Mistake 1: the map component did everything
I know that components should not fetch their own data when other components need it. I do it differently at work. But this was a prototype, I only wanted to see dots on a map, so my first real commit put everything inside EventMap.vue. It fetched the data, it had the loading state, it drew the map, and on click it built the object for the details card.
This is the actual first version (trimmed):
<template>
v-if="isLoading">Loading Events...
v-else-if="errorMessage">Error loading events: {{ errorMessage }}
id="map">
template>
const fetchEvents = async (eventStatus: EventStatus = 'open', limit: number = 20, days: number = 30): Promise<void> => {
isLoading.value = true;
errorMessage.value = null;
events.value = [];
try {
const response = await axios.get(`${API_URL}`, {
params: { status: eventStatus, limit, days },
});
events.value = response.data;
if (response.data.features?.length === 0) {
errorMessage.value = 'No events found';
}
} catch (err) {
// ...
} finally {
isLoading.value = false;
}
};
It worked. It also lasted exactly until I wanted to add a second component. Three problems:
- "No events" was an error. Empty data and a broken network showed the same message. They are not the same thing.
-
finallymeant "done", not "success". A booleanisLoadingplus an error string was too little to describe what was happening. - Only the map had the data. I wanted a list next to the map. The list could not use data that lives inside the map component.
So I moved things out. The HTTP call went to its own file, and it does nothing else:
// src/api/eonet.ts
const API_URL = 'https://eonet.gsfc.nasa.gov/api/v3/events/geojson';
type EventStatus = 'open' | 'closed' | 'all';
export const fetchNaturalEvents = async (
eventStatus: EventStatus = 'open',
limit: number = 20,
days: number = 30
): Promise<any> => {
const response = await axios.get(`${API_URL}`, {
params: { status: eventStatus, limit, days },
});
return response.data;
};
App.vue owns the data and the status. Four states, not a boolean:
type Status = 'loading' | 'success' | 'empty' | 'error';
const events = ref<any | null>(null);
const status = ref<Status>('loading');
const errorMessage = ref<string | null>(null);
And the map became a renderer. It gets the events as a prop, and a watch redraws them:
watch(
() => props.events,
(collection) => {
vectorSource.clear();
if (!collection) return;
vectorSource.addFeatures(
new GeoJSON().readFeatures(collection, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
})
);
},
{ immediate: true }
);
immediate: true matters. The data can arrive before or after the map is ready, and this way there is only one place that adds features.
One more thing, and this one is specific to maps: the loading and error messages now live next to the map, not instead of it. In a normal component you v-if things away all the time. Here, if the map element disappears, OpenLayers loses its target and you have to create the map again. The map div stays on the page all the time.
Mistake 2: emitting an object instead of an id
In the first version, the map click built a whole object and sent it up:
naturalEvent = {
id: data.id,
title: data.title,
category: data.categories?.[0]?.id ?? 'unknown',
longitude: Number(longitude.toFixed(2)),
latitude: Number(latitude.toFixed(2)),
};
// ...
emits('eventSelected', naturalEvent);
With one source of clicks, that is fine. Then I added the list. The list has the raw GeoJSON, not OpenLayers features in map coordinates. So either the list repeats all the coordinate logic, or it sends a different object, or App keeps two selections that can go out of sync.
The interesting part is why I did it. In OpenLayers, the thing you click is a Feature, a class instance with its own geometry in metres. It is tempting to turn it into something useful right there, because that is where you have it. But a list does not have Features, and a Feature should not end up in Vue state anyway.
The fix: both the map and the list send only an id. App keeps one currentEventId, and the details card is a computed from it.
EventList ββemit idβββ
βββ App.currentEventId ββ computed NaturalEvent ββ EventDetails
EventMap ββemit idβββ β
ββ selectedEventId prop β style + flyTo
The map click now looks like this:
map.value.on('click', (event) => {
const feature = map.value!.forEachFeatureAtPixel(event.pixel, (f) => f);
emits('eventSelected', feature ? feature.getId() : null);
});
Clicking on the empty ocean sends null. That is also a selection: "nothing". The card clears and the map goes back to the world view.
Mistake 3: NASA ids are not unique
After the id change, I clicked the fifth position of a cyclone, and the map flew to the first one. Every time.
It turns out EONET sends one GeoJSON feature per position of an event. A storm that moved for a week is many features, and they all have the same properties.id. So "find by id" always found the first one.
I give each feature my own id after the fetch, the original id plus its index:
async function load() {
status.value = 'loading';
errorMessage.value = null;
try {
const fetchedEvents = await fetchNaturalEvents();
const data = fetchedEvents.features;
const newEvents = data.map((item: any, index: number) => {
return { ...item, id: item.properties.id + ':' + index };
});
events.value = { ...fetchedEvents, features: newEvents };
status.value = fetchedEvents.features.length > 0 ? 'success' : 'empty';
} catch (e) {
events.value = null;
errorMessage.value = e instanceof Error ? e.message : 'Failed to load';
status.value = 'error';
}
}
The OpenLayers trap here: a feature has two ids. feature.get('id') reads NASA's properties.id, the duplicate one. feature.getId() reads the top-level GeoJSON id, which is mine. They look almost the same in the code, and only one of them is unique. The map, the list and the details all use getId() now.
The same data problem showed up in the list: "Tropical Storm Rachel" three times, and no way to tell which was which. Each position has its own date in EONET, so now every row shows it.
Mistake 4: I tried to paint the feature
I wanted the selected dot to be red. My first idea was feature.setFill(...). That does not exist. In OpenLayers a feature is data. The layer decides how it looks, with a style function:
const vectorLayer = new VectorLayer({
source: vectorSource,
style: (feature) => {
const selected = feature.getId() === props.selectedEventId;
return new Style({
image: new Circle({
radius: selected ? 8 : 5,
fill: new Fill({ color: selected ? COLORS.selected : COLORS.default }),
stroke: new Stroke({ color: 'white', width: 1 }),
}),
stroke: new Stroke({
color: selected ? COLORS.selected : COLORS.default,
width: selected ? 3 : 1,
}),
fill: new Fill({
color: selected ? COLORS.selectedFill : COLORS.defaultFill,
}),
});
},
});
image is used for points. stroke and fill are used for lines and shapes.
Three more things before this worked:
-
There are two
Circles.ol/geom/Circleis a shape.ol/style/Circleis how a point is drawn. Import the wrong one and you getimageStyle.getImageState is not a function, which does not point you anywhere useful. -
Vue reactivity does not reach the canvas. Changing
selectedEventIddoes not make OpenLayers redraw. A watcher callsvectorLayer.changed(). -
CSS variables do not work on the map. I tried
var(--ember)as a color. The map draws on a canvas, and the canvas does not know about CSS. So the map has its own constant with the same hex values as my CSS:
const COLORS = {
selected: '#b42318',
default: '#1f6f8b',
selectedFill: 'rgba(180, 35, 24, 0.25)',
defaultFill: 'rgba(31, 111, 139, 0.2)',
};
Mistake 5: animating the camera
To fly to an event, I first wrote something like map.setView(new View(...)) and then animate({ zoom: 0 }). Nothing moved. A new view starts already where you put it, so there is nothing to animate.
You keep one view and move it:
function flyTo(id: string | null) {
const view = map.value?.getView();
if (!view) return;
view.cancelAnimations();
if (!id) {
view.animate({
center: WORLD_CENTER,
zoom: WORLD_ZOOM,
duration: FLY_MS,
});
return;
}
const feature = vectorSource.getFeatures().find((f) => {
return f.getId() === id;
});
const geometry = feature?.getGeometry();
if (!geometry) return;
view.fit(geometry.getExtent(), {
duration: FLY_MS,
padding: [80, 80, 80, 80],
maxZoom: 8,
});
}
view.fit works for any shape, because every geometry has an extent (a bounding box). maxZoom: 8 is important: a point has an extent with zero size, and without a limit the map zooms in forever.
The watcher puts the style and the camera together:
watch(
() => props.selectedEventId,
(id) => {
vectorLayer.changed();
flyTo(id);
}
);
Mistake 6: not every event is a point
When I moved the details into a computed in App, I read the coordinates like this:
longitude: event.geometry.coordinates[0],
latitude: event.geometry.coordinates[1],
That is correct for a Point. For a LineString or a Polygon, coordinates[0] is not a number, it is a list of points (or a list of lists). The card showed nonsense.
I wrote a small helper. For a point it returns the coordinates. For other shapes it goes down until it finds the list of points, and returns the average:
export const getlonAndLatFromEvent = (event: any): [number, number] | null => {
if (!event?.geometry?.coordinates) return null;
if (event.geometry.type == 'Point') {
return [event.geometry.coordinates[0], event.geometry.coordinates[1]];
}
if (
event.geometry.type == 'Polygon' ||
event.geometry.type == 'MultiPolygon' ||
event.geometry.type == 'LineString' ||
event.geometry.type == 'MultiLineString'
) {
let sumX = 0;
let sumY = 0;
let flatCoordinates = event.geometry.coordinates;
while (typeof flatCoordinates[0][0] !== 'number') {
flatCoordinates = flatCoordinates[0];
}
for (const coordinate of flatCoordinates) {
sumX += coordinate[0];
sumY += coordinate[1];
}
return [sumX / flatCoordinates.length, sumY / flatCoordinates.length];
}
return null;
};
The only tricky part is knowing when to stop going down. If the first item of the first item is a number, I am holding a list of points. One level further and I would be holding a single point.
It is not a perfect center. For multi-part shapes it only uses the first part, and an average of points is not the true center of a polygon. For a details card that shows a rough location, it is enough.
The funny part: after all this work, I checked the real data. Right now the open events are almost all points, plus one line (an iceberg near Antarctica). In EONET, only floods come as polygons, and they are usually already closed when they show up. So the helper is correct, but you will rarely see it work on the live map. Lesson: look at the data before you design for it.
The filter
NASA categories are camelCase ids, like severeStorms. In the dropdown I want "Severe Storms", but I want to filter on the real id. So each option has both:
const eventTypes = computed(() => {
const values = [
...new Set(events.value?.features.map((event: any) => event.properties.categories?.[0]?.id).filter(Boolean) ?? []),
] as string[];
return values.map((value) => ({
name: camelCaseToName(value),
value,
}));
});
The filtered collection is one computed in App, and it goes to both the map and the list. The map does not know what a category is. It just draws what it receives.
const filteredEvents = computed(() => {
if (!events.value) return null;
if (selectedEventType.value === 'All') return events.value;
return {
...events.value,
features: events.value.features.filter(
(event: any) => event.properties.categories?.[0]?.id === selectedEventType.value
),
};
});
If the selected event is not in the filtered list anymore, a watcher sets the selection back to null.
The list rows are buttons
The rows started as , which works with a mouse and not with a keyboard. Now each row has a real button inside:
v-for="event in events" :key="event.id" :class="{ selected: event.id === selectedEventId }">
A button gives you Tab, Enter and Space for free. The padding is on the button, so the whole row is clickable. aria-pressed tells a screen reader which row is selected. Long names get an ellipsis, and the full name is in the title.
Deploying
The site is on GitHub Pages, under /earth-pulse/. Vite needs to know that, or the page loads blank:
export default defineConfig({
plugins: [vue()],
base: '/earth-pulse/',
});
One GitHub Actions workflow installs, type-checks, builds and deploys on every push to main. Pull requests build but do not deploy:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
Why I added CI before tests: I pushed a commit where a type said "this field is required" and the code did not set it. pnpm dev was fine, because Vite does not type-check. pnpm build runs vue-tsc first, and it failed. Now a broken build never goes online.
What is missing
-
No tests yet. The helpers in
utils.tsare the first candidates. They are also the functions that broke the most. - The map is mouse only. The list and the filter work with the keyboard, the map does not.
- The filter only reads the first category of each event.
That will be the next post.
Closing
What surprised me most is that the Vue side was the easy part. "The parent owns the state, the child only renders" is a rule I already follow at work. The project just reminded me why: every time I skipped it in the name of a quick prototype, something broke one feature later. The hard parts were the new things: projections, the difference between a feature and its style, a canvas that does not read CSS, and data that does not look like you expect.
If you are a Vue developer who never tried maps: OpenLayers looks scary, but the four words (map, view, source, layer), the projection option and "keep OpenLayers objects out of ref()" are most of what you need to start.
If you see something wrong or something I could do better, please tell me in the comments. A new library is always a good reason to be a beginner again.
A small note: the code, the mistakes and the decisions are mine. English is not my first language, so I used an AI assistant to help me shape the text + markdown and to check that every snippet matches the repo.
- Try it: https://andrearaccagni.github.io/earth-pulse/
- Code: https://github.com/AndreaRaccagni/earth-pulse
- Me: andrearaccagni.xyz Β· GitHub
Thanks for reading!
Top comments (0)