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
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.bootstrap.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ $bootstrap-icons-font-dir: '/fonts';
@import 'components/stage-badge';
@import 'components/terminal';
@import 'components/logo-wall';
@import 'components/latency-bars';
@import 'components/browser-frame';
@import 'pages/home';
@import 'pages/hardware';
Expand Down
108 changes: 108 additions & 0 deletions app/assets/stylesheets/components/_latency-bars.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// The latency comparison on /low-latency.
//
// One measure -- glass-to-glass -- across four receive paths, so this is a
// single hue on a shared scale rather than four categorical colours: the
// reader is comparing magnitude, not telling series apart. Each bar floats
// from the lowest to the highest figure users report for that path, because
// the spread is the honest part; collapsing each path to one number is exactly
// how the 2022 table came to promise something nobody could reach.
//
// The track sits far below 3:1 against the page on purpose. That is allowed
// here because it carries no value of its own -- every bar is labelled in text
// beside it, and the track is hidden from screen readers.
//
// Every cell is placed explicitly rather than left to auto-flow. The markup
// order is label, value, track so that the narrow layout can put the label and
// its number on one line with the bar underneath, without needing a second
// copy of the markup.
.latency-bars {
display: grid;
// The label column is capped rather than sized to its content: Russian
// labels are half again as long as the English ones and were eating the
// track, which is the part carrying the comparison. Past the cap they wrap.
grid-template-columns: minmax(7rem, 13rem) minmax(9rem, 1fr) max-content;
// Dense, because the markup runs label, value, track while the wide layout
// draws them label, track, value. Sparse packing never backfills the hole
// the value leaves behind, so the track dropped to a row of its own and the
// wide layout silently rendered as the narrow one.
grid-auto-flow: dense;
align-items: center;
column-gap: 1rem;
row-gap: .625rem;
}

.latency-bars__label {
grid-column: 1;
font-size: .9375rem;
line-height: 1.25;
}

.latency-bars__track {
grid-column: 2;
position: relative;
height: .5rem;
border-radius: .25rem;
background: rgba(var(--bs-primary-rgb), .13);
}

.latency-bars__range {
position: absolute;
top: 0;
bottom: 0;
min-width: .5rem;
border-radius: .25rem;
background: var(--bs-primary);
}

.latency-bars__value {
grid-column: 3;
font-weight: 600;
white-space: nowrap;
}

// Ticks sit under the track only, so they line up with the scale rather than
// with the whole row. Each is placed at its own fraction of the track and
// pulled back by half its width: `justify-content: space-between` would only
// centre the middle tick if all three labels were the same width, and "0" and
// "120" are not, which drags the midpoint visibly off centre.
.latency-bars__axis {
grid-column: 2;
position: relative;
height: 1.1rem;
font-size: .75rem;
color: var(--bs-secondary-color);

span { position: absolute; }
span:nth-child(1) { left: 0; }
span:nth-child(2) { left: 50%; transform: translateX(-50%); }
span:nth-child(3) { right: 0; }
}

.latency-bars__unit {
grid-column: 3;
white-space: nowrap;
}

// Narrow screens: the name and its number share a line, the bar gets the full
// width underneath. Three columns at this width left the bar too short to read
// position off, which is the only thing the bar is for.
@include media-breakpoint-down(md) {
.latency-bars {
grid-template-columns: 1fr max-content;
row-gap: .375rem;
}

.latency-bars__value { grid-column: 2; }

.latency-bars__track,
.latency-bars__axis {
grid-column: 1 / -1;
}

.latency-bars__track { margin-bottom: .75rem; }

.latency-bars__unit {
grid-column: 1 / -1;
margin-top: -.25rem;
}
}
4 changes: 3 additions & 1 deletion app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ def home
@meta_description = t('site.default_meta_description')
@wall_snapshots = Snapshot.latest_per_camera(limit: 5)
@soc_count = Soc.count
@vendor_names = Vendor.order(:name).pluck(:name)
# soc_vendors, not every Vendor: the table also holds sensor makers, and
# counting them as silicon we run on overstates the list.
@vendor_names = Vendor.soc_vendors.order(:name).pluck(:name)
render 'pages/home'
end

