I built Griini as a phone-first golf course distance tracker to be used in Finland. The practical job is simple: select a course, choose a hole, put the player marker at the current ball position and show the distance to the front, centre and back of the green. The implementation is built around making that work from community-mapped geometry, on a phone, with an unreliable connection.
The current version is a dependency-free JavaScript PWA served by a small Node.js static server. It loads golf courses through Overpass QL, converts the returned OpenStreetMap nodes, ways and relations into GeoJSON, builds a hole model from the result and renders it over satellite imagery with MapLibre GL JS. It also keeps course downloads, player settings and rounds in IndexedDB, so the map and scoring path can survive closing the browser or losing the network.
The high-level path looks like this:
Finland course query
-> searchable course index
-> course-specific Overpass query
-> GeoJSON conversion
-> hole line matched to a green polygon
-> GPS or dragged player position
-> front, centre and back distance
-> MapLibre or SVG fallback renderer
The local history contains five commits, all authored under my name on August 2, 2026, so I used that as the project date. Most of the implementation sits in one app.js file, with the service worker, runtime configuration, styles and static server kept beside it. That made the prototype quick to run because Node.js 18 or newer is enough and there is no package installation or build step, although the single large application module now needs splitting into smaller boundaries.

Turning Overpass responses into a course
The course list and the detailed course map are separate queries. The first asks for every leisure=golf_course object inside Finland and reads each result’s centre and tags. Opening one course then asks for golf features, nearby land cover, water, paths and the course boundary inside a configurable 2,800 metre radius.
Buildings needed a narrower query. A radius search around Tarina also returned thousands of buildings from the surrounding area, which added data the map would never draw as part of the course. The current query maps the course object to an Overpass area and asks for buildings only inside that area. If area handling fails, Griini retries the useful part of the query without buildings instead of failing the whole course download.
const base = `
[out:json][timeout:90];
(
nwr["golf"](around:${at});
nwr["leisure"="golf_course"](around:${at});
nwr["natural"~"^(water|wood|sand|beach|wetland)$"](around:${at});
nwr["landuse"~"^(forest|grass|meadow|recreation_ground)$"](around:${at});
way["waterway"~"^(river|stream|canal|ditch|drain)$"](around:${at});
way["highway"~"^(path|track|service)$"](around:${at});
);
out geom;
`;
if (!withBuildings) return base;
// Buildings are scoped to the inside of the course boundary. A plain radius
// search would pull in every house in the nearest town (2700+ at Tarina).
return `${base}
(
way["leisure"="golf_course"](around:${at});
rel["leisure"="golf_course"](around:${at});
)->.course;
.course map_to_area->.courseArea;
way["building"](area.courseArea);
out geom;
`;
fetchOverpass tries two configured endpoints in order and gives each request an abort timeout. This is still a public shared API, so the app stores a successful response rather than assuming the same query will be cheap or available during the round.
The conversion step has to handle three different OSM shapes. Nodes become GeoJSON points, open ways become lines and closed ways become polygons. Relations are more annoying because one course may contain several outer polygons and inner rings. The converter keeps the inner rings, then assigns each one to the outer polygon which contains its first point. Without that, a multipolygon boundary can look solid in places which were explicitly cut out by the mapper.
Matching hole lines to greens
OpenStreetMap golf data is not one consistent document. A golf=hole line normally contains the hole number, par and stroke index, while the target green is a separate polygon and may contain none of those tags. Griini joins them spatially. It takes the final coordinate of each hole line, finds the closest green centre and accepts the match when it is less than 250 metres away.
for (const line of holeLines) {
const coordinates = line.geometry.coordinates;
const lineEnd = coordinates[coordinates.length - 1];
let matched = null;
let matchedDistance = Infinity;
for (const entry of greenCenters) {
const distance = haversineMeters(lineEnd, entry.center);
if (distance < matchedDistance) {
matchedDistance = distance;
matched = entry;
}
}
const green = matched && matchedDistance < 250 ? matched.green : null;
if (green) usedGreens.add(green);
const greenCenter = green ? geometryCenter(green.geometry) : lineEnd;
That primary path avoids treating practice greens as extra holes when proper hole lines exist. For courses which only map greens, the fallback creates one hole per green and looks for a tee with the same ref, then uses the nearest tee when the tags do not match. If a course query returns no usable green at all, the app creates a clearly marked inferred ellipse at the course centre so the UI remains inspectable, and it tells the user that the result is inferred.
Green centres use a polygon area centroid, followed by a bounding-box centre, average point or first vertex when the calculated point falls outside an awkward ring. The default flag starts there, but remains draggable because a geometric centroid is not the real pin position.
Centre distance is a Haversine distance using an Earth radius of 6371008.8 metres. Front and back are currently the nearest and farthest green vertices from the player:
function greenFrontBack(player, hole) {
if (!player || !hole?.green) return null;
const ring = flattenGeometryCoordinates(hole.green.geometry);
if (!ring.length) return null;
let front = Infinity;
let back = -Infinity;
for (const vertex of ring) {
const distance = haversineMeters(player, vertex);
if (distance < front) front = distance;
if (distance > back) back = distance;
}
return { front, back, depth: Math.max(0, back - front) };
}
This is useful, but approximate. The nearest point on a polygon edge can sit between two vertices, and the values do not represent intersections along the player-to-flag line. A better version would project the green boundary into a local metric coordinate system and calculate those intersections explicitly. The browser’s GNSS accuracy and the quality of the mapped green are larger limits in many real situations anyway, which is why the interface keeps the reported accuracy visible and the README calls the project a prototype rather than a surveyed yardage instrument.
Making the map read like a golf course
Griini vendors MapLibre GL JS 6.1.0 and its workers locally, so loading the application shell does not depend on a CDN. The normal map combines Esri satellite tiles with Terrarium-encoded AWS elevation tiles. Separate raster DEM sources feed hillshade and 3D terrain, with a 1.35 terrain exaggeration, while a top-down toggle removes pitch and terrain without changing the player’s centre, zoom or bearing.
The OSM GeoJSON is drawn as a deliberate layer stack. Fairways, greens and bunkers get separate outline layers because MapLibre’s fill-outline-color stays one pixel wide and disappears against satellite imagery. Water remains blue whether it is mapped as natural water or a water hazard, dry penalty ground is red, out of bounds is dark with a white dashed line and woodland gets a small generated canopy pattern. A final mask dims everything outside the leisure=golf_course boundary, which leaves the playable area lit without hiding ponds or copses cut into the boundary.

Framing the camera along the current hole was one of the awkward visual details. The reset path calculates the bearing from player to flag and derives zoom from the remaining distance and the usable screen height, after subtracting 300 pixels for the bars which sit over the map. An earlier revision also applied a pixel offset from queryTerrainElevation. That made the result depend on whether the DEM tile had loaded, so an early reset and a reset after panning could place the same hole hundreds of pixels apart. The final path lets MapLibre handle terrain elevation and keeps the reset target independent of tile timing.
const distance = Math.max(90, haversineMeters(player, flag));
const bearing = bearingBetween(player, flag);
const center = midpoint(player, flag);
const effectiveHeight = Math.max(260, this.container.clientHeight - 300);
// MapLibre zoom is 512px-tile based: metres per CSS pixel at zoom z is
// 78271.517 * cos(lat) / 2^z.
const metersPerPixel = (distance * RESET_VIEW_PADDING_FACTOR) / effectiveHeight;
const zoom = clamp(Math.log2((78271.51696 * Math.cos(toRadians(center[1]))) / metersPerPixel), 13.5, 18.2);
// MapLibre's terrain-aware camera already adjusts for the elevation under
// the target centre. Adding the queried absolute elevation as a pixel
// offset made framing depend on whether the DEM tile had loaded: an early
// reset used zero, while a reset after panning could push the hole hundreds
// of pixels down the screen. Keep the reset target independent of tile
// loading so manual resets and live GPS updates frame identically.
this.map.easeTo({ center, zoom, bearing, pitch, offset: [0, 0], duration: 850, essential: true });
If MapLibre cannot initialize, the app switches to its own SVG adapter. It projects course coordinates into a 1000 by 1000 view box, turns polygons and lines into SVG paths and keeps marker dragging, panning, zooming and distance labels working. It cannot tilt a camera and it has no satellite image, but the golf geometry and measurement path remain usable. The ?preview=1&no-map=1 route forces this renderer with generated demo geometry, which also gives the project a deterministic page for visual checks.
Offline data has two different lifetimes
Offline support is split by data type. IndexedDB stores the course index, downloaded GeoJSON, tee settings, stroke-index overrides and rounds. The Cache API stores the application shell, local MapLibre files and satellite or elevation tiles which have already been viewed.
A downloaded course is used regardless of age. Griini only contacts Overpass on the first open or when the user explicitly presses Update. Schema versions still matter while online, because changes to the query or parser can invalidate an old shape, but an old stored shape is accepted offline because an outdated course is more useful than refusing to open it.
const key = courseCacheKey(course);
const cached = await cacheGet(key);
const cachedUsable = cached?.geojson && cached.schemaVersion === GEOMETRY_SCHEMA_VERSION;
if (cachedUsable && !forceRefresh) return { ...cached, fromCache: true };
if (!navigator.onLine) {
// Offline on the course: an older-format copy still beats no course at all,
// so never reject a download just because the schema moved on.
if (cached?.geojson) return { ...cached, fromCache: true };
throw new Error(
forceRefresh
? "No network available to update this course."
: "This course has not been downloaded yet and no network is available."
);
}
The service worker precaches shell files one by one with Promise.allSettled, so one unavailable asset does not abort the whole installation. Same-origin files are network-first with a three-second cached fallback, while Esri and AWS tiles are cache-first because a previously viewed tile is useful on the next round. Cache reads and writes are best-effort throughout. Storage quota, private browsing or eviction can break a write, but that should not turn a valid network response into an application error.
There are two rough edges here. The repository contains data/finland-courses.json with 103 fallback catalog entries, but the current application never fetches that file. A browser with no previously stored live index therefore gets the hardcoded demo and placeholder rows when it is offline. The courseIndexMaxAgeDays configuration value is also unused because index refresh is now explicitly user-driven. That catalog should either be connected to the initial load or removed, and the refresh policy should match the configuration rather than leaving two competing ideas in the repository.
Fully deterministic imagery is deliberately separate. offlineRasterTemplate can point at a legally distributable local XYZ tile set, but Griini does not bulk-download normal OpenStreetMap tiles. The offline notes suggest PMTiles as the next step for a packaged region because millions of individual XYZ files are a poor distribution format.
Adding a scorecard around incomplete map data
Par and stroke index can come from each golf=hole line, but course and slope ratings do not exist in the OSM data used here. Griini keeps those values per course loop, tee colour and men’s or women’s rating set. Player-entered values win over the bundled defaults, and pasted stroke indexes win over OSM when the tags are absent or wrong.
The current seed file contains published values for Tarina Golf’s two loops. It is a narrow seed rather than a national rating database, so other courses need manual entry. Once the values exist, the scorecard calculates gross totals, net difference and Stableford points, and stores the round after every stroke. A round object appears on the first stroke and is removed again if every hole is rolled back to zero, which avoids collecting empty rounds in IndexedDB.
The Course Handicap calculation follows the published 18-hole formula:
function courseHandicap(group = currentGroup()) {
const index = Number(state.player.handicapIndex);
const tee = teeRating(group, teeEntry(group).selected);
const par = groupPar(group);
if (!Number.isFinite(index) || !tee || !par) return null;
return Math.round(index * (tee.slope / 113) + (tee.rating - par));
}

The current function applies that same formula to every hole group. Proper 9-hole handling needs a halved and rounded Handicap Index with the 9-hole rating, slope and par, and that needs to be added before treating the scorecard as a general WHS calculator. Griini also does not post an official score or calculate a Handicap Index. It is a local round tracker which uses a supplied index.
What I checked and what still needs work
The repository has a check script which runs Node syntax validation over app.js, sw.js and server.mjs. For this draft, those three checks passed, and the local server returned HTTP 200 responses for both / and /app.js. The checked-in README records a manual stopped-server test where the shell, course list, all 36 Tarina holes and the three green distances loaded without a reachable origin.
There is no unit or browser automation suite yet, so geometry, scoring, IndexedDB migrations, service-worker updates and offline startup still depend too much on manual checking. The deterministic fallback route is a useful start. The most direct improvement is to extract the geometry and scoring functions from app.js and give them fixture-based tests. Useful cases include multipolygons with inner rings, hole endpoints near two greens, concave greens, missing OSM tags, negative handicaps, 9-hole rounds and rollback to an empty score.
After that, one browser test could download a fixture course, record a few strokes, reload offline and prove that the same hole, course geometry and score survive. That would exercise the boundary which matters on a real course instead of only proving that the JavaScript parses.
The current prototype already connects the whole useful path from an OSM relation to a draggable flag, a live distance and a stored round. Its remaining problems are fairly concrete: use the bundled fallback catalog, calculate the green edges geometrically, handle 9-hole handicaps correctly and turn the documented offline check into a repeatable test. Those changes would make the existing path more trustworthy without changing what Griini is trying to do.