Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@ Open `http://127.0.0.1:4173/`. Rebuild after editing. Build output belongs in
- Community listings: `data/communities.json` and `data/communities.fr.json`.
Run `python scripts/validate-communities.py --write` after editing the source.
It generates the directory pages and `docs/assets/radio-profiles.json`.
Keep `data/community-search-anchors.json` in sync when adding listings. These
are approximate search references, not claimed radio locations or coverage.
- Region tools: `docs/assets/regions/` and `docs/config/editor/`.
Follow the boundary proposal workflow; do not hand-edit generated geography.
- Broker settings: `docs/analyzer/observer-config.json`. The build generates the
broker reference table from this file, including its no-JavaScript version.
- Anonymous submissions: `tools/region-proposal-gateway/`. The site and gateway
deploy separately; check the gateway README before changing their contract.
- Header totals: `docs/assets/javascripts/network-status.js` reads public Beacon
aggregates and caches them per tab for five minutes. City search uses Natural
Resources Canada's current Geolocator API, only when a search is submitted.
- Homepage art: run `python scripts/generate-home-hero.py` after installing the
region dependencies to regenerate the decorative SVG from existing geography.
Compare the custom header with Material's template when upgrading the theme.

Never commit credentials, private keys, precise private locations, or test
submissions containing personal information. Human maintainers review changes
Expand Down
29 changes: 29 additions & 0 deletions data/community-search-anchors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"description": "Approximate search reference points, not radio locations or coverage boundaries. Existing community coordinates take precedence. Regional references use the published canada-regions.json seed coordinates; broad communities may have several references.",
"communities": {
"bc-mesh": ["mvrd", "crd"],
"salish-mesh": ["mvrd", "crd"],
"alberta-meshcore-networks": ["cal", "edm", "leth"],
"airdrie-meshcore-network": [],
"calgary-area-meshcore": ["cal"],
"calgary-meshcore-network": [],
"edmonton-meshcore-network": [],
"yegmesh-ca": [],
"yqlmesh": [],
"southern-alberta": ["leth"],
"yyc-meshcore-discord": ["cal"],
"stoonmesh": ["stoon"],
"yqrmesh": ["regina"],
"greater-ottawa-mesh-enthusiasts": ["ott", "gatout"],
"gta-lora-meshes": ["tor"],
"quinte-mesh-network": ["has", "pec"],
"charlevoix-yml": [{"label": "La Malbaie", "lat": 47.652419, "lon": -70.14951, "source": "https://geolocator.api.geo.ca/?q=La%20Malbaie&lang=en&keys=geonames"}],
"mesh-quebec": ["mtl", "capnat", "saglac"],
"montreal-mesh": ["mtl"],
"reseau-mesh-capitale-yqb": ["capnat"],
"reseau-mesh-saguenay-lac-saint-jean-ytf": ["saglac"],
"reseau-libre": ["mtl"],
"southern-new-brunswick": ["york", "sj", "westm"],
"lunenburg-county-mesh": ["lun"]
}
}
9 changes: 9 additions & 0 deletions docs/assets/canada-network-motif.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 231 additions & 81 deletions docs/assets/javascripts/communities.js

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions docs/assets/javascripts/network-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
(function () {
"use strict";
var MAX_AGE = 5 * 60 * 1000;
var CACHE_KEY = "meshcore-canada:network-totals:v1";
var pending = null;
var cache = null;

function count(value) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid network count");
return value;
}

function parseStats(overview, types) {
if (!overview || !Array.isArray(types) || types.length > 16) throw new Error("Invalid network statistics");
var result = { repeaters: 0, companions: 0, rooms: 0, sensors: 0, other: 0, total: 0,
observers: count(overview.activeObservers), areas: count(overview.activeIatas), packets: count(overview.totalPackets),
hours: count(overview.windowHours) };
if (result.hours < 1 || result.hours > 168) throw new Error("Invalid reporting window");
var seen = new Set();
types.forEach(function (item) {
var type = count(item.nodeType);
if (seen.has(type)) throw new Error("Duplicate device type");
seen.add(type);
var field = ({ 1: "companions", 2: "repeaters", 3: "rooms", 4: "sensors" })[type] || "other";
result[field] += count(item.count);
result.total += item.count;
});
count(result.total);
return result;
}

function readCache() {
try {
var saved = cache || JSON.parse(sessionStorage.getItem(CACHE_KEY));
if (!saved || !Number.isFinite(saved.at) || saved.at > Date.now() || Date.now() - saved.at >= MAX_AGE) return null;
["repeaters", "companions", "rooms", "sensors", "other", "total", "observers", "areas", "packets", "hours"].forEach(function (key) { count(saved.stats[key]); });
if (saved.stats.hours < 1 || saved.stats.hours > 168) return null;
return saved;
} catch (_) { return null; }
}

async function loadStats() {
var saved = readCache();
if (saved) return saved;
if (pending) return pending;
pending = (async function () {
var controller = new AbortController();
var timer = window.setTimeout(function () { controller.abort(); }, 8000);
try {
var responses = await Promise.all(["overview", "node-types"].map(async function (path) {
var response = await fetch("https://dev.meshcore.ca/api/v1/stats/" + path, { signal: controller.signal, credentials: "omit" });
if (!response.ok) throw new Error("Network statistics unavailable");
return response.json();
}));
cache = { at: Date.now(), stats: parseStats(responses[0], responses[1]) };
try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(cache)); } catch (_) { /* Counts work without storage. */ }
return cache;
} finally { window.clearTimeout(timer); controller.abort(); }
})().finally(function () { pending = null; });
return pending;
}