Expand Down
32 changes: 32 additions & 0 deletions app/helpers/pages_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,38 @@ module PagesHelper
# { name: 'GAINS', url: 'https://gains.company/', img: 'partners/gain_mini.png' }
].freeze

# The latency comparison on /low-latency.
#
# The table this replaces was unchanged 2022 announcement copy, keyed on
# resolution -- which is very nearly free. A 2026 audit of the OpenIPC and
# wfb-ng chat archives found those figures optimistic by 40-160 ms at the
# exact configurations they named, and simultaneously understating the floor
# by half. What actually decides the number is the receive path, so that is
# what these four bars compare.
#
# low and high are the lowest and highest figures users report for each path,
# not an average: collapsing a path to one number is how the old table came to
# promise something nobody could reach. The audit itself carries the
# per-report detail, which is a wiki subject rather than a landing-page one.
#
# Scale is fixed rather than derived from the data so the bars stay comparable
# if a figure changes.
LATENCY_SCALE_MAX = 120

LATENCY_PATHS = [
{ key: :ground_station, low: 26, high: 67 },
{ key: :goggles, low: 45, high: 65 },
{ key: :phone, low: 50, high: 100 },
{ key: :desktop, low: 60, high: 100 }
].freeze

# Percentage offsets for one bar, against the fixed scale above.
def latency_bar_style(path)
left = path[:low] * 100.0 / LATENCY_SCALE_MAX
width = (path[:high] - path[:low]) * 100.0 / LATENCY_SCALE_MAX
"left: #{left.round(1)}%; width: #{width.round(1)}%"
end

