diff --git a/docs/architecture.md b/docs/architecture.md index 632d570..bccf00b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -557,14 +557,29 @@ blanks the node sub-nav: Home and Config would be about whichever node served th two sections cannot drift apart. They are separate from the node grid because those cards stand for hardware on this network. The outbound arrow means "this leaves for another page", so a `mailto` card does not wear one. -- **How your node sees**: the flight-path diagram below. +- **Doppler Mapping Tool**: the flight-path diagram below. ### Flight simulator -A cut-down version of the primer's closing simulation. The left panel shows a tower, the node, -the wedge of sky the node hears and a flight path; the right panel plots what the node would +A cut-down version of the primer's closing simulation. The top panel shows a tower, the node, +the wedge of sky the node hears and a flight path; the panel under it plots what the node would record, range (bistatic excess path) across and Doppler up. +The two panels are stacked, each the full width of the page's column, at every window size. +They used to sit side by side, which left each too small to draw a path in comfortably and cut +the beam off at the edges of the sky. The sky's `viewBox` in `summary.html` is sized from two +numbers in `flight-sim.js`, `BEAM_R` (how far the node hears) and `WIDEST` (the widest the beam +opens): pointing up, as it starts, the whole wedge is in the picture at its widest. The view is +1.3 times the box that would just hold that wedge, so the tower, the node and the beam sit in the +middle with sky to spare all round, and a path can start and end well outside the beam. Steered +far enough off vertical, a wide beam still runs off the bottom edge; fitting every direction would +need a square panel. `tests/test_fleet.py` holds the view to the two numbers, and the area a path +can be drawn in (`SKY`) to the view. + +Because the far arc of the wedge is now in the picture, it is also a real limit: an aircraft +beyond it is not heard (`inBeam`). Before, only the angle was checked, which did not show while +the arc was off the edge of the panel. + It is progressive. The markup alone is a correct still diagram, with the beam and a default flight path drawn in. [flight-sim.js](../static/flight-sim.js) only adds the ability to fly it, so the controls ship `hidden` and the script reveals them. With no script, no pointer events, or @@ -573,17 +588,21 @@ rather than inline so the browser caches it. How the numbers are made: -- Range is `d1 + d2 - baseline` scaled by `KM` (0.15 km per SVG unit, baseline about 27 km). +- Range is `d1 + d2 - baseline` scaled by `KM` (0.125 km per SVG unit: baseline about 22 km, and + the beam reaches 25 km). - Doppler uses the real bistatic relation, `-(v . (u1 + u2)) / lambda`, with a fixed 213 MHz centre frequency and 170 m/s aircraft speed. It is zero when the heading is perpendicular to the sum of the two unit legs, which is not the same as perpendicular to the node. That sum has magnitude at most 2, which caps Doppler at `2v/lambda`. -- The scale is chosen so the picture cannot reach its own axes: drawn into the furthest corner - the scene tops out around 51 km and 242 Hz, inside a plot of 60 km and 300 Hz. A track pinned +- The scale is chosen so the picture cannot reach its own axes: heard at the far end of the beam + the scene tops out around 50 km and 242 Hz, inside a plot of 60 km and 300 Hz. A track pinned to a bound would say the aircraft stopped changing when really the plot ran out. The clamps in the plotting code are a backstop, not a working part. - Leg lengths are floored so a path drawn straight over the tower or the node does not divide by zero and make the track vanish. +- The plot's box is written twice, in the markup and as `PLOT` in the script, as is the default + flight path (so the still diagram does not jump when the script takes over). Tests hold both + pairs together. - Where the aircraft is outside the beam, the track has a gap. Nothing was heard, so nothing is drawn; that break is the point of the beam. @@ -594,7 +613,15 @@ Behaviour: - The loop runs at most 30 fps and stops (not just hides) when the tab is hidden or the section scrolls out of view. - Drawn samples closer than 4 units are dropped; a path is a shape, not a recording of the hand. +- The aircraft's heading turns steadily between one drawn point and the next (`tangent`, `at`). + Read off each straight piece of the path, it jumps at every point and takes Doppler with it, + which drew the track as a sawtooth; small in the old half-width plot, obvious in this one. - The beam can be steered by dragging the ring or with the arrow keys, and narrowed or widened. + The ring is labelled "Grab to Aim". The label ships undrawn (`display="none"`) and the + script reveals it, for the same reason the controls ship hidden. +- The ring takes keyboard focus so the arrow keys work, but it never shows the browser's focus + outline: a mouse grab focuses it too, and the outline then sat round it as a black box. + Keyboard focus is shown by the ring thickening (`:focus-visible`). The plot axes carry their real limits, because a plot whose bounds are a mystery teaches the wrong lesson about where a track sits. The link to the full primer shows only when `PRIMER_URL` diff --git a/docs/features/fleet-and-naming.md b/docs/features/fleet-and-naming.md index b463347..7483194 100644 --- a/docs/features/fleet-and-naming.md +++ b/docs/features/fleet-and-naming.md @@ -9,7 +9,7 @@ Every owl node on a LAN finds the others over mDNS (DNS-SD), and every page reti | [mdns_peers.py](../../src/mdns_peers.py) | `PeerDirectory`: runs `avahi-browse`, parses its output, probes each peer's `/healthz`, and holds the live peer list. Also `sort_key`, the fleet order. | | [routes/fleet.py](../../src/routes/fleet.py) | `/summary`, `/api/fleet/peers` and `/healthz`. Builds the banner tabs (`banner_nodes`) and Summary cards (`card_view`, `telemetry_view`). | | [node_name.py](../../src/node_name.py) | `NodeName`: reads, validates and writes the friendly name, then asks owl-os to re-advertise it. | -| [templates/_fleet_bar.html](../../templates/_fleet_bar.html) | The `fleet_bar` macro: brand block, Summary tab, one tab per node, outbound links. | +| [templates/_fleet_bar.html](../../templates/_fleet_bar.html) | The `fleet_bar` macro: brand block, Summary tab, one tab per node, the outbound link. | | [services.py](../../src/services.py) | Constructs the single `PeerDirectory` (`peers`) and `NodeName` (stored at `node-name` in the data directory). | | [routes/config.py](../../src/routes/config.py) | `POST /node-name`, the rename endpoint behind the name field on `/config`. | | owl-mdns-identity (owl-os) | Writes the `_owl-node._tcp` service file at boot and again on rename. | @@ -114,7 +114,7 @@ Display rules: | Tab tooltip | The node_id, plus the IPv4 address when known. | | Tab link | The node's absolute `http://ret.local/` URL (see [Node addresses](#node-addresses)). | | Active tab | This node's tab, unless the Summary tab is active. The page is always served by the node you are looking at, so there is nothing to track: the macro compares against the `node_id` passed in. | -| Outbound links | Retina Network Map and Retina Dashboard. Both leave the app, so both carry the outbound arrow. | +| Outbound link | Network Map and Dashboard (`app.retina.fm`). It leaves the app, so it carries the outbound arrow. | `discovered_nodes` guarantees this node is in the list. Discovery takes a second or two to populate, and it can come back empty on a network that blocks multicast; neither is a reason to draw a banner with no tabs. If the peer list does not already include this node, a stand-in entry is added (hostname `.local`, no address, no friendly name) and the list is re-sorted rather than prepended, so the tab does not sit first for a second and then move. The banner and the Summary cards share this function deliberately: two answers to "which nodes are there" would disagree during exactly the seconds after boot when someone is most likely to be looking. @@ -148,8 +148,8 @@ For peers, the card's telemetry comes from the last good `/healthz` payload. For The Summary page also carries fixed outbound links, defined in `routes/fleet.py`: -- `RESOURCES`, ordered by distance from the node: the company site, the manual (owl-os wiki), then the two live views of the wider network (map and dashboard). -- `HELP`: the blah2 Discord and a support email address. The Discord is the blah2 project's own community server and is labelled as such. Calling it ours would send an owner with a hardware or account problem into a volunteer channel expecting Offworld Labs support, and land that community with questions it cannot answer. +- `RESOURCES`, ordered by distance from the node: the company site, the manual (owl-os wiki), the live view of the wider network (Network Map and Dashboard), then two outside ones: Passive Radar News, a site about the field (written as `passiveradar.com`, without the `www.` it redirects to, because the card shows the host as written), and the blah2 Discord, the community around the radar software the node runs. The network's map and dashboard used to be two sites with a link each; they are one now, at `app.retina.fm`, and the banner's button goes to the same address (`_fleet_bar.html` writes it out, and `tests/test_fleet.py` holds the two together). +- `HELP`: our own channels only, the Retina Discord and a support email address. The blah2 Discord is not here. It is the blah2 project's own community server, and listing it as help would send an owner with a hardware or account problem into a volunteer channel expecting Offworld Labs support, and land that community with questions it cannot answer; as a resource, named for whose it is, it reads as somewhere to learn more. The two Discord cards show the same host (`discord.gg`), so the names are what tell them apart. A Discord invite can be made to expire; the Retina one has to be a permanent invite, or the card on every node goes dead with it. - `BUY_URL`: where "Add another node" sends an owner with only one node. - `PRIMER_URL`: empty until the full primer is published. A dead link on an owner's node is worse than no link, so the template omits the line while this is blank; turning it on is one string. diff --git a/src/routes/fleet.py b/src/routes/fleet.py index fd1e8e6..77f43f5 100644 --- a/src/routes/fleet.py +++ b/src/routes/fleet.py @@ -41,21 +41,27 @@ def _mailto(name, address): PRIMER_URL = "" # Fixed links out, ordered by distance from the node: the company, the manual -# for this box, then the two live views of the wider network. +# for this box, the live view of the wider network (dashboard and map are one +# site, also linked from the banner), news from the field, then the community +# around blah2, the radar software the node runs. That Discord is theirs, not +# Offworld Labs support, which is why it is here and not under Help. +# Each card shows its URL's host, so a URL is written the way it should read: +# passiveradar.com, not the www. address it redirects to. RESOURCES = ( _resource("Offworld Labs", "https://offworldlabs.com", "globe"), _resource("Retina Wiki", "https://github.com/offworldlabs/owl-os/wiki/4-Troubleshooting-and-Tuning", "book"), - _resource("Retina Network Map", "https://map.retina.fm", "map"), - _resource("Retina Dashboard", "https://dash.retina.fm", "chart"), + _resource("Network Map and Dashboard", "https://app.retina.fm", "map"), + _resource("Passive Radar News", "https://passiveradar.com/", "news"), + _resource("blah2 Discord", "https://discord.gg/ewNQbeK5Zn", "chat"), ) -# Where to go when something is wrong. The Discord is the blah2 project's -# community server, not Offworld Labs support, and is labelled as such. +# Where to go when something is wrong: our own channels only, the Retina +# Discord and the support address. # See docs/features/fleet-and-naming.md#resources-and-help-links. HELP = ( - _resource("blah2 Discord", "https://discord.gg/ewNQbeK5Zn", "chat"), + _resource("Retina Discord", "https://discord.gg/Fy7CN4DuhC", "chat"), _mailto("Email us", "info@offworldlabs.com"), ) diff --git a/static/common.css b/static/common.css index 4c919f8..d8bec2e 100644 --- a/static/common.css +++ b/static/common.css @@ -142,11 +142,11 @@ h1, h2, h3, h4, h5, h6 { z-index: 5; /* Bootstrap also defines .nav, and sets flex-wrap:wrap on it. Left to that, the banner folds onto a second row once the tab strip grows: at six nodes - on a 1400px screen the Retina Dashboard button dropped to its own line. + on a 1400px screen the last outbound button dropped to its own line. The tab strip scrolls instead (see .nav-tabs). */ flex-wrap: nowrap; } -/* The brand and the two outbound buttons keep their size; the tab strip is the +/* The brand and the outbound button keep their size; the tab strip is the only part that gives, so a long fleet never squashes the furniture around it. */ .nav > a { flex-shrink: 0; } .nav-tabs { @@ -168,7 +168,7 @@ h1, h2, h3, h4, h5, h6 { .nav-tabs > .nav-tab { flex-shrink: 0; } /* Below the app's narrow breakpoint there is not room for the brand, the tab - strip and both outbound buttons on one line, and holding nowrap there + strip and the outbound button on one line, and holding nowrap there squeezes the tab strip to nothing: every node tab disappears rather than scrolling. Wrapping is the right answer at this width (the footer already wraps), so the strip gets its own row and still scrolls within it. */ @@ -1285,7 +1285,7 @@ h1, h2, h3, h4, h5, h6 { .help-lead { margin: -6px 0 12px; font-size: 13px; color: var(--ink-2); max-width: 62ch; } /* ══════════════════════════════════════════════ - How your node sees: the flight path simulator + Doppler Mapping Tool: the flight path simulator ══════════════════════════════════════════════ */ .sim-head { margin-top: 28px; } @@ -1294,8 +1294,10 @@ h1, h2, h3, h4, h5, h6 { .sim-lead { margin: -6px 0 14px; font-size: 13px; line-height: 1.6; color: var(--ink-2); } -.sim { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; } -@media (max-width: 760px) { .sim { grid-template-columns: 1fr; } } +/* One above the other, each the full width of the column: the sky first, + then what the node records of it. Side by side they were too small to + draw in, and the sky could not show the whole beam. */ +.sim { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; } .sim-panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px 10px 8px; } @@ -1316,10 +1318,26 @@ h1, h2, h3, h4, h5, h6 { .sim-track { stroke: var(--accent); stroke-width: 1.8; fill: none; stroke-linecap: round; stroke-linejoin: round; } .sim-grid { stroke: var(--line-2); stroke-width: 1; } -.sim-axis-label { font-family: var(--font-body); font-size: 10px; fill: var(--ink-3); } +/* In the drawing's own units, so it scales with the panel: about 12px on a + desktop column, and held near 10px on a phone, where the panel is narrow. */ +.sim-axis-label { font-family: var(--font-body); font-size: 7.5px; fill: var(--ink-3); } +/* The sky is drawn 1.3 times further out than the plot, so its labels are + 1.3 times larger in its units to come out the same size on screen. */ +#fs-map .sim-axis-label { font-size: 9.75px; } +@media (max-width: 760px) { + .sim-axis-label { font-size: 12px; } + #fs-map .sim-axis-label { font-size: 15.6px; } +} .sim-mark { fill: var(--ink-2); } .sim-grab { cursor: grab; } .sim-grab:active { cursor: grabbing; } +/* No focus box, ever: grabbing the knob focuses it, and the browser's outline + then sat round it as a black rectangle. A keyboard user still needs to see + where they are, so for them the ring itself thickens. */ +.sim-grab:focus { outline: none; } +.sim-grab:focus-visible circle + circle { stroke-width: 3; } +.sim-aim-label { font-family: var(--font-body); font-size: 9.75px; font-weight: 600; fill: var(--accent); } +@media (max-width: 760px) { .sim-aim-label { font-size: 15.6px; } } .sim-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; } diff --git a/static/flight-sim.js b/static/flight-sim.js index 2504715..34f6df2 100644 --- a/static/flight-sim.js +++ b/static/flight-sim.js @@ -21,12 +21,23 @@ var aim = -Math.PI / 2, half = Math.PI / 3; // 120° across, pointing up var BASE = Math.hypot(T.x - N.x, T.y - N.y); - // The scale is chosen so the picture cannot reach its own axes. Drawn into - // the furthest corner this scene tops out at 51 km and 242 Hz, inside a - // plot that goes to 60 km and 300 Hz, so a track curves away from the edge - // instead of flattening along it. A track pinned to a bound is a lie: it - // says the aircraft stopped changing when really the plot ran out. - var KM = 0.15; // 1 unit ≈ 0.15 km, baseline ≈ 27 km + // How far the node hears, and how wide the beam can be opened. The sky's + // viewBox in summary.html is sized from these two, so the whole wedge is + // in the picture at its widest with sky to spare: change them and it has + // to follow. + // See docs/architecture.md#flight-simulator + var BEAM_R = 200, WIDEST = 1.35, NARROWEST = 0.2; + // The sky's viewBox, less a margin: where a path may be drawn. + var SKY = { left: -54, right: 478, top: -54, bottom: 247 }; + // The plot's box, as drawn in summary.html. + var PLOT = { x: 46, w: 356, mid: 98, h: 84 }; + + // The scale is chosen so the picture cannot reach its own axes. Heard at + // the far end of the beam this scene tops out at 50 km and 242 Hz, inside + // a plot that goes to 60 km and 300 Hz, so a track curves away from the + // edge instead of flattening along it. A track pinned to a bound is a lie: + // it says the aircraft stopped changing when really the plot ran out. + var KM = 0.125; // 1 unit = 0.125 km: baseline ≈ 22 km, beam ≈ 25 km var RANGE_MAX = 60; // km, the full width of the plot var DOPPLER_MAX = 300; // Hz, top and bottom of the plot @@ -40,7 +51,7 @@ var pts = []; for (var i = 0; i <= 40; i++) { var t = i / 40; - pts.push({ x: 20 + t * 265, y: 150 - Math.sin(t * Math.PI) * 96 }); + pts.push({ x: 20 + t * 380, y: 150 - Math.sin(t * Math.PI) * 118 }); } return pts; })(); @@ -57,6 +68,14 @@ } } + // The direction of travel through point k: from the point before it to + // the point after. + function tangent(k) { + var a = path[Math.max(0, k - 1)], b = path[Math.min(path.length - 1, k + 1)]; + var x = b.x - a.x, y = b.y - a.y, h = Math.hypot(x, y) || 1; + return { x: x / h, y: y / h }; + } + function at(d) { // Position and heading a distance d along the drawn path. if (total <= 0) return null; @@ -66,11 +85,18 @@ var a = path[i - 1], b = path[i] || path[i - 1]; var span = (lengths[i] || total) - lengths[i - 1]; var f = span > 0 ? (d - lengths[i - 1]) / span : 0; - var hx = b.x - a.x, hy = b.y - a.y, h = Math.hypot(hx, hy) || 1; - return { x: a.x + hx * f, y: a.y + hy * f, hx: hx / h, hy: hy / h }; + // The heading turns steadily from one point to the next. Taken from + // each straight piece instead, it would jump at every point, and + // Doppler with it: the track came out as a sawtooth. + var ta = tangent(i - 1), tb = tangent(Math.min(i, path.length - 1)); + var hx = ta.x + (tb.x - ta.x) * f, hy = ta.y + (tb.y - ta.y) * f, h = Math.hypot(hx, hy) || 1; + return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, hx: hx / h, hy: hy / h }; } + // Inside the wedge as it is drawn: within its angle and within its reach. + // The arc is in the picture, so an aircraft beyond it must not be heard. function inBeam(p) { + if (Math.hypot(p.x - N.x, p.y - N.y) > BEAM_R) return false; var delta = Math.atan2(p.y - N.y, p.x - N.x) - aim; while (delta > Math.PI) delta -= 2 * Math.PI; while (delta < -Math.PI) delta += 2 * Math.PI; @@ -98,7 +124,7 @@ } function drawBeam() { - var a1 = aim - half, a2 = aim + half, R = 200; + var a1 = aim - half, a2 = aim + half, R = BEAM_R; var p1 = { x: N.x + Math.cos(a1) * R, y: N.y + Math.sin(a1) * R }; var p2 = { x: N.x + Math.cos(a2) * R, y: N.y + Math.sin(a2) * R }; var d = "M" + N.x + " " + N.y + " L" + p1.x.toFixed(1) + " " + p1.y.toFixed(1) + @@ -126,9 +152,9 @@ // coordinate, not a working part: the scale above keeps every reachable // reading well inside the box. function place(r) { - var x = 46 + Math.max(0, Math.min(RANGE_MAX, r.km)) / RANGE_MAX * 240; + var x = PLOT.x + Math.max(0, Math.min(RANGE_MAX, r.km)) / RANGE_MAX * PLOT.w; var hz = Math.max(-DOPPLER_MAX, Math.min(DOPPLER_MAX, r.hz)); - return { x: x, y: 98 - hz / DOPPLER_MAX * 84 }; + return { x: x, y: PLOT.mid - hz / DOPPLER_MAX * PLOT.h }; } function paint(p) { @@ -207,7 +233,7 @@ // ── Drawing a path ──────────────────────────────────────── function inside(p) { - return { x: Math.max(4, Math.min(296, p.x)), y: Math.max(4, Math.min(216, p.y)) }; + return { x: Math.max(SKY.left, Math.min(SKY.right, p.x)), y: Math.max(SKY.top, Math.min(SKY.bottom, p.y)) }; } function svgPoint(svg, evt) { @@ -241,7 +267,7 @@ map.releasePointerCapture(evt.pointerId); if (path.length < 3) { path = []; drawPath(); hint.textContent = "That was a dot. Drag a line across the sky."; return; } measure(); - hint.textContent = "Drag the blue ring to steer the beam. The track breaks where the aircraft leaves it."; + hint.textContent = "Grab the blue ring to aim the beam. The track breaks where the aircraft leaves it."; play(); }); @@ -265,10 +291,10 @@ }); document.getElementById("fs-narrow").addEventListener("click", function () { - half = Math.max(0.2, half - 0.26); drawBeam(); resetTrack(); + half = Math.max(NARROWEST, half - 0.26); drawBeam(); resetTrack(); }); document.getElementById("fs-wide").addEventListener("click", function () { - half = Math.min(1.35, half + 0.26); drawBeam(); resetTrack(); + half = Math.min(WIDEST, half + 0.26); drawBeam(); resetTrack(); }); clearBtn.addEventListener("click", function () { pause(); resetTrack(); path = []; drawPath(); @@ -278,6 +304,9 @@ // ── First paint, standing still ─────────────────────────── var controls = document.getElementById("fs-controls"); if (controls) controls.hidden = false; + // The knob can be grabbed now, so it may say so. + var aimLabel = document.getElementById("fs-aim-label"); + if (aimLabel) aimLabel.removeAttribute("display"); measure(); drawBeam(); drawPath(); var start = at(0); diff --git a/templates/_fleet_bar.html b/templates/_fleet_bar.html index 077e459..f9fbd10 100644 --- a/templates/_fleet_bar.html +++ b/templates/_fleet_bar.html @@ -45,14 +45,12 @@ {% endfor %} - {# Both of these leave the app, so both carry the outbound arrow. #} - + {# The network's dashboard and map, one site. It leaves the app, so it + carries the outbound arrow. Same address as RESOURCES in routes/fleet.py. + See docs/features/fleet-and-naming.md#resources-and-help-links #} + - Retina Network Map - - - - Retina Dashboard + Network Map and Dashboard {% endmacro %} diff --git a/templates/summary.html b/templates/summary.html index 00fef6a..3c308aa 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -29,6 +29,11 @@ + {% elif item.icon == 'news' %} + {% elif item.icon == 'chat' %} Add another node {# It leaves the app, so it carries the same outbound arrow - the banner's Server and Dashboard buttons do. #} + the banner's outbound button does. #} @@ -178,7 +183,7 @@ {# Progressive: the markup alone is a correct still diagram, and flight-sim.js reveals the hidden controls. Keep it that way. See docs/architecture.md#flight-simulator #} -