function initialize() {
var root = document.querySelector("[data-network-summary]");
if (!root || root.dataset.networkReady) return;
root.dataset.networkReady = "true";
var french = document.documentElement.lang.startsWith("fr");
var locale = french ? "fr-CA" : "en-CA";
var numbers = new Intl.NumberFormat(locale);
var compact = new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 });
var summary = root.querySelector("summary");
var total = root.querySelector("[data-network-total]");
var updated = root.querySelector("[data-network-updated]");
async function refresh() {
if (document.visibilityState === "hidden") return;
try {
var data = await loadStats();
total.textContent = compact.format(data.stats.total);
summary.setAttribute("aria-label", (french ? "Statistiques du réseau : " : "Network statistics: ") + numbers.format(data.stats.total) + (french ? " appareils connus" : " known devices"));
root.querySelectorAll("[data-network-count]").forEach(function (element) {
element.textContent = numbers.format(data.stats[element.dataset.networkCount]);
});
root.querySelectorAll("[data-network-other]").forEach(function (element) { element.hidden = !data.stats.other; });
root.querySelector("[data-network-period]").textContent = french ? "Dernières " + data.stats.hours + " h" : "Last " + data.stats.hours + " hours";
updated.textContent = (french ? "Mis à jour à " : "Updated ") + new Date(data.at).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
root.dataset.networkState = "ready";
} catch (_) {
total.textContent = "—";
summary.setAttribute("aria-label", french ? "Statistiques du réseau indisponibles" : "Network statistics unavailable");
root.querySelectorAll("[data-network-count]").forEach(function (element) { element.textContent = "—"; });
root.querySelectorAll("[data-network-other]").forEach(function (element) { element.hidden = true; });
updated.textContent = french ? "Totaux indisponibles pour le moment." : "Network totals are temporarily unavailable.";
root.dataset.networkState = "unavailable";
}
}
document.addEventListener("pointerdown", function (event) { if (!root.contains(event.target)) root.open = false; });
root.addEventListener("focusout", function (event) { if (event.relatedTarget && !root.contains(event.relatedTarget)) root.open = false; });
root.addEventListener("keydown", function (event) { if (event.key === "Escape" && root.open) { root.open = false; summary.focus(); } });
document.addEventListener("visibilitychange", refresh);
window.setInterval(refresh, MAX_AGE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh counts when the cache actually expires

When the page remains visible after a fresh request, this interval starts before refresh() finishes, while the cache timestamp is recorded afterward. The first five-minute tick therefore sees an entry just under MAX_AGE and reuses it, and the next fetch does not occur until the ten-minute tick, leaving the header totals and update timestamp stale for nearly twice the intended cache lifetime. Schedule the next refresh relative to data.at or otherwise ensure the first post-expiry tick fetches new data.

Useful? React with 👍 / 👎.

refresh();
}

globalThis.MeshCoreNetworkStatus = { parseStats: parseStats };
if (typeof document === "undefined") return;
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initialize, { once: true });
else initialize();
})();
9 changes: 9 additions & 0 deletions docs/assets/regions/regions.css
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,15 @@ body:has([data-mcc-regions]) .md-typeset h3 {
background: var(--mcc-surface);
}

.mcc-map-canvas .leaflet-control-attribution {
color: var(--mcc-text);
background: var(--mcc-surface);
}

.mcc-map-canvas .leaflet-control-attribution a {
color: var(--md-typeset-a-color);
}

