mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 03:06:39 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
+180
-15
@@ -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('<H', len(pl)))
|
||||
for pt, _, px, py, ele, name in pl:
|
||||
buf += struct.pack('<BHHhB', pt, px, py, -32768 if ele is None else ele, len(name)) + name
|
||||
d = os.path.join(a.out, str(dz), str(tx))
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(os.path.join(d, f'{ty}.vp'), 'wb') as f:
|
||||
f.write(buf)
|
||||
total_bytes += len(buf)
|
||||
print(f'{len(points)} points', file=sys.stderr)
|
||||
with open(os.path.join(a.out, 'attribution.txt'), 'w') as f:
|
||||
f.write('© OpenStreetMap contributors (ODbL)\n')
|
||||
print(f'{total_tiles} tiles, {total_bytes / 1024:.0f} KB', file=sys.stderr)
|
||||
|
||||
Reference in New Issue
Block a user