Skip to content
DA DataAcuity by The Geek Network

GeoGlobal — Integration Guide for TagMe & Takemehome

Copy-paste-ready patterns for the two apps that get the most value from this service. All examples are .NET 10 / C# 14 / Blazor (UI.Shared) since both apps share that stack.

If you only have time for one thing, jump to the Top Wins at the top of each app section.


0. One-time setup (both apps)

Add a typed HTTP client for the GeoGlobal REST API. Drop this in UI.Shared/Services/ or wherever you keep API clients.

0.1 — The DTOs

UI.Shared/Models/GeoGlobal.cs

namespace TheGeekNetwork.UI.Shared.Models.GeoGlobal;

public record Place(
    string Name,
    string Country,
    string? Fclass,
    string? Fcode,
    long? Population,
    double Lat,
    double Lng,
    int? Meters = null,
    double? Sim = null,
    List<Poi>? InterestingNearby = null);

public record Poi(
    string Name,
    string? Description,
    string Category,
    string? Subcategory,
    string? Source,
    string Country,
    double Lat,
    double Lng,
    double InterestScore,
    List<string> Tags,
    int? Meters = null);

public record Maneuver(
    string Instruction,
    double DistanceKm,
    double DurationMin,
    List<string>? StreetNames);

public record RouteResult(
    double DistanceKm,
    double DurationMin,
    string Mode,
    List<Maneuver> Maneuvers);

public record QuestResult(
    double? TotalDistanceKm,
    double? TotalDurationMin,
    List<Poi> OrderedPois,
    int? LegCount,
    string Mode,
    string? Theme,
    string? RoutingError,
    string? Error);

public enum RouteMode { Auto, Bicycle, Pedestrian, Motorcycle, Truck }

0.2 — The client

UI.Shared/Services/IGeoGlobalClient.cs

using TheGeekNetwork.UI.Shared.Models.GeoGlobal;

namespace TheGeekNetwork.UI.Shared.Services;

public interface IGeoGlobalClient
{
    Task<List<Place>> GeocodeAsync(string query, int limit = 5,
        bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
        CancellationToken ct = default);

    Task<List<Place>> ReverseGeocodeAsync(double lat, double lng, int limit = 5,
        bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
        CancellationToken ct = default);

    Task<List<Place>> SearchPlacesAsync(string query, int limit = 10,
        bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
        CancellationToken ct = default);

    Task<List<Poi>> NearbyPoisAsync(double lat, double lng, double withinKm = 10.0,
        int limit = 10, string? theme = null, CancellationToken ct = default);

    Task<RouteResult> RouteAsync(double fromLat, double fromLng,
        double toLat, double toLng, RouteMode mode = RouteMode.Auto,
        CancellationToken ct = default);

    Task<QuestResult> DiscoverQuestAsync(double lat, double lng,
        string? theme = null, double withinKm = 20.0, int maxPois = 5,
        RouteMode mode = RouteMode.Auto, CancellationToken ct = default);
}

UI.Shared/Services/GeoGlobalClient.cs

using System.Net.Http.Json;
using TheGeekNetwork.UI.Shared.Models.GeoGlobal;

namespace TheGeekNetwork.UI.Shared.Services;

public sealed class GeoGlobalClient : IGeoGlobalClient
{
    private readonly HttpClient _http;
    private readonly ILogger<GeoGlobalClient> _log;

    public GeoGlobalClient(HttpClient http, ILogger<GeoGlobalClient> log)
    {
        _http = http;
        _log = log;
    }

    public async Task<List<Place>> GeocodeAsync(string query, int limit = 5,
        bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
        CancellationToken ct = default)
    {
        var url = $"/api/v2/geocode?query={Uri.EscapeDataString(query)}&limit={limit}"
                + $"&include_interesting={includeInteresting.ToString().ToLower()}"
                + $"&within_km={withinKm}&max_interesting={maxInteresting}";
        return await GetAsync<List<Place>>(url, ct) ?? new();
    }

