Having fun and making something useful with GeoLocation and open APIs
Working in a modern software development team is very rewarding and a lot of fun. Nowadays it seems most teams are autonomous, working tightly together, moving as one. Often using pair or even mob programming techniques. Scrumming in the morning and turning on a dime shipping to production several times during the day. This fast paced, hands on approach gets a lot of stuff done and I love it. But sometimes I miss the days where I could sit for hours with some good and loud music in my ears in complete flow state just playing around with my own ideas and the computer.
MX Trax
This is the story of how I made MXTrax, a simple web app that shows you motocross tracks near your location along with other useful information.
It all started after my oldest daughter started riding motocross. We have been to lots of tracks, and finding information about where the tracks are, how to get in contact, when they are open and so on was a bit cumbersome.
I figured that there had to be some place other than facebook groups where this information was available. I was right, there is a lot of information out there, but it is hard to find, and even harder to navigate once it has been found. So I decided I wanted a tool for myself, to be able to find and list the motocross tracks nearby. A perfect opportunity for getting into the flow state while grokking something new.
Gathering and curating the data took some time, but once that was done creating the website did not take long. At least that is how it felt. Time flies when you are having fun.
The website is a NextJS app running on a small Heroku dyno. Although I love github actions, there is something to be said for the UX in Heroku. It is perfect for small projects like this. Just link it to your github repo and tada:
For the UI components i ended up using Mantine with Tabler icons. The kit is a well balanced and simple library, with just the right amount of bells and whistles. Nothing fancy, but complete and easy to use. It also looks great IMO.
For the maps I used OpenStreetMap and LeafletJS. Using google maps was not an option for several reasons. The main one being cost.
For the Geo APIs I used open apis at geonorge
Splash images are supplied by Unsplash This is a service I highly recommend.
Weather data provided using open APIs at api.met.no
Digging in
Lets look at some details from the app. Please keep in mind that this code is thrown together in a relative short amount of time. It’s just for fun, so nothing too serious.
GeoLocation
The app is very centered around where you are located. Your location can be changed to a given address of your choice if you want, or if geolocation is not supported. Here is the react hook:
const GeolocationPositionError = {
[1]: "Vi mangler tilgang til din plassering. Vennligst endre instillinger og forsøk igjen.",
[2]: "Kan ikke finne din lokasjon akkurat nå. Prøv igjen litt senere.",
[3]: "Kunne ikke finne din lokasjon pga tidsavbrudd. Vennligst prøv igjen.",
};
const GeoLocationPositionUnsupported =
"Beklager, lokasjonstjenester støttes ikke av din nettleser. Prøv en annen nettleser";
export const useGeolocation = () => {
const [position, setPosition] = useState<GeolocationPosition | undefined>(undefined)
const [pending, setPending] = useState(true)
const [error, setError] = useState<string | undefined>(undefined)
useEffect(() => {
if (!("geolocation" in navigator)) {
setError(GeoLocationPositionUnsupported);
return;
}
if (!position) {
setPending(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setPosition(position);
setPending(false);
},
(e) => {
console.error(e);
setError(GeolocationPositionError[e.code]);
setPending(false);
},
);
}
}, [position]);
return {
position,
error,
pending,
};
};
As I mentioned the location can also be set manually. For this I use a simple typeahead component using the Mantine Autocomplete component and a hook backed by the sok API at geonorge.
// geonorge sok api handler
export default async function handler(req, res) {
const { sok } = await JSON.parse(req.body);
const api = "https://ws.geonorge.no/adresser/v1/sok?";
const response = await fetch(
api +
new URLSearchParams({
sok,
fuzzy: "true",
utkoordsys: "4258",
treffPerSide: "25",
side: "0",
asciiKompatibel: "true",
}),
);
if (!response.ok) {
res.status(response.status).send(response.statusText);
}
const json = await response.json();
res.status(200).json(json.adresser ?? []);
}
// hook for searching addresses
export const useAddressSearch = () => {
const [result, setResult] = useState<{ error: boolean; addresser: Address[] }>({
error: null,
addresser: [],
});
const [searchAddress, setSearchAddress] = useDebouncedState('', 250);
useEffect(() => {
(async () => {
if (searchAddress === '') {
setResult({ error: null, addresser: [] });
return;
}
const { error, addresser } = await searchAdresses({
sok: searchAddress,
});
setResult({ error, addresser });
})();
}, [searchAddress]);
const clearSearch = () => {
setSearchAddress('');
setResult({ error: null, addresser: [] });
};
return {
setSearchAddress,
clearSearch,
searchAddress,
result,
};
};
// the "you are here" component displayed in the banner
export function DuErHer() {
const {isSearch} = useGeoLocationContext();
return (isSearch ?
<FraSok/> :
<FraMinPlassering/>
);
}
// the typeahead search component
function FraSok() {
const {setSearchAddress, result, clearSearch} = useAddressSearch();
return (
<Paper shadow="xs" p="sm" opacity={0.9}>
<Autocomplete
type="search"
miw="40vw"
filter={({options}) => options}
placeholder="Søk etter adresse"
data={result}
value={value}
onChange={handleChange}
rightSection={value === '' ? null :
<ActionIcon size={32} radius="md" variant="subtle" onClick={() => {
clearSearch();
}}>
<IconXboxX stroke={1.5}/>
</ActionIcon>}
/>
<Button
variant="filled"
onClick={() => {
setLocationToCurrentPosition();
}}
>
Nullstill
</Button>
</Paper>
);
}
OpenStreetMap with Leaflet
The Leaflet map is rendered on the main page. Since this code needs to run on the client I use next/dynamic to tell next to bypass server side rendering. Also notice the little trick of using lat/lng as key in the MapContainer. This hack ensures the leaflet map is rerendered when the location changes.
// .
// ├── components
// │ ├── TrackMap.tsx
// │ ├── TrackMapDynamic.tsx
// ├── pages
// │ ├── index.tsx
// index.tsx
import { TrackMapDynamic } from "../components/TrackMapDynamic";
<TrackMapDynamic />;
// TrackMapDynamic.tsx
import dynamic from "next/dynamic";
export const TrackMapDynamic = dynamic(() => import("./TrackMap"), {
ssr: false,
});
// TrackMap.tsx
const TrackMap = () => {
return (
<MapContainer
key={`${location.latitude} ${location.longitude}`}
center={center}
zoom={9}
scrollWheelZoom={false}
style={{ height: "50vh" }}
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker
position={center}
icon={icon({
iconUrl: "/tabler-map-pin-filled.svg",
iconRetinaUrl: "/tabler-map-pin-filled.svg",
iconSize: [32, 32],
})}
>
<Popup>Du er her</Popup>
</Marker>
{tracks.map((track) => (
<Marker
key={track.name}
position={[track.coordinates.lat, track.coordinates.lng]}
icon={icon({
iconUrl: "/dirtbike-icon.png",
iconRetinaUrl: "/dirtbike-icon.png",
iconSize: [50, 50],
})}
>
<Popup minWidth={250}>
<TrackDetails track={track} />
</Popup>
</Marker>
))}
</MapContainer>
);
};
How’s the weather?
The weather forecast component is a simple hook combined with a little mapping code to make the results match my UI. I only want to show three icons per day (morning, noon and night) for the next week or so. The results from the API are comprehensive but not a perfect match. So I map the timeseries taking one detail for each period. One nifty detail is that I link to yr.no with the day that was clicked on in the table selected when you land. This is done by passing the index of the series as the ?i=
query param in the url. A bit brittle, but worth it. Sure hope it doesn’t break 😅
// the mapping code
const grouped: Record<
string,
Record<'morning' | 'noon' | 'evening', WeatherForecastSummary>
> = summary.reduce((acc, e) => {
const date = new Date(e.time);
const day = date.toDateString();
acc[day] = acc[day] || {};
const time = date.getUTCHours() < 12 ? 'morning' : date.getUTCHours() < 18 ? 'noon' : 'evening';
acc[day][time] = acc[day][time] || e;
return acc;
}, {});
// the data fetching hook
export const useWeatherForecast = ({ lat, lng }) => {
const [forecast, setForecast] = useState(null);
useEffect(() => {
const fetchForecast = async () => {
if (!lat || !lng) {
return;
}
const response = await fetch(
"https://api.met.no/weatherapi/locationforecast/2.0/compact?" +
new URLSearchParams({
lat,
lon: lng,
}),
);
const json = await response.json();
setForecast(json);
};
fetchForecast().catch(console.error);
}, [lat, lng]);
return { forecast };
};
// weather forecast table
export function WeatherForecast({ lat, lng }) {
return (
<Table mt={"xl"}>
<Table.Thead>
<Table.Tr>
<Table.Th></Table.Th>
<Table.Th>Morgen</Table.Th>
<Table.Th>Middag</Table.Th>
<Table.Th>Kveld</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{Object.entries(summary).map(([day, items], i) => (
<Table.Tr
key={day}
style={{ cursor: "pointer" }}
onClick={() => {
router.push(
`https://www.yr.no/nb/v%C3%A6rvarsel/timetabell/${lat},${lng}/?i=${i}`,
);
}}
>
<Table.Td>
<Text size={"xl"}>{formatDateTime(day)}</Text>
</Table.Td>
<Table.Td>
<ForecastCard item={items["morning"]} units={units} textSize={"xl"} />
</Table.Td>
<Table.Td>
<ForecastCard item={items["noon"]} units={units} textSize={"xl"} />
</Table.Td>
<Table.Td>
<ForecastCard item={items["evening"]} units={units} textSize={"xl"} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
// the forecast card, shown in the table cells
function ForecastCard({ item, textSize, units }) {
if (item === undefined) {
return null;
}
const { temp, symbol_code, precipitation_amount } = item;
return (
<Group gap="xs">
<WeatherIcon symbol_code={symbol_code} size={50} />
<Stack gap="xs">
<Text size={textSize} c="dimmed">
{temp} °{units.air_temperature[0].toUpperCase()}
</Text>
<Text size={textSize} c="dimmed">
{precipitation_amount} {units.precipitation_amount}
</Text>
</Stack>
</Group>
);
}
function WeatherIcon({ symbol_code, size }) {
return (
<Image
src={`/weathericon/svg/${symbol_code}.svg`}
alt={symbol_code}
width={size}
height={size}
/>
);
}
Unsplash
In the page header there is a background image showing a random high quality picture of some motocross content. This is made possible by the Unsplash API.
// the data fetching hook
export const useRandomMxImage = () => {
const [image, setImage] = useState(undefined);
useEffect(() => {
(async () => {
const response = await fetch("/api/unsplash/random", {
method: "GET",
});
if (!response.ok) {
return;
}
const randomImage = await response.json();
setImage(randomImage);
})();
}, []);
return { image };
};
// used in the banner
export function Banner() {
const { image } = useRandomMxImage();
return (
<Card
radius="md"
className={classes.card}
style={{
backgroundImage: image?.urls?.regular
? `url(${image?.urls?.regular})`
: "none",
}}
>
<Overlay className={classes.overlay} opacity={0.55} zIndex={0} />
...
</Card>
);
}
// and the api handler with some caching
const unsplash = createApi({ accessKey: process.env.UNSPLASH_ACCESS_KEY });
const cache = {
data: null,
timestamp: 0,
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (cache.data && Date.now() - cache.timestamp < 1_800_000) {
res.status(200).json(cache.data);
return;
}
const random = await unsplash.photos.getRandom({ query: "motocross" });
if (random.type === "error") {
res.status(500).json(random.errors);
} else {
cache.data = random.response;
cache.timestamp = Date.now();
res.status(200).json(random.response);
}
}
Conclusion
We are living in the golden age of software development. Never before have we been this efficient.
Never before have so many powerful tools and libraries been at our fingertips, for free or at a very low cost.
In the daily hustle it is easy to loose inspiration and flow.
I hope this article leaves you with some inspiration to go out and find something fun to make, just for the sake of making it.
GLHF