.mcc-region-borders {
image-rendering: auto;
}
Expand Down
4 changes: 4 additions & 0 deletions docs/assets/regions/regions.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@
"Step 1 of 4": "Étape 1 sur 4",
"What are you configuring?": "Quel appareil configurez-vous?",
"We will recommend forwarding paths, then show how to apply them.": "Nous recommanderons les chemins à relayer, puis nous vous montrerons comment les appliquer.",
"Browse the region map": "Parcourir la carte des régions",
"Open the region editor": "Ouvrir l’éditeur de régions",
"Device and experience": "Appareil et niveau d’expérience",
"Repeater": "Répéteur",
"Recommended for most operators": "Recommandé pour la plupart des exploitants",
Expand Down Expand Up @@ -2022,6 +2024,8 @@
'<p class="mcc-step-label">Step 1 of 4</p>' +
'<h2>What are you configuring?</h2>' +
'<p class="mcc-step-intro">We will recommend forwarding paths, then show how to apply them.</p>' +
'<p class="mcc-step-browse"><a data-action="view-map" href="' + esc(regionPageHref("map")) + '">Browse the region map</a> · ' +
'<a href="' + esc(new URL("editor/", regionPageHref("config")).href) + '">Open the region editor</a></p>' +
'<div class="mcc-choice-list mcc-choice-list-large" role="radiogroup" aria-label="Device and experience">' +
'<label class="mcc-choice"><input type="radio" name="mcc-device-role" value="repeater" checked><span><strong>Repeater</strong><small>Recommended for most operators</small></span></label>' +
'<label class="mcc-choice"><input type="radio" name="mcc-device-role" value="room"><span><strong>Room server with repeating</strong><small>Uses the same region paths</small></span></label>' +
Expand Down
31 changes: 31 additions & 0 deletions docs/assets/styles/communities.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@
color: var(--mc-color-text-muted);
}

.mc-directory-actions,
.mc-directory-choices {
display: flex;
flex-wrap: wrap;
gap: var(--mc-space-2);
}
.mc-directory-actions { grid-column: 1; grid-row: 2; }
.mc-directory-tools__check { grid-column: 2; grid-row: 2; }

.mc-directory-lookup,
.mc-directory-choices,
.mc-directory-credit {
grid-column: 1 / -1;
}

.mc-directory-lookup:empty { display: none; }
.mc-directory-tools [hidden] { display: none !important; }

.mc-directory-tools .mc-directory-credit {
margin: 0;
color: var(--mc-color-text-muted);
font-size: 0.82rem;
}

.mc-community-distance {
color: var(--mc-color-action);
font-weight: 650;
}

.mc-community-grid,
.mc-province-grid {
display: grid;
Expand Down Expand Up @@ -233,6 +262,8 @@
}