    public async Task<List<Place>> ReverseGeocodeAsync(double lat, double lng, int limit = 5,
        bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
        CancellationToken ct = default)
    {
        var url = $"/api/v2/reverse-geocode?lat={lat}&lng={lng}&limit={limit}"
                + $"&include_interesting={includeInteresting.ToString().ToLower()}"
                + $"&within_km={withinKm}&max_interesting={maxInteresting}";
        return await GetAsync<List<Place>>(url, ct) ?? new();
    }

    public async Task<List<Place>> SearchPlacesAsync(string query, int limit = 10,
        bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
        CancellationToken ct = default)
    {
        var url = $"/api/v2/search?query={Uri.EscapeDataString(query)}&limit={limit}"
                + $"&include_interesting={includeInteresting.ToString().ToLower()}"
                + $"&within_km={withinKm}&max_interesting={maxInteresting}";
        return await GetAsync<List<Place>>(url, ct) ?? new();
    }

    public async Task<List<Poi>> NearbyPoisAsync(double lat, double lng, double withinKm = 10.0,
        int limit = 10, string? theme = null, CancellationToken ct = default)
    {
        var url = $"/api/v2/poi/nearby?lat={lat}&lng={lng}&within_km={withinKm}&limit={limit}"
                + (theme is null ? "" : $"&theme={Uri.EscapeDataString(theme)}");
        return await GetAsync<List<Poi>>(url, ct) ?? new();
    }

    public async Task<RouteResult> RouteAsync(double fromLat, double fromLng,
        double toLat, double toLng, RouteMode mode = RouteMode.Auto,
        CancellationToken ct = default)
    {
        var body = new {
            from_lat = fromLat, from_lng = fromLng,
            to_lat = toLat, to_lng = toLng,
            mode = mode.ToString().ToLower()
        };
        var resp = await _http.PostAsJsonAsync("/api/v2/route", body, ct);
        resp.EnsureSuccessStatusCode();
        return (await resp.Content.ReadFromJsonAsync<RouteResult>(cancellationToken: ct))!;
    }

    public async Task<QuestResult> DiscoverQuestAsync(double lat, double lng,
        string? theme = null, double withinKm = 20.0, int maxPois = 5,
        RouteMode mode = RouteMode.Auto, CancellationToken ct = default)
    {
        var body = new {
            lat, lng, theme,
            within_km = withinKm,
            max_pois = maxPois,
            mode = mode.ToString().ToLower()
        };
        var resp = await _http.PostAsJsonAsync("/api/v2/quest", body, ct);
        resp.EnsureSuccessStatusCode();
        return (await resp.Content.ReadFromJsonAsync<QuestResult>(cancellationToken: ct))!;
    }

    private async Task<T?> GetAsync<T>(string url, CancellationToken ct)
    {
        try { return await _http.GetFromJsonAsync<T>(url, ct); }
        catch (Exception ex) { _log.LogWarning(ex, "GeoGlobal call failed: {Url}", url); return default; }
    }
}

0.3 — DI registration

In Web/Program.cs and Web.Client/Program.cs (and MauiProgram.cs for the MAUI flavour):

builder.Services.AddHttpClient<IGeoGlobalClient, GeoGlobalClient>(c =>
{
    c.BaseAddress = new Uri(builder.Configuration["GeoGlobal:BaseUrl"]
        ?? "https://maps.dataacuity.co.za");
    c.Timeout = TimeSpan.FromSeconds(15);
});

In appsettings.json:

{
  "GeoGlobal": { "BaseUrl": "https://maps.dataacuity.co.za" }
}

For local dev pointing straight at .106, use http://197.97.200.106:5020 (the maps_api host port).

0.4 — Auth header (passing through DataAcuity Keycloak token)

Both apps already have a DataAcuityTokenHandler or similar. Reuse it:

builder.Services.AddHttpClient<IGeoGlobalClient, GeoGlobalClient>(...)
    .AddHttpMessageHandler<DataAcuityTokenHandler>();

If you don't have one yet, the simplest pattern is a DelegatingHandler that fetches the bearer from ITokenStorageService and adds Authorization: Bearer {token} to every request.


1. TagMe — integration patterns

🏆 Top Wins

