From 117b7513bc474d845d66f0f149a1ab260c2b8435 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:25:08 +0200 Subject: [PATCH] feat(ui-lvgl): vector map route stripes and point labels Hiking routes are stored per stretch with the waymark colours of every route along it and drawn as side-by-side stripes on a white band (half the route ways in the Tatra sample carry two or more routes), less simplified; demanding / alpine paths are dotted. Named points (places, peaks with height, huts, passes, springs, viewpoints, caves) come in small .vp files and are placed by priority as a layer over the tiles, clear of each other and of the map controls. Co-Authored-By: Claude Opus 5.5 --- docs/development/l2-roadmap.md | 10 +- examples/companion_radio/ui-lvgl/MapScreen.h | 7 + .../ui-lvgl/map/VectorLabels.h | 302 ++++++++++++++++++ .../ui-lvgl/map/VectorTileProvider.h | 147 +++++++-- tools/maps/osm_vector.py | 195 ++++++++++- 5 files changed, 614 insertions(+), 47 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/map/VectorLabels.h diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index 98aeee2c..a488c720 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -259,9 +259,13 @@ Ideas to come back to (2026-09-26): scanline polygons + thick lines drawn into the 256 px tile; behind Map tools > Vector map (test), raster where no vector data. Measured on the L2: 8-60 ms drawing, SD read ~30 ms per new data file (cached after). -- [ ] Vector maps next: delta / varint coordinates (smaller files, faster - read), labels and POIs (peaks, shelters, springs), contours, a PBF - pipeline for regions + download on the device, styling. +- [x] Vector maps: VT3 (delta / varint points, ~half the size); hiking + routes per stretch, the routes sharing it as side-by-side stripes on a + white band, less simplified; demanding / alpine paths dotted; labels of + named points (places, peaks with height, huts, passes, springs...) as + a layer over the tiles, placed by priority. No street names (raster). +- [ ] Vector maps next: contours, a PBF pipeline for regions + download on + the device, styling. User list, second batch (2026-09-26): diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index ceb3823e..43e64915 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -17,6 +17,7 @@ #include #include "map/TileProvider.h" #include "map/VectorTileProvider.h" +#include "map/VectorLabels.h" #include "map/TileCache.h" #include "map/LiveCache.h" #include "map/TileDownloader.h" @@ -313,6 +314,8 @@ void UITask::buildMap() { lv_image_set_pivot(_map_tiles[i], 0, 0); // magnify from the top-left (overzoom) } + mapview::labels::buildLayer(body); // vector map names, over the tiles, under the markers + // Marker layer: same size as the map, lets presses through to it. _map_marks = lv_obj_create(body); lv_obj_remove_style_all(_map_marks); @@ -467,6 +470,9 @@ void UITask::layoutMap() { } } + mapview::labels::layout(left, top, w, h, _map_z, have_provider && mapview::s_provider == &mapview::s_vector && mapview::s_vector.hasData(), + _map_nav ? navmap::BAR_H : 0); + // Own position int32_t lat, lon; if (_core->course.currentLocation(lat, lon)) { @@ -527,6 +533,7 @@ void UITask::mapLoop() { if (!_map_area) return; uint32_t since_pan = millis() - mapview::s_last_pan_ms; if (since_pan < mapview::PAN_SETTLE_MS) return; // mid-drag: keep it smooth, decode after + if (mapview::labels::loadOne()) { layoutMap(); return; } // names of the view first: a small file int w = lv_obj_get_width(_map_area), h = lv_obj_get_height(_map_area); double left = _map_cx * mapview::TILE_PX - w / 2.0, top = _map_cy * mapview::TILE_PX - h / 2.0; if (!_map_pending) { // view complete: when idle, decode the next tile a pan would reveal diff --git a/examples/companion_radio/ui-lvgl/map/VectorLabels.h b/examples/companion_radio/ui-lvgl/map/VectorLabels.h new file mode 100644 index 00000000..4b4974c9 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/map/VectorLabels.h @@ -0,0 +1,302 @@ +#pragma once +// Labels of the vector map: named points (places, peaks with their height, +// huts, springs...) from the {dz}/{x}/{y}.vp files beside the vector tiles +// (tools/maps/osm_vector.py). Drawn over the tiles, not into them, so a name +// is never cut at a tile edge and stays crisp at any zoom: a layer under the +// markers, placed again on every layout -- by priority (towns, the highest +// peaks, huts first), each where it doesn't cover one already placed or the +// map's controls. No street names (the raster map has those). +// +// Files are read one per map loop pass while the map rests (they're small), +// never inside a layout, so panning doesn't wait for the card. +// +// Single-TU fragment: included by ui-lvgl/MapScreen.h after VectorTileProvider.h. + +namespace mapview { +namespace labels { + +enum : uint8_t { T_TOWN = 60, T_VILLAGE, T_HUT, T_PEAK, T_LAKE, T_HAMLET, T_PASS, T_SHELTER, T_SPRING, T_VIEW, T_CAVE }; +static const int LABEL_MAX = 40; +static const int16_t NO_ELE = -32768; + +struct Poi { uint8_t cls; int16_t ele; uint16_t u, v; char name[LABEL_MAX + 1]; }; + +// Points of the data tiles read last (PSRAM), least recently used replaced. +struct PoiTile { int dz = -1, x = 0, y = 0; Poi* p = nullptr; int n = 0, cap = 0; uint32_t used = 0; }; +static const int SLOTS = 12; +static PoiTile s_tiles[SLOTS]; +static uint32_t s_tick = 0; + +static PoiTile* find(int dz, int x, int y) { + for (PoiTile& t : s_tiles) if (t.dz == dz && t.x == x && t.y == y) { t.used = ++s_tick; return &t; } + return nullptr; +} + +// Reads a tile's points (none if it has no file: remembered too). +static void load(int dz, int x, int y) { + PoiTile* t = &s_tiles[0]; + for (PoiTile& s : s_tiles) if (s.used < t->used) t = &s; + t->dz = dz; t->x = x; t->y = y; t->n = 0; t->used = ++s_tick; + char path[64]; + snprintf(path, sizeof(path), "%s/%d/%d/%d.vp", VECTOR_ROOT, dz, x, y); + FILE* f = fopen(path, "rb"); + if (!f) return; + uint8_t h[6]; + if (fread(h, 1, 6, f) != 6 || memcmp(h, "VP1", 3) != 0) { fclose(f); return; } + int count = h[4] | (h[5] << 8); + if (count > t->cap) { + free(t->p); + t->p = psramBuf(count); + t->cap = t->p ? count : 0; + } + while (t->n < t->cap && t->n < count) { + uint8_t r[8]; + if (fread(r, 1, 8, f) != 8) break; + Poi& p = t->p[t->n]; + p.cls = r[0]; + p.u = r[1] | (r[2] << 8); + p.v = r[3] | (r[4] << 8); + p.ele = (int16_t)(r[5] | (r[6] << 8)); + int len = r[7] > LABEL_MAX ? LABEL_MAX : r[7]; + if (fread(p.name, 1, len, f) != (size_t)len) break; + if (r[7] > len) fseek(f, r[7] - len, SEEK_CUR); + p.name[len] = '\0'; + t->n++; + } + fclose(f); +} + +// What layout() placed, for the draw callback (names copied: a later read +// may reuse the tile's memory before LVGL draws). +struct Placed { + int16_t px, py; // the point, layer px + int16_t tx, ty; // the text's top-left + uint8_t cls; + char text[LABEL_MAX + 8]; +}; +static const int MAX_PLACED = 40; +static Placed* s_placed = psramBuf(MAX_PLACED); +static int s_n = 0; +static int s_want_dz = -1, s_want_x = 0, s_want_y = 0; // a tile to read next, dz -1: none +static lv_obj_t* s_layer = nullptr; + +static const lv_font_t* fontFor(uint8_t cls) { return cls == T_TOWN || cls == T_VILLAGE ? THEME_FONT_BODY : THEME_FONT_SMALL; } +static uint32_t colourFor(uint8_t cls) { + switch (cls) { + case T_LAKE: case T_SPRING: return 0x2A6A9A; + case T_HUT: case T_SHELTER: return 0x8A2A1A; + case T_PEAK: case T_PASS: return 0x5A3A20; + case T_HAMLET: return 0x55524C; + } + return 0x2A2824; +} +static bool hasIcon(uint8_t cls) { return cls >= T_HUT && cls != T_LAKE && cls != T_HAMLET; } + +// Candidates of the view, highest priority first. +struct Cand { const Poi* p; int16_t x, y; }; +static const int MAX_CAND = 512; +static Cand* s_cand = psramBuf(MAX_CAND); + +// Placing order: places, then peaks (the highest first), then huts... -- in +// the mountains the peaks are what you find your way by. +static int priority(uint8_t cls) { return cls == T_PEAK ? 2 * T_VILLAGE + 1 : cls == T_HUT ? 2 * T_VILLAGE + 2 : 2 * cls; } +static int cmpCand(const void* a, const void* b) { + const Poi* p = ((const Cand*)a)->p; + const Poi* q = ((const Cand*)b)->p; + if (p->cls != q->cls) return priority(p->cls) - priority(q->cls); + return q->ele - p->ele; // higher peaks first +} + +static void clear() { + s_n = 0; + s_want_dz = -1; +} + +// Places the labels for a view (world px of its top-left at zoom z, size; +// `bottom` px taken by a bar along the bottom). +static void layout(double left, double top, int w, int h, int z, bool on, int bottom) { + clear(); + if (!on || z < 10 || !s_layer || !s_placed || !s_cand) { if (s_layer) lv_obj_invalidate(s_layer); return; } + int dz = z >= 14 ? 14 : z >= 12 ? 12 : 10, k = z - dz; + double span = (double)(TILE_PX << k); // a data tile, in px at zoom z + int x0 = (int)floor(left / span), x1 = (int)floor((left + w) / span); + int y0 = (int)floor(top / span), y1 = (int)floor((top + h) / span); + int nc = 0; + for (int ty = y0; ty <= y1; ty++) + for (int tx = x0; tx <= x1; tx++) { + PoiTile* t = find(dz, tx, ty); + if (!t) { // read in mapLoop, one per pass + if (s_want_dz < 0) { s_want_dz = dz; s_want_x = tx; s_want_y = ty; } + continue; + } + for (int i = 0; i < t->n && nc < MAX_CAND; i++) { + const Poi& p = t->p[i]; + double px = (tx + p.u / 4096.0) * span - left, py = (ty + p.v / 4096.0) * span - top; + if (px < 0 || py < 0 || px >= w || py >= h) continue; + s_cand[nc++] = { &p, (int16_t)lround(px), (int16_t)lround(py) }; + } + } + qsort(s_cand, nc, sizeof(Cand), cmpCand); + + // Greedy placement: the icon, then the text right / left / above / below it. + struct Box { int16_t x1, y1, x2, y2; }; + static Box used[MAX_PLACED * 2 + 8]; + int nu = 0; + // The map's controls: button columns, the zoom pill between them, scale / + // credit and the centre button above the bottom bar, the bar. + const int16_t W = (int16_t)w, H = (int16_t)(h - bottom); + used[nu++] = { 0, 0, 50, 144 }; + used[nu++] = { (int16_t)(W - 50), 0, W, 144 }; + used[nu++] = { 0, 0, W, 36 }; + used[nu++] = { 0, (int16_t)(H - 34), 180, (int16_t)h }; + used[nu++] = { (int16_t)(W - 50), (int16_t)(H - 50), W, (int16_t)h }; + if (bottom) used[nu++] = { 0, H, W, (int16_t)h }; + auto clearOf = [&](const Box& b) { + if (b.x1 < 2 || b.y1 < 2 || b.x2 > w - 2 || b.y2 > h - 2) return false; + for (int i = 0; i < nu; i++) + if (b.x1 <= used[i].x2 && b.x2 >= used[i].x1 && b.y1 <= used[i].y2 && b.y2 >= used[i].y1) return false; + return true; + }; + for (int c = 0; c < nc && s_n < MAX_PLACED; c++) { + const Poi& p = *s_cand[c].p; + int px = s_cand[c].x, py = s_cand[c].y; + Placed& L = s_placed[s_n]; + if (p.name[0] && p.ele != NO_ELE && (p.cls == T_PEAK || p.cls == T_PASS)) snprintf(L.text, sizeof(L.text), "%s %d", p.name, p.ele); + else if (p.name[0]) snprintf(L.text, sizeof(L.text), "%s", p.name); + else if (p.ele != NO_ELE) snprintf(L.text, sizeof(L.text), "%d", p.ele); + else continue; + const lv_font_t* font = fontFor(p.cls); + lv_point_t sz; + lv_text_get_size(&sz, L.text, font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + int tw = sz.x, th = sz.y; + bool icon = hasIcon(p.cls); + Box ib = { (int16_t)(px - 5), (int16_t)(py - 5), (int16_t)(px + 5), (int16_t)(py + 5) }; + if (icon && !clearOf(ib)) continue; + struct { int x, y; } at[4]; + int na = 0; + if (icon) { + at[na++] = { px + 7, py - th / 2 }; + at[na++] = { px - 7 - tw, py - th / 2 }; + at[na++] = { px - tw / 2, py - 6 - th }; + at[na++] = { px - tw / 2, py + 6 }; + } else { + at[na++] = { px - tw / 2, py - th / 2 }; // a place name sits on the place + } + int pick = -1; + Box tb = {}; + for (int i = 0; i < na && pick < 0; i++) { + tb = { (int16_t)(at[i].x - 1), (int16_t)(at[i].y), (int16_t)(at[i].x + tw + 1), (int16_t)(at[i].y + th - 1) }; + if (clearOf(tb)) pick = i; + } + if (pick < 0) { + if (!icon) continue; + if (p.cls != T_HUT && dz < 14) continue; // far out: a bare icon is just clutter + if (p.cls >= T_SPRING) continue; // minor: no room, not shown + L.text[0] = '\0'; // the icon alone still tells there's a peak / hut + } + L.px = px; L.py = py; + L.tx = pick < 0 ? 0 : at[pick].x; + L.ty = pick < 0 ? 0 : at[pick].y; + L.cls = p.cls; + if (icon) used[nu++] = ib; + if (pick >= 0) used[nu++] = tb; + s_n++; + } + lv_obj_invalidate(s_layer); +} + +// A tile layout() found missing, read now; true if one was. +static bool loadOne() { + if (s_want_dz < 0) return false; + load(s_want_dz, s_want_x, s_want_y); + s_want_dz = -1; + return true; +} + +static void icon(lv_layer_t* layer, uint8_t cls, int32_t x, int32_t y) { + lv_draw_rect_dsc_t r; + lv_draw_rect_dsc_init(&r); + r.bg_opa = LV_OPA_COVER; + r.border_color = lv_color_hex(0xFFFFFF); + r.border_opa = LV_OPA_COVER; + lv_draw_triangle_dsc_t t; + lv_draw_triangle_dsc_init(&t); + t.bg_opa = LV_OPA_COVER; + lv_area_t a; + auto tri = [&](uint32_t col, int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { + t.bg_color = lv_color_hex(col); + t.p[0] = { (lv_value_precise_t)x0, (lv_value_precise_t)y0 }; + t.p[1] = { (lv_value_precise_t)x1, (lv_value_precise_t)y1 }; + t.p[2] = { (lv_value_precise_t)x2, (lv_value_precise_t)y2 }; + lv_draw_triangle(layer, &t); + }; + switch (cls) { + case T_PEAK: // a triangle with a light rim + tri(0xFFFFFF, x, y - 6, x - 6, y + 4, x + 6, y + 4); + tri(0x5A3A20, x, y - 4, x - 4, y + 3, x + 4, y + 3); + return; + case T_PASS: // a short bar + r.bg_color = lv_color_hex(0x5A3A20); + r.border_width = 1; + a = { x - 4, y - 2, x + 4, y + 1 }; + lv_draw_rect(layer, &r, &a); + return; + case T_HUT: case T_SHELTER: { // a house: roof over a square + uint32_t c = cls == T_HUT ? 0xC0301E : 0x8A5A2A; + r.bg_color = lv_color_hex(c); + r.border_width = 1; + a = { x - 4, y - 1, x + 4, y + 5 }; + lv_draw_rect(layer, &r, &a); + tri(c, x, y - 6, x - 6, y, x + 6, y); + return; + } + case T_SPRING: case T_VIEW: case T_CAVE: + r.bg_color = lv_color_hex(cls == T_SPRING ? 0x2A7AC8 : cls == T_VIEW ? 0xE08A1C : 0x302A26); + r.radius = LV_RADIUS_CIRCLE; + r.border_width = 1; + a = { x - 3, y - 3, x + 3, y + 3 }; + lv_draw_rect(layer, &r, &a); + return; + } +} + +// LV_EVENT_DRAW_MAIN of the layer. +static void draw(lv_event_t* e) { + if (!s_n) return; + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + for (int i = 0; i < s_n; i++) { + const Placed& L = s_placed[i]; + if (hasIcon(L.cls)) icon(layer, L.cls, a.x1 + L.px, a.y1 + L.py); + if (!L.text[0]) continue; + lv_draw_label_dsc_t d; + lv_draw_label_dsc_init(&d); + d.font = fontFor(L.cls); + d.text = L.text; + lv_area_t t = { a.x1 + L.tx, a.y1 + L.ty, a.x1 + L.tx + 400, a.y1 + L.ty + 40 }; + d.color = lv_color_hex(0xF7F4EE); // a halo: the text in paper colour around it + static const int8_t OFF[4][2] = { { -1, 0 }, { 1, 0 }, { 0, -1 }, { 0, 1 } }; + for (const auto& off : OFF) { + lv_area_t h = t; + lv_area_move(&h, off[0], off[1]); + lv_draw_label(layer, &d, &h); + } + d.color = lv_color_hex(colourFor(L.cls)); + lv_draw_label(layer, &d, &t); + } +} + +static void buildLayer(lv_obj_t* body) { + clear(); + s_layer = lv_obj_create(body); + lv_obj_remove_style_all(s_layer); + lv_obj_set_size(s_layer, LV_PCT(100), LV_PCT(100)); + lv_obj_remove_flag(s_layer, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(s_layer, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(s_layer, draw, LV_EVENT_DRAW_MAIN, NULL); +} + +} // namespace labels +} // namespace mapview diff --git a/examples/companion_radio/ui-lvgl/map/VectorTileProvider.h b/examples/companion_radio/ui-lvgl/map/VectorTileProvider.h index 81e1c3e1..335b3a3a 100644 --- a/examples/companion_radio/ui-lvgl/map/VectorTileProvider.h +++ b/examples/companion_radio/ui-lvgl/map/VectorTileProvider.h @@ -2,7 +2,7 @@ // Vector map (spike): tiles from tools/maps/osm_vector.py under VECTOR_ROOT, // {dz}/{x}/{y}.vt (format VT3) at data zooms 10, 12 and 14, drawn here into the 256x256 RGB565 // buffer the map asks for -- polygons by scanline (even-odd), lines as quads -// with round joins. No anti-aliasing, no labels yet. Where there is no vector +// with round joins. No anti-aliasing; names of points in VectorLabels.h. Where there is no vector // data (or below zoom 10) the raster provider draws instead, so the two mix. // // renderTile() times itself (lastMs()); the map shows it while this provider @@ -48,6 +48,7 @@ public: } const char* attribution() const override { return _have ? "\xC2\xA9 OpenStreetMap contributors (ODbL)" : _fb.attribution(); } + bool hasData() const { return _have; } uint32_t lastMs() const { return _last_ms; } // The last tile's time split: reading the data, areas, lines (ms). void lastSplit(uint32_t& load, uint32_t& fill, uint32_t& line) const { load = _t_load / 1000; fill = _t_fill / 1000; line = _t_line / 1000; } @@ -130,7 +131,13 @@ private: } // Styles. Widths in px at zoom 14, scaled with the zoom. - enum : uint8_t { L_STREAM = 20, L_RIVER = 21, L_PATH = 30, L_TRACK = 31, L_SERVICE = 32, L_TRUNK = 37, L_ROUTE = 50 }; + enum : uint8_t { L_STREAM = 20, L_RIVER = 21, L_PATH_HARD = 29, L_PATH = 30, L_TRACK = 31, L_SERVICE = 32, L_TRUNK = 37, + L_ROUTE = 50, L_ROUTES = 51 }; + // Waymark colours of L_ROUTES (index 1.., osm_vector.py PALETTE). + static uint16_t routeColour(int i) { + static const uint32_t P[] = { 0xE0302A, 0x2A5FE0, 0x2EA043, 0xE8C20E, 0x202020, 0xF08A1C, 0x9040C0, 0xF0F0F0, 0x8B5A2B, 0xD04040 }; + return rgb(i >= 1 && i <= 10 ? P[i - 1] : 0xD04040); + } static uint16_t polyColour(uint8_t c) { switch (c) { case 1: return rgb(0xE6DED6); // residential @@ -148,6 +155,7 @@ private: case L_STREAM: col = rgb(0x86B6D8); w = 1.0f; return true; case L_RIVER: col = rgb(0x86B6D8); w = 3.0f; return true; case L_PATH: col = rgb(0xA8502A); w = 1.2f; return true; + case L_PATH_HARD: col = rgb(0x8A3A1E); w = 1.2f; return true; // dotted case L_TRACK: col = rgb(0x94683A); w = 1.6f; return true; case 32: col = 0xFFFF; w = 2.2f; return true; // service case 33: col = 0xFFFF; w = 3.2f; return true; // minor @@ -186,21 +194,22 @@ private: _end = p; _bx = bb[0]; _by = bb[1]; bool road = cls >= L_SERVICE && cls <= L_TRUNK; - bool want = pass == 0 ? (cls < L_SERVICE) : pass == 3 ? cls == L_ROUTE : road; + bool want = pass == 0 ? (cls < L_SERVICE) : pass == 3 ? (cls == L_ROUTE || cls == L_ROUTES) : road; if (!want) continue; - if (_z < 13 && (cls == L_PATH || cls == 7)) continue; // paths from z13, buildings from z14 (data) + if (_z < 13 && (cls == L_PATH || cls == L_PATH_HARD || cls == 7)) continue; // paths from z13, buildings from z14 (data) // Off the drawn tile (with a margin for the widest line): not even read. const int32_t M = 12 * 16, S = TILE_PX * 16; if (sx(bb[2]) < -M || sx(bb[0]) > S + M || sy(bb[3]) < -M || sy(bb[1]) > S + M) continue; uint32_t t0 = micros(); if (cls < 10) { fillFeature(parts, nparts, polyColour(cls)); _t_fill += micros() - t0; continue; } + if (cls == L_ROUTES) { strokeRoutes(parts, nparts, col); _t_line += micros() - t0; continue; } uint16_t c; float w; if (!lineStyle(cls, c, w)) continue; w *= zoomScale(); if (cls == L_ROUTE) { c = col ? col : rgb(0xD04040); w = w < 2.5f ? 2.5f : w; } if (pass == 1) { c = rgb(0xB4ACA2); w += 1.6f; } // casing - strokeFeature(parts, nparts, c, w); + strokeFeature(parts, nparts, c, w, cls == L_PATH_HARD); _t_line += micros() - t0; } } @@ -323,11 +332,12 @@ private: } // A thin line: a DDA with a 1 or 2 px pen. - void thinSegment(int32_t ax, int32_t ay, int32_t bx, int32_t by, int pen, uint16_t col) { + void thinSegment(int32_t ax, int32_t ay, int32_t bx, int32_t by, int pen, uint16_t col, bool dots = false) { int32_t dx = bx - ax, dy = by - ay; int steps = (vmax(abs(dx), abs(dy)) >> 4) + 1; int32_t x = ax << 8, y = ay << 8, ix = (dx << 8) / steps, iy = (dy << 8) / steps; // 1/16 px << 8 for (int i = 0; i <= steps; i++, x += ix, y += iy) { + if (dots && (_dash++ & 3) >= 2) continue; // 2 px on, 2 off, carried across segments int px = x >> 12, py = y >> 12; for (int oy = 0; oy < pen; oy++) for (int ox = 0; ox < pen; ox++) { @@ -337,33 +347,112 @@ private: } } - void strokeFeature(const uint8_t* p, int nparts, uint16_t col, float w) { + // A part's points (1/16 px) for the stroke, and an offset copy of them. + static const int PTS = 2048; + int32_t* _pts = nullptr; // x, y pairs + int32_t* _off = nullptr; + uint32_t _dash = 0; + + bool reservePts() { + if (!_pts) _pts = psramBuf(2 * PTS); + if (!_off) _off = psramBuf(2 * PTS); + return _pts && _off; + } + + // Strokes each part: its points gathered (in runs of PTS), then `fn(n)`. + template void forParts(const uint8_t* p, int nparts, F fn) { + for (int i = 0; i < nparts; i++) { + int n = 0; + forPoints(p, true, [&](int32_t x, int32_t y, bool) { + if (n == PTS) { // a very long part: draw what's gathered, go on from its end + fn(n); + _pts[0] = _pts[2 * (n - 1)]; _pts[1] = _pts[2 * (n - 1) + 1]; + n = 1; + } + _pts[2 * n] = x; _pts[2 * n + 1] = y; + n++; + }); + if (n >= 2) fn(n); + } + } + + void strokeFeature(const uint8_t* p, int nparts, uint16_t col, float w, bool dots = false) { + if (!reservePts()) return; + _dash = 0; + forParts(p, nparts, [&](int n) { strokePoints(_pts, n, col, w, dots); }); + } + + // `pts` moved sideways by `off` (1/16 px, + to the left of the direction), + // corners mitred (limited, so a hairpin doesn't shoot out) into _off. + void offsetPoints(const int32_t* pts, int n, float off) { + float pnx = 0, pny = 0; + for (int i = 0; i < n; i++) { + float nx = 0, ny = 0; // the next segment's normal + if (i + 1 < n) { + float dx = pts[2 * i + 2] - pts[2 * i], dy = pts[2 * i + 3] - pts[2 * i + 1], len = sqrtf(dx * dx + dy * dy); + if (len > 0) { nx = dy / len; ny = -dx / len; } + } else { nx = pnx; ny = pny; } + if (i == 0) { pnx = nx; pny = ny; } + float mx = pnx + nx, my = pny + ny, m2 = mx * mx + my * my; + float ox = nx, oy = ny; + if (m2 > 0.01f) { + float k = 2.0f / m2; // mitre: (n1 + n2) / (1 + n1.n2) + if (k > 4.0f) k = 4.0f; + ox = mx * k; oy = my * k; + } + _off[2 * i] = pts[2 * i] + (int32_t)(ox * off); + _off[2 * i + 1] = pts[2 * i + 1] + (int32_t)(oy * off); + pnx = nx; pny = ny; + } + } + + // Hiking routes along a stretch: a stripe per route, side by side on a + // white band, in PALETTE order. + void strokeRoutes(const uint8_t* p, int nparts, uint16_t packed) { + if (!reservePts()) return; + uint16_t cols[4]; + int k = 0; + for (int i = 0; i < 4; i++) { + int c = (packed >> (4 * i)) & 15; + if (c) cols[k++] = routeColour(c); + } + if (!k) return; + float sw = 2.2f * zoomScale(); // a stripe + sw = sw < 1.6f ? 1.6f : sw > 4.5f ? 4.5f : sw; + forParts(p, nparts, [&](int n) { + strokePoints(_pts, n, 0xFFFF, k * sw + 1.6f, false); + for (int i = 0; i < k; i++) { + if (k == 1) { strokePoints(_pts, n, cols[0], sw, false); break; } + offsetPoints(_pts, n, (i - (k - 1) / 2.0f) * sw * 16); + strokePoints(_off, n, cols[i], sw, false); + } + }); + } + + void strokePoints(const int32_t* pts, int n, uint16_t col, float w, bool dots) { int32_t h = (int32_t)(w * 8); // half width, 1/16 px const int32_t S = TILE_PX * 16; bool thin = w < 2.2f; int pen = w < 1.8f ? 1 : 2; - for (int i = 0; i < nparts; i++) { - int32_t ax = 0, ay = 0; - forPoints(p, true, [&](int32_t bx, int32_t by, bool first) { - if (!first && !(vmax(ax, bx) < -h || vmin(ax, bx) > S + h || vmax(ay, by) < -h || vmin(ay, by) > S + h)) { - if (thin) { - thinSegment(ax, ay, bx, by, pen, col); - } else { - float dx = bx - ax, dy = by - ay, len = sqrtf(dx * dx + dy * dy); - if (len > 0) { - int32_t nx = (int32_t)(-dy / len * h), ny = (int32_t)(dx / len * h); - _ne = 0; - addEdge(ax + nx, ay + ny, bx + nx, by + ny); - addEdge(bx + nx, by + ny, bx - nx, by - ny); - addEdge(bx - nx, by - ny, ax - nx, ay - ny); - addEdge(ax - nx, ay - ny, ax + nx, ay + ny); - fillEdges(col); - } - if (w >= 2.8f) disc(bx, by, h, col); // round join - } - } - ax = bx; ay = by; - }); + for (int i = 1; i < n; i++) { + int32_t ax = pts[2 * i - 2], ay = pts[2 * i - 1], bx = pts[2 * i], by = pts[2 * i + 1]; + if (vmax(ax, bx) < -h || vmin(ax, bx) > S + h || vmax(ay, by) < -h || vmin(ay, by) > S + h) continue; + if (thin) { + if (pen == 2) { ax -= 8; ay -= 8; bx -= 8; by -= 8; } // a 2 px pen centred on the line + thinSegment(ax, ay, bx, by, pen, col, dots); + continue; + } + float dx = bx - ax, dy = by - ay, len = sqrtf(dx * dx + dy * dy); + if (len > 0) { + int32_t nx = (int32_t)(-dy / len * h), ny = (int32_t)(dx / len * h); + _ne = 0; + addEdge(ax + nx, ay + ny, bx + nx, by + ny); + addEdge(bx + nx, by + ny, bx - nx, by - ny); + addEdge(bx - nx, by - ny, ax - nx, ay - ny); + addEdge(ax - nx, ay - ny, ax + nx, ay + ny); + fillEdges(col); + } + if (w >= 2.8f) disc(bx, by, h, col); // round join } } }; diff --git a/tools/maps/osm_vector.py b/tools/maps/osm_vector.py index 77858112..e41e2dc2 100644 --- a/tools/maps/osm_vector.py +++ b/tools/maps/osm_vector.py @@ -4,9 +4,10 @@ Reads an Overpass JSON export (`out geom;`) and writes small binary tiles the device rasterises itself (ui-lvgl/map/VectorTileProvider.h): roads, paths, marked hiking routes in their waymark colours, water, forest, meadows, rock, -buildings. No labels yet. +buildings; and named points for the labels (places, peaks, huts, springs...). +No street names (the raster map has them). - tools/maps/osm_vector.py area.json --out vmap/ + tools/maps/osm_vector.py area.json [points.json] --out vmap/ Three data zooms: 10 (drawn at z10-11), 12 (z12-13), 14 (z14-18); the device picks the data tile covering the tile it draws and scales it. Copy the output folder to @@ -22,6 +23,16 @@ Tile format 'VT3' (little-endian): Points are deltas from the previous one; a part's first from (x0, y0). Coordinates: 0..4096 across the data tile, a little past its edges. Polygons: rings, even-odd. Lines: polylines. + Hiking routes (51) are per stretch of path, not per route: `colour` packs + the waymark colours of every route along it (4 bits each, PALETTE index, + lowest nibble first), drawn side by side. + +Points 'VP1' beside each tile ({y}.vp, little-endian), every point whose class +starts at or below that data zoom (so a zoom's file is complete on its own): + 'V' 'P' '1' dz:u8 count:u16 + count x (cls:u8 x:u16 y:u16 ele:i16 (-32768 none) namelen:u8 name (UTF-8)) + cls: 60 town, 61 village, 62 hut, 63 peak, 64 lake, 65 hamlet, 66 pass, + 67 shelter, 68 spring, 69 viewpoint, 70 cave. Get the data for a box (south, west, north, east) from Overpass, e.g.: [out:json][timeout:120]; @@ -33,6 +44,15 @@ Get the data for a box (south, west, north, east) from Overpass, e.g.: relation["landuse"~"forest|meadow"](S,W,N,E); relation["route"="hiking"](S,W,N,E); ); out geom; +and the points (a second file): + [out:json][timeout:90]; + ( node["natural"~"^(peak|saddle|spring|cave_entrance)$"](S,W,N,E); + node["tourism"~"^(alpine_hut|wilderness_hut|viewpoint)$"](S,W,N,E); + node["amenity"="shelter"](S,W,N,E); + node["place"~"^(city|town|village|hamlet)$"](S,W,N,E); + way["tourism"~"^(alpine_hut|wilderness_hut)$"](S,W,N,E); + nwr["natural"="water"]["name"](S,W,N,E); ); + out center tags; Map data (c) OpenStreetMap contributors, ODbL. """ import argparse, json, math, os, struct, sys @@ -46,7 +66,9 @@ DATA_ZOOMS = (10, 12, 14) # drawn at z10-11, z12-13, z14-18 P_RESIDENTIAL, P_MEADOW, P_SCRUB, P_FOREST, P_ROCK, P_WATER, P_BUILDING = 1, 2, 3, 4, 5, 6, 7 L_STREAM, L_RIVER = 20, 21 L_PATH, L_TRACK, L_SERVICE, L_MINOR, L_TERTIARY, L_SECONDARY, L_PRIMARY, L_TRUNK = 30, 31, 32, 33, 34, 35, 36, 37 -L_ROUTE = 50 +L_PATH_HARD = 29 # a path of demanding / alpine difficulty (sac_scale) +L_ROUTE = 50 # (old: one route, its RGB565 colour) +L_ROUTES = 51 # the routes along a stretch HIGHWAY = { 'path': L_PATH, 'footway': L_PATH, 'steps': L_PATH, 'bridleway': L_PATH, 'cycleway': L_PATH, @@ -59,7 +81,69 @@ HIGHWAY = { 'trunk': L_TRUNK, 'trunk_link': L_TRUNK, 'motorway': L_TRUNK, 'motorway_link': L_TRUNK, } # Lowest data zoom a class goes in (smaller ones would be clutter / weight). -MIN_DZ = {P_BUILDING: 14, L_SERVICE: 14, L_PATH: 12, L_TRACK: 12, L_STREAM: 12, L_MINOR: 12} +MIN_DZ = {P_BUILDING: 14, L_SERVICE: 14, L_PATH: 12, L_PATH_HARD: 12, L_TRACK: 12, L_STREAM: 12, L_MINOR: 12} +HARD_SAC = ('demanding_mountain_hiking', 'alpine_hiking', 'demanding_alpine_hiking', 'difficult_alpine_hiking') + +# Points (labels): class, lowest data zoom. +T_TOWN, T_VILLAGE, T_HUT, T_PEAK, T_LAKE, T_HAMLET, T_PASS, T_SHELTER, T_SPRING, T_VIEW, T_CAVE = range(60, 71) +POINT_DZ = {T_TOWN: 10, T_VILLAGE: 10, T_HUT: 10, T_PEAK: 10, T_LAKE: 12, T_HAMLET: 12, T_PASS: 12, + T_SHELTER: 12, T_SPRING: 14, T_VIEW: 14, T_CAVE: 14} +NAME_MAX = 40 # bytes +# Long words the labels shorten (map convention; the rest of the name stays). +ABBREV = (('Schronisko', 'Schr.'), ('Przełęcz', 'Przeł.'), ('Schutzhütte', 'Sch.'), ('Chata', 'Ch.')) + + +def point_class(t): + n, tour, pl = t.get('natural'), t.get('tourism'), t.get('place') + if pl in ('city', 'town'): + return T_TOWN + if pl == 'village': + return T_VILLAGE + if pl == 'hamlet': + return T_HAMLET + if tour in ('alpine_hut', 'wilderness_hut'): + return T_HUT + if n == 'peak': + return T_PEAK + if n == 'saddle': + return T_PASS + if n == 'water': + return T_LAKE + if t.get('amenity') == 'shelter': + return T_SHELTER + if n == 'spring': + return T_SPRING + if tour == 'viewpoint': + return T_VIEW + if n == 'cave_entrance': + return T_CAVE + return None + + +def elevation(t): + v = t.get('ele', '').replace(',', '.').split(' ')[0].rstrip('m') + try: + return max(-32767, min(32767, int(round(float(v))))) + except ValueError: + return None + + +def short_name(name): + if ' / ' in name: # "Świnica / Svinica" on a border: the first one + name = name.split(' / ')[0] + for a, b in ABBREV: + if name.startswith(a + ' ') and len(name) > 16: + name = b + name[len(a):] + b = name.encode('utf-8') + if len(b) <= NAME_MAX: + return b + b = b[:NAME_MAX] + while b and (b[-1] & 0xC0) == 0x80: # a cut multi-byte character + b = b[:-1] + if b and b[-1] >= 0xC0: + b = b[:-1] + return b + # Natural land cover (not roads, buildings, water): its edges are vague # anyway, so simplified harder -- most of the points are here. @@ -70,6 +154,9 @@ WAYMARK = { # osmc:symbol / colour -> RGB888 'red': 0xE0302A, 'blue': 0x2A5FE0, 'green': 0x2EA043, 'yellow': 0xE8C20E, 'black': 0x202020, 'orange': 0xF08A1C, 'purple': 0x9040C0, 'white': 0xF0F0F0, 'brown': 0x8B5A2B, } +# Route colours by index (1..), the device has the same table; also the +# order the stripes go side by side. +PALETTE = ['red', 'blue', 'green', 'yellow', 'black', 'orange', 'purple', 'white', 'brown', 'other'] def varint(v): @@ -120,20 +207,21 @@ def line_class(t): def route_colour(t): + """The waymark colour's PALETTE index (1..).""" c = t.get('colour', '').lower() if c in WAYMARK: - return WAYMARK[c] + return PALETTE.index(c) + 1 sym = t.get('osmc:symbol', '') if sym: first = sym.split(':')[0].lower() if first in WAYMARK: - return WAYMARK[first] + return PALETTE.index(first) + 1 # "white:red_bar" style: the bar's colour for part in sym.split(':')[1:]: k = part.split('_')[0].lower() if k in WAYMARK and k != 'white': - return WAYMARK[k] - return 0xD04040 + return PALETTE.index(k) + 1 + return len(PALETTE) def world(lon, lat): @@ -174,6 +262,35 @@ def join_rings(segments): return rings +def join_lines(ways): + """Polylines -> as few as possible, joined at shared ends.""" + segs = [list(w) for w in ways if len(w) >= 2] + out = [] + while segs: + line = segs.pop() + changed = True + while changed: + changed = False + for i, s in enumerate(segs): + if s[0] == line[-1]: + line += s[1:] + elif s[-1] == line[-1]: + line += s[-2::-1] + elif s[-1] == line[0]: + line = s[:-1] + line + elif s[0] == line[0]: + line = s[:0:-1] + line + else: + continue + segs.pop(i) + changed = True + break + if line[0][0] > line[-1][0]: # west to east: the stripes keep their sides between stretches + line.reverse() + out.append(line) + return out + + def simplify(pts, tol): """Douglas-Peucker on tile units.""" if len(pts) < 3 or tol <= 0: @@ -271,14 +388,29 @@ def clip_ring(pts, lo, hi): def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument('json') + ap.add_argument('json', nargs='+') ap.add_argument('--out', default='vmap') a = ap.parse_args() - data = json.load(open(a.json)) + elements = [] + for fn in a.json: + elements += json.load(open(fn))['elements'] feats = [] # (cls, colour, 'poly'|'line', [parts in world coords]) - for e in data['elements']: + way_routes = defaultdict(set) # way id -> route colours along it + way_geom = {} + points = {} # (cls, name, rounded pos) -> (cls, x, y, ele, name bytes): a node and a way can both be there + for e in elements: t = e.get('tags', {}) + pt = point_class(t) + if pt is not None: + pos = (e['lon'], e['lat']) if 'lat' in e else (e['center']['lon'], e['center']['lat']) if 'center' in e else None + name = t.get('name', '') + ele = elevation(t) + if pos and (name or (pt == T_PEAK and ele is not None)): # unnamed: just peaks, by their height + x, y = world(*pos) + points[(pt, name, round(x * 2e5), round(y * 2e5))] = (pt, x, y, ele, short_name(name)) + if 'geometry' not in e and 'members' not in e: + continue if e['type'] == 'way' and 'geometry' in e: pts = geom_pts(e['geometry']) closed = len(pts) >= 4 and pts[0] == pts[-1] @@ -286,14 +418,18 @@ def main(): if pc: feats.append((pc, 0, 'poly', [pts])) lc = line_class(t) + if lc == L_PATH and t.get('sac_scale') in HARD_SAC: + lc = L_PATH_HARD if lc and not pc: feats.append((lc, 0, 'line', [pts])) elif e['type'] == 'relation': members = e.get('members', []) if t.get('route') == 'hiking': - col = rgb565(route_colour(t)) - parts = [geom_pts(m['geometry']) for m in members if m.get('type') == 'way' and 'geometry' in m] - feats.append((L_ROUTE, col, 'line', [p for p in parts if len(p) >= 2])) + col = route_colour(t) + for m in members: + if m.get('type') == 'way' and 'geometry' in m: + way_routes[m['ref']].add(col) + way_geom[m['ref']] = geom_pts(m['geometry']) else: pc = poly_class(t) if not pc: @@ -303,7 +439,14 @@ def main(): rings = join_rings(outer) + join_rings(inner) if rings: feats.append((pc, 0, 'poly', rings)) - print(f'{len(feats)} features', file=sys.stderr) + bundles = defaultdict(list) # the same routes along adjacent ways: one line + for wid, cols in way_routes.items(): + if len(way_geom[wid]) >= 2: + bundles[tuple(sorted(cols))[:4]].append(way_geom[wid]) + for cols, ways in bundles.items(): + packed = sum(c << (4 * i) for i, c in enumerate(cols)) + feats.append((L_ROUTES, packed, 'line', join_lines(ways))) + print(f'{len(feats)} features, {len(way_routes)} route ways', file=sys.stderr) total_bytes = total_tiles = 0 for dz in DATA_ZOOMS: @@ -314,6 +457,8 @@ def main(): if MIN_DZ.get(cls, 0) > dz: continue tol = base_tol * (NATURAL_TOL if cls in NATURAL else 1) + if cls == L_ROUTES: # routes: close to the ground at every zoom + tol = min(tol, 1.0 if dz >= 12 else 2.0) allp = [p for part in parts for p in part] if not allp: continue @@ -372,6 +517,26 @@ def main(): f.write(buf) total_bytes += len(buf) total_tiles += 1 + for dz in DATA_ZOOMS: + n = 1 << dz + tiles = defaultdict(list) + for pt, x, y, ele, name in points.values(): + if POINT_DZ[pt] > dz or (not name and dz < 14): # unnamed peaks only close up + continue + tx, ty = int(x * n), int(y * n) + tiles[(tx, ty)].append((pt, -(ele or 0), min(EXTENT - 1, int((x * n - tx) * EXTENT)), + min(EXTENT - 1, int((y * n - ty) * EXTENT)), ele, name)) + for (tx, ty), pl in tiles.items(): + pl.sort() + buf = bytearray(b'VP1' + bytes([dz]) + struct.pack('