How your node sees

+

Doppler Mapping Tool

Your node measures two things: how much further the echo travelled than the direct signal, and how fast that distance is changing. Draw a flight @@ -188,19 +193,25 @@

The sky above your node

- + {# The view is 1.3 times the box that just holds the beam at its + widest, so there is sky to spare all round it: see flight-sim.js + (BEAM_R, WIDEST, SKY) and docs/architecture.md#flight-simulator #} + - + tower your node - + + {# Not drawn until flight-sim.js can make it true: without + the script nothing here can be grabbed. #} + Grab to Aim @@ -211,13 +222,14 @@

What your node records

- - - + + {# The box is PLOT in flight-sim.js: keep the two the same. #} + + {# 15, 30 and 45 km. #} - - - + + + {# The axes are labelled with their real limits, because a plot @@ -227,8 +239,8 @@ 0 Hz −300 0 - range - 60 km + range + 60 km
@@ -246,7 +258,7 @@

- Drag across the sky to draw your own path. Drag the blue ring to steer the + Drag across the sky to draw your own path. Grab the blue ring to aim the beam. The track breaks wherever the aircraft leaves it.

diff --git a/tests/test_fleet.py b/tests/test_fleet.py index 020482a..fa7b0ca 100644 --- a/tests/test_fleet.py +++ b/tests/test_fleet.py @@ -5,6 +5,8 @@ that it survives base.html inheritance and the blocks pages override. """ +import math +import re from pathlib import Path import pytest @@ -160,13 +162,33 @@ def test_the_summary_tab_has_no_node_mark(app_client, fleet): assert "nav-tab-icon" not in summary[0] -# ── Both external links ──────────────────────────────────────── +# ── The external link ────────────────────────────────────────── -def test_the_banner_carries_both_outbound_links(app_client, fleet): +def test_the_banner_carries_the_network_link(app_client, fleet): + """One button: the map and the dashboard are one site now.""" fleet(node(SELF, is_self=True)) body = app_client.get("/").data.decode() - assert "https://map.retina.fm" in body and "Retina Network Map" in body - assert "https://dash.retina.fm" in body and "Retina Dashboard" in body + assert "https://app.retina.fm" in body and "Network Map and Dashboard" in body + assert "map.retina.fm" not in body and "dash.retina.fm" not in body + assert "Retina Dashboard" not in body + + +def test_the_banner_and_the_resources_send_the_network_link_to_the_same_place(): + """The address is written twice, in the banner template and in RESOURCES.""" + network = [r for r in RESOURCES if r["name"] == "Network Map and Dashboard"] + assert [r["url"] for r in network] == ["https://app.retina.fm"] + banner = (PROJECT_ROOT / "templates" / "_fleet_bar.html").read_text() + assert 'href="https://app.retina.fm"' in banner + + +def test_the_resources_run_from_ours_to_the_wider_field(): + assert [r["name"] for r in RESOURCES] == [ + "Offworld Labs", "Retina Wiki", "Network Map and Dashboard", "Passive Radar News", + "blah2 Discord"] + news = RESOURCES[3] + assert news["url"] == "https://passiveradar.com/" + # Shown under the name as written, like the others: no www. + assert news["host"] == "passiveradar.com" # ── The child row ────────────────────────────────────────────── @@ -558,7 +580,7 @@ def test_the_offer_of_a_second_node_points_at_the_store(app_client, fleet, telem def test_both_ways_of_reaching_us_are_offered(app_client, fleet, telemetry): fleet(node(SELF, is_self=True)) cards = "".join(help_strip(app_client.get("/summary").data.decode())) - assert "https://discord.gg/ewNQbeK5Zn" in cards + assert "https://discord.gg/Fy7CN4DuhC" in cards assert "mailto:info@offworldlabs.com" in cards @@ -579,23 +601,35 @@ def test_the_email_card_does_not_open_a_tab(app_client, fleet, telemetry): assert "link-arrow" not in mail[0] -def test_the_discord_card_does_open_a_tab(app_client, fleet, telemetry): +def test_the_discord_cards_do_open_a_tab(app_client, fleet, telemetry): + body = app_client.get("/summary").data.decode() + discord = [c for c in help_strip(body) + resource_strip(body) if "discord.gg" in c] + assert len(discord) == 2 + for card in discord: + assert 'target="_blank"' in card and 'rel="noopener"' in card + assert "link-arrow" in card + + +def test_help_is_our_own_channels_only(app_client, fleet, telemetry): + """The Retina Discord and the support address. The blah2 server is that + project's community, not Offworld Labs support, so it is not offered as + help: an owner with a hardware problem would be sent into a volunteer + channel expecting us.""" cards = help_strip(app_client.get("/summary").data.decode()) - discord = [c for c in cards if "discord.gg" in c] - assert len(discord) == 1 - assert 'target="_blank"' in discord[0] and 'rel="noopener"' in discord[0] - assert "link-arrow" in discord[0] + names = [c.split('class="link-name">')[1].split("<")[0] for c in cards] + assert names == ["Retina Discord", "Email us"] + assert "discord.gg/Fy7CN4DuhC" in cards[0] + assert "blah2" not in "".join(cards) -def test_the_discord_is_named_for_whose_it_is(app_client, fleet, telemetry): - """It is the blah2 project's community server, not ours. Calling it ours - would send an owner with a hardware problem into a volunteer channel - expecting Offworld Labs support.""" - cards = "".join(help_strip(app_client.get("/summary").data.decode())) +def test_the_blah2_discord_is_a_resource_named_for_whose_it_is(app_client, fleet, telemetry): + last = resource_strip(app_client.get("/summary").data.decode())[-1] + assert "discord.gg/ewNQbeK5Zn" in last + cards = last assert "blah2 Discord" in cards -# ── How your node sees ───────────────────────────────────────── +# ── Doppler Mapping Tool ───────────────────────────────────────── # # The primer's closing simulation, cut down. It is the one piece here that # needs an animation loop, so what these pin down is mostly the fencing. @@ -615,6 +649,104 @@ def test_the_simulation_is_a_still_diagram_without_any_script(app_client, fleet, assert 'id="fs-path"' in sim and 'd="M20.0 150.0' in sim +def _sim_constants(): + """The numbers flight-sim.js draws the beam from.""" + js = (PROJECT_ROOT / "static" / "flight-sim.js").read_text() + node = re.search(r"N = \{ x: (\d+), y: (\d+) \}", js) + beam = re.search(r"var BEAM_R = (\d+), WIDEST = ([\d.]+)", js) + return js, (int(node.group(1)), int(node.group(2))), int(beam.group(1)), float(beam.group(2)) + + +def test_the_sky_holds_the_whole_beam_at_its_widest(app_client, fleet, telemetry): + """Opened right out and pointing up, as it starts, no part of the wedge + may be cut off by the edge of the picture.""" + sim = sim_section(app_client.get("/summary").data.decode()) + left, top, width, height = map(float, re.search( + r'id="fs-map" viewBox="([-\d. ]+)"', sim).group(1).split()) + _, (nx, ny), reach, widest = _sim_constants() + + assert left < nx - reach * math.sin(widest) and nx + reach * math.sin(widest) < left + width + assert top < ny - reach + assert ny < top + height # the node itself, where the wedge's edges meet + # And not edge to edge: the scene is drawn 30% further out than a snug fit. + assert width >= 1.3 * 2 * reach * math.sin(widest) and height >= 1.3 * reach + + +def test_a_path_can_be_drawn_anywhere_in_the_sky_and_nowhere_outside_it(app_client, fleet, telemetry): + """SKY in the script is the view in the markup, less a small margin.""" + sim = sim_section(app_client.get("/summary").data.decode()) + left, top, width, height = map(float, re.search( + r'id="fs-map" viewBox="([-\d. ]+)"', sim).group(1).split()) + js, _, _, _ = _sim_constants() + sky = dict((k, float(v)) for k, v in re.findall( + r"(left|right|top|bottom): (-?[\d.]+)", js.split("var SKY = {")[1].split("}")[0])) + assert 0 <= sky["left"] - left <= 8 and 0 <= (left + width) - sky["right"] <= 8 + assert 0 <= sky["top"] - top <= 8 and 0 <= (top + height) - sky["bottom"] <= 8 + + +def test_an_aircraft_beyond_the_drawn_beam_is_not_heard(): + """The arc is in the picture now, so the reach it draws has to be real.""" + js, _, _, _ = _sim_constants() + in_beam = js.split("function inBeam(p) {")[1].split("\n }")[0] + assert "> BEAM_R) return false" in in_beam + assert "R = BEAM_R" in js.split("function drawBeam() {")[1].split("\n }")[0] + + +def test_the_plot_is_drawn_where_the_script_plots(app_client, fleet, telemetry): + """The box is written twice, in the markup and in PLOT.""" + sim = sim_section(app_client.get("/summary").data.decode()) + js, _, _, _ = _sim_constants() + x, w, mid, h = map(int, re.search( + r"var PLOT = \{ x: (\d+), w: (\d+), mid: (\d+), h: (\d+) \}", js).groups()) + assert f'60 km<' in sim + + +def test_the_still_diagram_starts_on_the_script_s_own_flight_path(app_client, fleet, telemetry): + """Or the path would jump the moment the script took over.""" + sim = sim_section(app_client.get("/summary").data.decode()) + js, _, _, _ = _sim_constants() + x0, dx, y0, dy = map(float, re.search( + r"x: ([\d.]+) \+ t \* ([\d.]+), y: ([\d.]+) - Math\.sin\(t \* Math\.PI\) \* ([\d.]+)", js).groups()) + drawn = [tuple(map(float, pair.split())) for pair in + re.split(r"\s*[ML]", re.search(r'id="fs-path"[^>]* d="([^"]+)"', sim).group(1))[1:]] + assert len(drawn) == 41 + for i, (x, y) in enumerate(drawn): + t = i / 40 + assert abs(x - (x0 + t * dx)) < 0.06 and abs(y - (y0 - math.sin(t * math.pi) * dy)) < 0.06, i + + +def test_the_two_panels_are_stacked_at_every_width(): + css = (PROJECT_ROOT / "static" / "common.css").read_text() + rules = re.findall(r"\.sim \{([^}]*)\}", css) + assert rules and all("1fr 1fr" not in rule for rule in rules) + + +def test_the_section_is_titled_for_what_it_does(app_client, fleet, telemetry): + body = app_client.get("/summary").data.decode() + assert '

Doppler Mapping Tool

' in body + assert "How your node sees" not in body + + +def test_the_knob_says_grab_to_aim_only_once_it_can_be_grabbed(app_client, fleet, telemetry): + """Like the controls: the label ships undrawn and the script reveals it, + so the still diagram never invites a drag that cannot happen.""" + sim = sim_section(app_client.get("/summary").data.decode()) + knob = sim.split('")[0] + label = re.search(r']*>([^<]*)', knob) + assert label.group(1) == "Grab to Aim" and 'display="none"' in label.group(0) + js = (PROJECT_ROOT / "static" / "flight-sim.js").read_text() + assert 'aimLabel.removeAttribute("display")' in js.split("First paint")[1] + + +def test_grabbing_the_knob_draws_no_focus_box(): + """The knob takes focus when grabbed; the browser's outline round it read + as a stray black box. Keyboard focus is shown on the ring instead.""" + css = (PROJECT_ROOT / "static" / "common.css").read_text() + assert ".sim-grab:focus { outline: none; }" in css + assert ".sim-grab:focus-visible" in css + + def test_the_controls_are_hidden_until_something_can_drive_them(app_client, fleet, telemetry): """A Play button that cannot play is worse than no Play button."""