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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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`
Expand Down
8 changes: 4 additions & 4 deletions docs/features/fleet-and-naming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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<node_id>.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 `<node_id>.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.

Expand Down Expand Up @@ -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.

Expand Down
18 changes: 12 additions & 6 deletions src/routes/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)

Expand Down
32 changes: 25 additions & 7 deletions static/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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. */
Expand Down Expand Up @@ -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; }
Expand All @@ -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; }
Expand All @@ -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; }
Expand Down
Loading
Loading