Skip to content

web: replace OpenLayers with MapLibre and bundle a hex-grid basemap#21864

Draft
GirlBossRush wants to merge 31 commits into
goauthentik:mainfrom
GirlBossRush:issue-21849-swap-openlayers-with-maplibre-optional-self
Draft

web: replace OpenLayers with MapLibre and bundle a hex-grid basemap#21864
GirlBossRush wants to merge 31 commits into
goauthentik:mainfrom
GirlBossRush:issue-21849-swap-openlayers-with-maplibre-optional-self

Conversation

@GirlBossRush

@GirlBossRush GirlBossRush commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Details

Closes #21849.

The events map moves off OpenLayers onto MapLibre GL, and the basemap it draws now ships with authentik instead of being something you go and stand up yourself.

Getting the old map running meant choosing between pointing at a public tile service or self-hosting a Protomaps planet build, which is on the order of 100 GB. That is a lot of infrastructure for a panel whose job is plotting login locations, especially when those locations come from GeoIP and are city-accurate at best.

So the default is now a hexworld basemap. Land is drawn as an H3 hexagonal grid, with country and region borders that follow the hex edges rather than floating over them, plus country, region and locality labels. The whole thing is a 21.6 MB PMTiles archive served out of authentik's own static assets. No tile server, no external requests, no configuration, and it works in air-gapped deployments. Event locations aggregate into cells and light up through MapLibre feature-state, so the map keeps working as a filter control: click a cell and the table underneath narrows to it.

Detail is spent where people are. Cells within one hex of a town over 50,000 people are subdivided to about 20 km; everything else stays at about 52 km, and the world view is about 138 km. That keeps the Americas, Europe and Asia legible while the Sahara, the Siberian interior and Antarctica cost almost nothing.

The tradeoff is real and worth stating plainly: this is a picture of where your logins are rather than a map you can navigate, and the archive carries geometry only through z7. We landed there deliberately, since a crisper basemap would advertise a precision that GeoIP cannot actually deliver.

If you want a conventional basemap, that path is intact and unchanged. Set a tile URL on the brand under System > Brands > Map tiles and <ak-map> switches to buildBasemapStyle, taking either a pmtiles:// archive or an XYZ template. packages/tile-worker ships a Cloudflare Worker and R2 setup if you would rather host tiles yourself.

Generating the archive

packages/geo/scripts/hexworld/build.mjs builds the archive and is reproducible. It polyfills Natural Earth land polygons into H3 cells at three resolutions, assigns each cell a country and region from Natural Earth admin boundaries so borders can follow the hex edges, pulls place labels out of a Protomaps planet dump, and assembles the lot through tippecanoe and tile-join. Source URLs are pinned to a Natural Earth release tag so two runs a year apart produce the same map. It needs tippecanoe and go-pmtiles on PATH plus a z0-8 slice of a planet build, and --dry-run prints the pipeline without executing it.

The generator emits two cuts. detail is what ships, at 21.6 MB; plain drops the fine band entirely and comes to 12.1 MB. --detail-ring and --detail-min-pop control how much of the world gets the fine treatment, and --localities caps the label set (currently 50,000).

Subdividing everywhere rather than just around towns would have cost about 50 MB, and extending the fine band one more zoom level would have roughly doubled it again for detail past what GeoIP supports.

packages/geo/README.md documents the zoom bands. They are baked into any published archive, so changing HEX_BANDS invalidates existing tiles.

Attribution

Hex geometry and administrative borders come from Natural Earth, which is public domain. Labels are derived from OpenStreetMap data via Protomaps, so the map carries an ODbL attribution line.

Testing

packages/geo has 45 unit tests covering the zoom bands, antimeridian-safe cell geometry, land polyfill, country and region assignment, border extraction and dedupe, the detail zone, label normalization, the generator's tiling plan, and event aggregation. One test asserts the default style references no absolute URLs, which is the air-gap claim in executable form. authentik/brands passes (18 tests). Verified by decoding tiles out of the built archive rather than trusting the build log: both border levels present, no segment emitted twice, coastal edges closing countries against water, the fine band active around cities and absent in remote interiors. Also verified that the archive serves with Range requests, that glyphs resolve locally, and that a brand-configured tile URL still falls back to the conventional basemap.


Checklist

  • Local tests pass (ak test authentik/brands/; the full suite runs in CI)
  • The code has been formatted (make lint-fix)

If an API change has been made

  • The API schema and clients have been updated (make gen)

If changes to the frontend have been made

  • The code has been formatted (make web)

If applicable

  • The documentation has been updated
  • The documentation has been formatted (make docs)

Disclosure: Triaged and authored end-to-end by an agent running in a sandbox against goauthentik/authentik@main. Reviewed and shipped by @GirlBossRush.

