feat(core,fuse,gui): places on a world map

The Places view gets a Grid/Map toggle. The map shows a bubble per town,
sized by its number of photos, over the outline of the world's land. Towns
whose bubbles would overlap merge into one that counts all their photos, and
clicking it zooms in until they part; clicking a single town opens it like a
card does. Drag to pan, scroll or pinch to zoom, and the buttons zoom in,
zoom out or frame every place again. Hovering names the town, or the first
few towns of a merged bubble.

No tile server is involved: none that is free has terms allowing an app like
this one to use it. The map is drawn from Natural Earth's 1:50m land
polygons (public domain), trimmed and simplified by
scripts/natural-earth-land.py into crates/pdfs-gui/data/land.txt, in Web
Mercator. The About dialog credits Natural Earth.

PlaceInfo carries the town's latitude and longitude; a daemon older than the
map sends none, and its towns are left off the map.
This commit is contained in:
nvrl committed 2026-09-28 02:12:59 +02:00
1 parent 8749d6c2c6
commit b3c03a7162
21 files changed
+4223 -1693

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Regenerate crates/pdfs-gui/data/land.txt from Natural Earth's land polygons.
Usage:
curl -LO https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_land.geojson
scripts/natural-earth-land.py ne_50m_land.geojson crates/pdfs-gui/data/land.txt
Natural Earth is in the public domain; the About dialog credits it anyway.
The output is one ring per line, as space-separated "longitude,latitude"
pairs rounded to two decimals (about a kilometre). Each ring is simplified
with Ramer-Douglas-Peucker to TOLERANCE degrees, which is finer than the map
can show at its closest zoom, and rings left with fewer than four points
(islets smaller than the tolerance) are dropped. Holes (lakes) are dropped
too: the map fills the land and draws nothing on top of it.
"""
import json
import sys
TOLERANCE = 0.04
def point_line_distance(p, a, b):
(x, y), (x1, y1), (x2, y2) = p, a, b
dx, dy = x2 - x1, y2 - y1
if dx == 0 and dy == 0:
return ((x - x1) ** 2 + (y - y1) ** 2) ** 0.5
t = max(0, min(1, ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy)))
return ((x - x1 - t * dx) ** 2 + (y - y1 - t * dy) ** 2) ** 0.5
def simplify(points):
"""Ramer-Douglas-Peucker, iterative so long coastlines don't recurse deep."""
keep = [False] * len(points)
keep[0] = keep[-1] = True
stack = [(0, len(points) - 1)]
while stack:
first, last = stack.pop()
worst, index = 0.0, None
for i in range(first + 1, last):
d = point_line_distance(points[i], points[first], points[last])
if d > worst:
worst, index = d, i
if index is not None and worst > TOLERANCE:
keep[index] = True
stack.append((first, index))
stack.append((index, last))
return [p for p, k in zip(points, keep) if k]
def main(source, target):
features = json.load(open(source))["features"]
lines = []
for feature in features:
geometry = feature["geometry"]
polygons = (
geometry["coordinates"]
if geometry["type"] == "MultiPolygon"
else [geometry["coordinates"]]
)
for polygon in polygons:
ring = simplify(polygon[0])
if len(ring) < 4:
continue
lines.append(" ".join(f"{lon:.2f},{lat:.2f}" for lon, lat in ring))
with open(target, "w") as out:
out.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])