@media screen and (max-width: 44.9844em) {
.mc-directory-actions,
.mc-directory-tools__check { grid-column: auto; grid-row: auto; }
.mc-directory-tools {
grid-template-columns: 1fr;
padding: var(--mc-space-4);
Expand Down
20 changes: 20 additions & 0 deletions docs/assets/styles/home.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.mc-home-hero {
position: relative; isolation: isolate; overflow: hidden;
min-height: 11rem; padding: 1.5rem; margin: 1rem 0 2rem;
border: 1px solid var(--mc-color-border); border-radius: var(--mc-radius-md);
background: linear-gradient(115deg, var(--mc-color-surface), var(--mc-color-surface-raised));
}
.mc-home-hero > .mc-home-hero__art {
position: absolute; z-index: -1; top: 0; right: 0; height: 100%; width: 68%;
object-fit: contain; pointer-events: none; opacity: 0.9;
mask-image: linear-gradient(to right, transparent, #000 28%);
}
.mc-home-hero__art img { width: 100%; height: 100%; object-fit: contain; }
.mc-home-hero > p { position: relative; max-width: 38ch; margin: 0; }
.mc-home-hero .mc-home-hero__credit { margin-top: 1rem; font-size: 0.7rem; color: var(--mc-color-text-muted); }
.mc-preset-note { font-size: 0.85rem; color: var(--mc-color-text-muted); }
@media (max-width: 45em) {
.mc-home-hero { padding: 1.25rem; min-height: 10rem; }
.mc-home-hero > .mc-home-hero__art { width: 100%; opacity: 0.25; mask-image: none; }
.mc-home-hero > p { max-width: none; }
}
33 changes: 33 additions & 0 deletions docs/assets/styles/network-status.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.mc-network { position: relative; flex: 0 0 auto; margin-inline: 0.3rem; }
.mc-network > summary {
display: flex; align-items: center; gap: 0.35rem; min-height: 2.75rem;
padding: 0.25rem 0.35rem; border-radius: var(--mc-radius-sm);
color: #fff; cursor: pointer; list-style: none; font-size: 0.75rem;
}
.mc-network > summary::-webkit-details-marker { display: none; }
.mc-network > summary:hover, .mc-network[open] > summary { background: rgb(255 255 255 / 8%); }
.mc-network__icon { display: flex; }
.mc-network__icon svg { width: 1.1rem; height: 1.1rem; fill: currentColor; }
.mc-network__total { display: inline-block; min-width: 3.2rem; text-align: end; font-variant-numeric: tabular-nums; }
.mc-network__panel {
position: absolute; top: calc(100% + 0.25rem); right: 0; z-index: 5;
width: 19rem; max-width: calc(100vw - 1.5rem); padding: 1rem;
color: var(--mc-color-text); background: var(--mc-color-surface);
border: 1px solid var(--mc-color-border); border-radius: var(--mc-radius-md);
box-shadow: var(--mc-shadow-2); font-size: 0.85rem; line-height: 1.5;
}
.mc-network__panel p { margin: 0 0 0.6rem; }
.mc-network__panel dl { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.25rem 1rem; margin: 0 0 0.9rem; }
.mc-network__panel dt { color: var(--mc-color-text-muted); }
.mc-network__panel dd { margin: 0; font-variant-numeric: tabular-nums; font-weight: 650; }
.mc-network__panel .mc-network__period { border-top: 1px solid var(--mc-color-border); padding-top: 0.6rem; font-weight: 650; }
.mc-network__panel .mc-network__updated { color: var(--mc-color-text-muted); font-size: 0.75rem; }
.mc-network__panel a { color: var(--mc-color-action); text-decoration: underline; }
.mc-network [hidden] { display: none !important; }
@media (max-width: 72em) { .mc-network__label { display: none; } }
@media (max-width: 45em) {
.mc-network { margin-inline: 0; }
.mc-network__total { display: none; }
.mc-network > summary { min-width: 2rem; justify-content: center; padding: 0.25rem; }
.mc-network__panel { position: fixed; top: 3.5rem; right: 0.75rem; }
}
4 changes: 2 additions & 2 deletions docs/config/index.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ tested_with:
difficulty: intermediate
estimated_time: 5-10 minutes
page_styles:
- assets/regions/regions.css?v=20260904-1
- assets/regions/regions.css?v=20260905-1
page_scripts:
- assets/javascripts/radio-profiles.js?v=20260904-1
- assets/regions/modules/configurator-support.js?v=20260904-1
- assets/regions/regions.js?v=20260904-1
- assets/regions/regions.js?v=20260905-1
hide:
- navigation
- toc
Expand Down
4 changes: 2 additions & 2 deletions docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ tested_with:
difficulty: intermediate
estimated_time: 5-10 minutes
page_styles:
- assets/regions/regions.css?v=20260904-1
- assets/regions/regions.css?v=20260905-1
page_scripts:
- assets/javascripts/radio-profiles.js?v=20260904-1
- assets/regions/modules/configurator-support.js?v=20260904-1
- assets/regions/regions.js?v=20260904-1
- assets/regions/regions.js?v=20260905-1
hide:
- navigation
- toc
Expand Down
4 changes: 2 additions & 2 deletions docs/config/map.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ tested_with:
region_catalog: national-partition-2026-07-19
difficulty: beginner
page_styles:
- assets/regions/regions.css?v=20260904-1
- assets/regions/regions.css?v=20260905-1
page_scripts:
- assets/javascripts/radio-profiles.js?v=20260904-1
- assets/regions/modules/configurator-support.js?v=20260904-1
- assets/regions/regions.js?v=20260904-1
- assets/regions/regions.js?v=20260905-1
hide:
- toc
---
Expand Down
4 changes: 2 additions & 2 deletions docs/config/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ tested_with:
region_catalog: national-partition-2026-07-19
difficulty: beginner
page_styles:
- assets/regions/regions.css?v=20260904-1
- assets/regions/regions.css?v=20260905-1
page_scripts:
- assets/javascripts/radio-profiles.js?v=20260904-1
- assets/regions/modules/configurator-support.js?v=20260904-1
- assets/regions/regions.js?v=20260904-1
- assets/regions/regions.js?v=20260905-1
hide:
- toc
---
Expand Down
8 changes: 0 additions & 8 deletions docs/hardware/index.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@ Choisissez d’abord le rôle de l’appareil. Avant de l’acheter, confirmez e
la carte exacte et la cible du micrologiciel dans le
[programme officiel de mise à jour MeshCore](https://meshcore.io/flasher).

<div class="mc-guide-status" data-status="draft" markdown>

**Vérifiez avant d’acheter.** Les révisions de produits et la prise en charge
du micrologiciel changent. Les appareils liés sont des options à comparer, et
non des garanties de compatibilité.

</div>

## Choisir un type d’appareil

<div class="mc-device-chooser">
Expand Down
6 changes: 0 additions & 6 deletions docs/hardware/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,6 @@ page_styles:

Choose what the device will do, then confirm the exact board and firmware target in the [official MeshCore flasher](https://meshcore.io/flasher) before buying.

<div class="mc-guide-status" data-status="draft" markdown>

**Check before buying.** Product revisions and firmware support change. Treat the linked devices as options to compare, not compatibility guarantees.

</div>

## Choose a device type

<div class="mc-device-chooser">
Expand Down
Loading
Loading