@netlify

netlify Bot commented Apr 27, 2026

Copy link
Copy Markdown

Deploy Preview for authentik-integrations ready!

Name Link
🔨 Latest commit cd20a53
🔍 Latest deploy log https://app.netlify.com/projects/authentik-integrations/deploys/6a5e13023624e50008db0f12
😎 Deploy Preview https://deploy-preview-21864--authentik-integrations.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Apr 27, 2026

Copy link
Copy Markdown

Deploy Preview for authentik-storybook ready!

Name Link
🔨 Latest commit e3ff8e0
🔍 Latest deploy log https://app.netlify.com/projects/authentik-storybook/deploys/6a5e2248cc721f00082dcfd7
😎 Deploy Preview https://deploy-preview-21864--authentik-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Apr 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (d0824fa) to head (782d801).
⚠️ Report is 36 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #21864      +/-   ##
==========================================
- Coverage   93.50%   93.45%   -0.05%     
==========================================
  Files        1060     1060              
  Lines       63630    63633       +3     
  Branches      424      424              
==========================================
- Hits        59495    59471      -24     
- Misses       4135     4162      +27     
Flag Coverage Δ
conformance 35.51% <100.00%> (+<0.01%) ⬆️
e2e 40.48% <100.00%> (+<0.01%) ⬆️
integration 31.53% <100.00%> (-0.47%) ⬇️
rust 0.00% <ø> (ø)
unit 92.68% <100.00%> (+<0.01%) ⬆️
unit-migrate 92.70% <100.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@netlify

netlify Bot commented Apr 27, 2026

Copy link
Copy Markdown

Deploy Preview for authentik-docs ready!

Name Link
🔨 Latest commit cd20a53
🔍 Latest deploy log https://app.netlify.com/projects/authentik-docs/deploys/6a5e1302377da3000949b4bb
😎 Deploy Preview https://deploy-preview-21864--authentik-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@GirlBossRush GirlBossRush changed the title Issue 21849 swap openlayers with maplibre optional self web: Swap OpenlLayers with MapLibre Apr 29, 2026
GirlBossRush and others added 17 commits July 20, 2026 03:00
Adds a per-brand string field carrying an XYZ-style URL template (e.g.
`/tiles/{z}/{x}/{y}.mvt`) that the events map will consume in place of
the hard-coded OSM raster tiles. Empty value means "fall back to the
DefaultBrand value", which the frontend resolves to the relative path
proxied by the authentik server.

Wires the field through the Brand model, BrandSerializer,
CurrentBrandSerializer, the regenerated TypeScript client, the
WithBrandConfig mixin, and DefaultBrand. Existing brand fixtures in
tests.py are extended with the new key.

Co-Authored-By: ${GIT_CO_AUTHOR_NAME} <${GIT_CO_AUTHOR_EMAIL}>
Introduces a new internal workspace at packages/geo (symlinked into
web/packages/geo so the web bundler resolves it) providing:

- A Lit `<ak-map>` element wrapping maplibre-gl. It reads a vector
  tile XYZ template from a `tile-url` attribute, renders a Protomaps
  basemap style via @protomaps/basemaps (light/dark flavors), and
  exposes a `markers` property + `marker-select` event.
- A style builder that composes the Protomaps source + layers and
  resolves relative tile URLs against the current origin.
- A thin Dockerfile around the `pmtiles` Go CLI to extract and serve
  PMTiles archives on port 8484, plus pull/serve helper scripts that
  wrap podman/docker.

Also drops the OpenLayers-based `<ak-events-map>` implementation in
favor of a thin wrapper around `<ak-map>` that pulls the tile URL
from the new `brandingMapTiles` brand setting (with the relative
`/tiles/{z}/{x}/{y}.mvt` proxy default), removes
@openlayers-elements from the web workspace, and excludes geo's
script directory from the web tsconfig walk.

