mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
8749d6c2c6
commit
b3c03a7162
21 files changed
+4223
-1693
No files matched your search
@@ -75,7 +75,7 @@ pdfs-app # the desktop app
|
||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0); install `iso-codes` for country names in your language.
|
||||
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines (public domain), so no map tiles are fetched; install `iso-codes` for country names in your language.
|
||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||
|
||||
@@ -1456,6 +1456,12 @@ pub struct PlaceInfo {
|
||||
pub name: String,
|
||||
/// ISO 3166-1 alpha-2 code of the town's country.
|
||||
pub country: String,
|
||||
/// Where the town is, in decimal degrees, for the map. Zero from a daemon
|
||||
/// older than the map.
|
||||
#[serde(default)]
|
||||
pub latitude: f64,
|
||||
#[serde(default)]
|
||||
pub longitude: f64,
|
||||
/// How many photos were taken there, counting a RAW+JPEG shot once.
|
||||
pub photo_count: usize,
|
||||
/// The newest photo taken there, shown as the place's cover.
|
||||
|
||||
@@ -764,6 +764,8 @@ impl Core {
|
||||
id: place.id,
|
||||
name: city.name.to_string(),
|
||||
country: city.country.to_string(),
|
||||
latitude: city.latitude,
|
||||
longitude: city.longitude,
|
||||
photo_count: place.count,
|
||||
cover: self.photo_item(place.cover),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -89,3 +89,7 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
||||
|
||||
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
|
||||
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
|
||||
/* The Places map: water a faint wash of the text colour, bubbles (drawn in
|
||||
* the area's own colour) in the accent. */
|
||||
.places-map-frame { border-radius: 12px; background: color-mix(in srgb, currentColor 5%, transparent); }
|
||||
.places-map { color: var(--accent-bg-color); }
|
||||
@@ -15,6 +15,7 @@ use pages::login::*;
|
||||
use pages::photo_viewer::*;
|
||||
use pages::photos::*;
|
||||
use pages::places::*;
|
||||
use pages::places_map::*;
|
||||
use pages::shared::*;
|
||||
use pages::shared_by_me::*;
|
||||
use pages::status::*;
|
||||
@@ -562,6 +563,8 @@ fn build_window(app: &adw::Application) {
|
||||
places_mapping_label: gallery_widgets.places_mapping_label.clone(),
|
||||
places_mapping_bar: gallery_widgets.places_mapping_bar.clone(),
|
||||
places_poll: RefCell::new(None),
|
||||
places_map: gallery_widgets.places_map.clone(),
|
||||
places_map_btn: gallery_widgets.places_map_btn.clone(),
|
||||
place_count: Cell::new(0),
|
||||
place: RefCell::new(None),
|
||||
album: RefCell::new(None),
|
||||
@@ -1360,6 +1363,14 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
"Places are named with data from <a href=\"https://www.geonames.org/\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\">CC BY 4.0</a>.",
|
||||
)),
|
||||
);
|
||||
dialog.add_legal_section(
|
||||
"Natural Earth",
|
||||
None,
|
||||
gtk4::License::Custom,
|
||||
Some(&gettext(
|
||||
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/\">Natural Earth</a> land outlines, which are in the public domain.",
|
||||
)),
|
||||
);
|
||||
dialog.add_legal_section(
|
||||
"proton-sdk-rs",
|
||||
Some("© 2026 Nils Pukropp (contact@narl.io)"),
|
||||
|
||||
@@ -8,6 +8,7 @@ pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
pub(crate) mod photos;
|
||||
pub(crate) mod places;
|
||||
pub(crate) mod places_map;
|
||||
pub(crate) mod shared;
|
||||
pub(crate) mod shared_by_me;
|
||||
pub(crate) mod status;
|
||||
|
||||
@@ -76,6 +76,9 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) places_mapping_label: gtk4::Label,
|
||||
pub(crate) places_mapping_bar: gtk4::ProgressBar,
|
||||
pub(crate) places_poll: RefCell<Option<glib::SourceId>>,
|
||||
/// The place map, and the Grid/Map toggle's Map half.
|
||||
pub(crate) places_map: Rc<PlacesMap>,
|
||||
pub(crate) places_map_btn: gtk4::ToggleButton,
|
||||
/// How many places the grid shows, for the subtitle when back returns there.
|
||||
pub(crate) place_count: Cell<usize>,
|
||||
/// The place currently open, paged by [`load_gallery`] like an album.
|
||||
@@ -426,6 +429,8 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) places_mapping: gtk4::Box,
|
||||
pub(crate) places_mapping_label: gtk4::Label,
|
||||
pub(crate) places_mapping_bar: gtk4::ProgressBar,
|
||||
pub(crate) places_map: Rc<PlacesMap>,
|
||||
pub(crate) places_map_btn: gtk4::ToggleButton,
|
||||
pub(crate) back: gtk4::Button,
|
||||
/// The kind toggles and date jump, as one box so an album view can hide them.
|
||||
pub(crate) filters: gtk4::Box,
|
||||
@@ -827,6 +832,21 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
places_stack.set_vexpand(true);
|
||||
places_stack.add_named(&places_scroll, Some("grid"));
|
||||
places_stack.add_named(&places_status, Some("status"));
|
||||
let places_map = PlacesMap::new();
|
||||
places_stack.add_named(places_map.widget(), Some("map"));
|
||||
// The places as cards or on the map.
|
||||
let places_grid_btn = gtk4::ToggleButton::builder()
|
||||
.label(pgettext("layout", "Grid"))
|
||||
.active(true)
|
||||
.build();
|
||||
let places_map_btn = gtk4::ToggleButton::builder()
|
||||
.label(pgettext("layout", "Map"))
|
||||
.group(&places_grid_btn)
|
||||
.build();
|
||||
let places_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
places_switch.add_css_class("linked");
|
||||
places_switch.append(&places_grid_btn);
|
||||
places_switch.append(&places_map_btn);
|
||||
// While a refresh reads the library's locations (the first one after an
|
||||
// update reads all of them), a bar says how far it has got.
|
||||
let places_mapping_label = gtk4::Label::new(None);
|
||||
@@ -836,11 +856,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let places_mapping = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
places_mapping.set_hexpand(true);
|
||||
places_mapping.append(&places_mapping_label);
|
||||
places_mapping.append(&places_mapping_bar);
|
||||
places_mapping.set_visible(false);
|
||||
let places_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
places_bar.append(&places_switch);
|
||||
places_bar.append(&places_mapping);
|
||||
let places_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
places_page.append(&places_mapping);
|
||||
places_page.append(&places_bar);
|
||||
places_page.append(&places_stack);
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
@@ -926,6 +950,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
places_mapping,
|
||||
places_mapping_label,
|
||||
places_mapping_bar,
|
||||
places_map,
|
||||
places_map_btn,
|
||||
back,
|
||||
filters,
|
||||
},
|
||||
|
||||
@@ -83,7 +83,8 @@ pub(crate) fn load_places(ui: &Rc<Ui>) {
|
||||
}
|
||||
Ok(Ok(Response::Places { items, .. })) => {
|
||||
fill_places(&ui, &items);
|
||||
ui.gallery.places_stack.set_visible_child_name("grid");
|
||||
ui.gallery.places_map.set_places(&items);
|
||||
show_places_layout(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &gettext("Couldn't load places"), &message, kind);
|
||||
@@ -161,12 +162,12 @@ fn fill_places(ui: &Rc<Ui>, places: &[PlaceInfo]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn places_subtitle(count: usize) -> String {
|
||||
pub(crate) fn places_subtitle(count: usize) -> String {
|
||||
ngettext_f("{n} place", "{n} places", count as u64, &[])
|
||||
}
|
||||
|
||||
/// "Germany · 12 photos": where the town is, and how much was taken there.
|
||||
fn place_subtitle(place: &PlaceInfo) -> String {
|
||||
pub(crate) fn place_subtitle(place: &PlaceInfo) -> String {
|
||||
ngettext_f(
|
||||
// Translators: {country} is a country name, {n} a number of photos.
|
||||
"{country} · {n} photo",
|
||||
@@ -285,4 +286,34 @@ pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||
show_places(&ui_places);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_layout = ui.clone();
|
||||
ui.gallery.places_map_btn.connect_toggled(move |_| {
|
||||
// A status page (loading, empty, an error) stays until a listing
|
||||
// replaces it; only the grid and the map swap.
|
||||
if ui_layout
|
||||
.gallery
|
||||
.places_stack
|
||||
.visible_child_name()
|
||||
.as_deref()
|
||||
!= Some("status")
|
||||
{
|
||||
show_places_layout(&ui_layout);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_open = ui.clone();
|
||||
ui.gallery
|
||||
.places_map
|
||||
.connect_open(move |place| open_place(&ui_open, place));
|
||||
}
|
||||
|
||||
/// Show the places as cards or on the map, as the Grid/Map toggle says.
|
||||
fn show_places_layout(ui: &Rc<Ui>) {
|
||||
let layout = if ui.gallery.places_map_btn.is_active() {
|
||||
"map"
|
||||
} else {
|
||||
"grid"
|
||||
};
|
||||
ui.gallery.places_stack.set_visible_child_name(layout);
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! The map of the Places view: the towns the photos were taken in, drawn as
|
||||
//! bubbles over an outline of the world's land.
|
||||
//!
|
||||
//! The map is drawn here rather than loaded from a tile server: no tile source
|
||||
//! with terms that allow an app like this one to use it is free, and a town
|
||||
//! bubble needs no more detail than coastlines. The land is Natural Earth's
|
||||
//! 1:50m land polygons (public domain), trimmed into `data/land.txt` by
|
||||
//! `scripts/natural-earth-land.py`.
|
||||
//!
|
||||
//! The projection is Web Mercator, the one every other map uses, so the
|
||||
//! world looks the way people expect. Towns that would overlap on screen merge
|
||||
//! into one bubble counting all their photos; clicking it zooms in until they
|
||||
//! part, clicking a single town opens it.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const LAND: &str = include_str!("../../../data/land.txt");
|
||||
|
||||
/// Latitude past which Mercator is cut off, as on every web map.
|
||||
const MAX_LATITUDE: f64 = 85.0;
|
||||
|
||||
/// How far in the map zooms, as a multiple of the whole world fitting the
|
||||
/// widget's width.
|
||||
const MAX_ZOOM: f64 = 2048.0;
|
||||
|
||||
/// How close "show all" zooms onto a single town, the same way.
|
||||
const FIT_MAX_ZOOM: f64 = 64.0;
|
||||
|
||||
/// The zoom step of one scroll notch and of the zoom buttons.
|
||||
const ZOOM_STEP: f64 = 1.25;
|
||||
const BUTTON_ZOOM_STEP: f64 = 2.0;
|
||||
|
||||
/// A press that moves less than this many px is a click, not a drag.
|
||||
const CLICK_SLOP: f64 = 4.0;
|
||||
|
||||
/// Coastline points closer than this many px to the last one drawn are
|
||||
/// skipped: at world zoom most of the outline would fall onto one pixel.
|
||||
const MIN_SEGMENT: f64 = 0.75;
|
||||
|
||||
/// A point in projected world space: `x` and `y` both run 0..1, from the
|
||||
/// antimeridian eastwards and from the north edge southwards.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct World {
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
fn project(latitude: f64, longitude: f64) -> World {
|
||||
let lat = latitude.clamp(-MAX_LATITUDE, MAX_LATITUDE).to_radians();
|
||||
World {
|
||||
x: (longitude + 180.0) / 360.0,
|
||||
y: (1.0 - (lat.tan() + 1.0 / lat.cos()).ln() / std::f64::consts::PI) / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// One land outline, projected, with its bounding box for culling.
|
||||
struct Ring {
|
||||
points: Vec<World>,
|
||||
min: World,
|
||||
max: World,
|
||||
}
|
||||
|
||||
fn land() -> &'static [Ring] {
|
||||
static LAND_RINGS: OnceLock<Vec<Ring>> = OnceLock::new();
|
||||
LAND_RINGS.get_or_init(|| LAND.lines().filter_map(parse_ring).collect())
|
||||
}
|
||||
|
||||
fn parse_ring(line: &str) -> Option<Ring> {
|
||||
let points: Vec<World> = line
|
||||
.split(' ')
|
||||
.filter_map(|pair| {
|
||||
let (lon, lat) = pair.split_once(',')?;
|
||||
Some(project(lat.parse().ok()?, lon.parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let first = *points.first()?;
|
||||
let (min, max) = points.iter().fold((first, first), |(min, max), p| {
|
||||
(
|
||||
World {
|
||||
x: min.x.min(p.x),
|
||||
y: min.y.min(p.y),
|
||||
},
|
||||
World {
|
||||
x: max.x.max(p.x),
|
||||
y: max.y.max(p.y),
|
||||
},
|
||||
)
|
||||
});
|
||||
Some(Ring { points, min, max })
|
||||
}
|
||||
|
||||
/// Radius in px of a bubble holding `photos` photos: grows with the count,
|
||||
/// but slowly, so a home town with thousands doesn't cover a continent.
|
||||
fn bubble_radius(photos: usize) -> f64 {
|
||||
7.0 + 3.0 * (photos.max(1) as f64).ln()
|
||||
}
|
||||
|
||||
/// What the map shows: which part of the world, at what size.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct View {
|
||||
/// The world point at the middle of the widget.
|
||||
center: World,
|
||||
/// px per world unit: the whole world is this wide on screen.
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
/// One bubble as last drawn: where, how big, and the places it stands for
|
||||
/// (indices into [`MapState::places`], the one with the most photos first).
|
||||
struct Bubble {
|
||||
x: f64,
|
||||
y: f64,
|
||||
radius: f64,
|
||||
photos: usize,
|
||||
members: Vec<usize>,
|
||||
}
|
||||
|
||||
struct MapState {
|
||||
places: Vec<(PlaceInfo, World)>,
|
||||
view: Cell<View>,
|
||||
/// False until the view has been fitted to the places at a real size, and
|
||||
/// again whenever new places arrive before the user moved the map.
|
||||
fitted: Cell<bool>,
|
||||
moved: Cell<bool>,
|
||||
bubbles: RefCell<Vec<Bubble>>,
|
||||
pointer: Cell<(f64, f64)>,
|
||||
/// The view a drag started from.
|
||||
drag_origin: Cell<Option<View>>,
|
||||
/// The scale a pinch started from.
|
||||
pinch_origin: Cell<Option<View>>,
|
||||
}
|
||||
|
||||
/// What clicking a town on the map does.
|
||||
type OpenPlace = Box<dyn Fn(PlaceInfo)>;
|
||||
|
||||
/// The Places map widget.
|
||||
pub(crate) struct PlacesMap {
|
||||
root: gtk4::Overlay,
|
||||
area: gtk4::DrawingArea,
|
||||
state: RefCell<MapState>,
|
||||
on_open: RefCell<Option<OpenPlace>>,
|
||||
}
|
||||
|
||||
impl PlacesMap {
|
||||
pub(crate) fn new() -> Rc<Self> {
|
||||
let area = gtk4::DrawingArea::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.has_tooltip(true)
|
||||
.build();
|
||||
area.add_css_class("places-map");
|
||||
|
||||
let zoom_in = map_button("zoom-in-symbolic", &gettext("Zoom In"));
|
||||
let zoom_out = map_button("zoom-out-symbolic", &gettext("Zoom Out"));
|
||||
let fit = map_button("zoom-fit-best-symbolic", &gettext("Show All Places"));
|
||||
let zoom = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
zoom.add_css_class("linked");
|
||||
zoom.append(&zoom_in);
|
||||
zoom.append(&zoom_out);
|
||||
let buttons = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
buttons.set_halign(gtk4::Align::End);
|
||||
buttons.set_valign(gtk4::Align::Start);
|
||||
buttons.set_margin_top(12);
|
||||
buttons.set_margin_end(12);
|
||||
buttons.append(&zoom);
|
||||
buttons.append(&fit);
|
||||
|
||||
let root = gtk4::Overlay::new();
|
||||
root.set_child(Some(&area));
|
||||
root.add_overlay(&buttons);
|
||||
root.set_overflow(gtk4::Overflow::Hidden);
|
||||
root.add_css_class("places-map-frame");
|
||||
|
||||
let map = Rc::new(Self {
|
||||
root,
|
||||
area,
|
||||
state: RefCell::new(MapState {
|
||||
places: Vec::new(),
|
||||
view: Cell::new(View {
|
||||
center: World { x: 0.5, y: 0.5 },
|
||||
scale: 1.0,
|
||||
}),
|
||||
fitted: Cell::new(false),
|
||||
moved: Cell::new(false),
|
||||
bubbles: RefCell::new(Vec::new()),
|
||||
pointer: Cell::new((0.0, 0.0)),
|
||||
drag_origin: Cell::new(None),
|
||||
pinch_origin: Cell::new(None),
|
||||
}),
|
||||
on_open: RefCell::new(None),
|
||||
});
|
||||
|
||||
let weak = Rc::downgrade(&map);
|
||||
map.area.set_draw_func(move |area, cr, width, height| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.draw(area, cr, f64::from(width), f64::from(height));
|
||||
}
|
||||
});
|
||||
map.wire(&zoom_in, &zoom_out, &fit);
|
||||
map
|
||||
}
|
||||
|
||||
pub(crate) fn widget(&self) -> >k4::Overlay {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// What clicking a town does.
|
||||
pub(crate) fn connect_open(&self, f: impl Fn(PlaceInfo) + 'static) {
|
||||
self.on_open.replace(Some(Box::new(f)));
|
||||
}
|
||||
|
||||
/// Show `places`. The view is fitted to them unless the user has already
|
||||
/// moved the map, so a refresh while they look around leaves it be.
|
||||
pub(crate) fn set_places(&self, places: &[PlaceInfo]) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.places = places
|
||||
.iter()
|
||||
.filter(|p| p.latitude != 0.0 || p.longitude != 0.0)
|
||||
.map(|p| (p.clone(), project(p.latitude, p.longitude)))
|
||||
.collect();
|
||||
if !state.moved.get() {
|
||||
state.fitted.set(false);
|
||||
}
|
||||
drop(state);
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
fn wire(self: &Rc<Self>, zoom_in: >k4::Button, zoom_out: >k4::Button, fit: >k4::Button) {
|
||||
let weak = Rc::downgrade(self);
|
||||
zoom_in.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.zoom_at_middle(BUTTON_ZOOM_STEP);
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
zoom_out.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.zoom_at_middle(1.0 / BUTTON_ZOOM_STEP);
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
fit.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.moved.set(false);
|
||||
state.fitted.set(false);
|
||||
drop(state);
|
||||
map.area.queue_draw();
|
||||
}
|
||||
});
|
||||
|
||||
let motion = gtk4::EventControllerMotion::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
motion.connect_motion(move |_, x, y| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.state.borrow().pointer.set((x, y));
|
||||
let over = map.bubble_at(x, y).is_some();
|
||||
map.area
|
||||
.set_cursor_from_name(Some(if over { "pointer" } else { "default" }));
|
||||
}
|
||||
});
|
||||
self.area.add_controller(motion);
|
||||
|
||||
let scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||
let weak = Rc::downgrade(self);
|
||||
scroll.connect_scroll(move |_, _, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
let (x, y) = map.state.borrow().pointer.get();
|
||||
map.zoom_at(x, y, ZOOM_STEP.powf(-dy));
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
self.area.add_controller(scroll);
|
||||
|
||||
// One drag gesture for both panning and clicking: a press that barely
|
||||
// moves is a click on whatever bubble it started on.
|
||||
let drag = gtk4::GestureDrag::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_begin(move |_, _, _| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.drag_origin.set(Some(state.view.get()));
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_update(move |_, dx, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if dx.hypot(dy) < CLICK_SLOP {
|
||||
return;
|
||||
}
|
||||
let state = map.state.borrow();
|
||||
let Some(origin) = state.drag_origin.get() else {
|
||||
return;
|
||||
};
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: World {
|
||||
x: origin.center.x - dx / origin.scale,
|
||||
y: origin.center.y - dy / origin.scale,
|
||||
},
|
||||
..origin
|
||||
});
|
||||
drop(state);
|
||||
map.clamp_view();
|
||||
map.area.queue_draw();
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_end(move |gesture, dx, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
map.state.borrow().drag_origin.set(None);
|
||||
if dx.hypot(dy) >= CLICK_SLOP {
|
||||
return;
|
||||
}
|
||||
if let Some((x, y)) = gesture.start_point() {
|
||||
map.click(x, y);
|
||||
}
|
||||
});
|
||||
self.area.add_controller(drag);
|
||||
|
||||
let pinch = gtk4::GestureZoom::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
pinch.connect_begin(move |_, _| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.pinch_origin.set(Some(state.view.get()));
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
pinch.connect_scale_changed(move |gesture, scale| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(origin) = map.state.borrow().pinch_origin.get() else {
|
||||
return;
|
||||
};
|
||||
let (x, y) = gesture
|
||||
.bounding_box_center()
|
||||
.unwrap_or_else(|| map.middle());
|
||||
let now = map.state.borrow().view.get().scale;
|
||||
map.zoom_at(x, y, origin.scale * scale / now);
|
||||
});
|
||||
self.area.add_controller(pinch);
|
||||
|
||||
let weak = Rc::downgrade(self);
|
||||
self.area
|
||||
.connect_query_tooltip(move |_, x, y, _keyboard, tooltip| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
let Some(text) = map.tooltip_at(f64::from(x), f64::from(y)) else {
|
||||
return false;
|
||||
};
|
||||
tooltip.set_text(Some(&text));
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
fn middle(&self) -> (f64, f64) {
|
||||
(
|
||||
f64::from(self.area.width()) / 2.0,
|
||||
f64::from(self.area.height()) / 2.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// The whole world's width at the least zoom: the widget's own width.
|
||||
fn min_scale(&self) -> f64 {
|
||||
f64::from(self.area.width().max(self.area.height()).max(1))
|
||||
}
|
||||
|
||||
fn zoom_at_middle(&self, factor: f64) {
|
||||
let (x, y) = self.middle();
|
||||
self.zoom_at(x, y, factor);
|
||||
}
|
||||
|
||||
/// Zoom by `factor`, keeping the world point under `(x, y)` where it is.
|
||||
fn zoom_at(&self, x: f64, y: f64, factor: f64) {
|
||||
let min = self.min_scale();
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
{
|
||||
let state = self.state.borrow();
|
||||
let view = state.view.get();
|
||||
let scale = (view.scale * factor).clamp(min, min * MAX_ZOOM);
|
||||
let under = World {
|
||||
x: view.center.x + (x - width / 2.0) / view.scale,
|
||||
y: view.center.y + (y - height / 2.0) / view.scale,
|
||||
};
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: World {
|
||||
x: under.x - (x - width / 2.0) / scale,
|
||||
y: under.y - (y - height / 2.0) / scale,
|
||||
},
|
||||
scale,
|
||||
});
|
||||
}
|
||||
self.clamp_view();
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
/// Keep the world on screen: the map can't be dragged off into nothing.
|
||||
fn clamp_view(&self) {
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
let state = self.state.borrow();
|
||||
let mut view = state.view.get();
|
||||
view.center.x = clamp_axis(view.center.x, width / 2.0 / view.scale);
|
||||
view.center.y = clamp_axis(view.center.y, height / 2.0 / view.scale);
|
||||
state.view.set(view);
|
||||
}
|
||||
|
||||
/// Frame every place, or the whole world when there are none.
|
||||
fn fit(&self, width: f64, height: f64) {
|
||||
let min = self.min_scale();
|
||||
let state = self.state.borrow();
|
||||
let view = match fit_view(&state.places, width, height) {
|
||||
Some(view) => View {
|
||||
scale: view.scale.clamp(min, min * FIT_MAX_ZOOM),
|
||||
..view
|
||||
},
|
||||
None => View {
|
||||
center: World { x: 0.5, y: 0.5 },
|
||||
scale: min,
|
||||
},
|
||||
};
|
||||
state.view.set(view);
|
||||
state.fitted.set(true);
|
||||
drop(state);
|
||||
self.clamp_view();
|
||||
}
|
||||
|
||||
fn draw(&self, area: >k4::DrawingArea, cr: >k4::cairo::Context, width: f64, height: f64) {
|
||||
if width < 1.0 || height < 1.0 {
|
||||
return;
|
||||
}
|
||||
if !self.state.borrow().fitted.get() {
|
||||
self.fit(width, height);
|
||||
}
|
||||
let state = self.state.borrow();
|
||||
// Land in the text colour at low alpha, so it reads in light and dark
|
||||
// alike; bubbles in the accent colour the stylesheet gives the area.
|
||||
let text = area.parent().map_or_else(|| area.color(), |p| p.color());
|
||||
let accent = area.color();
|
||||
let bubbles = paint(
|
||||
cr,
|
||||
state.view.get(),
|
||||
(width, height),
|
||||
&state.places,
|
||||
(&text, &accent),
|
||||
);
|
||||
state.bubbles.replace(bubbles);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the land and the bubbles for `places` as seen through `view`, and
|
||||
/// hand back the bubbles for hit-testing.
|
||||
fn paint(
|
||||
cr: >k4::cairo::Context,
|
||||
view: View,
|
||||
(width, height): (f64, f64),
|
||||
places: &[(PlaceInfo, World)],
|
||||
(text, accent): (>k4::gdk::RGBA, >k4::gdk::RGBA),
|
||||
) -> Vec<Bubble> {
|
||||
let to_screen = |p: World| {
|
||||
(
|
||||
(p.x - view.center.x) * view.scale + width / 2.0,
|
||||
(p.y - view.center.y) * view.scale + height / 2.0,
|
||||
)
|
||||
};
|
||||
let min = World {
|
||||
x: view.center.x - width / 2.0 / view.scale,
|
||||
y: view.center.y - height / 2.0 / view.scale,
|
||||
};
|
||||
let max = World {
|
||||
x: view.center.x + width / 2.0 / view.scale,
|
||||
y: view.center.y + height / 2.0 / view.scale,
|
||||
};
|
||||
for ring in land() {
|
||||
if ring.max.x < min.x || ring.min.x > max.x || ring.max.y < min.y || ring.min.y > max.y {
|
||||
continue;
|
||||
}
|
||||
let mut last: Option<(f64, f64)> = None;
|
||||
for &point in &ring.points {
|
||||
let (x, y) = to_screen(point);
|
||||
match last {
|
||||
None => cr.move_to(x, y),
|
||||
Some((lx, ly)) if (x - lx).hypot(y - ly) < MIN_SEGMENT => continue,
|
||||
Some(_) => cr.line_to(x, y),
|
||||
}
|
||||
last = Some((x, y));
|
||||
}
|
||||
cr.close_path();
|
||||
}
|
||||
set_color(cr, text, 0.12);
|
||||
let _ = cr.fill_preserve();
|
||||
set_color(cr, text, 0.25);
|
||||
cr.set_line_width(0.75);
|
||||
let _ = cr.stroke();
|
||||
|
||||
let screen: Vec<(f64, f64)> = places.iter().map(|(_, p)| to_screen(*p)).collect();
|
||||
let counts: Vec<usize> = places.iter().map(|(p, _)| p.photo_count).collect();
|
||||
let bubbles = cluster(&screen, &counts, width, height);
|
||||
|
||||
// Cairo's own text is enough for a few digits, and needs no layout.
|
||||
cr.select_font_face(
|
||||
"Sans",
|
||||
gtk4::cairo::FontSlant::Normal,
|
||||
gtk4::cairo::FontWeight::Bold,
|
||||
);
|
||||
for bubble in &bubbles {
|
||||
// The last label left a current point; an arc would line up from it.
|
||||
cr.new_path();
|
||||
cr.arc(
|
||||
bubble.x,
|
||||
bubble.y,
|
||||
bubble.radius,
|
||||
0.0,
|
||||
std::f64::consts::TAU,
|
||||
);
|
||||
set_color(cr, accent, 0.85);
|
||||
let _ = cr.fill_preserve();
|
||||
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9);
|
||||
cr.set_line_width(1.5);
|
||||
let _ = cr.stroke();
|
||||
|
||||
let label = compact_count(bubble.photos);
|
||||
cr.set_font_size(bubble.radius.clamp(7.0, 14.0));
|
||||
let Ok(extents) = cr.text_extents(&label) else {
|
||||
continue;
|
||||
};
|
||||
if extents.width() > bubble.radius * 1.8 {
|
||||
continue;
|
||||
}
|
||||
cr.move_to(
|
||||
bubble.x - extents.width() / 2.0 - extents.x_bearing(),
|
||||
bubble.y - extents.height() / 2.0 - extents.y_bearing(),
|
||||
);
|
||||
cr.set_source_rgba(1.0, 1.0, 1.0, 1.0);
|
||||
let _ = cr.show_text(&label);
|
||||
}
|
||||
bubbles
|
||||
}
|
||||
|
||||
impl PlacesMap {
|
||||
/// The bubble under `(x, y)`, the topmost (last drawn) first.
|
||||
fn bubble_at(&self, x: f64, y: f64) -> Option<Vec<usize>> {
|
||||
let state = self.state.borrow();
|
||||
let bubbles = state.bubbles.borrow();
|
||||
bubbles
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| (b.x - x).hypot(b.y - y) <= b.radius)
|
||||
.map(|b| b.members.clone())
|
||||
}
|
||||
|
||||
fn click(&self, x: f64, y: f64) {
|
||||
let Some(members) = self.bubble_at(x, y) else {
|
||||
return;
|
||||
};
|
||||
if let [only] = members[..] {
|
||||
let place = self.state.borrow().places[only].0.clone();
|
||||
if let Some(open) = self.on_open.borrow().as_ref() {
|
||||
open(place);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A merged bubble zooms in on its towns until they part.
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
let target = {
|
||||
let state = self.state.borrow();
|
||||
let chosen: Vec<(PlaceInfo, World)> =
|
||||
members.iter().map(|&i| state.places[i].clone()).collect();
|
||||
fit_view(&chosen, width, height)
|
||||
};
|
||||
let Some(target) = target else {
|
||||
return;
|
||||
};
|
||||
let min = self.min_scale();
|
||||
{
|
||||
let state = self.state.borrow();
|
||||
let view = state.view.get();
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: target.center,
|
||||
// At least one real step in, even when the towns sit on top of
|
||||
// each other at every zoom the fit would pick.
|
||||
scale: target
|
||||
.scale
|
||||
.max(view.scale * BUTTON_ZOOM_STEP)
|
||||
.clamp(min, min * MAX_ZOOM),
|
||||
});
|
||||
}
|
||||
self.clamp_view();
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
/// "Berlin" over "Germany · 12 photos" for a town, or the number of places
|
||||
/// over the first few of their names for a merged bubble.
|
||||
fn tooltip_at(&self, x: f64, y: f64) -> Option<String> {
|
||||
let members = self.bubble_at(x, y)?;
|
||||
let state = self.state.borrow();
|
||||
if let [only] = members[..] {
|
||||
let place = &state.places[only].0;
|
||||
return Some(format!("{}\n{}", place.name, place_subtitle(place)));
|
||||
}
|
||||
let mut lines = vec![places_subtitle(members.len())];
|
||||
lines.extend(
|
||||
members
|
||||
.iter()
|
||||
.take(TOOLTIP_NAMES)
|
||||
.map(|&i| state.places[i].0.name.clone()),
|
||||
);
|
||||
if members.len() > TOOLTIP_NAMES {
|
||||
lines.push("…".to_owned());
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many town names a merged bubble's tooltip lists.
|
||||
const TOOLTIP_NAMES: usize = 5;
|
||||
|
||||
fn map_button(icon: &str, tooltip: &str) -> gtk4::Button {
|
||||
let button = gtk4::Button::from_icon_name(icon);
|
||||
button.set_tooltip_text(Some(tooltip));
|
||||
button.add_css_class("osd");
|
||||
button
|
||||
}
|
||||
|
||||
/// Keep `center` so that half a view, `half`, either side stays inside 0..1 —
|
||||
/// or centred, when the view is wider than the world.
|
||||
fn clamp_axis(center: f64, half: f64) -> f64 {
|
||||
if half * 2.0 >= 1.0 {
|
||||
0.5
|
||||
} else {
|
||||
center.clamp(half, 1.0 - half)
|
||||
}
|
||||
}
|
||||
|
||||
/// The view framing `places` in a `width` × `height` widget, with room for
|
||||
/// the bubbles at the edges. `None` when there is nothing to frame.
|
||||
fn fit_view(places: &[(PlaceInfo, World)], width: f64, height: f64) -> Option<View> {
|
||||
let first = places.first()?.1;
|
||||
let (min, max) = places.iter().fold((first, first), |(min, max), (_, p)| {
|
||||
(
|
||||
World {
|
||||
x: min.x.min(p.x),
|
||||
y: min.y.min(p.y),
|
||||
},
|
||||
World {
|
||||
x: max.x.max(p.x),
|
||||
y: max.y.max(p.y),
|
||||
},
|
||||
)
|
||||
});
|
||||
// A margin of the largest bubble on every side, plus the zoom buttons'
|
||||
// column on the right.
|
||||
let margin = 2.0 * bubble_radius(places.iter().map(|(p, _)| p.photo_count).max()?) + 48.0;
|
||||
let span_x = (max.x - min.x).max(1e-9);
|
||||
let span_y = (max.y - min.y).max(1e-9);
|
||||
let scale =
|
||||
((width - 2.0 * margin).max(1.0) / span_x).min((height - 2.0 * margin).max(1.0) / span_y);
|
||||
Some(View {
|
||||
center: World {
|
||||
x: (min.x + max.x) / 2.0,
|
||||
y: (min.y + max.y) / 2.0,
|
||||
},
|
||||
scale,
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge places whose bubbles would overlap on screen, the one with the most
|
||||
/// photos claiming its neighbours. Places off screen are left out.
|
||||
fn cluster(screen: &[(f64, f64)], counts: &[usize], width: f64, height: f64) -> Vec<Bubble> {
|
||||
let mut order: Vec<usize> = (0..screen.len()).collect();
|
||||
order.sort_by(|a, b| counts[*b].cmp(&counts[*a]));
|
||||
let mut bubbles: Vec<Bubble> = Vec::new();
|
||||
for index in order {
|
||||
let (x, y) = screen[index];
|
||||
let reach = bubble_radius(counts[index]);
|
||||
if x < -reach || y < -reach || x > width + reach || y > height + reach {
|
||||
continue;
|
||||
}
|
||||
match bubbles
|
||||
.iter_mut()
|
||||
.find(|b| (b.x - x).hypot(b.y - y) < b.radius + reach * 0.5)
|
||||
{
|
||||
Some(bubble) => {
|
||||
bubble.photos += counts[index];
|
||||
bubble.radius = bubble_radius(bubble.photos);
|
||||
bubble.members.push(index);
|
||||
}
|
||||
None => bubbles.push(Bubble {
|
||||
x,
|
||||
y,
|
||||
radius: reach,
|
||||
photos: counts[index],
|
||||
members: vec![index],
|
||||
}),
|
||||
}
|
||||
}
|
||||
// Smallest last, so a small bubble next to a big one stays clickable.
|
||||
bubbles.sort_by(|a, b| b.radius.total_cmp(&a.radius));
|
||||
bubbles
|
||||
}
|
||||
|
||||
/// "7", "840", "1.2k", "35k": short enough to fit inside a bubble. Digits
|
||||
/// and the "k" read the same in every language the app ships.
|
||||
fn compact_count(n: usize) -> String {
|
||||
match n {
|
||||
0..1_000 => n.to_string(),
|
||||
1_000..10_000 => format!("{:.1}k", n as f64 / 1_000.0),
|
||||
_ => format!("{}k", n / 1_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_color(cr: >k4::cairo::Context, color: >k4::gdk::RGBA, alpha: f32) {
|
||||
cr.set_source_rgba(
|
||||
f64::from(color.red()),
|
||||
f64::from(color.green()),
|
||||
f64::from(color.blue()),
|
||||
f64::from(color.alpha() * alpha),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn projection_puts_the_equator_and_meridian_in_the_middle() {
|
||||
let p = project(0.0, 0.0);
|
||||
assert!((p.x - 0.5).abs() < 1e-9 && (p.y - 0.5).abs() < 1e-9);
|
||||
// North is up, east is right.
|
||||
let berlin = project(52.52, 13.40);
|
||||
assert!(berlin.x > 0.5 && berlin.y < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_land_ring_parses() {
|
||||
assert_eq!(land().len(), LAND.lines().count());
|
||||
assert!(land().iter().all(|ring| ring.points.len() >= 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_towns_merge_into_one_bubble() {
|
||||
let screen = [(100.0, 100.0), (104.0, 102.0), (400.0, 300.0)];
|
||||
let counts = [3, 10, 1];
|
||||
let bubbles = cluster(&screen, &counts, 800.0, 600.0);
|
||||
assert_eq!(bubbles.len(), 2);
|
||||
let merged = bubbles.iter().find(|b| b.members.len() == 2).unwrap();
|
||||
// The town with more photos claims the other and keeps its position.
|
||||
assert_eq!(merged.members, vec![1, 0]);
|
||||
assert_eq!((merged.x, merged.photos), (104.0, 13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn towns_off_screen_get_no_bubble() {
|
||||
let bubbles = cluster(&[(-500.0, 10.0)], &[1], 800.0, 600.0);
|
||||
assert!(bubbles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_shorten_to_fit_a_bubble() {
|
||||
assert_eq!(compact_count(840), "840");
|
||||
assert_eq!(compact_count(1_240), "1.2k");
|
||||
assert_eq!(compact_count(35_900), "35k");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ crates/pdfs-gui/src/app/pages/login.rs
|
||||
crates/pdfs-gui/src/app/pages/photo_viewer.rs
|
||||
crates/pdfs-gui/src/app/pages/photos.rs
|
||||
crates/pdfs-gui/src/app/pages/places.rs
|
||||
crates/pdfs-gui/src/app/pages/places_map.rs
|
||||
crates/pdfs-gui/src/app/pages/shared.rs
|
||||
crates/pdfs-gui/src/app/pages/shared_by_me.rs
|
||||
crates/pdfs-gui/src/app/pages/status.rs
|
||||
|
||||
+217
-187
File diff suppressed because it is too large.
Load diff
+211
-187
File diff suppressed because it is too large.
Load diff
+219
-188
File diff suppressed because it is too large.
Load diff
Executable
+72
@@ -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])
|
||||
Reference in new issue
Block a user