def page_title
[@page_title, 'OpenIPC'].join(' - ')
end
Expand Down
10 changes: 8 additions & 2 deletions app/models/snapshot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ class TooSoon < StandardError
# limit is interpolated after to_i, not bound, because it lands in a LIMIT
# clause where a bind parameter is not accepted; to_i is what makes that safe.
def self.latest_per_camera(limit: nil)
# The tie-break on id matters: two rows for one camera can share a
# created_at, and comparing timestamps alone then calls both of them the
# latest. On the homepage, where the result is cut to five, that spent two
# of the five tiles on one camera.
sql = 'SELECT s1.* FROM snapshots s1 LEFT JOIN snapshots s2' \
' ON (s1.mac_address = s2.mac_address AND s1.created_at < s2.created_at)' \
' ON (s1.mac_address = s2.mac_address' \
' AND (s1.created_at < s2.created_at' \
' OR (s1.created_at = s2.created_at AND s1.id < s2.id)))' \
' WHERE s2.id IS NULL AND s1.created_at > SUBDATE(NOW(), INTERVAL 1 DAY)' \
' ORDER BY created_at DESC'
' ORDER BY created_at DESC, id DESC'
sql += " LIMIT #{limit.to_i}" if limit
find_by_sql(sql)
end
Expand Down
7 changes: 5 additions & 2 deletions app/views/pages/ecosystem.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@
{
title: t('.section_lowlat_title'), text: t('.section_lowlat_text_html'),
projects: [
# In chain order -- camera, air, ground -- because that is how the three
# read together. devourer is not R&D: it is what PixelPilot receives
# through on Android today, across five Realtek hardware backends.
{ name: 'waybeam_venc', desc: t('.proj_waybeam'), repo: "#{gh}/waybeam_venc", stage: 'done' },
{ name: 'devourer', desc: t('.proj_devourer'), repo: "#{gh}/devourer", stage: 'done' },
{ name: 'PixelPilot_rk', desc: t('.proj_pixelpilot'), repo: "#{gh}/PixelPilot_rk", stage: 'done' },
{ name: 'aviateur', desc: t('.proj_aviateur'), repo: "#{gh}/aviateur", stage: 'done' },
{ name: 'telemetry', desc: t('.proj_telemetry'), repo: "#{gh}/telemetry", stage: 'done' },
{ name: 'devourer', desc: t('.proj_devourer'), repo: "#{gh}/devourer", stage: 'rnd' }
{ name: 'telemetry', desc: t('.proj_telemetry'), repo: "#{gh}/telemetry", stage: 'done' }
]
},
{
Expand Down
32 changes: 17 additions & 15 deletions app/views/pages/get_started.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,28 @@

<div class="container">
<article>
<%# Three steps %>
<%# Three steps.

The terminal is step 1's command, so it sits directly after step 1 in
the markup and a narrow screen reads 1, command, 2, 3. order-lg-last
moves it to the end of the row on a desktop, where the three steps are
side by side and the command wants the full width beneath them.

It used to live inside the step-1 column, where the ipctool URL is
longer than a third of the article is wide: the visible line ended at
"https://github.com/OpenI" and the rest sat behind a horizontal
scrollbar most systems do not draw. It wraps rather than scrolls now,
so nothing is ever hidden -- but at full width it does not have to wrap
at all, and two wrapped lines read as two commands. %>
<div class="row g-4 mb-5">
<div class="col-lg-4">
<h2 class="h5"><span class="text-data text-primary me-2">1</span><%= t('.step1_title') %></h2>
<p class="text-body-secondary"><%= t('.step1_text_html') %></p>
</div>
<div class="col-12 order-lg-last">
<%= render 'shared/terminal', id: 'ipctool-cmd', title: 'camera shell',
code: "curl -L -o /tmp/ipctool https://github.com/OpenIPC/ipctool/releases/download/latest/ipctool\nchmod +x /tmp/ipctool && /tmp/ipctool" %>
</div>
<div class="col-lg-4">
<h2 class="h5"><span class="text-data text-primary me-2">2</span><%= t('.step2_title') %></h2>
<p class="text-body-secondary"><%= t('.step2_text') %></p>
Expand All @@ -21,20 +37,6 @@
</div>
</div>

<%# The command for step 1, given the full width of the article rather than
the third of it the step sits in.

It was inside the step-1 column, where the ipctool URL is longer than
the column is wide: the visible line ended at "https://github.com/OpenI"
and the rest sat behind a horizontal scrollbar most systems do not draw.
The block wraps rather than scrolls now, so nothing is ever hidden -- but
at this width the command does not have to wrap at all on a desktop, and
two lines that are two commands read as two commands. %>
<div class="mb-5">
<%= render 'shared/terminal', id: 'ipctool-cmd', title: 'camera shell',
code: "curl -L -o /tmp/ipctool https://github.com/OpenIPC/ipctool/releases/download/latest/ipctool\nchmod +x /tmp/ipctool && /tmp/ipctool" %>
</div>

<%# Honesty box %>
<div class="alert alert-warning d-flex gap-3 mb-5">
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
Expand Down
8 changes: 7 additions & 1 deletion app/views/pages/home.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@
<a href="<%= snapshot_path(snapshot) %>" class="wall-tile">
<%= image_tag snapshot.file.variant(:thumb), alt: t('.wall_snapshot_alt'),
loading: (idx.zero? ? 'eager' : 'lazy') %>
<span class="wall-caption"><%= snapshot.soc.upcase %> · <%= snapshot.sensor.upcase %></span>
<%# soc and sensor are whatever the camera chose to send: both are
nullable, and the upload endpoint permits either to be absent.
One such upload used to take the whole homepage down with it. %>
<% caption = [snapshot.soc, snapshot.sensor].reject(&:blank?).map(&:upcase).join(' · ') %>
<% if caption.present? %>
<span class="wall-caption"><%= caption %></span>
<% end %>
</a>
<% end %>
<% (5 - @wall_snapshots.size).times do %>
Expand Down
54 changes: 35 additions & 19 deletions app/views/pages/low_latency.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
</div>
</div>
</div>
<p class="text-center text-body-secondary text-data small mb-5">TX&nbsp;&rarr;&nbsp;wfb-ng&nbsp;&rarr;&nbsp;RX</p>
<p class="text-center text-body-secondary text-data small mb-5">TX&nbsp;&rarr;&nbsp;devourer&nbsp;&rarr;&nbsp;RX</p>

<%# FPV %>
<div class="row g-5 mb-5" id="fpv">
Expand All @@ -49,25 +49,41 @@
</div>
</div>

<%# Latency, honestly %>
<h2 class="h4"><%= t('.latency_title') %></h2>
<p class="text-body-secondary"><%= t('.latency_intro') %></p>
<div class="table-responsive mb-2" style="max-width: 36rem">
<table class="table">
<thead>
<tr>
<th><%= t('.latency_column_config') %></th>
<th class="text-end"><%= t('.latency_column_g2g') %></th>
</tr>
</thead>
<tbody class="text-data">
<tr><td><%= t('.latency_720p60') %></td><td class="text-end">~60 ms</td></tr>
<tr><td><%= t('.latency_1080p60') %></td><td class="text-end">~80 ms</td></tr>
<tr><td><%= t('.latency_1080p30') %></td><td class="text-end">~100 ms</td></tr>
</tbody>
</table>
<%# How fast, really.

One measure across four receive paths, so the bars are one hue on a
shared scale rather than four colours: the reader is comparing
magnitude, not telling series apart. Each bar spans the lowest and
highest figures people report for that path -- see
PagesHelper::LATENCY_PATHS for why it is a range and not a single
number. The track is decoration for the value beside it, so it is
hidden from screen readers. %>
<div class="row g-4 g-lg-5 align-items-center mb-5">
<div class="col-lg-5">
<h2 class="h4"><%= t('.latency_title') %></h2>
<p class="text-body-secondary"><%= t('.latency_intro') %></p>
<p class="small text-body-secondary mb-0"><%= t('.latency_note') %></p>
</div>
<div class="col-lg-7">
<div class="latency-bars">
<% PagesHelper::LATENCY_PATHS.each do |path| %>
<span class="latency-bars__label"><%= t(".latency_path_#{path[:key]}") %></span>
<%# The unit is carried per value as well as once under the axis. A
screen reader reaches the numbers one at a time and would
otherwise hear "26 to 67" with nothing saying what of. %>
<span class="latency-bars__value text-data"><%= "#{path[:low]}–#{path[:high]}" %><span class="visually-hidden"> <%= t('.latency_axis_unit') %></span></span>
<span class="latency-bars__track" aria-hidden="true">
<span class="latency-bars__range" style="<%= latency_bar_style(path) %>"></span>
</span>
<% end %>
<span class="latency-bars__axis" aria-hidden="true">
<span>0</span><span><%= PagesHelper::LATENCY_SCALE_MAX / 2 %></span>
<span><%= PagesHelper::LATENCY_SCALE_MAX %></span>
</span>
<span class="latency-bars__unit small text-body-secondary" aria-hidden="true"><%= t('.latency_axis_unit') %></span>
</div>
</div>
</div>
<p class="small text-body-secondary mb-5"><i class="bi bi-stopwatch me-1"></i><%= t('.latency_note') %></p>

<%# Hardware + credits %>
<div class="row g-4">
Expand Down
10 changes: 8 additions & 2 deletions app/views/snapshots/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@
<p><%=t('.subtitle') %></p>
<%# The wall is in the navigation and the footer now, so it gets visitors who
have not seen it before and cannot tell from a grid of stills whether
these were collected or volunteered. Two days is the retention
PurgeImagesJob::RETENTION actually enforces. %>
these were collected or volunteered.

The copy says "a couple of days" rather than naming two, and does not
claim the uploads are verified. PurgeImagesJob::RETENTION is two days,
but it is a cutoff for a sweep that runs once a night, so an image can
outlive it by most of a day; and the upload endpoint authenticates
nobody, so we cannot promise every image came from a camera whose owner
opted in. Both were promised here before, and neither was true. %>
<p class="text-body-secondary small" style="max-width: 70ch"><i class="bi bi-camera-video me-1"></i><%= t('.intro_html') %></p>
</header>

Expand Down
2 changes: 1 addition & 1 deletion config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ en:
icon:
snapshot_alt: 'Image: Snapshot'
index:
intro_html: Every image here was uploaded voluntarily by a camera running OpenIPC firmware, and is deleted automatically after two days. Want to join? Enable the Open Wall option in your camera's web interface.
intro_html: Cameras send these themselves — nobody goes out and collects them. Each image is removed a couple of days after it arrives. Want yours here? Turn on the Open Wall option in your camera's web interface.
no_signal: No signal
snapshot_alt: 'Image: Snapshot'
stay_tuned: stay tuned
Expand Down
Loading
Loading