diff --git a/MigrationMap/MANIFEST b/MigrationMap/MANIFEST new file mode 100644 index 000000000..38dee026f --- /dev/null +++ b/MigrationMap/MANIFEST @@ -0,0 +1 @@ +MigrationMap/README.md diff --git a/MigrationMap/MigrationMap.gpr.py b/MigrationMap/MigrationMap.gpr.py new file mode 100644 index 000000000..b88847ae0 --- /dev/null +++ b/MigrationMap/MigrationMap.gpr.py @@ -0,0 +1,48 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Brian Caudill +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . +# + +# ------------------------------------------------------------------------ +# +# Register the Migration Map tool +# +# ------------------------------------------------------------------------ +register( + TOOL, + id="MigrationMap", + name=_("Migration Map"), + description=_( + "Generate an animated, interactive map of how people and families " + "moved over time, built from dated events whose places have " + "coordinates. Opens in your web browser with a timeline you can play." + ), + version="0.0.1", + gramps_target_version="6.0", + status=STABLE, + audience=EXPERT, + fname="MigrationMap.py", + category=TOOL_ANAL, + toolclass="MigrationMapWindow", + optionclass="MigrationMapOptions", + tool_modes=[TOOL_MODE_GUI], + authors=["Brian Caudill"], + authors_email=["brian.m.caudill@gmail.com"], + maintainers=["Brian Caudill"], + maintainers_email=["brian.m.caudill@gmail.com"], + help_url="Addon:MigrationMap", +) diff --git a/MigrationMap/MigrationMap.py b/MigrationMap/MigrationMap.py new file mode 100644 index 000000000..2fbab0dfd --- /dev/null +++ b/MigrationMap/MigrationMap.py @@ -0,0 +1,419 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Brian Caudill +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . +# + +""" +Migration Map tool. + +Collects every dated event whose place has coordinates, then writes a +self-contained HTML page with an animated Leaflet map: press play and each +person's moves draw themselves across the map as the years advance, with a +pulsing frontier on the moves happening "now" and a running readout of who is +moving where. Pairs with the Geocode Places tool, which fills in the +coordinates this relies on. +""" + +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import os +import html +import json +import pathlib +import tempfile +import webbrowser + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gen.display.place import displayer as place_displayer +from gramps.gen.utils.place import conv_lat_lon +from gramps.gen.plug.menu import BooleanOption, StringOption +from gramps.gui.plug import MenuToolOptions, PluginWindows +from gramps.gen.const import GRAMPS_LOCALE as glocale + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.gettext + + +# ------------------------------------------------------------------------- +# +# HTML template (Leaflet; data spliced in at the marked tokens) +# +# ------------------------------------------------------------------------- +HTML = r""" + + +__TITLE__ + + + +
+

__TITLE__

+

Press or drag the gold handle to watch each person's moves + draw themselves across the years. Pulsing dots are moves happening that year.

