Alex BandClaude Opus 5.5 0f394fc8dd feat(site): themeable chrome, header logo and home link, instance discovery (#9)
* feat(site): themeable chrome, header logo and home link, instance discovery

Theming: every colour, font and header style the page chrome uses is now a
token at the top of public/style.css. site.theme_css in config.yaml is
copied to site-theme.css at container start, and both pages load it last,
so a theme is just a stylesheet that redefines tokens. Data colours
(status, planned/real markers, packet outcomes, coverage ramp) stay fixed:
the map draws them from JS and the legend has to keep matching it. With no
theme configured the default look is pixel-identical to before.

Branding: site.logo (local image, copied in, or an http(s) URL) shows in
the header on the map and analytics pages; site.home_url links the logo
and title back to the deployment's own site. document.title now follows
site.name instead of a hardcoded "ScotMesh Repeater Coverage". A missing
theme or logo file is logged and the site starts with the built-in look.

Discovery: <meta name="generator" content="HopReach"> on both pages, and
/.well-known/hopreach.json (name, version, backend, map centre) written by
-prepare and served CORS-open, so other instances can be found.

Also:
- #plan-panel number inputs were never styled (the shared rule covered
  #sim-panel only), so they rendered as white browser defaults.
- npm test was failing on main: check_module_refs didn't know the
  HopReachCartoTiles global added in #6. CI never ran npm test, so it went
  unnoticed; the frontend job now runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(site): an empty site.subtitle drops the subtitle line

With subtitle: "" the subtitle element is hidden, so the header is just the
logo and the title, centred on each other — e.g. a network wordmark logo
followed by "Repeater Coverage". A config test pins that an explicit empty
value wins over the built-in default subtitle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(map): line up the header subtitle and the right-hand map controls

The subtitle carried a 0.5rem left margin, leftover from when it sat
inline beside the title, so it started 8px right of the title it sits
under. The boxes down the map's right edge (Map display, Neighbours
observed, Map detail, region scope filter, legend) were each sized to
their own content, so their left edges stepped in and out. They now share
a 220px minimum width - the legend's own max-width, and enough for Map
display expanded - so they form one column collapsed or expanded. Scoped
to .leaflet-right, so the phone Map options sheet is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(site): address review of the theming PR

- Discovery reported the wrong backend: it read source.type raw, skipping
  config.Resolve's deprecated beacon.enabled alias and case handling, so
  a beacon.enabled deployment advertised "corescope". It now uses Resolve;
  an unknown type is omitted rather than guessed.
- checkHomeURL warned about relative paths ("/", "../") that the page
  links to fine. It now mirrors site-brand.js's safeUrl, and says so
  specifically when a value looks like a domain missing https://.
- installLogo cleared site-logo.* before reading the source, so a source
  that was itself one of those files was deleted. It reads first now.
- A plain-http logo URL now logs a mixed-content warning.
- Every color-mix() declaration is preceded by its rgba() equivalent at
  the default token value, so browsers without color-mix (Safari/iOS
  < 16.2, Chrome < 111) render as before instead of dropping the tint.
- Static <h1> placeholder matches the neutral <title>; the dead
  `header > div:first-child` phone selector is gone and the split
  .site-brand rule is merged.
- buildJSConfig's test moves to prepare_test.go (Rule 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:07:03 +01:00

HopReach

See where your mesh actually reaches — over real terrain, not circles on a map.

Live demo — ScotMesh Repeater Coverage  ·  Technical reference  ·  Simulator model


HopReach

HopReach turns a MeshCore observation backend — CoreScope or MeshCore Beacon — into an interactive coverage map for your repeater network, then lets you plan changes to it and simulate how traffic would actually flow.

It reads every role=repeater node, keeps the ones inside your region, and computes a terrain-aware coverage estimate from real elevation data with line-of-sight and diffraction analysis on every path. Not a distance circle around each site — the actual hills in the way.

Everything runs from one Docker container that refreshes itself daily.

Bring your own observation backend

HopReach does not watch the mesh itself — it reads someone else's record of what was heard. Which one is a single config key:

source:
  type: corescope   # or: beacon

Both are first-class: same map, same coverage rasters, same planner and simulator, same packet replay. Everything the app needs goes through one internal interface, so the browser never sees a vendor's API and adding a third backend means implementing that interface — not touching the front end.

One difference is worth knowing. Beacon scopes its region list to the observers in your configured IATAs, so it answers "regions somebody local was heard on" rather than "every region on this mesh". Region-wide features need the complete set to be truthful — a region missing from a filter looks like a region with no repeaters in it — so with a partial list they are switched off rather than drawn wrong.