Co-Authored-By: Agent <agent@authentik-i21849-systematic-latest-aquamarine.girlbossru.sh>
Adds a TilesConfig block (`AUTHENTIK_TILES__UPSTREAM`) and a new
configureTiles() handler in internal/web that, when the upstream is
non-empty, registers a /tiles/* prefix on the logging router. Requests
have the /tiles/ segment stripped (preserving the configured Web.Path
sub-path) before being forwarded; cookies and Authorization headers
are dropped because tile fetches don't need user state, and the
upstream response gains a one-day immutable Cache-Control header when
none was set.

Wires the tiles service into the generated docker-compose stack under
a `tiles` profile so it's opt-in: `docker compose --profile tiles
up` brings up a pmtiles server bound to ./tiles. Adds tiles-pull,
tiles-serve, and tiles-clean Makefile targets that delegate to the
@goauthentik/geo package's npm scripts. Updates the
DefaultBrand.brandingMapTiles fallback to include a `world` tileset
segment, matching the default name used by the pull script.

Co-Authored-By: Agent <agent@authentik-i21849-systematic-latest-aquamarine.girlbossru.sh>
…orkers + R2

A standalone Cloudflare Worker package that serves Protomaps PMTiles
archives out of an R2 bucket, intended to power a public tile endpoint
(e.g. tiles.goauthentik.io) and to be self-hostable for airgapped
deployments.

The implementation cribs the small, useful parts of the ISP Nexus
worker reference at /home/agent/workspace/universe/api: an R2-backed
PMTiles `Source` that performs HTTP byte-range reads against
`<TILES_PATH>/<tileset>.pmtiles`, a CloudflareWorkerPMTiles wrapper
with a shared resolved-value cache and gzip decompression, and the
file-extension <-> tile-type tables. Multi-tenant scoping, geolocation
endpoints, and the unrelated DB route are deliberately omitted.

Two HTTP routes are exposed: `/<tileset>/{z}/{x}/{y}.<ext>` returns
the tile bytes with the right Content-Type and immutable cache
headers, and `/<tileset>.json` returns a TileJSON 3.0 document built
from the archive header + metadata. Responses are stored in
caches.default via ctx.waitUntil so repeated tile fetches don't hit
R2. CORS is driven by the `ALLOWED_ORIGINS` env var (defaults to
`*`), with a comma-separated allowlist supported.

Ships a wrangler.jsonc with the R2 binding, env-var defaults, and a
commented-out custom-domain route, plus a Node 24 Dockerfile that
runs `wrangler dev` for local testing without a Cloudflare account.

Co-Authored-By: Agent <agent@authentik-i21849-systematic-latest-aquamarine.girlbossru.sh>
The @goauthentik/geo workspace lives at the repo root packages/geo and
is symlinked into web/packages/geo so npm picks it up as a workspace.
At type-check time TypeScript walks the symlink and finds hoisted deps
in web/node_modules, but esbuild was resolving via the realpath and
failing to locate `lit`, `maplibre-gl`, and `@protomaps/basemaps`
when bundling the web entry points. Setting `preserveSymlinks: true`
keeps esbuild on the symlinked path so module resolution walks into
web/node_modules just like tsc does.

Also runs prettier across the new files in @goauthentik/geo,
@goauthentik/tile-worker, and the rewritten EventMap to match the
project's import-ordering and formatting conventions.

Co-Authored-By: Agent <agent@authentik-i21849-systematic-latest-aquamarine.girlbossru.sh>
Switch the event map from an XYZ tile endpoint (go-pmtiles upstream) to a
single static PMTiles archive loaded via MapLibre's pmtiles:// protocol.
The ~25 MB world basemap (OSM roads/places/boundaries + a Natural Earth
coastline, z0-7, English labels) ships as a static web asset, so the map
renders with no tile server or external dependency.

- packages/geo: style.ts builds a pmtiles:// vector source and supports
  light/dark/grayscale/black flavors; ak-map registers the pmtiles
  protocol and takes pmtiles-url/flavor/max-zoom; index exports
  FlavorName. Adds the pmtiles dependency.
- web: build-web copies web/src/assets/basemap.pmtiles to
  static/dist/assets/; EventMap passes pmtiles-url (brand override, else
  the bundled asset). Adds the pmtiles dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The basemap now loads from a bundled static PMTiles archive via the
pmtiles:// protocol, so the go-pmtiles serving path is unused. Remove:

- internal/web/tiles.go (the /tiles/* reverse proxy), its
  configureTiles() call, and the AUTHENTIK_TILES__ config struct.
- the `tiles` compose service (compose.yml + generate_compose.py).
- packages/geo go-pmtiles tooling: Dockerfile, scripts/{pull,serve,
  _runtime}.mjs, the tiles/ dir, the tiles:pull/serve npm scripts, and
  the Makefile tiles-* targets.

The brand-level basemap URL (branding_map_tiles) is kept; it now points
at a PMTiles archive rather than an XYZ endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the bundled hexworld basemap. Introduces the canonical
zoom→H3 resolution mapping (res 3 → z0-3, res 4 → z4-6, res 5 → z7-8)
that both the build-time tile generator and the runtime overlay share,
plus a cell-boundary helper that keeps polygons crossing ±180° inside
a single continuous longitude range so downstream tiling doesn't draw
world-spanning slivers.

Adds h3-js and @types/geojson to packages/geo, an eslint config so the
package participates in the repo-wide lint sweep, and a node:test-based
test script (npm test) covering both helpers.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
Second hexworld slice. landCells walks Polygon and MultiPolygon land
features, feeds each ring through h3.polygonToCells at the caller's
resolution, and returns the deduped cell set — the build step calls it
once per zoom band. hexFeature wraps a single H3 cell in a GeoJSON
Feature whose `h3` property carries the cell id, which the runtime
overlay later promotes to a MapLibre feature id for O(1) event lookup.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
Third hexworld slice. The Protomaps places layer publishes the same
country/region/locality feature at every zoom from its min_zoom up to
z8, so a naive extract yields tens of millions of duplicate points.
normalizePlace filters to the three kinds we render, dedupePlaces
collapses repeats to one point per kind+name+res-5 cell keeping the
earliest zoom, and capLocalities preserves every country and region
while capping localities to the top-N by population — the last knob
that keeps the archive size below the 5-35MB budget.

placeFeature wraps the survivor in a GeoJSON Feature with the
tippecanoe `minzoom` extension so labels vanish under their configured
zoom instead of stacking on top of one another.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
…ng plan)

Fourth hexworld slice — the build-time side that produces the archive.

The CLI (npm run hexworld:build -- --dump <planet-z8.pmtiles>) fetches
Natural Earth 50m land once, computes H3 cell sets at res 3/4/5, walks
the Protomaps dump's places layer tile-by-tile through pmtiles + MVT
decoders, then hands off to tippecanoe and tile-join to cut both
hexworld-r4.pmtiles (res 3+4) and hexworld-r5.pmtiles (res 3+4+5).
Both cuts run every invocation; the size decision is a gate, not a
code path.

The pmtiles source is a small file-handle adapter that plugs into the
pmtiles reader without needing an HTTP round trip. buildPlan is the
pure ordered-command generator, unit-tested against the six shell
commands (three hex bands + places + two tile-joins) so the pipeline
shape is protected without needing tippecanoe on the test box.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
… ramp

Fifth hexworld slice — runtime side. buildHexworldStyle emits a
MapLibre StyleSpecification with:

- a single vector source pointed at a local pmtiles:// archive, with
  promoteId `{ hex: "h3" }` so the ak-map overlay can look up cells by
  H3 id via feature-state,
- three symbol layers gated by kind and zoom so country/region/locality
  labels appear in that order, both with a name:en → name coalesce so
  the fallback surfaces when English isn't present,
- a color ramp on the hex fill that interpolates from the palette's
  neutral through hexLit at one event to hexHot at 25+, driven by the
  `events` feature-state the overlay writes on zoomend/marker change.

The style deliberately references no external URLs so the airgap test
(no https://) passes on the default. Palettes ship for light and dark;
ak-map's existing pmtiles-protocol registration is reused (per the
plan addendum) rather than reinvented here.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
Sixth hexworld slice — where the runtime overlay meets the archive.

When ak-map's pmtiles-url is empty the component enters hexworld mode:
it swaps in the hexworld style pointed at hexworld-url (default
/static/dist/assets/maps/hexworld.pmtiles) instead of the conventional
Protomaps basemap. Non-empty pmtiles-url keeps today's behaviour
exactly, so a brand-configured PMTiles override remains a supported
path — only the zero-config default flips.

Event lighting rides on MapLibre feature-state. On load and whenever
markers or the zoom band change, the overlay aggregates every marker
into its res-3/4/5 cell via cellCounts (Task 1's bandForZoom decides
the resolution), then writes the count onto the hex source-layer's
feature-state so the style's color ramp lights the cell in one paint
step — no per-marker DOM, no manual layer diffing. The lit cell set
is tracked so a re-sync only touches the cells that changed.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
Adds a short section on the events overview page describing the
default map: what hexworld is, that it renders offline (relevant to
air-gapped installs), the OSM/Natural Earth attribution, and where to
override with a conventional tile URL.

Ships a README for packages/geo covering the two style builders, the
generator (deps, dry-run vs full run, both size cuts), the bundling
step, and the zoom-band table with the note that changing bands
invalidates published archives.

Cspell dictionary picks up geojson, geojsonl, hexworld, maplibre,
pmtiles, protomaps, and tippecanoe so the map terms don't trip the
docs check on every touch.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
Natural Earth 50m draws Antarctica as a full-width rectangle that
touches lat -89.9989 with a seam edge along the antimeridian. Handed
to h3-js's polygonToCells the ring trips two separate cases: at res 3
polyfill silently returns nothing, at res 4 and above it throws
E_FAILED. The generator crashed on this exact feature during the first
host run and no archive got produced.

Fix in three parts, applied per polygon before polyfill:

- Latitudes clamped to the web-mercator bounds (+/-85.051129). Points
  past that are off-map and near the pole where h3 gets picky.
- Any ring wider than 180 deg longitude gets Sutherland-Hodgman clipped
  at meridians -60 and 60 into three ~120 deg slices, so no ring hits
  h3's transmeridian limit. The full 360 deg Antarctica rectangle
  becomes three east/mid/west polygons that polyfill cleanly.
- Polyfill via polygonToCellsExperimental with containmentCenter — same
  semantics as the legacy polygonToCells but survives polar rings that
  the legacy variant refuses. Cells from all slices are unioned via a
  Set so seam-adjacent duplicates collapse.

The plan test suite adds two guards: the Antarctica-shaped rectangle
polyfills at every band with a non-zero count, and repeated runs on
the same input yield the same cell set (no drift from seam handling).

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
The generator wrote the intermediate .geojsonl files through
createWriteStream + write() + end() without awaiting drain. The three
hex bands survived by luck because the subsequent await extractLabels()
kept the event loop free long enough for their buffers to flush. The
places file is written immediately before the tippecanoe / tile-join
loop, and spawnSync blocks the event loop, so its stream never
drained — tippecanoe read an empty file and bailed with
"Did not read any valid geometries".

Switch writeLines to a single writeFileSync. Fully-formed buffer, one
syscall, no race with the child process. A test would need to
reproduce the event-loop starvation the same way the generator does;
the comment on the function carries the reasoning instead.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
Wires the bundled hexworld archive into the event map's default path.

DefaultBrand.brandingMapTiles flips from the go-pmtiles XYZ template
to an empty string; EventMap forwards that string through to ak-map,
which reads empty as "hexworld mode" (Task 7) and loads the archive
from /static/dist/assets/maps/hexworld.pmtiles. A brand-configured
value still overrides everything, so admins who want a conventional
Protomaps basemap keep that path intact.

build-assets.mjs copies packages/geo/tiles/hexworld.pmtiles plus its
fonts/ directory into web/dist/assets/maps/. Present when the branch
has run tiles:pull-hexworld, silently skipped otherwise so dev builds
without the archive stay green.

Adds a pull-hexworld.mjs script (npm run tiles:pull-hexworld) that
either copies a local path or fetches the published release asset.
Both the source URL and the glyph base honor
AUTHENTIK_HEXWORLD_SOURCE / AUTHENTIK_HEXWORLD_GLYPHS overrides for
airgap and CI environments. .gitignore keeps generated tiles,
geojsonl intermediates, and pulled glyphs out of the tree.

Brand help_text catches up: now describes both XYZ and pmtiles:// as
supported forms and says empty means the bundled hexworld basemap.
The migration text carries the same wording — amended in place since
the field is unreleased on main and the branch's history is being
rewritten anyway.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
@GirlBossRush
GirlBossRush force-pushed the issue-21849-swap-openlayers-with-maplibre-optional-self branch from 2229fb2 to cd20a53 Compare July 20, 2026 12:22
GirlBossRush and others added 3 commits July 20, 2026 12:37
CI's clean checkout could not resolve @goauthentik/geo: web's tsc and
esbuild both ran before anything built the package's out/, and
packages/geo/.gitignore keeps out/ out of git on purpose. Local
checkouts pass because prior builds populated out/ into a warm tree.

Match the client-ts convention (packages/client-ts/package.json) and
add a `prepare: pnpm run build` script to packages/geo. pnpm runs
`prepare` for workspace members during install, so out/ is populated
before any consumer resolves it — the same mechanism the API client
package already relies on.

Verified against a clean state: `rm -rf packages/geo/out node_modules
web/node_modules && pnpm install --frozen-lockfile` populates out/,
then `pnpm --dir web run tsc && pnpm --dir web run build` succeed.

The spell dictionary catches up with the new prose: adds `geojson`,
`geojsonl`, `hexworld`, `lngs`, `lons`, `maplibre`, `maplibregl`,
`pmtiles`, `protomaps`, `tippecanoe` to software-terms; `Sutherland`
and `Hodgman` to people. `neighbourhood`/`Mitte` in a label test are
example strings, not real product terms — inline `spellchecker:disable-next-line`
instead of polluting the dictionary.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
Two fixes bundled — the server image build needs both to go green.

## Docker-side @goauthentik/geo resolution

`lifecycle/container/Dockerfile` runs `pnpm install --frozen-lockfile`
with `WORKDIR /work/web` against web's own workspace root, not the
repo-root workspace. In that install context `@goauthentik/geo` is
a `link:` dependency, not a workspace member, so pnpm never installs
its transitive deps and never fires its `prepare` script. CI's clean
tree therefore hit two failures in the web esbuild stage: `./out/*.js`
was missing (prepare never emitted), and once out/ was populated by
other means the bundle still failed on bare imports of `h3-js`,
`maplibre-gl`, and `@protomaps/basemaps` because those never landed
in the image's node_modules either.

Two changes cover it, and both mirror the existing packages/client-ts
convention:

- Geo now uses source-first exports (`./src/*.ts` instead of
  `./out/*.js`). esbuild bundles the TypeScript directly, tsc reads it
  in place, and out/ is no longer on the critical path for any
  consumer — matching how `@goauthentik/api` (packages/client-ts) is
  wired via `link:` from web.
- `h3-js`, `maplibre-gl`, and `@protomaps/basemaps` move up to
  web/package.json as first-class deps so pnpm's web-only install
  resolves them from web/node_modules for the bundler.

Geo's `prepare` script stays as belt-and-suspenders for the
standalone test path (tests import compiled `../out/*.js`).

## Archive committed to git

Ships `packages/geo/tiles/hexworld.pmtiles` (r4 cut, 8.8 MB) directly
in the repo, along with a `.gitignore` `!hexworld.pmtiles` exception.
main already tracks a 10 MB binary (the WebAuthn MDS blob), so the
new archive is under the largest-file precedent already set. Committing
makes the image build with no network access, keeps the image
reproducible, and dodges the release-asset chicken-and-egg the
previous plan created.

`web/scripts/build-assets.mjs` copy step is now unconditional: a
missing archive is a build error rather than a silent skip. Glyphs
(Latin ranges of Noto Sans) stay gitignored and are fetched from the
Protomaps CDN once, cached in `packages/geo/tiles/fonts/`, and reused
on every subsequent build. `AUTHENTIK_HEXWORLD_GLYPHS` overrides the
CDN for airgapped environments.

`packages/geo/README.md` explains the committed-archive story and how
to regenerate.

Verified end-to-end against a genuinely clean tree and a fresh Docker
image: node-builder stage exits zero, and `/work/web/dist/assets/maps/`
inside the built image contains `hexworld.pmtiles` (8.8 MB) plus both
Noto Sans font ranges.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
The previous commit fetched the four glyph PBFs from raw.githubusercontent.com
on first build and cached them under `tiles/fonts/`. That's a supply-chain
smell for an IdP vendor: unpinned dependency on a third party's `main` branch,
build breaks behind restricted networks, and inconsistent with committing
the 8.8 MB archive to dodge exactly this class of problem.

Commit the 408 KB of PBFs directly alongside the archive. Ships with SIL Open
Font License 1.1 (`tiles/fonts/OFL.txt`, copied verbatim from the Protomaps
basemaps-assets source), same layout as `web/fonts/RedHat/OFL.txt` already
in the tree.

`ensureGlyphs` and the `AUTHENTIK_HEXWORLD_GLYPHS` env plumbing come out of
`build-assets.mjs` — the copy step now reads glyphs from the tree the same
way it reads the archive, and fails loudly if either is missing.
`tiles:pull-hexworld` still fetches from the CDN for regeneration workflows,
but a normal web build never touches the network for basemap assets.

.gitignore drops the blanket `/tiles/fonts/` exclusion; intermediates and
generator scratch stay ignored via the existing rules.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
@GirlBossRush
GirlBossRush force-pushed the issue-21849-swap-openlayers-with-maplibre-optional-self branch from 7503b3c to e3ff8e0 Compare July 20, 2026 13:27
@GirlBossRush GirlBossRush changed the title web: Swap OpenlLayers with MapLibre web: replace OpenLayers with MapLibre and bundle a hex-grid basemap Jul 20, 2026
GirlBossRush and others added 2 commits July 20, 2026 13:45
Follow-up regen the earlier commit missed. Only `CurrentBrand.ts` was
regenerated when `schema.yml` picked up the new `branding_map_tiles`
field; the other Brand-shaped models — `Brand.ts`, `BrandRequest.ts`,
`PatchedBrandRequest.ts` in the TypeScript client, `model_brand.go`
in Go, `brand.rs` in Rust — kept the pre-field shape and drifted from
`schema.yml`.

Ran `make gen-clients` under the container runtime (podman shim, uid
$(id -u):id -g)); output matches a second dry-run of the same target
(`git stash && make gen-clients` produces an identical diff), so CI's
`git diff --exit-code -- schema.yml blueprints/schema.json
packages/client-go packages/client-rust packages/client-ts` will pass.

No hand edits. The doc-comment wording carried across from
`schema.yml` follows the wording I set in the migration + model
help_text earlier ("Supports XYZ templates with {z}, {x} and {y}
placeholders, or pmtiles:// archive URLs. When empty, the frontend
uses the bundled hexworld basemap.").

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
GirlBossRush and others added 9 commits July 20, 2026 15:59
…' of github.com:GirlBossRush/authentik into issue-21849-swap-openlayers-with-maplibre-optional-self
Field of hexes without country boundaries is close to unreadable — the
labels float on a uniform grid with nothing to orient against. Add
country borders that share the map's geometry: for every land cell,
walk its six H3 neighbors, and where a neighbor belongs to a different
country emit the shared cell edge as a LineString. Because every edge
is discoverable from both sides, canonicalize on lexical order of the
two cell ids so each segment ships exactly once.

Three new modules, all unit-tested:

- `hexworld/countries.ts` — buildCountryIndex + assignCountries.
  Point-in-polygon on the cell centroid against Natural Earth admin-0
  countries, prefiltered by pre-computed bounding box so ~250 country
  polygons against ~600k res-5 cells stays tractable. Handles Polygon
  and MultiPolygon; picks ISO_A2 with fallbacks to iso_a2 and ADM0_A3
  for the few countries where the primary field is missing on NE data.
- `hexworld/borders.ts` — borderEdges walks each cell's gridDisk(1),
  emits the shared `cellsToDirectedEdge` boundary for cross-country
  neighbor pairs, dedupes on the canonical (min-cell, max-cell) key.
- `hexworld/style.ts` — new `hexworld-borders` line layer sourced
  from the `borders` source-layer. Renders above the hex outline and
  under the labels, with a zoom-interpolated width heavier than the
  outline at every stop. Palette gets a matching `border` color for
  light and dark themes.

`hexFeature` now optionally carries the ISO code on the hex, cheap
and useful if we ever want per-country hex tinting.

The generator's `buildPlan` grows three per-band `tippecanoe`
invocations for the borders layer, and `tile-join` slots them
between the hex layers and the places layer. The build-plan test
guards the exact ordering.

## Pinned inputs

Bumped the generator off Natural Earth's `master` branch:

```
NE_TAG = "v5.1.2"
NE_BASE = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/${NE_TAG}/geojson"
```

Same shape of problem as the glyph CDN one — an unpinned `main`
dependency in the pipeline that produces the shipped map. Two runs a
year apart would silently produce different tiles. README also notes
the Protomaps planet build the current archive was cut from
(`20260521`) so someone can reproduce it exactly.

The committed `tiles/hexworld.pmtiles` predates this change and will
render without borders until it's regenerated with the new pipeline;
that's a host-side re-run against the planet dump.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
…hipped cut

Regenerates the bundled archive from the borders-aware generator so the
`borders` source-layer the style already referenced actually exists. The
archive now carries three layers: `hex` (with a `country` property per
cell), `borders`, and `places`. 8.8 MB to 9.3 MB.

Also fixes a rendering bug the regeneration made visible. The vector
source declared `maxzoom: 8`, but the shipped res 3+4 cut only carries
hex and border geometry to z6 while `places` extends to z8. MapLibre
therefore fetched the real z7/z8 tiles instead of overzooming z6, and
those tiles hold labels only, so land and borders vanished above z6 and
the labels floated on empty ocean. `ak-map`'s max zoom of 10 let users
reach that state. The source maxzoom now defaults to 6 and is
overridable via `HexworldStyleOptions.maxzoom` for anyone shipping the
finer res-5 cut.

Generated from the Protomaps 20260521 planet build via
`packages/geo/scripts/hexworld/build.mjs`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Countries alone left the map hard to orient at the res-4 band —
labels floating on a uniform grid. Extends the border pipeline to
carry state and province lines in the same `borders` source-layer,
with a `level` property (0 = country, 1 = region) so the runtime
style splits them into two visually distinct line layers without
touching the archive schema.

- `countries.ts` refactors the country logic behind a generic
  `buildAdminIndex` shared by countries and regions, then re-exports
  `buildCountryIndex` / `buildRegionIndex` / `assignCountries` /
  `assignRegions` on the same shape. Region code lookup prefers
  `iso_3166_2`, falling back to `adm1_code` and `code_hasc`.
- `borders.ts` takes a `{ country, region? }` bundle rather than a
  single map. For each neighbor pair it picks the strongest applicable
  level — country wins when countries differ, region only fires when
  countries match — so a coincident border never ships twice. Dedupe
  on canonical cell-pair order stays as it was.
- `buildHexworldStyle` gains a `hexworld-region-borders` line layer
  filtered to `level == 1`, gated to `minzoom: 4` (res-4 is the
  finest band shipped and admin-1 detail below that turns the
  continents into a mesh), styled lighter/thinner than country lines
  in both palettes. `hexworld-borders` gets a matching
  `level == 0` filter and a heavier weight ramp so country lines
  keep visual precedence at every stop.
- `build.mjs` pulls `ne_10m_admin_1_states_provinces.geojson` from
  the pinned Natural Earth tag (v5.1.2). The 50m admin-1 file only
  covers nine countries; 10m gives all 241, and detail past the
  ~52 km res-4 cell gets flattened to cell edges anyway so the
  higher-res source costs nothing at render time.

Six new border/country tests: cross-region-within-country emits
level 1, country beats region when both differ, region borders skip
cells without a region code, and every earlier guarantee (dedupe,
same-country no-op, ocean-neighbor no-op) is preserved on the new
signature.

## README cell-size table correction

The published table claimed cells were ~1000 km / ~400 km / ~130 km
wide across the three bands. Measured with `getHexagonEdgeLengthAvg`
and `getHexagonAreaAvg` the actual numbers are roughly 7-8x smaller:
res 3 ≈ 138 km, res 4 ≈ 52 km, res 5 ≈ 20 km. Table now shows edge
length, cell width, and area explicitly so nobody has to guess which
figure was quoted. Cross-checks: 11,600 res-3 land cells × 12,393 km²
≈ 144 M km², matching Earth's ~149 M km² of land within tolerance.

Beyond documentation the correction changes the design math: at
~52 km, res 4 resolves states and provinces well (Colorado is ~12
hexes across), which is why admin-1 borders fit the res-4 band at
all.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
Rebuilds the bundled archive from the admin-1 aware generator so the
`level: 1` region borders the style filters on actually exist. The
`borders` layer now carries country (`level: 0`) and region (`level: 1`)
segments; 9.3 MB to 10.6 MB.

Verified by decoding tiles rather than trusting the build log: sampled
tiles carry both levels (Europe z4 has 496 country and 1724 region
segments), interior tiles correctly carry region borders with no country
border, and no segment is emitted at both levels across the sampled
tiles, so nothing is drawn twice at two widths.

Generated from the Protomaps 20260521 planet build via
`packages/geo/scripts/hexworld/build.mjs`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fidelity pass with the archive budget raised to 30 MB.

HEX_BANDS shifts to res 3 -> z0-2 and res 4 -> z3-6 so region borders
have geometry fine enough to mean something at z3 (~52 km cells rather
than ~138 km); the region layer minzoom drops to 3 to match. This
invalidates published archives, per the note in the README.

Country outlines now include coastline: an edge is emitted wherever a
land cell has a non-land neighbour, so countries are enclosed against
water instead of bleeding into the ocean. Deduped against the
country-vs-country pass the same way region borders are.

Adds a detail zone (src/hexworld/detail-zone.ts) so res-5 geometry can
be spent only where people are. Cells within --detail-ring of a locality
above --detail-min-pop get res-5 children; everything else stays res 4.
At z7-8 the archive carries a res-4 base fill under a res-5 overlay,
which avoids the interlocking-child artifact a single mixed-resolution
fill would produce. Locality cap raised 15k -> 50k.

Co-Authored-By: Agent (authentik-i21864-following-functional-ultramarine) <279763771+playpen-agent@users.noreply.github.com>
The fidelity pass generated a res-5 detail band but never shipped it.
buildPlan still selected cuts by `band.res <= cut`, a rule that predates
the detail zone, so the r4 cut dropped the res-5 overlay and left z7
carrying a base fill with no borders at all — base borders are emitted
only outside the zone, by design, so the overlay was the only thing
providing them there.

Cuts are now named rather than resolution-filtered. `detail` is the
shipped archive and carries every slice; `plain` drops the whole z7
band for anyone who wants the small one.

Caps the detail band at z7 and limits the zone to localities >= 50k, per
the measured budget: the full z7-8 band came to 50 MB against a 30 MB
ceiling, and z8 costs as much as z7 while resolving detail finer than
GeoIP supports. Result is 21.6 MB (plain: 12.1 MB).

Two invariants are now derived rather than restated, because both had
already drifted once:

- `places` tiles to MAX_BAND_ZOOM instead of a literal z8. Tiling labels
  past the last hex band is what makes land vanish while labels float.
- The style's source maxzoom is MAX_BAND_ZOOM too. It was pinned at 6
  while the archive reached z7, which would have silently wasted the
  entire detail band to overzoom.

`bandForZoom` clamps to the last band so overzoomed zooms resolve to its
resolution instead of falling through to the coarsest.

Also fixes three lint failures that were already on the branch:
`withResProp` and `stripCellProps` used before definition, and
`pushBorder` exceeding the parameter limit (now takes an options object).

Verified by decoding tiles: Basel z7 hex=257 with both border levels,
Siberia z7 hex=25 with the res-4 base only, remote borders present
outside the zone (Algeria/Mali, Atacama), coastal edges on the Lisbon
coast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swap OpenLayers with MapLibre + optional self hosted Protomaps

1 participant