Public Access
Tab goes on from the Sky to a third view (issue #16): a rose that turns with the device, the heading, where north came from and how long ago, the sun on the rose, and a level. m marks where you stand as a Place, t picks one as the target: an arrowhead on the rose, its distance and bearing. The sensor's directions were checked by hand on the device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
580 lines
23 KiB
C++
580 lines
23 KiB
C++
#include "app_keys.h"
|
|
#include "gnss_app.h"
|
|
|
|
#include <Arduino.h>
|
|
#include <M5Unified.h>
|
|
#include <SD.h>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
|
|
#include "geo_format.h"
|
|
#include "track.h"
|
|
#include "ui/canvas.h"
|
|
#include "ui/fonts.h"
|
|
#include "ui/theme.h"
|
|
#include "ui/widgets.h"
|
|
|
|
namespace roro {
|
|
|
|
using namespace gnss;
|
|
|
|
namespace {
|
|
|
|
// One colour per constellation, from the theme: readable on its background.
|
|
uint16_t colorOf(Constellation c) {
|
|
switch (c) {
|
|
case Constellation::Gps: return theme::kAccent;
|
|
case Constellation::Glonass: return theme::kRed;
|
|
case Constellation::Galileo: return theme::kGood;
|
|
case Constellation::BeiDou: return theme::kYellow;
|
|
case Constellation::Qzss: return theme::kViolet;
|
|
default: return theme::kText;
|
|
}
|
|
}
|
|
|
|
const char* fixLabel(FixType f) { return f == FixType::ThreeD ? "3D Fix" : f == FixType::TwoD ? "2D Fix" : "No Fix"; }
|
|
|
|
std::string duration(uint32_t ms) {
|
|
uint32_t s = ms / 1000;
|
|
char buf[24];
|
|
if (s < 60) std::snprintf(buf, sizeof buf, "%lu s", (unsigned long)s);
|
|
else std::snprintf(buf, sizeof buf, "%lu min %02lu s", (unsigned long)(s / 60), (unsigned long)(s % 60));
|
|
return buf;
|
|
}
|
|
|
|
// How the IMU sits in the device, checked by hand (issue #16): which way the sensor's x and y run on the
|
|
// screen, for the level's bubble, which goes to the side that is higher.
|
|
constexpr float kLevelX = 1.0f, kLevelY = -1.0f;
|
|
constexpr uint32_t kImuEveryMs = 10;
|
|
constexpr uint32_t kCompassFrameMs = 50;
|
|
constexpr uint32_t kSayMs = 4000;
|
|
|
|
} // namespace
|
|
|
|
void GnssApp::onEnter() {
|
|
mode_ = Mode::View;
|
|
dialog_.reset();
|
|
heading_.forget(); // the turns made while the App was closed were not followed
|
|
imuUs_ = 0;
|
|
courseSecond_ = -1;
|
|
requestRedraw();
|
|
}
|
|
|
|
void GnssApp::say(const std::string& text) {
|
|
refusal_ = text;
|
|
refusalMs_ = millis();
|
|
requestRedraw();
|
|
}
|
|
|
|
bool GnssApp::onKey(const KeyEvent& e) {
|
|
requestRedraw();
|
|
if (mode_ != Mode::View) return placesKey(e);
|
|
if (e.key == Key::Tab) {
|
|
view_ = view_ == View::Position ? View::Sky : view_ == View::Sky ? View::Compass : View::Position;
|
|
if (view_ == View::Compass) loadPlaces();
|
|
return true;
|
|
}
|
|
if (e.key == Key::Char && (e.ch == 'r' || e.ch == 'R')) { // record a Track, or stop it
|
|
if (gnss_.tracking()) gnss_.stopTrack();
|
|
else say(gnss_.startTrack(millis()));
|
|
return true;
|
|
}
|
|
if (view_ == View::Compass && gnss_.on()) return compassKey(e);
|
|
return false; // Back leaves the App
|
|
}
|
|
|
|
bool GnssApp::compassKey(const KeyEvent& e) {
|
|
if (e.key != Key::Char) return false;
|
|
switch (e.ch) {
|
|
case 'm': case 'M': mark(); return true;
|
|
case 's': case 'S': aimAtSun(); return true;
|
|
case 't': case 'T':
|
|
loadPlaces();
|
|
list_.setCount(static_cast<int>(places_.size()) + 1);
|
|
list_.select(target_ + 1);
|
|
mode_ = Mode::Places;
|
|
return true;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
bool GnssApp::placesKey(const KeyEvent& e) {
|
|
if (dialog_) { // remove this Place?
|
|
dialog_->onKey(e);
|
|
int result = dialog_->result();
|
|
if (result == DialogModel::kPending) return true;
|
|
dialog_.reset();
|
|
int at = list_.selected() - 1;
|
|
if (result == 1 && at >= 0 && at < static_cast<int>(places_.size())) {
|
|
places_.erase(places_.begin() + at);
|
|
if (target_ == at) target_ = -1;
|
|
else if (target_ > at) target_--;
|
|
savePlaces();
|
|
list_.setCount(static_cast<int>(places_.size()) + 1);
|
|
}
|
|
return true;
|
|
}
|
|
if (mode_ == Mode::Name) {
|
|
switch (e.key) {
|
|
case Key::Char: name_.insert(e.ch); break;
|
|
case Key::Delete: name_.backspace(); break;
|
|
case Key::Left: name_.left(); break;
|
|
case Key::Right: name_.right(); break;
|
|
case Key::Back: mode_ = Mode::View; break;
|
|
case Key::Select: {
|
|
const GnssState& s = gnss_.state();
|
|
mode_ = Mode::View;
|
|
if (!s.positionValid) {
|
|
say("The Fix was lost");
|
|
break;
|
|
}
|
|
std::string name = name_.text();
|
|
if (name.empty()) name = "Place " + std::to_string(places_.size() + 1);
|
|
places_.push_back({name, s.latitude, s.longitude});
|
|
target_ = static_cast<int>(places_.size()) - 1;
|
|
savePlaces();
|
|
say("Marked");
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
switch (e.key) {
|
|
case Key::Up: list_.up(); break;
|
|
case Key::Down: list_.down(); break;
|
|
case Key::Back: mode_ = Mode::View; break;
|
|
case Key::Select:
|
|
target_ = list_.selected() - 1;
|
|
mode_ = Mode::View;
|
|
break;
|
|
case Key::Delete:
|
|
if (list_.selected() > 0) dialog_.reset(new DialogModel({"Cancel", "Remove"}));
|
|
break;
|
|
case Key::Char:
|
|
if ((e.ch == 'd' || e.ch == 'D') && list_.selected() > 0) dialog_.reset(new DialogModel({"Cancel", "Remove"}));
|
|
break;
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void GnssApp::mark() {
|
|
loadPlaces();
|
|
if (!gnss_.state().positionValid) return say("No Fix yet");
|
|
if (!storage_.state().present) return say("No SD card");
|
|
if (places_.size() >= kMaxPlaces) return say("Too many places");
|
|
name_.setText("");
|
|
mode_ = Mode::Name;
|
|
}
|
|
|
|
void GnssApp::aimAtSun() {
|
|
const GnssState& s = gnss_.state();
|
|
if (!s.positionValid || !s.timeValid) return say("No Fix yet");
|
|
SunPosition sun = sunPosition(s.utcSeconds(), s.latitude, s.longitude);
|
|
if (sun.elevationDeg < 0) return say("The sun is down");
|
|
heading_.sun(sun.azimuthDeg);
|
|
say("North from the sun");
|
|
}
|
|
|
|
// The Places are few and the file is small: read whole once, written whole at each change.
|
|
void GnssApp::loadPlaces() {
|
|
if (placesLoaded_ || !storage_.state().present) return;
|
|
std::string text;
|
|
bool ran = storage_.runAndWait([&]() {
|
|
File f = SD.open(kPlacesPath, FILE_READ);
|
|
if (!f) return;
|
|
size_t size = std::min<size_t>(f.size(), 16 * 1024);
|
|
text.resize(size);
|
|
text.resize(f.read(reinterpret_cast<uint8_t*>(&text[0]), size));
|
|
f.close();
|
|
});
|
|
if (!ran) return;
|
|
places_ = parsePlaces(text);
|
|
placesLoaded_ = true;
|
|
target_ = -1;
|
|
}
|
|
|
|
void GnssApp::savePlaces() {
|
|
std::string text = placesGpx(places_);
|
|
storage_.runJob([text]() {
|
|
if (!SD.exists("/gnss")) SD.mkdir("/gnss");
|
|
File f = SD.open(kPlacesPath, FILE_WRITE);
|
|
if (!f) return;
|
|
f.write(reinterpret_cast<const uint8_t*>(text.data()), text.size());
|
|
f.close();
|
|
});
|
|
}
|
|
|
|
void GnssApp::readImu() {
|
|
if (!M5.Imu.update()) return;
|
|
auto data = M5.Imu.getImuData();
|
|
uint32_t us = micros();
|
|
float dt = imuUs_ ? (us - imuUs_) / 1e6f : 0;
|
|
imuUs_ = us;
|
|
if (dt > 0.2f) dt = 0; // the loop was held up: a turn that long ago is not known
|
|
const float gyro[3] = {data.gyro.x, data.gyro.y, data.gyro.z};
|
|
const float accel[3] = {data.accel.x, data.accel.y, data.accel.z};
|
|
heading_.motion(gyro, accel, dt);
|
|
}
|
|
|
|
void GnssApp::update(uint32_t nowMs) {
|
|
// The heading is followed in every view, so the Compass is right when Tab comes back to it.
|
|
if (nowMs - imuMs_ >= kImuEveryMs) {
|
|
imuMs_ = nowMs;
|
|
readImu();
|
|
}
|
|
const GnssState& s = gnss_.state();
|
|
if (gnss_.on() && s.timeValid && s.second != courseSecond_) { // a new report from the receiver
|
|
courseSecond_ = s.second;
|
|
if (s.positionValid && s.courseValid) heading_.course(s.courseDeg, s.speedKmh);
|
|
}
|
|
if (view_ == View::Compass && mode_ == Mode::View && nowMs - lastDrawMs_ >= kCompassFrameMs) {
|
|
bool turned = heading_.known() && std::fabs(angleDiff(heading_.heading(), shownHeading_)) >= 1.0f;
|
|
bool tilted = std::fabs(heading_.tiltX() - shownTiltX_) > 0.01f || std::fabs(heading_.tiltY() - shownTiltY_) > 0.01f;
|
|
if (turned || tilted) {
|
|
lastDrawMs_ = nowMs;
|
|
requestRedraw();
|
|
}
|
|
}
|
|
if (nowMs - lastDrawMs_ >= 1000) { // the receiver reports once a second
|
|
lastDrawMs_ = nowMs;
|
|
requestRedraw();
|
|
}
|
|
}
|
|
|
|
void GnssApp::draw(Canvas& c) {
|
|
c.setTextDatum(top_left);
|
|
const auto& area = theme::kContent;
|
|
if (!gnss_.on()) {
|
|
c.setFont(&fonts::bold);
|
|
c.setTextColor(theme::kText);
|
|
bool held = gnss_.heldForLora();
|
|
c.drawString(held ? "GNSS is paused" : "GNSS is off", 4, area.y + 4);
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString(held ? "The LoRa radio is listening, and" : "On: Settings > GNSS and radio > GNSS", 4, area.y + 22);
|
|
if (held) c.drawString("Settings pauses GNSS for it.", 4, area.y + 22 + theme::kLineHeight);
|
|
return;
|
|
}
|
|
if (mode_ != Mode::View) return drawPlaces(c);
|
|
switch (view_) {
|
|
case View::Position: drawPosition(c); break;
|
|
case View::Sky: drawSky(c); break;
|
|
case View::Compass: drawCompass(c); break;
|
|
}
|
|
}
|
|
|
|
void GnssApp::help(std::vector<KeyHelp>& out) const {
|
|
if (dialog_) return keys::add(out, keys::kDialog);
|
|
if (mode_ == Mode::Name) return keys::add(out, keys::kText);
|
|
if (mode_ == Mode::Places) return keys::add(out, keys::kGnssPlaces);
|
|
if (view_ == View::Compass) return keys::add(out, keys::kGnssCompass);
|
|
keys::add(out, keys::kGnss);
|
|
}
|
|
|
|
const char* GnssApp::helpTitle() const {
|
|
if (mode_ == Mode::Name) return "GNSS: a new place";
|
|
if (mode_ == Mode::Places) return "GNSS: the places";
|
|
return view_ == View::Compass ? "GNSS: the compass" : nullptr;
|
|
}
|
|
|
|
void GnssApp::drawPosition(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
const GnssState& s = gnss_.state();
|
|
uint32_t now = millis();
|
|
int used = 0;
|
|
for (auto& sat : s.satellites) used += sat.used;
|
|
char line[64];
|
|
|
|
c.setFont(&fonts::bold);
|
|
c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kGood);
|
|
if (!gnss_.receiving(now)) std::snprintf(line, sizeof line, "No data from the receiver");
|
|
else if (s.fix == FixType::None)
|
|
std::snprintf(line, sizeof line, "Searching: %u in view, %s", (unsigned)s.satellites.size(),
|
|
duration(now - gnss_.onSinceMs()).c_str());
|
|
else std::snprintf(line, sizeof line, "%s, %d of %u satellites", fixLabel(s.fix), s.satellitesUsed,
|
|
(unsigned)s.satellites.size());
|
|
c.drawString(line, 4, area.y + 2);
|
|
|
|
c.setFont(&fonts::body);
|
|
int y = area.y + 19;
|
|
auto row = [&](const char* label, const std::string& value) {
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString(label, 4, y);
|
|
c.setTextColor(theme::kText);
|
|
c.drawString(value.c_str(), 64, y);
|
|
y += theme::kLineHeight;
|
|
};
|
|
bool dms = settings_.getBool(Setting::CoordinatesDms);
|
|
if (s.positionValid) {
|
|
row("Lat", dms ? formatDms(s.latitude, true) : formatDecimal(s.latitude, true));
|
|
row("Lon", dms ? formatDms(s.longitude, false) : formatDecimal(s.longitude, false));
|
|
row("Locator", maidenhead(s.latitude, s.longitude));
|
|
std::snprintf(line, sizeof line, s.altitudeValid ? "%.0f m" : "-", s.altitudeM);
|
|
row("Altitude", line);
|
|
if (s.courseValid && s.speedKmh >= 1.0f)
|
|
std::snprintf(line, sizeof line, "%.1f km/h, %.0f\xC2\xB0", s.speedKmh, s.courseDeg);
|
|
else std::snprintf(line, sizeof line, "%.1f km/h", s.speedKmh);
|
|
row("Speed", line);
|
|
} else {
|
|
row("Lat", "-");
|
|
row("Lon", "-");
|
|
y += theme::kLineHeight * 3;
|
|
}
|
|
std::snprintf(line, sizeof line, "%.1f", s.hdop);
|
|
row("HDOP", s.fix == FixType::None ? "-" : line);
|
|
if (s.timeValid) std::snprintf(line, sizeof line, "%02d:%02d:%02d UTC", s.hour, s.minute, s.second);
|
|
row("Time", s.timeValid ? line : "-");
|
|
|
|
// The Track line, at the bottom left.
|
|
c.setFont(&fonts::small);
|
|
if (gnss_.tracking()) {
|
|
c.setTextColor(theme::kWarning);
|
|
std::snprintf(line, sizeof line, "REC %d point%s, %s", gnss_.trackPoints(),
|
|
gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str());
|
|
} else if (!refusal_.empty() && now - refusalMs_ < kSayMs) {
|
|
c.setTextColor(theme::kWarning);
|
|
std::snprintf(line, sizeof line, "%s", refusal_.c_str());
|
|
} else line[0] = 0;
|
|
c.drawString(line, 4, area.y + area.h - 9);
|
|
}
|
|
|
|
void GnssApp::drawSky(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
const GnssState& s = gnss_.state();
|
|
const int radius = (area.h - 6) / 2, cx = 4 + radius, cy = area.y + area.h / 2;
|
|
|
|
// The sky from above: horizon, 30° and 60° elevation rings, north up.
|
|
c.drawCircle(cx, cy, radius, theme::kMuted);
|
|
c.drawCircle(cx, cy, radius * 2 / 3, theme::kFaint);
|
|
c.drawCircle(cx, cy, radius / 3, theme::kFaint);
|
|
c.drawFastHLine(cx - radius, cy, 2 * radius, theme::kFaint);
|
|
c.drawFastVLine(cx, cy - radius, 2 * radius, theme::kFaint);
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.setTextDatum(middle_center);
|
|
auto compass = [&](const char* letter, int x, int y) {
|
|
c.fillRect(x - 3, y - 4, 7, 9, theme::kBackground); // off the lines, so it reads
|
|
c.drawString(letter, x, y);
|
|
};
|
|
compass("N", cx, cy - radius + 5);
|
|
compass("S", cx, cy + radius - 4);
|
|
compass("E", cx + radius - 4, cy);
|
|
compass("W", cx - radius + 4, cy);
|
|
c.setTextDatum(top_left);
|
|
|
|
int inView[8] = {}, usedBy[8] = {};
|
|
for (auto& sat : s.satellites) {
|
|
int k = static_cast<int>(sat.system);
|
|
inView[k]++;
|
|
usedBy[k] += sat.used;
|
|
SkyPoint p = skyPosition(sat.azimuth, sat.elevation, cx, cy, radius - 3);
|
|
uint16_t color = colorOf(sat.system);
|
|
if (sat.used) c.fillCircle(p.x, p.y, 3, color);
|
|
else c.drawCircle(p.x, p.y, 3, sat.snr < 0 ? theme::kMuted : color);
|
|
}
|
|
|
|
// Legend: used / in view per constellation, then the Fix.
|
|
int x = cx + radius + 12, y = area.y + 4;
|
|
c.setFont(&fonts::body);
|
|
for (int k = 1; k < 7; k++) {
|
|
if (!inView[k]) continue;
|
|
auto system = static_cast<Constellation>(k);
|
|
c.fillCircle(x + 3, y + 6, 3, colorOf(system));
|
|
char line[32];
|
|
std::snprintf(line, sizeof line, "%-8s%d/%d", constellationName(system), usedBy[k], inView[k]);
|
|
c.setTextColor(theme::kText);
|
|
c.drawString(line, x + 10, y);
|
|
y += theme::kLineHeight;
|
|
}
|
|
if (s.satellites.empty()) {
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("No satellites", x, y);
|
|
y += theme::kLineHeight;
|
|
}
|
|
c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kGood);
|
|
c.drawString(fixLabel(s.fix), x, y + 4);
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("filled: in use", x, area.y + area.h - 20);
|
|
}
|
|
|
|
void GnssApp::drawCompass(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
const GnssState& s = gnss_.state();
|
|
const int radius = 54, cx = 4 + radius + 2, cy = area.y + area.h / 2 + 2;
|
|
bool known = heading_.known();
|
|
float heading = known ? heading_.heading() : 0; // north up until north is known
|
|
shownHeading_ = heading;
|
|
shownTiltX_ = heading_.tiltX();
|
|
shownTiltY_ = heading_.tiltY();
|
|
|
|
// A point of the rose: `bearing` from north, `r` from the centre. The top of the screen is
|
|
// where the top of the device points.
|
|
auto at = [&](float bearing, float r, int& x, int& y) {
|
|
float a = (bearing - heading) * static_cast<float>(M_PI) / 180.0f;
|
|
x = cx + static_cast<int>(std::lround(r * std::sin(a)));
|
|
y = cy - static_cast<int>(std::lround(r * std::cos(a)));
|
|
};
|
|
c.drawCircle(cx, cy, radius, known ? theme::kMuted : theme::kFaint);
|
|
for (int b = 0; b < 360; b += 10) {
|
|
int x0, y0, x1, y1;
|
|
bool major = b % 30 == 0;
|
|
at(b, radius - (major ? 6 : 3), x0, y0);
|
|
at(b, radius - 1, x1, y1);
|
|
c.drawLine(x0, y0, x1, y1, major && known ? theme::kMuted : theme::kFaint);
|
|
}
|
|
if (known) { // the needle: its bright half to the north
|
|
int nx, ny, sx, sy, lx, ly, rx, ry;
|
|
at(0, radius - 24, nx, ny);
|
|
at(180, radius - 24, sx, sy);
|
|
at(90, 5, rx, ry);
|
|
at(270, 5, lx, ly);
|
|
c.fillTriangle(sx, sy, lx, ly, rx, ry, theme::kFaint);
|
|
c.fillTriangle(nx, ny, lx, ly, rx, ry, theme::kRed);
|
|
}
|
|
c.setTextDatum(middle_center);
|
|
static const char* const kLetters[4] = {"N", "E", "S", "W"};
|
|
for (int i = 0; i < 4; i++) {
|
|
int x, y;
|
|
at(i * 90.0f, radius - 14, x, y);
|
|
c.setFont(i == 0 ? &fonts::bold : &fonts::body);
|
|
c.setTextColor(!known ? theme::kFaint : i == 0 ? theme::kRed : theme::kText);
|
|
c.drawString(kLetters[i], x, y);
|
|
}
|
|
|
|
bool placed = s.positionValid;
|
|
if (known && placed && s.timeValid) { // the sun, where it is now: something to check north against
|
|
SunPosition sun = sunPosition(s.utcSeconds(), s.latitude, s.longitude);
|
|
if (sun.elevationDeg > 0) {
|
|
int x, y;
|
|
at(sun.azimuthDeg, radius - 4, x, y);
|
|
c.fillCircle(x, y, 3, theme::kYellow);
|
|
}
|
|
}
|
|
const Place* target = target_ >= 0 && target_ < static_cast<int>(places_.size()) ? &places_[static_cast<size_t>(target_)] : nullptr;
|
|
double bearing = 0, distance = 0;
|
|
if (target && placed) {
|
|
bearing = bearingDegrees(s.latitude, s.longitude, target->latitude, target->longitude);
|
|
distance = distanceMeters(s.latitude, s.longitude, target->latitude, target->longitude);
|
|
if (known) { // an arrowhead on the rim, the way to go
|
|
int tx, ty, ax, ay, bx, by;
|
|
at(static_cast<float>(bearing), radius + 1, tx, ty);
|
|
at(static_cast<float>(bearing) - 7, radius - 12, ax, ay);
|
|
at(static_cast<float>(bearing) + 7, radius - 12, bx, by);
|
|
c.fillTriangle(tx, ty, ax, ay, bx, by, theme::kAccent);
|
|
}
|
|
}
|
|
// The mark the rose is read against: the top of the device.
|
|
c.fillTriangle(cx, cy - radius + 2, cx - 4, cy - radius - 5, cx + 4, cy - radius - 5, theme::kText);
|
|
|
|
// The readouts, to the right.
|
|
const int x = cx + radius + 10;
|
|
char line[48];
|
|
c.setTextDatum(top_left);
|
|
c.setFont(&fonts::large);
|
|
c.setTextColor(known ? theme::kText : theme::kMuted);
|
|
if (known) std::snprintf(line, sizeof line, "%03d\xC2\xB0 %s", static_cast<int>(std::lround(heading)) % 360, cardinal(heading));
|
|
else std::snprintf(line, sizeof line, "---\xC2\xB0");
|
|
c.drawString(line, x, area.y + 3);
|
|
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
if (!known) {
|
|
c.drawString("North is not known:", x, area.y + 22);
|
|
c.drawString("walk, or aim at the", x, area.y + 31);
|
|
c.drawString("sun and press s", x, area.y + 40);
|
|
} else {
|
|
const char* from = heading_.source() == HeadingFilter::Source::Sun ? "the sun" : "the course";
|
|
int held = static_cast<int>(heading_.heldSeconds());
|
|
if (held < 3) std::snprintf(line, sizeof line, "from %s", from);
|
|
else if (held < 120) std::snprintf(line, sizeof line, "%s, %d s ago", from, held);
|
|
else std::snprintf(line, sizeof line, "%s, %d min ago", from, held / 60);
|
|
// The gyroscope drifts: after a few minutes without north, say it is a guess.
|
|
c.setTextColor(held >= 180 ? theme::kWarning : theme::kMuted);
|
|
c.drawString(line, x, area.y + 22);
|
|
c.setTextColor(theme::kMuted);
|
|
if (placed) {
|
|
std::snprintf(line, sizeof line, "%.1f km/h", s.speedKmh);
|
|
c.drawString(line, x, area.y + 31);
|
|
}
|
|
}
|
|
|
|
c.setFont(&fonts::bold);
|
|
int y = area.y + 54;
|
|
if (target) {
|
|
c.setTextColor(theme::kAccent);
|
|
c.drawString(target->name.c_str(), x, y);
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(theme::kText);
|
|
if (placed) {
|
|
std::snprintf(line, sizeof line, "%s %03d\xC2\xB0", formatDistance(distance).c_str(), static_cast<int>(std::lround(bearing)) % 360);
|
|
c.drawString(line, x, y + theme::kLineHeight);
|
|
} else c.drawString("No Fix", x, y + theme::kLineHeight);
|
|
} else {
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("No target: t", x, y);
|
|
c.drawString("m marks this place", x, y + 9);
|
|
}
|
|
|
|
// The level: the bubble goes to the higher side, and is in the middle when the device is flat.
|
|
const int lr = 17, bubble = 5, lx = x + lr, ly = area.y + area.h - lr - 3;
|
|
float bx = kLevelX * heading_.tiltX(), by = kLevelY * heading_.tiltY();
|
|
bool flat = std::sqrt(bx * bx + by * by) < 0.035f; // within 2 degrees
|
|
const float kFull = 0.26f; // 15 degrees puts the bubble at the edge
|
|
float reach = std::sqrt(bx * bx + by * by) / kFull;
|
|
if (reach > 1.0f) {
|
|
bx /= reach;
|
|
by /= reach;
|
|
}
|
|
c.drawCircle(lx, ly, lr, theme::kMuted);
|
|
c.drawCircle(lx, ly, bubble + 2, theme::kFaint);
|
|
const float travel = lr - bubble - 1;
|
|
c.fillCircle(lx + static_cast<int>(std::lround(bx / kFull * travel)), ly + static_cast<int>(std::lround(by / kFull * travel)), bubble,
|
|
flat ? theme::kGood : theme::kText);
|
|
c.setFont(&fonts::small);
|
|
if (!refusal_.empty() && millis() - refusalMs_ < kSayMs) {
|
|
c.setTextColor(theme::kWarning);
|
|
c.drawString(refusal_.c_str(), lx + lr + 5, ly - 3);
|
|
} else {
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString(flat ? "flat" : "tilted", lx + lr + 5, ly - 3);
|
|
}
|
|
}
|
|
|
|
void GnssApp::drawPlaces(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
const GnssState& s = gnss_.state();
|
|
c.setFont(&fonts::bold);
|
|
c.setTextColor(theme::kText);
|
|
if (mode_ == Mode::Name) {
|
|
c.drawString("A name for this place", 4, area.y + 4);
|
|
widgets::lineEditor(c, name_, {4, area.y + 24, area.w - 8, 0});
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("The Compass will lead back to it.", 4, area.y + 46);
|
|
c.drawString("Left empty, it gets a number.", 4, area.y + 46 + theme::kLineHeight);
|
|
return;
|
|
}
|
|
c.drawString("Where to?", 4, area.y + 2);
|
|
theme::Rect rows{0, area.y + 18, area.w, list_.visibleRows() * theme::kLineHeight};
|
|
widgets::list(
|
|
c, list_, rows,
|
|
[&](int i) { return i == 0 ? std::string("No target") : places_[static_cast<size_t>(i - 1)].name; },
|
|
[&](int i) {
|
|
if (i == 0 || !s.positionValid) return std::string();
|
|
const Place& p = places_[static_cast<size_t>(i - 1)];
|
|
return formatDistance(distanceMeters(s.latitude, s.longitude, p.latitude, p.longitude));
|
|
});
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString(places_.empty() ? "No place yet: m in the compass marks one" : "Enter: go there d: remove it", 4, area.y + area.h - 9);
|
|
if (dialog_ && list_.selected() > 0) widgets::dialog(c, "Remove this place?", places_[static_cast<size_t>(list_.selected() - 1)].name, *dialog_);
|
|
}
|
|
|
|
} // namespace roro
|