# Win Files touched Effort
1 Auto-fill address on POI creation CreatePOI.razor 30 min
2 Smart POI suggestions at check-in CheckInService.cs 1 h
3 Turn-by-turn directions to events Events.razor, new Directions.razor 2-3 h
4 GeoBlast campaign target validation GeoBlast.razor 1 h
5 "Nearby Landmarks" trending carousel Index.razor 2 h

1.1 — Auto-fill address on POI creation

Problem: Users skip the Address field on CreatePOI.razor:48 because typing a full address is annoying.

Fix: After they pin a location on the map, reverse-geocode the coordinate and pre-fill the field. They edit if needed.

// In CreatePOI.razor.cs (or @code block)
[Inject] IGeoGlobalClient Geo { get; set; } = default!;

private async Task OnMapPinned(double lat, double lng)
{
    PoiCoordinates = $"{lat:F6}, {lng:F6}";

    // New: prefill address from reverse geocode (best-effort)
    var places = await Geo.ReverseGeocodeAsync(lat, lng, limit: 1);
    if (places is [{ Name: var name, Country: var cc }, ..])
    {
        AddressInput = $"{name}, {cc}"; // user can refine
        // Optional: also surface POI category hints
        var pois = await Geo.NearbyPoisAsync(lat, lng, withinKm: 0.1, limit: 3);
        if (pois.Count > 0)
            SuggestedCategoryFromPoi = pois[0].Category; // "tourism", "amenity", ...
    }
    StateHasChanged();
}

1.2 — Smart POI suggestions at check-in

Problem: CheckInService.CreateCheckInAsync(lat, lng, ...) writes coordinates only; users have to manually type "where" they are.

Fix: Look up the nearest 1–3 POIs in a 50m radius and offer them as "Are you at...?" pre-fills before the user types.

// In CheckInService.cs (extension or wrapped service)
public async Task<List<Poi>> SuggestVenuesAsync(double lat, double lng, CancellationToken ct = default)
{
    var pois = await _geo.NearbyPoisAsync(lat, lng, withinKm: 0.05, limit: 3, ct: ct);
    return pois.Where(p => p.Meters <= 60).Take(3).ToList(); // tight radius, very close only
}

UI on CheckIn.razor:

@if (suggestedVenues.Count > 0)
{
    <div class="venue-suggestions">
        <span>Are you at...</span>
        @foreach (var v in suggestedVenues)
        {
            <button @onclick="() => PickVenue(v)">@v.Name <small>(@v.Meters m)</small></button>
        }
    </div>
}

1.3 — Turn-by-turn directions to events

Problem: Events.razor has a map view but no "How do I get there?" — users open Google Maps to actually navigate. RSVP conversion suffers.

Fix: Add a Directions button on the event card; show maneuvers in a side panel using RouteAsync.

@page "/event/{EventId:guid}/directions"
@inject IGeoGlobalClient Geo
@inject IGeolocationService Geolocation

<h2>Directions to @currentEvent.Name</h2>

@if (route is null)
{
    <p>Loading…</p>
}
else
{
    <p><strong>@route.DistanceKm.ToString("F1") km · @route.DurationMin.ToString("F0") min</strong></p>
    <ol class="maneuvers">
        @foreach (var m in route.Maneuvers)
        {
            <li>
                <strong>@m.Instruction</strong>
                <small>@m.DistanceKm km · @m.DurationMin min</small>
            </li>
        }
    </ol>
}

@code {
    [Parameter] public Guid EventId { get; set; }
    private TagMeEvent currentEvent = default!;
    private RouteResult? route;

    protected override async Task OnInitializedAsync()
    {
        currentEvent = await EventService.GetAsync(EventId);
        var me = await Geolocation.GetCurrentAsync();
        try {
            route = await Geo.RouteAsync(me.Lat, me.Lng, currentEvent.Lat, currentEvent.Lng);
        } catch (HttpRequestException) {
            // Outside Africa or routing failure — show fallback "Open in Google Maps" link
        }
    }
}

1.4 — GeoBlast campaign target validation

Problem: GeoBlast.razor:80 lets users pick a campaign center coord, but doesn't show the human-readable place name back to them. Easy to target the wrong area.

