diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e79310..6e62503 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
## [Unreleased]
+## 0.5.4
+### Neu
+- **Begrüßungsseite** für neue Besucher (#44): was TerraRF ist, die fünf Bereiche, wie man anfängt – auch wie man den
+ eigenen Repeater aus MeshCore übernimmt. Jederzeit wieder über „?“ oben. Abschaltbar unter Administration → Zugang.
+- **„Aus MeshCore übernehmen“** in „Planen“ (#45): Repeater nahe der Kartenmitte oder per Suche auswählen und auf einmal
+ als Stationen übernehmen. Auch Benutzer und Mitarbeitende können das in Projekten, die sie bearbeiten dürfen.
+- **Repeater-Höhe vom Dach** (#43): Steht ein Repeater laut GPS auf oder direkt an einem Gebäude (höchstes Objekt im
+ Umkreis von 8 m im Oberflächenmodell, mindestens 2,5 m), gilt diese Höhe + 1 m als Vorgabe – beim Übernehmen, im
+ Profil von Strecken und Messpunkten und im CoreScope-Abgleich (🏠). Sonst bleibt es bei 10 m.
+- **Hinweis-Banner** (#46): Der Admin schaltet unter Administration → Zugang einen Hinweis für alle ein (Beta, Baustelle,
+ Wartung, Updates kommen oder eigener Text).
+- Datenschutzerklärung: erwähnt den Browser-Eintrag, dass die Begrüßung schon gesehen wurde.
+
## 0.5.3
### Behoben
- Projektmenü: Die Einträge waren weiß auf weiß und damit unsichtbar (die Schriftfarbe der oberen Leiste galt auch im Menü).
diff --git a/app/analysis/compare.py b/app/analysis/compare.py
index 938326b..23e2d6f 100644
--- a/app/analysis/compare.py
+++ b/app/analysis/compare.py
@@ -88,10 +88,11 @@ def _calibrate_one(project, station_id, partner, stations_by_pk, overrides, rx_h
row.update(rx_src="station", rx_station_id=as_station["id"])
prof = cov.profile(project, station_id, 0, 0, rx_station_id=as_station["id"])
else:
- height = entered.get("height_m", rx_height)
+ guess = None if "height_m" in entered else cov.mast_height_guess(project, float(partner["lat"]), float(partner["lon"]))
+ height = entered.get("height_m", guess if guess is not None else rx_height)
gain = entered.get("gain_dbi", rx_gain)
cable = entered.get("cable_db", rx_cable)
- row.update(rx_src="entered" if entered else "standard")
+ row.update(rx_src="entered" if entered else "building" if guess is not None else "standard")
prof = cov.profile(project, station_id, float(partner["lat"]), float(partner["lon"]), rx_height=float(height),
rx_gain=float(gain), rx_cable=float(cable), rx_extra_loss=0.0)
except (ValueError, KeyError, terrain.SourceError) as e:
diff --git a/app/api/coverage.py b/app/api/coverage.py
index f88882d..854c707 100644
--- a/app/api/coverage.py
+++ b/app/api/coverage.py
@@ -59,6 +59,7 @@ async def profile(request: Request, body: dict = Body(...)):
@router.post("/api/elevation", dependencies=MEMBER)
async def elevation(body: dict = Body(...)):
try:
- return await run_in_threadpool(cov.point_elevation, body.get("project") or {}, float(body["lat"]), float(body["lon"]))
+ radius = min(30.0, max(0.0, float(body.get("radius_m") or 0)))
+ return await run_in_threadpool(cov.point_elevation, body.get("project") or {}, float(body["lat"]), float(body["lon"]), radius)
except (ValueError, terrain.SourceError, KeyError) as e:
fail(e)
diff --git a/app/model/coverage.py b/app/model/coverage.py
index 3b247d1..d60911c 100644
--- a/app/model/coverage.py
+++ b/app/model/coverage.py
@@ -498,7 +498,7 @@ def profile(project, station_id, lat, lon, rx_height=None, rx_gain=None, rx_stat
res_true = cs * kscale
n = int(min(6000, max(50, math.ceil(D / (res_true / 2)))))
if float(s.get("near_m") or 0) > 0 and res_true > float(s.get("near_res_m") or 5.0) * 1.01:
- n = int(min(6000, max(n, math.ceil(D / (float(s.get("near_res_m") or 5.0) / 2))))) # Nahfeld fein (#23)
+ n = int(min(6000, max(n, math.ceil(D / (float(s.get("near_res_m") or 5.0) / 2))))) # fine near field (#23)
t = np.linspace(0, 1, n + 1)
fxs = ((x0 + (x1 - x0) * t) - grid.x0) / cs - 0.5
fys = (grid.y1 - (y0 + (y1 - y0) * t)) / cs - 0.5
@@ -603,7 +603,15 @@ def profile(project, station_id, lat, lon, rx_height=None, rx_gain=None, rx_stat
}
-def point_elevation(project, lat, lon):
+# #43: a repeater whose GPS position lies on or right next to a building is assumed to stand on its roof.
+MAST_SEARCH_M = 8.0 # how far from the position to look for a roof (consumer GPS is off by a few metres)
+MIN_BUILDING_M = 2.5 # objects lower than this are not taken as a building (cars, hedges, noise)
+ABOVE_ROOF_M = 1.0 # antenna this much above the highest roof point nearby (wish in #43)
+
+
+def point_elevation(project, lat, lon, radius_m=0.0):
+ """Ground and surface height at a point. With radius_m also the highest object (surface − ground) within that
+ radius on the fine near-field grid, and a mast height guess for a repeater there (#43)."""
s = merged_settings(project)
cs = project_cs(project, s)
x, y = lonlat_to_merc(lon, lat)
@@ -611,7 +619,27 @@ def point_elevation(project, lat, lon):
G, S, _ = terrain_pair(s, grid)
fx = (x - grid.x0) / cs - 0.5
fy = (grid.y1 - y) / cs - 0.5
- return {"ground_m": round(float(prop.sample(G, fx, fy)), 1), "surface_m": round(float(prop.sample(S, fx, fy)), 1)}
+ out = {"ground_m": round(float(prop.sample(G, fx, fy)), 1), "surface_m": round(float(prop.sample(S, fx, fy)), 1)}
+ if radius_m > 0:
+ fine = min(cs, float(s.get("near_res_m") or 5.0))
+ g = Grid.around(x, y, radius_m + 2 * fine, fine)
+ G2, S2, _ = terrain_pair(s, g)
+ xs = g.x0 + (np.arange(G2.shape[1]) + 0.5) * fine
+ ys = g.y1 - (np.arange(G2.shape[0]) + 0.5) * fine
+ inside = ((xs[None, :] - x) ** 2 + (ys[:, None] - y) ** 2) <= (radius_m + fine / 2) ** 2
+ heights = np.where(inside & np.isfinite(S2 - G2), S2 - G2, 0.0)
+ highest = float(heights.max()) if heights.size else 0.0
+ out["object_max_m"] = round(highest, 1)
+ out["mast_guess_m"] = round(highest + ABOVE_ROOF_M, 1) if highest >= MIN_BUILDING_M else None
+ return out
+
+
+def mast_height_guess(project, lat, lon):
+ """Antenna height above ground for a repeater that sits on a roof (#43), or None if no building is near."""
+ try:
+ return point_elevation(project, lat, lon, MAST_SEARCH_M).get("mast_guess_m")
+ except Exception: # noqa: BLE001 – no elevation data there: no guess
+ return None
def clear_memory_caches():
diff --git a/app/site/legal.py b/app/site/legal.py
index 4d2b407..d5c1ec8 100644
--- a/app/site/legal.py
+++ b/app/site/legal.py
@@ -104,8 +104,9 @@ def datenschutz_body():
Besucher erhalten keine Cookies. Nur nach der Anmeldung des Administrators wird das Cookie
terrarf_session gesetzt. Es ist für den angemeldeten Bereich unbedingt erforderlich (§ 25 Abs. 2 Nr. 2
TDDDG) und wird beim Schließen des Browsers gelöscht, sofern nicht „angemeldet bleiben“ gewählt wurde (dann höchstens
-{days} Tage). Wählen Sie oben die Sprache der Oberfläche, speichert Ihr Browser nur diese Wahl im lokalen Speicher (localStorage,
-Schlüssel „terrarf.lang“); sie verlässt Ihr Gerät nicht. Darüber hinaus wird lokaler Browserspeicher nicht verwendet.
+{days} Tage). Im lokalen Speicher Ihres Browsers (localStorage) legt die Seite nur zwei Einträge ab, die Ihr Gerät nicht verlassen:
+„terrarf.lang“ – die oben gewählte Sprache – und „terrarf.welcome“ – dass Sie die Begrüßungsseite schon gesehen haben,
+damit sie nicht bei jedem Besuch erscheint. Darüber hinaus wird lokaler Browserspeicher nicht verwendet.
Kartendarstellung
{maps}
{cs}
Messfahrten
Messwerte von Messfahrten (z. B. aus der App MeshMapper) werden mit Ort, Zeit, Signalwerten,
diff --git a/app/static/index.html b/app/static/index.html
index 41e8e86..989d0d5 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -30,6 +30,7 @@
${t("Visitors without login see projects marked “public” (read only).")}
- ${check("measurements_public", "Visitors may see measurements (without time and notes)")}
+ ${check("measurements_public", "Visitors may see measurements (without time and notes)")}
+ ${check("welcome_enabled", "Show the welcome page to first-time visitors")}
+
${t("Notice banner")}
+
${t("A strip below the top bar for everybody – e.g. while you are working on the site. Visitors can close it until the next visit.")}
+ ${check("notice_enabled", "Show the notice")}
+ ${select("notice_kind", "Kind", [["beta", "Beta – things may change"], ["construction", "Under construction – I am working on it right now"], ["maintenance", "Maintenance – the site may be gone for a while"], ["updates", "Updates are coming"], ["custom", "Own text only"]])}
+
${t("Website")}
${input("parent_name", "Belongs to (name)")}${input("parent_url", "Belongs to (link)")}
${check("show_github", "Show link to the source code")}
diff --git a/app/static/js/inspector.js b/app/static/js/inspector.js
index f1a24fa..681e388 100644
--- a/app/static/js/inspector.js
+++ b/app/static/js/inspector.js
@@ -11,7 +11,7 @@ import { map } from "./map/map.js";
import { RADIO_PRESETS, LORA_REQUIRED_SNR, sensitivityDbm, noiseFloorDbm, estimatedVerticalBeamwidth, applyCatalogAntenna, drawStation, removeStationMarker, startPlacing, newStation, withoutLabel } from "./map/stations.js";
import { compute, resultsByStation, sampleStation, setSolo, view, requiredMargin } from "./map/coverage.js";
import { showProfile } from "./profile.js";
-import { net, nodeByPk, linkTo, snrText, showNeighbours, clearNetworkSelection, setFocus, loadMeta, saveOverride, nearbyNodes, repeaterName, enabled as networkEnabled, nodePosition } from "./network.js";
+import { net, nodeByPk, linkTo, snrText, mastHeightGuess, showNeighbours, clearNetworkSelection, setFocus, loadMeta, saveOverride, nearbyNodes, repeaterName, enabled as networkEnabled, nodePosition } from "./network.js";
import { meas, seriesById, seriesName, receiverText, receiverFor, loadMeasurements, focusSeries } from "./measurements.js";
import { exportPdf } from "./workspaces/report.js";
@@ -234,7 +234,7 @@ function repeaterOverview(body, pk) {
: `
${t("MeshCore does not announce height, antenna and power – enter them below if known.")}
@@ -267,21 +267,27 @@ function repeaterMeasurements(body, pk) {
if (b) b.onclick = () => { emit("compare-station", { stationId: stationOfRepeater(pk).id }); openWorkspace("compare"); };
}
-function adoptRepeater(pk) {
+/** Take a CoreScope repeater over as a station of the project (#45: also several at once from "Plan"). */
+export async function adoptRepeater(pk, { open = true } = {}) {
const existing = stationOfRepeater(pk);
- if (existing) { inspectStation(existing); return; }
+ if (existing) { if (open) inspectStation(existing); return existing; }
const n = nodeByPk(pk) || linkTo(pk);
- if (!n) return;
+ if (!n) return null;
const own = net.overrides[pk] || {};
+ const roof = own.height_m == null ? await mastHeightGuess(n.lat, n.lon) : null;
const st = newStation(n.lat, n.lon);
- Object.assign(st, { name: n.name || st.name, corescope_pk: pk, height_m: own.height_m ?? 10, height_ref: "ground",
- notes: t("Taken over from CoreScope. Height, antenna and power are assumptions – please check.") });
+ Object.assign(st, { name: n.name || st.name, corescope_pk: pk, height_m: own.height_m ?? roof ?? 10, height_ref: "ground",
+ notes: (roof != null ? t("Height: roof in the surface model + 1 m.") + " " : "")
+ + t("Taken over from CoreScope. Height, antenna and power are assumptions – please check.") });
if (own.gain_dbi != null) st.antenna = { ...st.antenna, gain_dbi: own.gain_dbi, catalog_id: undefined };
state.project.stations.push(st); drawStation(st);
if (net.focusPk === pk) setFocus(null);
emit("stations-changed"); emit("project-changed", { station: st });
- inspectStation(st, "settings");
- status(t("“{name}” added – check height, antenna and power, then save.", { name: st.name }));
+ if (open) {
+ inspectStation(st, "settings");
+ status(t("“{name}” added – check height, antenna and power, then save.", { name: st.name }));
+ }
+ return st;
}
// ================================================================ link between two repeaters (#26)
@@ -315,10 +321,11 @@ export function inspectLink(centrePk, partnerPk) {
if (centreStation) openLinkProfile(centreStation, partnerStation, link); // #26: profile right away
}
-function openLinkProfile(centreStation, partnerStation, link) {
+async function openLinkProfile(centreStation, partnerStation, link) {
if (partnerStation) { showProfile(centreStation.id, null, { rxStationId: partnerStation.id }); return; }
const own = net.overrides[link.pk] || {};
- showProfile(centreStation.id, L.latLng(link.lat, link.lon), { rx: { h: own.height_m ?? 10, g: own.gain_dbi ?? 3, c: 1, label: link.name || link.pk.slice(0, 8) }, repeaterPk: link.pk });
+ const roof = own.height_m == null && isMember() ? await mastHeightGuess(link.lat, link.lon) : null;
+ showProfile(centreStation.id, L.latLng(link.lat, link.lon), { rx: { h: own.height_m ?? roof ?? 10, g: own.gain_dbi ?? 3, c: 1, label: link.name || link.pk.slice(0, 8) }, repeaterPk: link.pk });
}
// ================================================================ measurement ping (#27)
@@ -373,9 +380,10 @@ const receiverSummary = (r) => `${fmt(r.h, 1)} m · ${fmt(r.g, 1)} dBi${r.c ? `
/**
* Profile from the repeater that heard a ping to the place of the ping, with the receiver of its series.
* A repeater that is not a station of the project is computed as a temporary station (members only):
- * radio values like the last station, height and gain from the values entered for the repeater, else assumptions.
+ * radio values like the last station, height and gain from the values entered for the repeater, else the roof
+ * height nearby + 1 m (#43) or 10 m.
*/
-function openPingProfile(ping, row) {
+async function openPingProfile(ping, row) {
if (!row?.rep_pk) return;
const rx = { ...receiverFor(row, seriesById(row.series_id)), label: t("measurement point") };
const station = stationOfRepeater(row.rep_pk);
@@ -384,8 +392,9 @@ function openPingProfile(ping, row) {
if (!node || !isMember()) { status(t("A profile needs the repeater as a station of the project.")); return; }
const own = net.overrides[row.rep_pk] || {};
const temporary = newStation(node.lat, node.lon);
+ const roof = own.height_m == null ? await mastHeightGuess(node.lat, node.lon) : null;
Object.assign(temporary, { id: "tmp-" + row.rep_pk.slice(0, 12), name: node.name || row.rep_pk.slice(0, 8), corescope_pk: row.rep_pk,
- height_m: own.height_m ?? 10, height_ref: "ground" });
+ height_m: own.height_m ?? roof ?? 10, height_ref: "ground" });
if (own.gain_dbi != null) temporary.antenna = { ...temporary.antenna, gain_dbi: own.gain_dbi, catalog_id: undefined };
showProfile(temporary.id, L.latLng(ping.lat, ping.lon), { rx, txStation: temporary });
}
diff --git a/app/static/js/main.js b/app/static/js/main.js
index a654267..16d81fd 100644
--- a/app/static/js/main.js
+++ b/app/static/js/main.js
@@ -28,6 +28,7 @@ import { renderCompare } from "./workspaces/compare.js";
import { renderReport } from "./workspaces/report.js";
import { openAdmin } from "./admin.js";
import { openMembers } from "./members.js";
+import { openWelcome, maybeWelcome, renderNotice } from "./welcome.js";
// ---------------------------------------------------------------- projects
function emptyProject() { return { id: null, name: t("New project"), public: false, owner: state.role || "admin", stations: [] }; }
@@ -215,12 +216,13 @@ function wire() {
$("btnProjectMenu").onclick = (e) => { e.stopPropagation(); openProjectMenu(); };
document.addEventListener("click", (e) => { if (!e.target.closest("#projectMenu, #btnProjectMenu")) $("projectMenu").classList.add("hidden"); });
$("btnAdmin").onclick = () => openAdmin();
+ $("btnHelp").onclick = () => openWelcome();
const langSelect = $("langSelect");
langSelect.value = lang();
langSelect.onchange = async () => {
await setLanguage(langSelect.value, { remember: true });
- applyRole(); renderSiteLinks(); refreshLayersPanel(); redrawAllStations();
+ applyRole(); renderSiteLinks(); renderNotice(); refreshLayersPanel(); redrawAllStations();
for (const render of [renderPlan, renderMeasure, renderCompare, renderReport]) render();
initNetworkWorkspace();
closeInspector();
@@ -236,10 +238,10 @@ on("project-changed", (payload = {}) => {
setDirty(true);
if (payload.station && !payload.keepCoverage) stale.add(payload.station.id);
});
-on("config-reloaded", () => { applyRole(); renderSiteLinks(); });
+on("config-reloaded", () => { applyRole(); renderSiteLinks(); renderNotice(); });
on("settings-changed", () => { resetCoverage(); emit("project-loaded"); });
-async function init() {
+async function start() {
await setLanguage(detectLanguage());
state.config = await api("/api/config");
state.role = state.config.auth?.role || null;
@@ -248,6 +250,7 @@ async function init() {
applyRole();
wire();
renderSiteLinks();
+ renderNotice();
openWorkspace("plan", { openSheet: false });
state.project = emptyProject();
try { state.catalog = await api("/api/catalog"); emit("catalog-loaded"); } catch (e) { console.warn("catalog", e); }
@@ -275,4 +278,9 @@ async function init() {
else status(t("No public projects yet."));
}
+async function init() {
+ await start();
+ maybeWelcome();
+}
+
init().catch((e) => { console.error(e); status(t("Start failed: {msg}", { msg: e.message }), "err"); });
diff --git a/app/static/js/network.js b/app/static/js/network.js
index bc90f73..ebdf79d 100644
--- a/app/static/js/network.js
+++ b/app/static/js/network.js
@@ -167,6 +167,20 @@ export function clearNetworkSelection() {
drawNetwork(); emit("network-focus", { pk: null }); emit("neighbours-loaded", { pk: null });
}
+const guesses = {};
+/**
+ * #43: antenna height of a repeater that stands on a roof – highest object within 8 m of its GPS position
+ * (surface model) + 1 m. null when there is no building or no elevation data. Logged-in users only.
+ */
+export async function mastHeightGuess(lat, lon) {
+ const key = `${lat.toFixed(5)},${lon.toFixed(5)}`;
+ if (!(key in guesses)) {
+ try { guesses[key] = (await api("/api/elevation", { method: "POST", body: { project: state.project, lat, lon, radius_m: 8 } })).mast_guess_m ?? null; }
+ catch { guesses[key] = null; }
+ }
+ return guesses[key];
+}
+
/** Fade the neighbour lines (e.g. while drive-test points are compared, #40); showing neighbours again ends it. */
export function muteLines(on) { net.muted = !!on; drawNetwork(); }
diff --git a/app/static/js/welcome.js b/app/static/js/welcome.js
new file mode 100644
index 0000000..80fc12d
--- /dev/null
+++ b/app/static/js/welcome.js
@@ -0,0 +1,65 @@
+/**
+ * #44 welcome page: a short "what is this and how do I use it" for first-time visitors, again any time via "?".
+ * #46 notice banner: a strip the admin switches on (beta, under construction, maintenance, updates, own text).
+ */
+import { t } from "./core/i18n.js";
+import { $, escapeHtml } from "./core/util.js";
+import { state, isMember } from "./core/state.js";
+import { openWorkspace } from "./core/ui.js";
+
+const SEEN_KEY = "terrarf.welcome"; // remembered in the browser so the page is shown once (privacy policy)
+
+export function openWelcome() {
+ const network = !!state.config?.corescope?.enabled;
+ const steps = [
+ ["◎ " + t("Plan"), t("Stations on the map: coverage, overlap, best server and line of sight over real terrain (NRW laser scan, Copernicus elsewhere).")],
+ ["⋈ " + t("Network"), t("The real MeshCore network from CoreScope: repeaters, their neighbours and the SNR they report.")],
+ ["⌖ " + t("Measure"), t("Drive tests from the MeshMapper app, CSV files or entered by hand.")],
+ ["⇄ " + t("Compare"), t("How well the model matches the measurements – per station and source.")],
+ ["▤ " + t("Report"), t("Key figures and a PDF report per station.")],
+ ];
+ $("welcomeDlg").innerHTML = `
${t("Welcome to TerraRF")}
+
${t("TerraRF is a hub for everything about radio reception in the mesh: plan where a station would reach, look at the real network, measure in the field and compare the model with reality.")}
+
${steps.map(([a, b]) => `${a}${b}`).join("")}
+
${t("How to start")}
+
+
${t("Open a project in the menu ▾ next to the project name; public projects can be viewed without login.")}
+
${t("Tap a station, a repeater or a measurement point for its details. Right click (long press on phones) anywhere shows the levels and the terrain profile there.")}
+
${t("Switch views and base maps with “Layers” at the top right of the map.")}
+ ${network ? `
${t("Already running a MeshCore repeater? Logged in, use “Take over from MeshCore” in “Plan”: the repeater becomes a station with its position from CoreScope – then only height, antenna and power need checking.")}
` : ""}
+
${isMember() ? t("You are logged in: changes are saved with “Save”.") : t("To plan your own stations or share measurements, ask the operator of this site for a login.")}
+
+
${t("The model is checked against measurements, but it is a model: treat coverage maps as an estimate, not a promise.")}
+ `;
+ const close = () => { try { localStorage.setItem(SEEN_KEY, "1"); } catch { /* private mode */ } $("welcomeDlg").close(); };
+ $("welcomeDlg").querySelector('[data-a="go"]').onclick = close;
+ $("welcomeDlg").querySelector('[data-a="network"]').onclick = () => { close(); if (network) openWorkspace("network"); };
+ $("welcomeDlg").onclose = () => { try { localStorage.setItem(SEEN_KEY, "1"); } catch { /* ignore */ } };
+ if (!$("welcomeDlg").open) $("welcomeDlg").showModal();
+}
+
+/** Show the welcome page once for visitors who have not seen it (and are not logged in). */
+export function maybeWelcome() {
+ if (!state.config?.site?.welcome || isMember() || $("loginDlg").open) return;
+ let seen = false;
+ try { seen = !!localStorage.getItem(SEEN_KEY); } catch { /* ignore */ }
+ if (!seen) openWelcome();
+}
+
+const NOTICES = {
+ beta: "Beta: TerraRF is still evolving – things may change or break.",
+ construction: "Under construction: I am working on the site right now – some things may not work for a moment.",
+ maintenance: "Maintenance: the site may be unavailable for a while.",
+ updates: "Updates are coming – new features soon.",
+ custom: "",
+};
+
+export function renderNotice() {
+ const bar = $("noticeBar"), notice = state.config?.site?.notice;
+ const text = notice ? [t(NOTICES[notice.kind] || ""), notice.text].filter(Boolean).join(" ") : "";
+ bar.className = notice && text ? notice.kind : "hidden";
+ document.body.classList.toggle("has-notice", !!(notice && text));
+ if (!notice || !text) return;
+ bar.innerHTML = `${escapeHtml(text)}`;
+ bar.querySelector("button").onclick = () => { bar.className = "hidden"; document.body.classList.remove("has-notice"); };
+}
diff --git a/app/static/js/workspaces/compare.js b/app/static/js/workspaces/compare.js
index 781bee7..a8a25ea 100644
--- a/app/static/js/workspaces/compare.js
+++ b/app/static/js/workspaces/compare.js
@@ -148,7 +148,7 @@ function csHtml() {
const head = `
`;
const r = result.cs; if (!r) return "";
- const origin = { station: ["📍", t("values of the station in the project")], entered: ["✎", t("own values for the repeater")], standard: ["", t("default values above")] };
+ const origin = { station: ["📍", t("values of the station in the project")], entered: ["✎", t("own values for the repeater")], building: ["🏠", t("height from the roof in the surface model + 1 m")], standard: ["", t("default values above")] };
return head + `
${t("Noise floor {n} dBm · {k} partners with directly reported SNR · window {d} days. Tap a row for the profile.", { n: fmt(r.noise_dbm), k: r.rows.length, d: r.window_days ?? "?" })}
${t("Partner")}
${t("Height")}
km
LOS
${t("meas.")}
${t("model")}
Δ
${r.rows.map((x, i) => x.error ? `
${escapeHtml(x.name || "")}
${escapeHtml(x.error)}
`
diff --git a/app/static/js/workspaces/plan.js b/app/static/js/workspaces/plan.js
index a5beac4..b64a476 100644
--- a/app/static/js/workspaces/plan.js
+++ b/app/static/js/workspaces/plan.js
@@ -2,13 +2,58 @@
* Workspace "Plan": the project's stations, adding new ones and computing coverage.
*/
import { t } from "../core/i18n.js";
-import { $, escapeHtml, fmt } from "../core/util.js";
+import { $, escapeHtml, fmt, fmtAgo } from "../core/util.js";
import { state, on, emit, isMember, isCollaborator, canEditProject } from "../core/state.js";
import { status } from "../core/ui.js";
import { map } from "../map/map.js";
import { compute, resultsByStation, busy, stale, isComputing, composite } from "../map/coverage.js";
import { startPlacing, drawStation } from "../map/stations.js";
-import { inspectStation } from "../inspector.js";
+import { inspectStation, adoptRepeater } from "../inspector.js";
+import { net, enabled as networkEnabled, nearbyNodes } from "../network.js";
+
+// #45: pick repeaters from CoreScope near the map centre and take them over as stations
+const picker = { open: false, search: "", chosen: new Set() };
+
+function pickerCandidates() {
+ const c = map.getCenter(), q = picker.search.trim().toLowerCase();
+ const taken = new Set(state.project.stations.map((s) => s.corescope_pk).filter(Boolean));
+ const list = q.length >= 2
+ ? net.nodes.filter((n) => (n.name || "").toLowerCase().includes(q) || n.pk.startsWith(q)).map((n) => ({ node: n, km: null })).slice(0, 40)
+ : nearbyNodes(c.lat, c.lng, 30, 40);
+ return list.filter(({ node }) => !taken.has(node.pk));
+}
+
+function pickerHtml() {
+ if (!net.nodes.length) return `
${t("No repeaters loaded from CoreScope yet – see workspace “Network”.")}
`;
+ const list = pickerCandidates();
+ return `
+
${t("Repeaters near the map centre (or search by name). Their position comes from CoreScope; height is taken from the roof nearby + 1 m where there is a building, otherwise 10 m – antenna and power are assumptions to check.")}
${t("No stations yet.")} ${canEditProject() ? t("Add one with “Add station” and a click on the map.") : ""}
`}
${!isMember() ? `
${t("Log in to add stations or change them.")}
` : !canEditProject() ? `
${isCollaborator() ? t("Your login works on its shared project only; this one you can only look at.") : t("Only the creator of this project and the admin can change it. Create your own project in the project menu.")}
` : ""}`;
+ const fromMc = el.querySelector("#btnFromMeshCore");
+ if (fromMc) fromMc.onclick = () => { picker.open = !picker.open; picker.chosen.clear(); renderPlan(); };
+ if (picker.open) bindPicker(el);
const add = el.querySelector("#btnAddStation");
if (add) add.onclick = () => startPlacing("new");
const run = el.querySelector("#btnCompute");
@@ -56,4 +106,6 @@ export function renderPlan() {
});
}
+on("network-loaded", () => { if (picker.open) renderPlan(); });
+map.on("moveend", () => { if (picker.open && !picker.search) renderPlan(); });
for (const ev of ["project-loaded", "stations-changed", "coverage-updated", "busy-changed", "computing", "project-changed"]) on(ev, renderPlan);
diff --git a/app/static/locales/de.json b/app/static/locales/de.json
index 1d982e7..6d40311 100644
--- a/app/static/locales/de.json
+++ b/app/static/locales/de.json
@@ -3,6 +3,7 @@
"30 days": "30 Tage",
"7 days": "7 Tage",
"A profile needs the repeater as a station of the project.": "Für ein Profil muss der Repeater als Station im Projekt sein.",
+ "A strip below the top bar for everybody – e.g. while you are working on the site. Visitors can close it until the next visit.": "Ein Streifen unter der oberen Leiste für alle – z. B. während du an der Seite bastelst. Besucher können ihn bis zum nächsten Besuch schließen.",
"Access & website": "Zugang & Webseite",
"Add as station": "Als Station übernehmen",
"Add measurements": "Messungen hinzufügen",
@@ -12,6 +13,7 @@
"Administration": "Administration",
"Adverts": "Adverts",
"Aerial photo NRW (DOP)": "Luftbild NRW (DOP)",
+ "Already running a MeshCore repeater? Logged in, use “Take over from MeshCore” in “Plan”: the repeater becomes a station with its position from CoreScope – then only height, antenna and power need checking.": "Du betreibst schon einen MeshCore-Repeater? Angemeldet übernimmst du ihn unter „Planen“ mit „Aus MeshCore übernehmen“: Er wird zur Station mit dem Standort aus CoreScope – dann musst du nur noch Höhe, Antenne und Leistung prüfen.",
"Altitude": "Höhe ü. NHN",
"Antenna": "Antenne",
"Antenna (catalog id)": "Antenne (Katalog-ID)",
@@ -38,6 +40,8 @@
"Belongs to (name)": "Gehört zu (Name)",
"Best level (margin above sensitivity)": "Bester Pegel (Reserve über Empfindlichkeit)",
"Best server (station colour)": "Bester Server (Stationsfarbe)",
+ "Beta – things may change": "Beta – es kann sich noch etwas ändern",
+ "Beta: TerraRF is still evolving – things may change or break.": "Beta: TerraRF entwickelt sich noch – manches kann sich ändern oder kaputtgehen.",
"Both ways": "In beide Richtungen",
"Building footprints": "Gebäudegrundrisse",
"Building mode": "Gebäudemodus",
@@ -124,6 +128,7 @@
"Done in {s} s – {n} computed, {c} from cache.": "Fertig in {s} s – {n} berechnet, {c} aus dem Cache.",
"Download": "Herunterladen",
"Drive tests": "Messfahrten",
+ "Drive tests from the MeshMapper app, CSV files or entered by hand.": "Messfahrten aus der MeshMapper-App, CSV-Dateien oder von Hand eingetragen.",
"Drive tests: repeater → handheld": "Messfahrten: Repeater → Handgerät",
"E-mail": "E-Mail",
"Earth radius factor k": "Erdradiusfaktor k",
@@ -168,9 +173,12 @@
"Height": "Höhe",
"Height (m)": "Höhe (m)",
"Height above": "Höhe über",
+ "Height: roof in the surface model + 1 m.": "Höhe: Dach im Oberflächenmodell + 1 m.",
"Hex prefix or key": "Hex-Präfix oder Schlüssel",
"Hide neighbours": "Nachbarn ausblenden",
"Horizontal beam width (°)": "Horizontale Öffnung (°)",
+ "How to start": "So geht’s los",
+ "How well the model matches the measurements – per station and source.": "Wie gut das Modell zu den Messungen passt – je Station und Quelle.",
"ITU-R P.833: part of the direct ray inside objects of the surface model": "ITU-R P.833: Teil des direkten Strahls innerhalb von Objekten des Oberflächenmodells",
"Import (JSON)": "Importieren (JSON)",
"Import GeoJSON": "GeoJSON importieren",
@@ -182,12 +190,14 @@
"In the MeshMapper app: Settings → API endpoints → custom endpoint": "In der MeshMapper-App: Einstellungen → API-Endpunkte → eigener Endpunkt",
"Install as app": "Als App installieren",
"Key": "Schlüssel",
+ "Key figures and a PDF report per station.": "Kennzahlen und PDF-Bericht je Station.",
"Key figures of every computed station. The PDF contains the map, the figures, the model settings and – if available – the comparison with measurements.": "Kennzahlen jeder berechneten Station. Das PDF enthält Karte, Kennzahlen, Modell-Einstellungen und – falls vorhanden – den Abgleich mit Messungen.",
"Kind": "Art",
"Largest deviations first (at most 40). The points on the map are now coloured by Δ SNR: green ±3 dB, orange/red model too good, blue/purple model too pessimistic.": "Größte Abweichungen zuerst (höchstens 40). Die Punkte auf der Karte sind jetzt nach Δ SNR gefärbt: grün ±3 dB, orange/rot Modell zu gut, blau/lila Modell zu pessimistisch.",
"Last heard": "Zuletzt gehört",
"Latitude": "Breite",
"Layers": "Ebenen",
+ "Let's go": "Los geht’s",
"Level above sensitivity. Covered from {n} dB (fade/location margin).": "Pegel über der Empfindlichkeit. Versorgt ab {n} dB (Schwund-/Ortsreserve).",
"Levels": "Pegel",
"Line of sight / Fresnel zone / roofs": "Sichtlinie / Fresnelzone / Dächer",
@@ -216,6 +226,8 @@
"Longitude": "Länge",
"Looking up elevation data …": "Lese Höhendaten …",
"Loss outside (dB)": "Dämpfung außerhalb (dB)",
+ "Maintenance – the site may be gone for a while": "Wartung – die Seite kann kurz weg sein",
+ "Maintenance: the site may be unavailable for a while.": "Wartung: Die Seite ist eventuell eine Weile nicht erreichbar.",
"Maker": "Hersteller",
"Margin": "Reserve",
"Margin (required {n})": "Reserve (nötig {n})",
@@ -261,6 +273,7 @@
"No measurements yet.": "Noch keine Messungen.",
"No neighbour matches the filters.": "Kein Nachbar passt zu den Filtern.",
"No public projects yet.": "Noch keine öffentlichen Projekte.",
+ "No repeaters loaded from CoreScope yet – see workspace “Network”.": "Noch keine Repeater aus CoreScope geladen – siehe Bereich „Netz“.",
"No stations yet.": "Noch keine Stationen.",
"Noise figure Rx (dB)": "Rauschzahl Rx (dB)",
"Noise floor": "Rauschboden",
@@ -270,6 +283,7 @@
"Note": "Notiz",
"Note (only for you)": "Notiz (nur für dich)",
"Notes": "Notizen",
+ "Notice banner": "Hinweis-Banner",
"Okumura-Hata, for comparison only": "Okumura-Hata, nur zum Vergleich",
"Only": "Nur",
"Only for this profile – the map stays as computed": "Nur für dieses Profil – die Karte bleibt wie berechnet",
@@ -280,6 +294,7 @@
"Only {n} usable drive-test values – at least 10 are needed for a suggestion.": "Nur {n} auswertbare Messwerte – für einen Vorschlag braucht es mindestens 10.",
"Opacity": "Deckkraft",
"Open": "Öffnen",
+ "Open a project in the menu ▾ next to the project name; public projects can be viewed without login.": "Ein Projekt öffnest du im Menü ▾ neben dem Projektnamen; öffentliche Projekte lassen sich ohne Anmeldung ansehen.",
"Open station “{name}”": "Station „{name}“ öffnen",
"OpenStreetMap": "OpenStreetMap",
"OpenStreetMap (grey)": "OpenStreetMap (grau)",
@@ -287,6 +302,8 @@
"Order of precedence: a height in the single measurement, then the values here, then device and placement, then the global receiver.": "Reihenfolge: eine Höhe in der einzelnen Messung, dann die Werte hier, dann Gerät und Trageort, dann der globale Empfänger.",
"Overlap (number of stations)": "Überlappung (Anzahl Stationen)",
"Overview": "Übersicht",
+ "Own text (optional, added to the notice)": "Eigener Text (optional, wird an den Hinweis angehängt)",
+ "Own text only": "Nur eigener Text",
"PDF created.": "PDF erstellt.",
"Packets counted": "Gezählte Pakete",
"Part of {name}": "Gehört zu {name}",
@@ -338,6 +355,7 @@
"Repeater": "Repeater",
"Repeater details": "Repeater-Details",
"Repeaters": "Repeater",
+ "Repeaters near the map centre (or search by name). Their position comes from CoreScope; height is taken from the roof nearby + 1 m where there is a building, otherwise 10 m – antenna and power are assumptions to check.": "Repeater nahe der Kartenmitte (oder nach Namen suchen). Der Standort kommt aus CoreScope; als Höhe gilt das Dach in der Nähe + 1 m, wenn dort ein Gebäude steht, sonst 10 m – Antenne und Leistung sind Annahmen, bitte prüfen.",
"Report": "Bericht",
"Required SNR (dB)": "Nötiger SNR (dB)",
"Required margin": "Nötige Reserve",
@@ -378,6 +396,9 @@
"Show link to the source code": "Link zum Quellcode zeigen",
"Show neighbours": "Nachbarn zeigen",
"Show series": "Messreihe zeigen",
+ "Show the network": "Netz zeigen",
+ "Show the notice": "Hinweis anzeigen",
+ "Show the welcome page to first-time visitors": "Neuen Besuchern die Begrüßungsseite zeigen",
"Single values: right-click (long press on phones) on the map → “Enter measurement”.": "Einzelwerte: Rechtsklick (Handy: lange tippen) in die Karte → „Messung eintragen“.",
"Source": "Quelle",
"Specific attenuation (custom)": "Spezifische Dämpfung (eigene)",
@@ -386,20 +407,26 @@
"Station": "Station",
"Station {n}": "Station {n}",
"Stations of this project. Tap one to edit it; the coverage map shows all ticked stations together.": "Stationen dieses Projekts. Antippen zum Bearbeiten; die Abdeckungskarte zeigt alle angehakten Stationen zusammen.",
+ "Stations on the map: coverage, overlap, best server and line of sight over real terrain (NRW laser scan, Copernicus elsewhere).": "Stationen auf der Karte: Abdeckung, Überlappung, bester Server und Sichtlinie über echtem Gelände (NRW-Laserscan, sonst Copernicus).",
"Stay logged in on this device": "Auf diesem Gerät angemeldet bleiben",
"Storage": "Speicher",
"Suggestion for the model": "Vorschlag für das Modell",
"Summary": "Zusammenfassung",
"Surface (buildings, trees)": "Oberfläche (Gebäude, Bäume)",
"Surface model (buildings, trees)": "Oberflächenmodell (Gebäude, Bäume)",
+ "Switch views and base maps with “Layers” at the top right of the map.": "Ansichten und Grundkarten wechselst du mit „Ebenen“ oben rechts auf der Karte.",
"System": "System",
"Systematic offset: median {m} dB with scatter {s} dB. Suggestion: receiver extra loss {v} dB instead of {c} dB.": "Systematischer Versatz: Median {m} dB bei {s} dB Streuung. Vorschlag: Zusatzdämpfung am Empfänger {v} dB statt {c} dB.",
+ "Take over ({n})": "Übernehmen ({n})",
+ "Take over from MeshCore": "Aus MeshCore übernehmen",
"Taken over from CoreScope. Height, antenna and power are assumptions – please check.": "Aus CoreScope übernommen. Höhe, Antenne und Leistung sind Annahmen – bitte prüfen.",
"Tap a repeater name for its details; the profile shows the path from the strongest repeater (tap ⛰ for another one).": "Repeater-Namen antippen für Details; das Profil zeigt die Strecke vom stärksten Repeater (⛰ für einen anderen).",
"Tap a repeater on the map and choose “Show neighbours”.": "Einen Repeater auf der Karte antippen und „Nachbarn zeigen“ wählen.",
+ "Tap a station, a repeater or a measurement point for its details. Right click (long press on phones) anywhere shows the levels and the terrain profile there.": "Station, Repeater oder Messpunkt antippen zeigt die Details. Rechtsklick (Handy: lange tippen) irgendwo zeigt Pegel und Geländeprofil an dieser Stelle.",
"Temperature": "Temperatur",
"TerraRF as an app": "TerraRF als App",
"TerraRF can be put on the home screen and then opens without the browser bar.": "TerraRF lässt sich auf den Startbildschirm legen und öffnet sich dann ohne Browserleiste.",
+ "TerraRF is a hub for everything about radio reception in the mesh: plan where a station would reach, look at the real network, measure in the field and compare the model with reality.": "TerraRF ist ein Hub rund um Funkempfang im Mesh: planen, wohin eine Station reicht, das echte Netz ansehen, draußen messen und das Modell mit der Wirklichkeit abgleichen.",
"TerraRF project “{project}”: {link} – log in with “{name}” and the password {password}": "TerraRF-Projekt „{project}“: {link} – anmelden mit „{name}“ und dem Passwort {password}",
"Terrain": "Gelände",
"Terrain / surface": "Gelände / Oberfläche",
@@ -407,10 +434,12 @@
"Terrain model (bare ground)": "Geländemodell (Boden)",
"Terrain {g} m · surface {s} m (Δ {d} m) → antenna at {a} m above sea level": "Gelände {g} m · Oberfläche {s} m (Δ {d} m) → Antenne auf {a} m ü. NHN",
"Test at map centre": "In der Kartenmitte testen",
+ "The model is checked against measurements, but it is a model: treat coverage maps as an estimate, not a promise.": "Das Modell wird mit Messungen abgeglichen, bleibt aber ein Modell: Abdeckungskarten sind eine Schätzung, kein Versprechen.",
"The model is too pessimistic (median {m} dB, scatter {s} dB). A receiver value cannot fix that – the extra loss comes from diffraction, obstacles or clutter. Compare the link budget in the profile, and try the model settings (e.g. receiver clutter, building mode) before measuring again.": "Das Modell ist zu pessimistisch (Median {m} dB, Streuung {s} dB). Ein Empfängerwert kann das nicht beheben – die Zusatzdämpfung kommt aus Beugung, Hindernissen oder Clutter. Linkbudget im Profil ansehen und die Modell-Einstellungen (z. B. Clutter am Empfänger, Gebäudemodus) prüfen, bevor erneut gemessen wird.",
"The noise floor counts where this station receives: its sensitivity on the map, and in the comparison the SNR it reports itself about other repeaters.": "Das Grundrauschen zählt dort, wo diese Station empfängt: bei ihrer Empfindlichkeit auf der Karte und im Abgleich beim SNR, das sie selbst über andere Repeater meldet.",
"The project is private: only you and its collaborators can open it.": "Das Projekt ist privat: Nur du und die Mitarbeitenden können es öffnen.",
"The project is public: everybody with the link can look at it.": "Das Projekt ist öffentlich: Alle mit dem Link können es ansehen.",
+ "The real MeshCore network from CoreScope: repeaters, their neighbours and the SNR they report.": "Das echte MeshCore-Netz aus CoreScope: Repeater, ihre Nachbarn und das gemeldete SNR.",
"The server is still computing … next try in 10 s ({n})": "Der Server rechnet noch … nächster Versuch in 10 s ({n})",
"The user may see everything, add own stations and enter measurements. Catalogs, model and other people's stations stay with the admin.": "Der Benutzer sieht alles, legt eigene Stationen an und trägt Messungen ein. Kataloge, Modell und fremde Stationen bleiben beim Admin.",
"The values scatter a lot (± {s} dB) around a median of {m} dB. The cause is local (terrain, buildings, placement) – no global value fixes it.": "Die Werte streuen stark (± {s} dB) um einen Median von {m} dB. Die Ursache ist örtlich (Gelände, Gebäude, Trageort) – kein globaler Wert behebt das.",
@@ -421,14 +450,19 @@
"Tilt (°)": "Neigung (°)",
"Tilt, + = down (°)": "Neigung, + = nach unten (°)",
"Tip: drag the white handle at the end of the beam on the map to turn the antenna.": "Tipp: Den weißen Griff am Ende der Strahlrichtung auf der Karte ziehen, um die Antenne zu drehen.",
+ "To plan your own stations or share measurements, ask the operator of this site for a login.": "Um eigene Stationen zu planen oder Messungen beizutragen, frag den Betreiber dieser Seite nach einem Zugang.",
"Total path loss": "Gesamte Streckendämpfung",
"Transmit power": "Sendeleistung",
"Transmit power (dBm)": "Sendeleistung (dBm)",
"Turn antenna": "Antenne drehen",
"Type": "Typ",
+ "Under construction – I am working on it right now": "Baustelle – ich arbeite gerade daran",
+ "Under construction: I am working on the site right now – some things may not work for a moment.": "Baustelle: Ich arbeite gerade an der Seite – manches funktioniert kurz nicht.",
"Unlink": "Verknüpfung lösen",
"Unsaved changes": "Ungespeicherte Änderungen",
"Update nodes from CoreScope": "Knoten aus CoreScope aktualisieren",
+ "Updates are coming": "Updates kommen",
+ "Updates are coming – new features soon.": "Updates kommen – bald gibt es neue Funktionen.",
"Upright: SNR directly reported (zero hop). Italic: from packet paths, uncertain. Tap a row for the link details and profile.": "Aufrecht: SNR direkt gemeldet (0 Hop). Kursiv: aus Paketpfaden, unsicher. Zeile antippen zeigt Strecken-Details und Profil.",
"User": "Benutzer",
"User name": "Benutzername",
@@ -442,8 +476,11 @@
"Visitors may see measurements (without time and notes)": "Besucher dürfen Messungen sehen (ohne Zeit und Notizen)",
"Visitors without login see projects marked “public” (read only).": "Besucher ohne Anmeldung sehen Projekte, die als „öffentlich“ markiert sind (nur lesen).",
"Website": "Webseite",
+ "Welcome to TerraRF": "Willkommen bei TerraRF",
+ "What is TerraRF?": "Was ist TerraRF?",
"Which sources are used is set under “Model”. Here you test them and load areas for offline use.": "Welche Quellen gerechnet werden, steht unter „Modell“. Hier werden sie getestet und Gebiete für den Offline-Betrieb geladen.",
"Width (°)": "Breite (°)",
+ "You are logged in: changes are saved with “Save”.": "Du bist angemeldet: Änderungen speicherst du mit „Speichern“.",
"Your login works on its shared project only; this one you can only look at.": "Dein Zugang gilt nur für das geteilte Projekt; dieses kannst du nur ansehen.",
"all": "alle",
"all of NRW": "ganz NRW",
@@ -488,6 +525,7 @@
"global receiver": "globaler Empfänger",
"heard by the neighbour / from the neighbour": "vom Nachbarn gehört / den Nachbarn gehört",
"heard today": "heute gehört",
+ "height from the roof in the surface model + 1 m": "Höhe vom Dach im Oberflächenmodell + 1 m",
"importing …": "importiere …",
"include in the map": "in die Karte aufnehmen",
"install icon on the right of the address bar.": "Installieren-Symbol rechts in der Adressleiste.",
@@ -582,6 +620,7 @@
"{n} new, {total} footprints stored.": "{n} neu, {total} Grundrisse gespeichert.",
"{n} outside the computed radius": "{n} außerhalb des Rechenradius",
"{n} rep.": "{n} Rep.",
+ "{n} repeater(s) taken over – check height, antenna and power, then save.": "{n} Repeater übernommen – Höhe, Antenne und Leistung prüfen, dann speichern.",
"{n} repeaters": "{n} Repeater",
"{n} st.": "{n} St.",
"{n} stations": "{n} Stationen",
diff --git a/app/static/style.css b/app/static/style.css
index f1921aa..135b704 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -305,3 +305,19 @@ body.project-readonly #btnSave, body.project-readonly #dirtyDot { display: none;
/* share & collaborators */
#membersDlg { width: min(560px, 94vw); }
#membersDlg .secret textarea { display: block; width: 100%; margin: 6px 0; font-size: 12px; }
+
+/* #46 notice banner on top of the map */
+#noticeBar { position: absolute; z-index: 1100; top: 0; left: 0; right: 0; display: flex; gap: 10px; align-items: center; justify-content: center;
+ background: #fef3c7; color: #78350f; border-bottom: 1px solid #fcd34d; padding: 6px 40px 6px 12px; font-size: 13px; }
+#noticeBar.construction, #noticeBar.maintenance { background: #fee2e2; color: #7f1d1d; border-color: #fca5a5; }
+#noticeBar.updates { background: #dbeafe; color: #1e3a8a; border-color: #93c5fd; }
+#noticeBar button { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); border: 0; background: none; color: inherit; font-size: 16px; }
+body.has-notice #mapwrap .leaflet-top, body.has-notice #soloChip, body.has-notice #btnLayers, body.has-notice #layersPanel { margin-top: 34px; }
+/* #44 welcome page */
+#welcomeDlg { width: min(640px, 94vw); max-height: 90vh; overflow: auto; }
+#welcomeDlg .ws-list { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; margin: 10px 0; }
+#welcomeDlg .ws-list b { white-space: nowrap; }
+
+/* #45 MeshCore picker */
+.picker .picklist { max-height: 40vh; overflow: auto; margin: 6px 0; display: flex; flex-direction: column; gap: 2px; }
+#btnFromMeshCore.on { background: var(--acc-soft); border-color: var(--acc); }
diff --git a/app/store/appconfig.py b/app/store/appconfig.py
index 651db5c..528c369 100644
--- a/app/store/appconfig.py
+++ b/app/store/appconfig.py
@@ -16,7 +16,7 @@
_lock = threading.Lock()
DEFAULTS = {
- # zweiter Zugang: lesen + eigene Stationen anlegen
+ # second login: own projects, measurements
"user_enabled": False,
"user_name": os.environ.get("RFP_USER_NAME", "user"),
"user_password": os.environ.get("RFP_USER_PASSWORD", ""),
@@ -38,11 +38,18 @@
# measurements: key for the MeshMapper forwarding, visibility for visitors
"mm_api_key": os.environ.get("RFP_MEASURE_KEY", ""),
"measurements_public": False,
+ # #46: notice banner for everybody (beta, under construction, maintenance …); kind picks a ready-made text
+ "notice_enabled": False,
+ "notice_kind": "beta",
+ "notice_text": "",
+ # #44: welcome page for first-time visitors
+ "welcome_enabled": True,
}
if DEFAULTS["user_password"]:
DEFAULTS["user_enabled"] = True
-MODES = {"imprint_mode": ("generated", "link", "off"), "privacy_mode": ("generated", "link")}
+MODES = {"imprint_mode": ("generated", "link", "off"), "privacy_mode": ("generated", "link"),
+ "notice_kind": ("beta", "construction", "maintenance", "updates", "custom")}
GITHUB_URL = "https://github.com/RideMyByte/TerraRF"
@@ -93,4 +100,6 @@ def public():
"github": GITHUB_URL if c["show_github"] else None,
"user_enabled": bool(c["user_enabled"]),
"measurements_public": bool(c["measurements_public"]),
+ "notice": {"kind": c["notice_kind"], "text": c["notice_text"]} if c["notice_enabled"] else None,
+ "welcome": bool(c["welcome_enabled"]),
}
diff --git a/tests/test_buildings.py b/tests/test_buildings.py
index 7741354..19cb95d 100644
--- a/tests/test_buildings.py
+++ b/tests/test_buildings.py
@@ -92,3 +92,14 @@ def test_sector_attenuates_outside(bldg):
pj0, pj1 = _proj(lat, lon, 25), _proj(lat, lon, 25, ant)
assert abs(_raster(pj1, 0, 400) - _raster(pj0, 0, 400)) < 0.5 # Norden: im Sektor
assert _raster(pj0, 0, -400) - _raster(pj1, 0, -400) > 24 # Süden: außerhalb
+
+
+def test_mast_height_from_roof(bldg):
+ """#43: a repeater on (or right next to) a 30 m building gets 30 m + 1 m; in the open no guess."""
+ with settings_store.temporary(_settings(10)):
+ on_roof = cov.mast_height_guess({}, *_at(15, 0))
+ beside = cov.mast_height_guess({}, *_at(0, 0)) # 5 m west of the building: still within 8 m
+ open_field = cov.mast_height_guess({}, *_at(-300, 300))
+ assert on_roof == pytest.approx(31.0, abs=0.2)
+ assert beside == pytest.approx(31.0, abs=0.2)
+ assert open_field is None
diff --git a/tests/test_v052.py b/tests/test_v052.py
index 588403b..0e870ba 100644
--- a/tests/test_v052.py
+++ b/tests/test_v052.py
@@ -64,3 +64,17 @@ def test_collaborator_works_on_its_project_only(admin, project):
admin.post("/api/projects/geteilt2/members", json={"name": "carla"})
assert admin.delete("/api/projects/geteilt2").status_code == 200
assert accounts.project_of("carla") is None
+
+
+def test_notice_and_welcome_in_config(admin):
+ """#46 notice banner and #44 welcome switch reach every visitor through /api/config."""
+ with TestClient(app) as anon:
+ assert anon.get("/api/config").json()["site"]["notice"] is None
+ assert anon.get("/api/config").json()["site"]["welcome"] is True
+ assert admin.put("/api/admin/appconfig", json={"notice_kind": "nope"}).status_code == 400
+ assert admin.put("/api/admin/appconfig", json={"notice_enabled": True, "notice_kind": "construction", "notice_text": "Bis 20 Uhr",
+ "welcome_enabled": False}).status_code == 200
+ with TestClient(app) as anon:
+ site = anon.get("/api/config").json()["site"]
+ assert site["notice"] == {"kind": "construction", "text": "Bis 20 Uhr"} and site["welcome"] is False
+ admin.put("/api/admin/appconfig", json={"notice_enabled": False, "welcome_enabled": True})