From bb0017d654a41cdba521691b0360443d19949974 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 21:23:30 +0000 Subject: [PATCH] 0.5.4: welcome page, take over from MeshCore, roof height, notice banner - Welcome page for first-time visitors, again via "?" (#44) - "Take over from MeshCore" in Plan: pick repeaters near the map centre or by search and add them as stations at once (#45) - Repeater on or next to a building: highest object within 8 m + 1 m as default height (adopt, link/ping profiles, CoreScope calibration) (#43) - Admin notice banner: beta, under construction, maintenance, updates, own text (#46) Fixes #43, Fixes #44, Fixes #45, Fixes #46 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGjjQLPhPeQBYB3UA7v7kB --- CHANGELOG.md | 13 ++++++ app/analysis/compare.py | 5 ++- app/api/coverage.py | 3 +- app/model/coverage.py | 34 +++++++++++++-- app/site/legal.py | 5 ++- app/static/index.html | 3 ++ app/static/js/admin.js | 11 ++++- app/static/js/inspector.js | 37 +++++++++------- app/static/js/main.js | 14 +++++-- app/static/js/network.js | 14 +++++++ app/static/js/welcome.js | 65 +++++++++++++++++++++++++++++ app/static/js/workspaces/compare.js | 2 +- app/static/js/workspaces/plan.js | 56 ++++++++++++++++++++++++- app/static/locales/de.json | 39 +++++++++++++++++ app/static/style.css | 16 +++++++ app/store/appconfig.py | 13 +++++- tests/test_buildings.py | 11 +++++ tests/test_v052.py | 14 +++++++ 18 files changed, 323 insertions(+), 32 deletions(-) create mode 100644 app/static/js/welcome.js 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 @@

+ @@ -61,6 +62,7 @@
+
@@ -89,6 +91,7 @@

+ diff --git a/app/static/js/admin.js b/app/static/js/admin.js index c4116e8..4f2839d 100644 --- a/app/static/js/admin.js +++ b/app/static/js/admin.js @@ -38,7 +38,8 @@ async function reloadConfig() { // ---------------------------------------------------------------- access & website const ACCESS_KEYS = ["user_enabled", "user_name", "user_password", "parent_name", "parent_url", "show_github", "imprint_mode", "imprint_url", - "imprint_name", "imprint_email", "imprint_address", "imprint_extra", "privacy_mode", "privacy_url", "privacy_extra", "measurements_public"]; + "imprint_name", "imprint_email", "imprint_address", "imprint_extra", "privacy_mode", "privacy_url", "privacy_extra", "measurements_public", + "notice_enabled", "notice_kind", "notice_text", "welcome_enabled"]; async function accessPage(body) { const c = await api("/api/admin/appconfig"); @@ -51,7 +52,13 @@ async function accessPage(body) { ${check("user_enabled", "enabled")}
${input("user_name", "User name")}${input("user_password", "Password", "password")}

${t("Visitors")}

${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.")}

`}
- ${st ? `` : isAdmin() ? `` : ""} + ${st ? `` : canEditProject() ? `` : ""}
${!st && isAdmin() ? `

${t("Values for the comparison")}

@@ -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 = `

${sourceBadge("corescope")} ${t("CoreScope: repeater ↔ repeater")}

`; if (result.csError) return head + `
${escapeHtml(result.csError)}
`; 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 ?? "?" })}

${r.rows.map((x, i) => 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.")}

+ +
${list.map(({ node, km }) => ``).join("") + || `

${t("nothing found")}

`}
+
`; +} + +function bindPicker(el) { + const search = el.querySelector("#pickSearch"); + if (!search) return; + search.oninput = () => { picker.search = search.value; const pos = search.selectionStart; renderPlan(); const s2 = root().querySelector("#pickSearch"); s2.focus(); s2.setSelectionRange(pos, pos); }; + el.querySelectorAll(".picklist input").forEach((box) => box.onchange = () => { + if (box.checked) picker.chosen.add(box.dataset.pk); else picker.chosen.delete(box.dataset.pk); + el.querySelector("#pickGo").disabled = !picker.chosen.size; + el.querySelector("#pickGo").textContent = t("Take over ({n})", { n: picker.chosen.size }); + }); + el.querySelector("#pickGo").onclick = async () => { + const pks = [...picker.chosen]; + let last = null; + for (const pk of pks) last = (await adoptRepeater(pk, { open: false })) || last; + picker.open = false; picker.chosen.clear(); + renderPlan(); + if (last) inspectStation(last, "settings"); + status(t("{n} repeater(s) taken over – check height, antenna and power, then save.", { n: pks.length })); + }; +} const root = () => $("ws-plan"); @@ -21,8 +66,10 @@ export function renderPlan() {

${t("Stations of this project. Tap one to edit it; the coverage map shows all ticked stations together.")}

${canEditProject() ? `` : ""} + ${canEditProject() && networkEnabled() ? `` : ""} ${stations.length ? `` : ""}
+ ${picker.open ? pickerHtml() : ""}
${stations.map((st) => { const r = resultsByStation[st.id], b = busy[st.id]; const state_ = b ? (b.state === "queued" ? t("waiting") : `${t("computing")} ${b.progress ? Math.round(b.progress * 100) + " %" : "…"}`) @@ -34,6 +81,9 @@ export function renderPlan() { ${escapeHtml(state_)}
`; }).join("") || `

${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})
${t("Partner")}${t("Height")}kmLOS${t("meas.")}${t("model")}Δ
${escapeHtml(x.name || "")}${escapeHtml(x.error)}