Fix: After they pick a coord, reverse-geocode and show "Targeting: Sandton, ZA (5 km radius)". Refuse if places is empty (likely the middle of an ocean).

private string TargetDescription = "";
private bool TargetIsValid;

private async Task OnTargetPicked(double lat, double lng, double radiusKm)
{
    var places = await Geo.ReverseGeocodeAsync(lat, lng, limit: 1);
    if (places is [{ Name: var name, Country: var cc, Population: var pop }, ..])
    {
        TargetDescription = $"Targeting: {name}, {cc} (~{pop ?? 0:N0} people · {radiusKm:F1} km radius)";
        TargetIsValid = true;
    }
    else
    {
        TargetDescription = "⚠️ No populated places near this point. Pick a different center.";
        TargetIsValid = false;
    }
}

Problem: Index.razor:60 has a "Trending Locations" carousel that's currently powered by check-in counts only. Long tail places never trend.

Fix: Add a parallel "Landmarks near you" carousel sourced from the 3.5M POI dataset, ranked by interest_score within 25 km.

// On Index.razor OnInitializedAsync, after geolocation
nearbyLandmarks = await Geo.NearbyPoisAsync(
    me.Lat, me.Lng,
    withinKm: 25,
    limit: 12,
    theme: "tourism");

Render as a horizontal scroll component with the existing carousel widget. Each card: name, category, distance, optional Wikipedia thumbnail (when source=wikipedia).


2. Takemehome.co.za — integration patterns

Takemehome already has 7 maps endpoints implemented backend-side that were never wired to the UI. This is the lowest-hanging fruit in the entire ecosystem.

🏆 Top Wins

# Win Files touched Effort Impact
1 Interactive map on PropertyDetail with property pin + nearby POIs PropertyDetail.razor 1 day Direct CTR to affiliate booking link
2 "Nearby attractions" section on PropertyDetail PropertyDetail.razor 0.5 day Differentiator vs Booking.com
3 Destination radius search ("lodges within 20 km of Kruger Gate") Home.razor, Search.razor 1 day Underserved destination discovery
4 Travel-time matrix on search results ("45 min from CBD") Search.razor 1 day Filter by commute
5 Reverse-geocoded "near me" entry point Home.razor 0.5 day Mobile-first discovery
6 Road-trip itinerary builder new Itinerary.razor 2-3 days Affiliate multi-night bookings
7 AI-generated guides via Butler Butler skill 1 day SEO long tail

2.1 — Interactive map on PropertyDetail with nearby POIs overlay

Problem: PropertyDetail.razor:48-49 shows the address as plain text. Users have to copy-paste to Google Maps to understand "where" the property actually is.

Fix: Embed a map (DataAcuity tile layer) centred on the property, with discover_quest-style overlay of the top 5 nearby attractions.

@inject IGeoGlobalClient Geo

<div class="property-map">
    <MapEmbed
        Lat="@property.Location.Lat"
        Lng="@property.Location.Lng"
        Zoom="14"
        Markers="@mapMarkers" />
</div>

@code {
    private List<MapMarker> mapMarkers = new();

    protected override async Task OnParametersSetAsync()
    {
        // Marker for the property itself
        mapMarkers.Add(new MapMarker(property.Location.Lat, property.Location.Lng, "🏨 " + property.Name, MarkerKind.Property));

        // Top 5 nearby POIs
        var pois = await Geo.NearbyPoisAsync(
            property.Location.Lat, property.Location.Lng,
            withinKm: 5, limit: 5);
        mapMarkers.AddRange(pois.Select(p =>
            new MapMarker(p.Lat, p.Lng, p.Name, MarkerKind.Attraction)));
    }
}

2.2 — "Nearby Attractions" section on PropertyDetail

Problem: Same page lacks any "things to do nearby" — the conversion lever Booking.com uses heavily.

Fix: A card grid below the property gallery showing 5–8 nearby POIs with travel time (Valhalla route).