If you know your regions, just say so and they come back on:

source:
  type: beacon
  scopes: ["#sco", "#fif", "#tay"]

That is enumeration only. Which repeater carries which region is still decoded from real traffic either way, so a region you list that nobody has been heard on gets no observations rather than an invented one.

The backend declares what it can answer (Capabilities.ScopeCatalog), so the pipeline, the API and the front end all switch together. See Beacon compatibility for the feature-by-feature matrix, verified by loading one real mesh into both backends and comparing the output.

Quick start

git clone https://github.com/A13xB0/hopreach.git
cd hopreach
cp config.example.yaml config.yaml
docker compose up --build

Open http://localhost:8080. No GPU required, no API keys, no accounts.

HopReach map view, showing estimated coverage over central Scotland

⚠️ Vibe-coded software. Built with heavy AI assistance rather than fully by hand. The core physics is cross-checked between the server and the WebAssembly build the browser shares with it, it's been exercised against real data, and human review has been endeavoured throughout — but it hasn't had independent code review from someone who wasn't also steering the AI. Read the source before relying on it for anything safety-critical or professional.


CARTO basemap API key

The default Dark basemap (and its label/road overlays) comes from CARTO, whose raster tiles now require an API key - without one, every tile is served with an "API KEY REQUIRED" watermark. The other basemaps (OSM Streets, Esri Satellite, OpenTopoMap Terrain) need no key.

  1. Get a free key at https://carto.com/basemaps/apikey - the free tier covers 5,000,000 tile requests a month, counted across raster and vector, which is far beyond what one deployment serves.

  2. Put it in a .env file next to docker-compose.yml:

    CARTO_API_KEY=your-key-here
    

    or set map.carto_api_key in your config.yaml - the environment variable wins if both are set.

  3. docker compose up -d again. The key only ever rides CARTO tile URLs.

Keep the CARTO and OpenStreetMap attribution visible on the map - that is the condition of the free tier, and HopReach shows it by default.

What you can do with it

See real coverage

A nightly terrain-aware raster over your whole region, rendered as a heatmap you can fade in and out. Four versions are computed — reported vs. calibrated positions, each at standard and high detail — and a dropdown switches between them live. Repeaters are colour-coded active / degraded / silent, and each one's popup lists the neighbours it's actually been heard by.

