Skip to content

Hilbert Grid Overlay

This guide shows how to render the Yoro Hilbert grid on a Leaflet map. As users pan and zoom, the grid updates to show all cells at the appropriate precision level.

The Concept

At each zoom level, we compute which Yoro cells are visible in the map viewport and draw them as rectangles with their code labels. This gives users a visual understanding of the addressing system.

The Yoro codec is pure math — no database needed. For the best UX, compute cells directly in JavaScript using the port of the algorithm.

Step 1: Include the Yoro JavaScript Codec

You can either copy the codec from the source or use it inline. Here's a minimal version:

<script src="/static/js/yoro_codec.js"></script>

JavaScript codec

The JavaScript port (yoro_codec.js) implements the same Hilbert curve algorithm as the Python library. The encode/decode results are identical. See the repository for the full source.

Step 2: Grid Overlay Layer

// Grid overlay for Leaflet
class YoroGridOverlay {
    constructor(map, options = {}) {
        this.map = map;
        this.domain = options.domain || 'CI';
        this.maxCells = options.maxCells || 1500;
        this.gridLayer = L.layerGroup().addTo(map);
        this.showLabels = options.showLabels !== false;

        // Colors for different precision levels
        this.colors = {
            2: '#e65100', 4: '#f57c00', 7: '#ff9800',
            9: '#ffa726', 12: '#ffb74d', 14: '#ffcc02',
            17: '#4caf50', 19: '#2196f3', 21: '#9c27b0',
        };

        // Update grid when map moves
        map.on('moveend zoomend', () => this.update());
        this.update();
    }

    update() {
        this.gridLayer.clearLayers();

        const zoom = this.map.getZoom();
        const precision = this._zoomToPrecision(zoom);
        const bounds = this.map.getBounds();

        // Use the JS codec to get visible cells
        const cells = AltiusCodec.cellsInBounds(
            bounds.getSouth(), bounds.getNorth(),
            bounds.getWest(), bounds.getEast(),
            precision, this.domain, this.maxCells
        );

        if (cells.length === 0) return;  // Too many cells or out of domain

        const color = this.colors[precision] || '#ff9800';

        cells.forEach(cell => {
            const rect = L.rectangle(
                [[cell.bounds.lat_min, cell.bounds.lon_min],
                 [cell.bounds.lat_max, cell.bounds.lon_max]],
                {
                    color: color,
                    weight: 1,
                    fillOpacity: 0.05,
                    interactive: true,
                }
            );

            // Show code on hover
            rect.bindTooltip(cell.code, {
                direction: 'center',
                className: 'yoro-tooltip',
                permanent: this.showLabels && cells.length < 200,
            });

            // Show code on click
            rect.on('click', () => {
                navigator.clipboard.writeText(cell.code);
            });

            this.gridLayer.addLayer(rect);
        });
    }

    _zoomToPrecision(zoom) {
        if (zoom <= 8)  return 4;
        if (zoom <= 10) return 7;
        if (zoom <= 12) return 9;
        if (zoom <= 14) return 12;
        if (zoom <= 16) return 14;
        if (zoom <= 18) return 17;
        return 19;
    }

    setDomain(domain) {
        this.domain = domain;
        this.update();
    }

    remove() {
        this.gridLayer.clearLayers();
        this.map.removeLayer(this.gridLayer);
    }
}

Step 3: Usage

const map = L.map('map').setView([6.8, -5.3], 12);

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '&copy; OpenStreetMap'
}).addTo(map);

// Add the Yoro grid
const grid = new YoroGridOverlay(map, {
    domain: 'CI',
    showLabels: true,
    maxCells: 1500,
});

// Toggle with a layer control
const overlays = { 'Yoro Grid': grid.gridLayer };
L.control.layers(null, overlays).addTo(map);

Step 4: Styling

.yoro-tooltip {
    background: rgba(230, 81, 0, 0.85);
    color: white;
    border: none;
    font-family: monospace;
    font-size: 11px;
    padding: 2px 6px;
    border-radius: 3px;
}

Server-Side Approach (for Django)

If you prefer computing cells on the server (useful when you need to join with database records):

View

import json
from django.http import JsonResponse
from yoro.codec import cells_in_bounds, domain_for_country

def grid_cells_view(request):
    """Return Yoro cells for a map viewport."""
    lat_min = float(request.GET['lat_min'])
    lat_max = float(request.GET['lat_max'])
    lon_min = float(request.GET['lon_min'])
    lon_max = float(request.GET['lon_max'])
    precision = int(request.GET.get('precision', 12))
    domain = domain_for_country(request.GET.get('domain', 'CI'))

    cells = cells_in_bounds(
        lat_min, lat_max, lon_min, lon_max,
        precision=precision, domain=domain, max_cells=2000,
    )

    # Convert to GeoJSON for Leaflet
    features = []
    for cell in cells:
        b = cell['bounds']
        features.append({
            'type': 'Feature',
            'properties': {'code': cell['code']},
            'geometry': {
                'type': 'Polygon',
                'coordinates': [[
                    [b['lon_min'], b['lat_min']],
                    [b['lon_max'], b['lat_min']],
                    [b['lon_max'], b['lat_max']],
                    [b['lon_min'], b['lat_max']],
                    [b['lon_min'], b['lat_min']],
                ]],
            },
        })

    return JsonResponse({
        'type': 'FeatureCollection',
        'features': features,
    })

JavaScript (fetch from server)

async function loadGrid() {
    const bounds = map.getBounds();
    const precision = zoomToPrecision(map.getZoom());

    const resp = await fetch(
        `/api/grid/?lat_min=${bounds.getSouth()}&lat_max=${bounds.getNorth()}` +
        `&lon_min=${bounds.getWest()}&lon_max=${bounds.getEast()}` +
        `&precision=${precision}&domain=CI`
    );
    const geojson = await resp.json();

    if (gridLayer) map.removeLayer(gridLayer);
    gridLayer = L.geoJSON(geojson, {
        style: { color: '#e65100', weight: 1, fillOpacity: 0.05 },
        onEachFeature: (feature, layer) => {
            layer.bindTooltip(feature.properties.code, {
                direction: 'center', permanent: geojson.features.length < 200,
            });
        },
    }).addTo(map);
}

map.on('moveend', loadGrid);

Performance Tips

Zoom Level Precision Visible Cells Label?
6-8 4 ~20 Yes
9-10 7 ~100 Yes
11-12 9 ~200 Yes
13-14 12 ~800 No (tooltip on hover)
15-16 14 ~2000 No
17+ 17 Use client-side No

Cell count limit

cells_in_bounds() returns an empty list if the count exceeds max_cells (default 2000). This prevents the browser from freezing at low zoom levels with high precision.