@if (nearbyAttractions.Count > 0)
{
    <section class="nearby-attractions">
        <h3>Things to do nearby</h3>
        <div class="grid">
            @foreach (var (poi, travelTime) in nearbyAttractions)
            {
                <div class="card">
                    <strong>@poi.Name</strong>
                    <small>@poi.Category · @poi.InterestScore.ToString("P0") interesting</small>
                    <p>@((MarkupString)(poi.Description ?? ""))</p>
                    <small>@travelTime.DistanceKm km · @travelTime.DurationMin min by car</small>
                </div>
            }
        </div>
    </section>
}

@code {
    private List<(Poi Poi, RouteResult Route)> nearbyAttractions = new();

    protected override async Task OnParametersSetAsync()
    {
        var pois = await Geo.NearbyPoisAsync(
            property.Location.Lat, property.Location.Lng,
            withinKm: 30, limit: 8, theme: "tourism");

        // Get travel times in parallel
        var tasks = pois.Select(async p => (Poi: p, Route: await Geo.RouteAsync(
            property.Location.Lat, property.Location.Lng,
            p.Lat, p.Lng,
            RouteMode.Auto)));

        nearbyAttractions = (await Task.WhenAll(tasks)).ToList();
    }
}

Cost-conscious version: skip the per-POI route call and just show poi.Meters (already returned by the nearby endpoint). Trade some accuracy (straight-line vs road) for speed.

Problem: Home.razor:12-143 and Search.razor take a destination string only — exact match. Users searching "Kruger" don't see lodges 30 km outside the park gate.

Fix: Geocode the destination, then run the property search with a radius around the centroid. Lift radius from a slider on Search.razor.

// Search.razor.cs
private async Task<SearchResults> RunSearch(string destination, double? radiusKm)
{
    // 1. Resolve destination text -> centroid (with fuzzy fallback)
    var places = await Geo.GeocodeAsync(destination, limit: 1);
    if (places.Count == 0)
        places = await Geo.SearchPlacesAsync(destination, limit: 1); // fuzzy fallback

    if (places.Count == 0)
        return SearchResults.Empty($"Couldn't find \"{destination}\"");

    var center = places[0];

    // 2. Run existing property search with bounding box around (center.Lat, center.Lng)
    return await PropertySearch.SearchInRadiusAsync(center.Lat, center.Lng, radiusKm ?? 25);
}

2.4 — Travel-time matrix on Search results