Filter the whole map down to a single region tag (#edi, #fif, …) — including a coverage overlay computed for just that region's repeaters, so you can see what one community's own infrastructure covers on its own.

Planning and what-if tools

Sketch changes to the network entirely in your browser — nothing is sent anywhere, and it uses the same physics as the real map.

Add repeater mode: a planned site's predicted coverage overlaid in blue→purple over the real map's orange→green

  • Add a repeater — click to drop a site and watch its predicted coverage appear in blue→purple, deliberately distinct from the real map's orange→green so proposed and existing never blur together. Drag it around and the prediction follows.
  • Check line of sight — click a chain of points and get each hop drawn by margin: green clear, orange marginal, red blocked, with distances and dB in the panel.
  • 🔗 Connect two repeaters — it works out the fewest new sites needed to bridge them, reusing existing infrastructure for free, and offers up to three route options to choose between.
  • ▱ Cover an area — draw a polygon and it places up to N new repeaters for maximum coverage of what's inside, reporting the before/after percentage.
  • ✎ Adjust a real repeater — reposition or re-height any existing site for yourself to test a "what if we moved it" idea. The official marker never changes; your version renders as a linked amber marker.
  • 📍 Companion pin — drop a pin anywhere and see who'd hear a handheld at that spot, with its own adjustable height.

Connect repeaters: a route bridging two real repeaters with 5 new relays, with alternative options listed

Plans live in your browser, export to .json or .kml for Google Earth, and Share produces a link anyone can open — carrying the plan's structure, never a stale rendered image, so it stays live and interactive for whoever you send it to.

LoRa flood simulator

Test and tune MeshCore's flood-relay timing before touching a real device.

The LoRa flood simulator with 73 real repeaters loaded and 364 terrain-derived links built

Built from a faithful port of MeshCore's own airtime, packet-score, retransmit-delay and duty-cycle formulas — verified line-for-line against the firmware source, not a secondhand approximation.

  • Load your network — planned repeaters, the real ones, or both, plus virtual companion devices anywhere you click.
  • Choose how links are decided — the terrain model, the backend's real observed reach data, or a blend of the two.
  • Schedule sends and watch the flood — an animated replay steps through it wave by wave, with a scrub bar to drag back and forth through time.
  • See exactly why a packet didn't arrive — not one vague "collided", but the real cause: nothing ever locked, lock lost to a stronger interferer, or the listener's own transmitter was keyed (LoRa is half-duplex).
  • 🏆 Per-repeater scoreboard — duty cycle, real delivery, and the one that matters most: unique deliveries vs. redundant relays. Is this repeater's airtime reaching anyone new?
  • 🧬 Search better settings — grid-search and multi-rule policy search ranked by actual delivery ratio, ending in a per-repeater action list with copy-pasteable MeshCore CLI lines.
  • 🔥 Stress test — push synthetic load until it breaks and report the knee: how many messages this specific network can actually handle.

Replay a real packet. Paste a packet hash and it reconstructs what genuinely happened — every relay there's proof of — then runs its own simulation from the same origin and compares. Hops the model predicted but nobody observed show as dashed amber (candidates for where interference actually hit); hops that really happened but the model doesn't even think possible show in blue. That difference is the interesting part.

Works properly on a phone

Not a shrunken desktop layout. Below 700px the whole thing switches to a phone design: a bottom tab bar, and Plan/Simulate/Map as drag-resizable bottom sheets you can minimise to a title strip to watch a simulation run on the live map behind them.

HopReach on a phone: full-bleed map with a bottom tab bar The Map options sheet, holding the basemap picker, filters and legend The simulator as a bottom sheet with the map still visible above it


Not just Scotland

Scotland is the built-in default, but nothing is hard-wired to it. Point region.boundary_url or region.boundary_path at any GeoJSON — a country, a county, a custom shape you drew — and that becomes the area of interest. See Region.

Make it look like your site

Every colour, font and header style the page chrome uses is a CSS variable, declared at the top of public/style.css. A theme is just a stylesheet that redefines the ones you want — nothing to rebuild:

/* theme.css */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap");
:root {
  --bg: #111827;
  --card-bg: #1F2937;
  --border: #374151;
  --accent: #0D9488;
  --header-bg: #111827;
  --font-sans: "Inter", system-ui, sans-serif;
  --logo-height: 36px;
}

Mount it, plus a logo if you want one, and point config.yaml at them:

site:
  theme_css: /config/theme.css
  logo: /config/logo.svg              # or an https:// URL
  home_url: https://your-network.example/   # logo and title link back here
  name: Repeater Coverage
  subtitle: ""                        # no subtitle line: title centres on the logo

The theme loads after every other stylesheet, on both the map and the analytics page. Repeater status colours and the other data colours are deliberately not themeable — the map draws them from JS, and the legend has to keep matching it. A missing theme or logo file is logged at startup and the site comes up with the built-in look rather than not at all.

Finding other HopReach maps

Every instance identifies itself, so you can find other deployments:

  • /.well-known/hopreach.json on any instance says what it is — name, version, backend, map centre: curl https://some-map.example/.well-known/hopreach.json
  • Search engines index the page's <meta name="generator" content="HopReach"> tag, so source-searching services such as PublicWWW find it directly, and the map's attribution carries a "HopReach on GitHub" link.

Want it faster?

Coverage computation is the expensive part. With a compatible GPU it's roughly 50× faster for the same raster — one extra compose file, no code changes. There's also a remote-worker mode, so a headless box with a GPU can do the maths for a small VPS that hasn't got one. See GPU-accelerated compute.

Going further

Technical reference How the coverage estimate works, position calibration, the detail tiers, GPU and remote-worker setup, the shared WASM core, full configuration reference, local development, project layout
Simulator model The event model, airtime and frame sizing, capture/collision rules, timing, region scoping, and where it deliberately diverges from real firmware
config.example.yaml Every setting, documented inline

License

AGPL-3.0 plus the Commons Clause — see LICENSE for the full text. In short: use, copy, and modify freely; if you distribute it or run a modified version as a network service others can talk to, you must make the corresponding source available under the same license; you may not sell this software or a service substantially based on it. Provided free of charge, for personal and non-commercial use, with no warranty and no support.

S
Description
No description provided
Readme AGPL-3.0
15 MiB
0 Stars 1 Watchers 0 Forks
Languages
Go 51.4%
JavaScript 41.1%
CSS 3.9%
HTML 2.9%
Dockerfile 0.3%
Other 0.4%