+
+
+
+
+
Drag the timeline or press play…
+
+
+
+
+ + + Drag the gold handle to scrub through time. +
+
+
+
+ + +""" + + +# ------------------------------------------------------------------------- +# +# MigrationMapOptions +# +# ------------------------------------------------------------------------- +class MigrationMapOptions(MenuToolOptions): + """ + Options for the Migration Map tool. + """ + + def add_menu_options(self, menu): + """ + Add the tool options. + """ + category = _("Options") + + draw_paths = BooleanOption(_("Draw migration paths"), True) + draw_paths.set_help( + _("Connect each person's locations in time order with a line that " + "grows as the animation plays.") + ) + menu.add_option(category, "draw_paths", draw_paths) + + output = StringOption(_("Output HTML file (blank = temp file)"), "") + output.set_help(_("Where to write the map; left blank uses a temp file.")) + menu.add_option(category, "output", output) + + +# ------------------------------------------------------------------------- +# +# MigrationMapWindow +# +# ------------------------------------------------------------------------- +class MigrationMapWindow(PluginWindows.ToolManagedWindowBatch): + """ + Tool window that builds and opens the animated migration map. + """ + + def get_title(self): + """ + Return the tool window title. + """ + return _("Migration Map") + + def initial_frame(self): + """ + Return the name of the options frame. + """ + return _("Options") + + def collect_events(self): + """ + Return a list of dated, geocoded event dicts for every person. + """ + events = [] + self.progress.set_pass( + _("Collecting located events..."), self.db.get_number_of_people() + ) + for person in self.db.iter_people(): + self.progress.step() + name = person.get_primary_name().get_name() + surname = person.get_primary_name().get_surname() + pid = person.get_gramps_id() + for eref in person.get_event_ref_list(): + event = self.db.get_event_from_handle(eref.ref) + if event is None: + continue + handle = event.get_place_handle() + if not handle: + continue + place = self.db.get_place_from_handle(handle) + if place is None: + continue + lat = (place.get_latitude() or "").strip() + lon = (place.get_longitude() or "").strip() + if not lat or not lon: + continue + dlat, dlon = conv_lat_lon(lat, lon, "D.D8") + if not dlat or not dlon: + continue + year = event.get_date_object().get_year() + if not year: + continue + # Escape free-text fields: rendered as HTML in the Leaflet popup. + events.append( + { + "p": pid, + "name": html.escape(name), + "s": html.escape(surname), + "year": year, + "lat": float(dlat), + "lon": float(dlon), + "place": html.escape(place_displayer.display(self.db, place)), + "etype": html.escape(str(event.get_type())), + } + ) + return events + + def run(self): + """ + Build the animated map and open it in the browser. + """ + opts = self.options.handler.options_dict + events = self.collect_events() + + self.add_results_frame(_("Results")) + if not events: + self.results_write( + _( + "No dated events with coordinates were found. Add place " + "coordinates first (for example with the Geocode Places " + "tool), then run this again.\n" + ) + ) + return + + title = html.escape(_("Migration Map - %s") % self.db.get_dbname()) + page = ( + HTML.replace("__DATA__", json.dumps(events)) + .replace("__PATHS__", "true" if opts["draw_paths"] else "false") + .replace("__TITLE__", title) + ) + + output = (opts["output"] or "").strip() + if not output: + output = os.path.join(tempfile.gettempdir(), "gramps_migration_map.html") + with open(output, "w", encoding="utf-8") as handle: + handle.write(page) + + years = [event["year"] for event in events] + people = len({event["p"] for event in events}) + self.results_write( + _("Mapped %(events)d located events for %(people)d people, " + "%(start)d-%(end)d.\n") + % {"events": len(events), "people": people, + "start": min(years), "end": max(years)} + ) + self.results_write(_("Opening: %s\n") % output) + webbrowser.open(pathlib.Path(output).as_uri()) diff --git a/MigrationMap/README.md b/MigrationMap/README.md new file mode 100644 index 000000000..8581204b3 --- /dev/null +++ b/MigrationMap/README.md @@ -0,0 +1,40 @@ +# Migration Map + +A Gramps **Tool** that builds an animated, interactive map of how people and +families moved over time, then opens it in your web browser. + +It reads every dated event whose place has coordinates, groups them per person +in time order, and writes a self-contained HTML page (Leaflet) with a timeline +you can play: each person's moves **draw themselves across the years**, dots +**pulse** on the moves happening that year, and a readout shows who is moving +where. Paths are colored by surname. + +## Usage + +**Tools → Analysis and Exploration → Migration Map** + +Options: + +| Option | Default | Meaning | +|--------|---------|---------| +| Draw migration paths | on | Connect each person's locations in time order with a line that grows as the animation plays. | +| Output HTML file | (blank) | Where to write the page; blank uses a temp file. | + +Press **▶ Play**, drag the gold handle to scrub through time, or **⏮ Restart**. + +## Requirements + +The map only shows places that have **coordinates**. Genealogy imports usually +have place names but no coordinates, so run a geocoder first — for example the +companion **Geocode Places** tool, or set coordinates with the **Place +Coordinate Gramplet**. Events also need a year. + +## Notes + +- The page loads Leaflet and OpenStreetMap tiles from the internet (with + Subresource Integrity pinned), so an internet connection is needed to view it. +- All person, place, and event text is HTML-escaped before being embedded. + +## Contact + +Brian Caudill — brian.m.caudill@gmail.com diff --git a/MigrationMap/po/template.pot b/MigrationMap/po/template.pot new file mode 100644 index 000000000..1e588ace4 --- /dev/null +++ b/MigrationMap/po/template.pot @@ -0,0 +1,83 @@ +# Translations template for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-06-01 16:03-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: MigrationMap/MigrationMap.py:337 +msgid "Collecting located events..." +msgstr "" + +#: MigrationMap/MigrationMap.py:299 +msgid "" +"Connect each person's locations in time order with a line that grows as " +"the animation plays." +msgstr "" + +#: MigrationMap/MigrationMap.py:297 +msgid "Draw migration paths" +msgstr "" + +#: MigrationMap/MigrationMap.gpr.py:30 +msgid "" +"Generate an animated, interactive map of how people and families moved " +"over time, built from dated events whose places have coordinates. Opens " +"in your web browser with a timeline you can play." +msgstr "" + +#: MigrationMap/MigrationMap.py:413 +#, python-format +msgid "" +"Mapped %(events)d located events for %(people)d people, " +"%(start)d-%(end)d.\n" +msgstr "" + +#: MigrationMap/MigrationMap.gpr.py:28 MigrationMap/MigrationMap.py:323 +msgid "Migration Map" +msgstr "" + +#: MigrationMap/MigrationMap.py:397 +#, python-format +msgid "Migration Map - %s" +msgstr "" + +#: MigrationMap/MigrationMap.py:390 +msgid "" +"No dated events with coordinates were found. Add place coordinates first " +"(for example with the Geocode Places tool), then run this again.\n" +msgstr "" + +#: MigrationMap/MigrationMap.py:418 +#, python-format +msgid "Opening: %s\n" +msgstr "" + +#: MigrationMap/MigrationMap.py:295 MigrationMap/MigrationMap.py:329 +msgid "Options" +msgstr "" + +#: MigrationMap/MigrationMap.py:304 +msgid "Output HTML file (blank = temp file)" +msgstr "" + +#: MigrationMap/MigrationMap.py:386 +msgid "Results" +msgstr "" + +#: MigrationMap/MigrationMap.py:305 +msgid "Where to write the map; left blank uses a temp file." +msgstr "" +