Problem: Search results show price and rating, but nothing about how far the property is from a reference point (airport, CBD, user's location).

Fix: Add an optional "How far from..." dropdown. Run route for each result row in parallel (capped at ~20 properties at a time).

private async Task EnrichWithTravelTime(IEnumerable<Property> results, double refLat, double refLng)
{
    var batch = results.Take(20);
    var routes = await Task.WhenAll(batch.Select(p =>
        Geo.RouteAsync(refLat, refLng, p.Location.Lat, p.Location.Lng)
           .ContinueWith(t => (Property: p, Route: t.IsCompletedSuccessfully ? t.Result : null))));

    foreach (var (prop, route) in routes)
    {
        prop.TravelTime = route is null ? null
            : $"{route.DistanceKm:F0} km · {route.DurationMin:F0} min from ref";
    }
}

UI as an extra column / chip on each result row.

2.5 — Reverse-geocoded "near me" entry point

Problem: Mobile users opening Takemehome have no quick "what's near me" path. They have to type a destination cold.

Fix: A hero-button on Home.razor — "📍 Properties near you" — that grabs geolocation, reverse-geocodes to find the nearest city, and runs a radius search.

<button class="hero-btn" @onclick="ExploreNearby">📍 Properties near you</button>

@code {
    private async Task ExploreNearby()
    {
        var pos = await Geolocation.GetCurrentAsync();
        var places = await Geo.ReverseGeocodeAsync(pos.Lat, pos.Lng, limit: 1);
        if (places.Count == 0) { /* fallback to "destination unknown" */ return; }

        Nav.NavigateTo($"/search?destination={Uri.EscapeDataString(places[0].Name)}&radius=30");
    }
}

2.6 — Road-trip itinerary builder

Problem: Takemehome has nothing for multi-city trips. Booking.com does. Users planning a 2-week Africa road trip leak to competitors.

Fix: A new Itinerary.razor page. User picks N stops; we use discover_quest to suggest POIs at each stop, and route to link the stops with driving times. Each leg shows recommended properties at the destination.

// At each leg of the itinerary
foreach (var (from, to) in legs)
{
    var leg = await Geo.RouteAsync(from.Lat, from.Lng, to.Lat, to.Lng, RouteMode.Auto);
    var nearbyAttractionsAtDest = await Geo.NearbyPoisAsync(to.Lat, to.Lng, withinKm: 30, limit: 5);
    var propertiesAtDest = await PropertySearch.SearchInRadiusAsync(to.Lat, to.Lng, 20);
    // ... assemble the itinerary view
}

2.7 — AI-generated travel guides via B! (Butler)

Problem: Long tail SEO traffic ("best weekend getaways from Pretoria") is dominated by content sites.

Fix: Register geo_mcp as a Butler MCP source. Butler then composes (reverse_geocodediscover_quest) to generate fresh, on-demand guides for any query.

// in butler's MCP config (Butler / B! AI ops settings)
{
  "mcpServers": {
    "geo-global": {
      "url": "http://geo_mcp:8000/sse",
      "transport": "sse"
    }
  }
}

Once registered, B! can answer prompts like "Plan a 3-day weekend break from Pretoria, drivable, with at least 2 history sites and 1 wildlife stop" end-to-end by composing the tools. See CircleAI / Butler docs for how the skill builder consumes MCP tool definitions.


3. Common patterns across both apps

3.1 — Graceful degradation when outside Africa

Routing fails outside Africa. Detect and offer a fallback (link to Google Maps, hide the directions button, etc.):

try
{
    var route = await Geo.RouteAsync(fromLat, fromLng, toLat, toLng);
    // render maneuvers
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.BadRequest)
{
    // outside Africa — show fallback
    fallbackLink = $"https://www.google.com/maps/dir/{fromLat},{fromLng}/{toLat},{toLng}";
}

3.2 — Caching at the call site

The server caches geocoding for 24h. But every UI re-render shouldn't re-call. Memoize at the component level using IMemoryCache:

[Inject] IMemoryCache Cache { get; set; } = default!;

private async Task<List<Place>> ReverseGeocodeCached(double lat, double lng)
{
    var key = $"geo-rev-{lat:F4}-{lng:F4}";
    if (Cache.TryGetValue<List<Place>>(key, out var cached) && cached is not null)
        return cached;

    var result = await Geo.ReverseGeocodeAsync(lat, lng);
    Cache.Set(key, result, TimeSpan.FromHours(6));
    return result;
}

3.3 — Handling slow / no network (CircleAether mesh fallback)

Both apps run over CircleAether mesh in offline mode. GeoGlobal calls will fail — wrap them defensively. Return a cached/local result when the network is mesh-only, and queue the fresh call when internet comes back:

if (Aether.IsMeshOnly)
{
    // Use last-known geocode/POI from local cache
    return await LocalGeoCache.GetReverseAsync(lat, lng);
}

3.4 — Telemetry

Log all GeoGlobal calls into BigBruh telemetry so we can see usage, p99 latency, error rates. The HttpClient already inherits the app's IHttpMessageHandlerFactory — add a LoggingHandler if not already global.


Both apps. Smallest, lowest-risk changes first.

  1. (both) Add the IGeoGlobalClient registration to DI. Ship it. No UI changes yet — just makes the client available.
  2. TagMe Auto-fill address on CreatePOI.razor (Section 1.1). Tiny UI change, big UX win.
  3. TagMe Smart venue suggestions on check-in (Section 1.2).
  4. Takemehome Embed map on PropertyDetail with POI overlay (Section 2.1) — the single highest-revenue change.
  5. Takemehome "Nearby attractions" section on PropertyDetail (Section 2.2).
  6. TagMe Turn-by-turn to events (Section 1.3).
  7. Takemehome Destination radius search (Section 2.3).
  8. (both) Register MCP with Butler so B! gains geographic reasoning.

Then circle back for the larger items (Takemehome itinerary builder, TagMe trending landmarks carousel).

Something went wrong on this page. Reload