From 8e54aa35a1337ecf9dd2edc8f39831183b5ebff4 Mon Sep 17 00:00:00 2001 From: SNoiraud Date: Wed, 13 Mar 2024 19:25:59 +0100 Subject: [PATCH] Add heatmap to the narrative web. --- gramps/plugins/webreport/basepage.py | 19 + gramps/plugins/webreport/common.py | 33 +- gramps/plugins/webreport/heatmap.py | 1032 ++++++++++++++++++++++ gramps/plugins/webreport/multiselect.py | 334 +++++++ gramps/plugins/webreport/narrativeweb.py | 66 ++ gramps/plugins/webreport/person.py | 4 +- gramps/plugins/webreport/place.py | 3 + 7 files changed, 1482 insertions(+), 9 deletions(-) create mode 100644 gramps/plugins/webreport/heatmap.py create mode 100644 gramps/plugins/webreport/multiselect.py diff --git a/gramps/plugins/webreport/basepage.py b/gramps/plugins/webreport/basepage.py index bbae7d08365..2487afdd65d 100644 --- a/gramps/plugins/webreport/basepage.py +++ b/gramps/plugins/webreport/basepage.py @@ -1891,6 +1891,7 @@ def display_nav_links(self, currentsection, cal=0): ("families", self._("Families"), self.report.inc_families), ("events", self._("Events"), self.report.inc_events), ("places", self._("Places"), self.report.inc_places), + ("heatmaps", self._("Heatmaps"), self.report.inc_heatmaps), ("sources", self._("Sources"), self.report.inc_sources), ("repositories", self._("Repositories"), inc_repos), ( @@ -1964,6 +1965,9 @@ def display_nav_links(self, currentsection, cal=0): elif nav_text == self._("Places"): if "plc" in self.report.cur_fname: check_cs = True + elif nav_text == self._("Heatmap"): + if "heat" in self.report.cur_fname: + check_cs = True elif nav_text == self._("Events"): if "evt" in self.report.cur_fname: check_cs = True @@ -3130,6 +3134,21 @@ def doc_link(self, handle, name, uplink=False, usedescr=True): thumbnail += document_link return thumbnail + def heatmap_link(self, name, uplink=False): + """ + Returns a hyperlink for heatmap link + + @param: name -- repository title + @param: uplink -- If True, then "../../../" is inserted in front of + the result. + """ + url = self.report.build_url_fname_html(name, "heat", uplink) + + hyper = Html("a", html_escape(self._(name)), href=url.replace(" ", ""), title=html_escape(self._(name))) + + # return hyperlink to its callers + return hyper + def place_link(self, handle, name, gid=None, uplink=False): """ Returns a hyperlink for place link diff --git a/gramps/plugins/webreport/common.py b/gramps/plugins/webreport/common.py index e5eac1c33ab..345341f5acf 100644 --- a/gramps/plugins/webreport/common.py +++ b/gramps/plugins/webreport/common.py @@ -229,6 +229,7 @@ OSM_MARKERS = """ window.addEventListener("load", function() { var map; + var heat = '%s'; var tracelife = %s; var iconStyle = new ol.style.Style({ image: new ol.style.Icon(({ @@ -241,6 +242,10 @@ }); var markerSource = new ol.source.Vector({ }); + var centerCoord = new ol.proj.transform([%s, %s], 'EPSG:4326', 'EPSG:3857'); + var zoom = %d; + var radius = %d; + var blur = %d; for (var i = 0; i < tracelife.length; i++) { var loc = tracelife[i]; var iconFeature = new ol.Feature({ @@ -260,13 +265,27 @@ source: markerSource, style: iconStyle }); - var centerCoord = new ol.proj.transform([%s, %s], 'EPSG:4326', 'EPSG:3857'); - map = new ol.Map({ - target: 'map_canvas', - layers: [new ol.layer.Tile({ source: new ol.source.OSM() }), - markerLayer, tooltip], - view: new ol.View({ center: centerCoord, zoom: %d }) - }); + heatmap = new ol.layer.Heatmap({ + source: markerSource, + radius: radius, + blur: blur, + style: iconStyle + }); + if (heat == "heatmap") { + map = new ol.Map({ + target: 'map_canvas', + layers: [new ol.layer.Tile({ source: new ol.source.OSM() }), + heatmap], + view: new ol.View({ center: centerCoord, zoom: zoom }) + }); + } else { + map = new ol.Map({ + target: 'map_canvas', + layers: [new ol.layer.Tile({ source: new ol.source.OSM() }), + markerLayer, tooltip], + view: new ol.View({ center: centerCoord, zoom: zoom }) + }); + }; """ STAMEN_MARKERS = """ diff --git a/gramps/plugins/webreport/heatmap.py b/gramps/plugins/webreport/heatmap.py new file mode 100644 index 00000000000..a1b338e8aad --- /dev/null +++ b/gramps/plugins/webreport/heatmap.py @@ -0,0 +1,1032 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2021 Matthias Kemmer +# +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +"""Heatmap web report.""" + + +# ------------------------------------------------ +# python modules +# ------------------------------------------------ +import logging +from string import Template +from decimal import Decimal, getcontext + + +# ------------------------------------------------------------------------ +# +# GRAMPS modules +# +# ------------------------------------------------------------------------ +from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.utils.place import conv_lat_lon +from gramps.plugins.lib.libhtml import Html +from gramps.gen.display.name import displayer as _nd +from gramps.gen.display.place import displayer as _pd + +# ------------------------------------------------ +# specific narrative web import +# ------------------------------------------------ +from gramps.plugins.webreport.basepage import BasePage +from gramps.gen.lib import ( + EventType, + Event, + Family, + Name, + Person, +) +from gramps.plugins.webreport.common import ( + alphabet_navigation, + FULLCLEAR, + AlphabeticIndex, + MARKER_PATH, + OPENLAYER, + OSM_MARKERS, + html_escape, +) +from gramps.plugins.webreport.multiselect import ( + get_surnames_list, + get_tags_list, +) + +_ = glocale.translation.sgettext +LOG = logging.getLogger(".Heatmap") +getcontext().prec = 8 + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.gettext + + +# ------------------------------------------------------------------------ +# +# HeatmapPlaces Class +# +# ------------------------------------------------------------------------ +class HeatmapPlace: + """Class storing heatmap place data.""" + + def __init__(self, name, latitude, longitude, count, titlename): + self.name = name # gramps_id + self.lat = latitude + self.lon = longitude + self.count = count + self.title_name = titlename + self.events = [] + + def to_list(self): + return [self.lat, self.lon, self.count] + + def add_event(self, event_ID): + self.events.append(event_ID) + + def get_all(self): + return [self.name, self.lat, self.lon, self.count, self.title_name, self.events] + + +# ------------------------------------------------------------------------ +# +# Heatmap Report Class +# +# ------------------------------------------------------------------------ +class HeatmapPage(BasePage): + """ + This class is responsible for displaying information about Heatmaps + + The base class 'BasePage' is initialised once for each page that is + displayed. + """ + + def __init__(self, report, the_lang, the_title, the_filter): + """ + @param: report -- The instance of the main report class + for this report + @param: the_lang -- The lang to process + @param: the_title -- The title page related to the language + """ + BasePage.__init__(self, report, the_lang, the_title, the_filter) + self.report = report + self.sort_name = None + self.person = None + self.rel_class = None + self.index_fname = None + self.events = [] + self.places = [] + self.count = 0 + self.radius = int(report.options["radius"]) + self.blur = int(report.options["blur"]) + self.vallat = float(0.0) + self.vallon = float(0.0) + self.lookingfor = None + self.selected_filter = the_filter + self.display_pages(the_lang, the_title) + + def display_pages(self, the_lang, the_title): + """ + Generate and output the pages under the Individuals tab, namely the + individual index and the individual pages. + + @param: the_lang -- The lang to process + @param: the_title -- The title page related to the language + """ + default_types = [name[1] for name in EventType._DATAMAP] # Initial local name + custom_types = [name for name in self.r_db.get_event_types()] + sorted_default_types = sorted([*default_types, *custom_types]) + selected = self.report.options["selected_evts"] + + def in_standard_event(value): + result = False + for evt in EventType._DATAMAP: + if value == evt[1]: + result = True + return result + + saved_evts = [] + for evt in selected: + index = 0 + for typ in sorted_default_types: + if index == int(evt) and not in_standard_event(typ): + self.events.append(typ) + if index == int(evt): + # Save the local name + saved_evts.append(typ) + index += 1 + default_types = [name for name in EventType._DATAMAP] # Initial local name + for evt in saved_evts: + index = 0 + for typ in default_types: + if typ[1] == evt: + self.events.append(typ[2]) + index += 1 + if "All Events" not in self.events: + self.events.append("All Events") # All events + self.event_types = sorted([*default_types]) + + self.heatmaplistpage(self.report, the_lang, the_title, self.events) + progress_title = self.report.pgrs_title(the_lang) + message = _("Creating heatmap pages for events") + nbs = len(self.events) + with self.r_user.progress(progress_title, message, nbs) as step: + for event_type in sorted(self.events): + step() + self.heatmappage( + self.report, the_lang, the_title, event_type=event_type + ) + step() + + def heatmaplistpage(self, report, the_lang, the_title, events): + """Heatmap report class.""" + date = 0 + output_file, sio = self.report.create_file("heatmaps") + self.index_fname = output_file.name + self.uplink = False + result = self.write_header(self._("Heatmaps")) + indlistpage, dummy_head, dummy_body, outerwrapper = result + + # begin events division + with Html("div", class_="content", id="Events") as eventlist: + outerwrapper += eventlist + # Individual List page message + msg = self._( + "The next section contains an index of all the events for an Heatmap map." + ) + eventlist += Html("p", msg, id="description") + + # Assemble the alphabeticIndex + index = AlphabeticIndex(self.rlocale) + for event in self.events: + index.addRecord(self._(event), None) + + # Extract the buckets from the index + index_list = [] + index.resetBucketIterator() + while index.nextBucket(): + if index.bucketRecordCount != 0: + index_list.append(index.bucketLabel) + # Output the navigation + href_label = "event" + alpha_nav = alphabet_navigation( + index_list, + self.rlocale, + rtl=self.dir, + ) + if alpha_nav: + eventlist += alpha_nav + + # begin events table + with Html( + "table", class_="infolist primobjlist eventlist " + self.dir + ) as table: + eventlist += table + + # begin table head + thead = Html("thead") + table += thead + + trow = Html("tr") + thead += trow + + trow.extend( + Html("th", label, class_=colclass, inline=True) + for (label, colclass) in [ + [self._("Letter"), "ColumnLetter"], + [self._("Name", "Event Name"), "ColumnName"], + [self._("Description"), "ColumnDescription"], + ] + ) + + # begin table body + tbody = Html("tbody") + table += tbody + + # For each bucket, output the places in that bucket + index.resetBucketIterator() + output = [] + dup_index = 0 + while index.nextBucket(): + if index.bucketRecordCount != 0: + bucket_letter = index.bucketLabel + bucket_link = bucket_letter + if bucket_letter in output: + bucket_link = "%s (%i)" % (bucket_letter, dup_index) + dup_index += 1 + output.append(bucket_letter) + # Assemble all the events in this bucket into a dict for + # sorting + event_dict = {} + first_event = True + while index.nextRecord(): + event_name = index.recordName + value = index.recordData + event_dict[event_name] = value + + letter = bucket_letter + for event in events: + if self._(event) != event_name: + continue + trow = Html("tr") + tbody += trow + tcell = Html("td", class_="ColumnLetter", inline=True) + trow += tcell + if first_event: + first_event = False + trow.attr = 'class = "BeginLetter"' + ttle = ( + self._("Places beginning " "with letter %s") + % letter + ) + tcell += Html( + "a", + letter, + name="%s_%s" % (href_label, letter), + title=ttle, + id_=bucket_link, + ) + else: + tcell += " " + trow += Html( + "td", + self.heatmap_link(event), + class_="ColumnName", + ) + dname = self._( + "Heatmap for %s within the selected filter" + ) % self._(event) + trow.extend( + Html( + "td", + data or " ", + class_=colclass, + inline=True, + ) + for (colclass, data) in [ + ["ColumnDescription", dname], + ] + ) + + people = self.report.options["selected_surnames"] + if people: + # begin surnames division + with Html("div", class_="content", id="Surnames") as surnamelist: + outerwrapper += surnamelist + msg = self._( + "The next section contains an index of all the surnames for an Heatmap map." + ) + surnamelist += Html("p", msg, id="description") + selected_people = get_surnames_list(self.r_db) + index1 = AlphabeticIndex(self.rlocale) + surnames = [] + for item in people: + index = 0 + for value in sorted( + selected_people, key=lambda x: x[1], reverse=True + ): + if int(item) == index: + surnames.append(value[0]) + index1.addRecord(value[0], None) + self.heatmappage( + self.report, the_lang, the_title, surname=value + ) + index += 1 + + # Extract the buckets from the index + index_list = [] + index1.resetBucketIterator() + while index1.nextBucket(): + if index1.bucketRecordCount != 0: + index_list.append(index1.bucketLabel) + # Output the navigation + href_label = "surname" + alpha_nav = alphabet_navigation( + index_list, + self.rlocale, + rtl=self.dir, + ) + if alpha_nav: + surnamelist += alpha_nav + + # begin surnames table1 + with Html( + "table", class_="infolist primobjlist surnamelist " + self.dir + ) as table1: + surnamelist += table1 + + # begin table1 head + thead = Html("thead") + table1 += thead + + trow = Html("tr") + thead += trow + + trow.extend( + Html("th", label, class_=colclass, inline=True) + for (label, colclass) in [ + [self._("Letter"), "ColumnLetter"], + [self._("Surname", "Name"), "ColumnName"], + [self._("Description"), "ColumnDescription"], + ] + ) + + # begin table1 body + tbody = Html("tbody") + table1 += tbody + + # For each bucket, output the places in that bucket + index1.resetBucketIterator() + output = [] + dup_index = 0 + while index1.nextBucket(): + if index1.bucketRecordCount != 0: + bucket_letter = index1.bucketLabel + bucket_link = bucket_letter + if bucket_letter in output: + bucket_link = "%s (%i)" % (bucket_letter, dup_index) + dup_index += 1 + output.append(bucket_letter) + # Assemble all the surnames in this bucket into a dict for + # sorting + surname_dict = {} + first_surname = True + while index1.nextRecord(): + surname_name = index1.recordName + value = index1.recordData + surname_dict[surname_name] = value + + letter = bucket_letter + for surname in surnames: + if self._(surname) != surname_name: + continue + trow = Html("tr") + tbody += trow + tcell = Html( + "td", class_="ColumnLetter", inline=True + ) + trow += tcell + if first_surname: + first_surname = False + trow.attr = 'class = "BeginLetter"' + ttle = ( + self._("Places beginning " "with letter %s") + % letter + ) + tcell += Html( + "a", + letter, + name="%s_%s" % (href_label, letter), + title=ttle, + id_=bucket_link, + ) + else: + tcell += " " + trow += Html( + "td", + self.heatmap_link(surname), + class_="ColumnName", + ) + dname = self._( + "Heatmap for %s within the selected filter" + ) % self._(surname) + trow.extend( + Html( + "td", + data or " ", + class_=colclass, + inline=True, + ) + for (colclass, data) in [ + ["ColumnDescription", dname], + ] + ) + + sel_tags = self.report.options["selected_tags"] + if sel_tags: + # begin tags division + with Html("div", class_="content", id="Surnames") as taglist: + outerwrapper += taglist + msg = self._( + "The next section contains an index of all the tags for an Heatmap map." + ) + taglist += Html("p", msg, id="description") + selected_tags = get_tags_list(self.r_db) + index2 = AlphabeticIndex(self.rlocale) + tags = [] + for item in sel_tags: + index = 0 + for value in selected_tags: + if int(item) == index: + tags.append(value[0]) + index2.addRecord(value[0], None) + self.heatmappage( + self.report, the_lang, the_title, tag=value + ) + index += 1 + + # Extract the buckets from the index + index_list = [] + index2.resetBucketIterator() + while index2.nextBucket(): + if index2.bucketRecordCount != 0: + index_list.append(index2.bucketLabel) + # Output the navigation + href_label = "tag" + alpha_nav = alphabet_navigation( + index_list, + self.rlocale, + rtl=self.dir, + ) + if alpha_nav: + taglist += alpha_nav + + # begin tags table1 + with Html( + "table", class_="infolist primobjlist taglist " + self.dir + ) as table1: + taglist += table1 + + # begin table1 head + thead = Html("thead") + table1 += thead + + trow = Html("tr") + thead += trow + + trow.extend( + Html("th", label, class_=colclass, inline=True) + for (label, colclass) in [ + [self._("Letter"), "ColumnLetter"], + [self._("Tag"), "ColumnName"], + [self._("Description"), "ColumnDescription"], + ] + ) + + # begin table1 body + tbody = Html("tbody") + table1 += tbody + + # For each bucket, output the places in that bucket + index2.resetBucketIterator() + output = [] + dup_index = 0 + while index2.nextBucket(): + if index2.bucketRecordCount != 0: + bucket_letter = index2.bucketLabel + bucket_link = bucket_letter + if bucket_letter in output: + bucket_link = "%s (%i)" % (bucket_letter, dup_index) + dup_index += 1 + output.append(bucket_letter) + # Assemble all the tags in this bucket into a dict for + # sorting + tag_dict = {} + first_tag = True + while index2.nextRecord(): + tag_name = index2.recordName + value = index2.recordData + tag_dict[tag_name] = value + + letter = bucket_letter + for tag in tags: + if self._(tag) != self._(tag_name): + continue + trow = Html("tr") + tbody += trow + tcell = Html( + "td", class_="ColumnLetter", inline=True + ) + trow += tcell + if first_tag: + first_tag = False + trow.attr = 'class = "BeginLetter"' + ttle = ( + self._("Places beginning " "with letter %s") + % letter + ) + tcell += Html( + "a", + letter, + name="%s_%s" % (href_label, letter), + title=ttle, + id_=bucket_link, + ) + else: + tcell += " " + trow += Html( + "td", + self.heatmap_link(tag), + class_="ColumnName", + ) + dname = self._( + "Heatmap for %s within the selected filter" + ) % self._(tag) + trow.extend( + Html( + "td", + data or " ", + class_=colclass, + inline=True, + ) + for (colclass, data) in [ + ["ColumnDescription", dname], + ] + ) + + # create footer section + footer = self.write_footer(date) + outerwrapper += (FULLCLEAR, footer) + + # send page out for processing + # and close the file + self.xhtml_writer(indlistpage, output_file, sio, date) + + def heatmappage( + self, report, the_lang, the_title, event_type=None, surname=None, tag=None + ): + """Heatmap report class.""" + date = 0 + self.places = [] + self.tracelife = [] + self.count = 0 + self.vallat = float(0.0) + self.vallon = float(0.0) + self.uplink = True + if event_type: + self.lookingfor = self._(event_type) + output_file, sio = self.report.create_file( + event_type.replace(" ", ""), "heat" + ) + result = self.write_header(event_type) + events = self.get_all_events(event_type) + msg = self._( + "This section contains the map for all people with the event: %s (%s)" + ) % (event_type, self.selected_filter) + if not len(self.places): + msg += self._(" which has no event for the selected filter.") + tracelife = self.create_tracelife(self.places, events) + elif surname: + selected = [] + output_file, sio = self.report.create_file( + surname[0].replace(" ", ""), "heat" + ) + result = self.write_header(surname[0]) + selected = self.get_surname_events(surname[2]) + msg = self._("This section contains the map of all events for: %s (%s)") % ( + surname[0], + self.selected_filter, + ) + if not len(self.places): + msg += self._(" which has no event for the selected filter.") + tracelife = self.create_tracelife(self.places, selected) + elif tag: + output_file, sio = self.report.create_file(tag[0].replace(" ", ""), "heat") + result = self.write_header(tag[0]) + selected = self.get_tag_events(tag) + msg = self._( + "This section contains the map for all people, events with the tag: %s (%s)" + ) % (tag[0], self.selected_filter) + if not len(self.places): + msg += self._(" which has no event for the selected filter.") + tracelife = self.create_tracelife(self.places, tag) + heatmappage, head, dummy_body, outerwrapper = result + + minx, maxx = Decimal("0.00000001"), Decimal("0.00000001") + miny, maxy = Decimal("0.00000001"), Decimal("0.00000001") + xwidth, yheight = [], [] + midx_, midy_, dummy_spanx, spany = [None] * 4 + + # add narrative-maps CSS... + if the_lang and not self.usecms: + fname = "/".join(["..", "css", "narrative-maps.css"]) + else: + fname = "/".join(["css", "narrative-maps.css"]) + url = self.report.build_url_fname(fname, None, self.uplink) + head += Html( + "link", + href=url, + type="text/css", + media="screen", + rel="stylesheet", + ) + + # add MapService specific javascript code. We use only OSM maps. + src_js = "https://" + src_js += "ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js" + head += Html("script", type="text/javascript", src=src_js, inline=True) + olv = self.report.options["ol_version"] + build = "legacy" + if olv < "v7.0.0": + build = "build" + if olv == "latest": + build = "legacy" + src_js = ("https://openlayers.org/en/" "%(ver)s/%(bld)s/ol.js") % { + "ver": olv, + "bld": build, + } + head += Html("script", type="text/javascript", src=src_js, inline=True) + css = "legacy" + if olv < "v7.0.0": + css = "css" + if olv == "latest": + css = "legacy" + url = ("https://openlayers.org/en/" "%(ver)s/%(css)s/ol.css") % { + "ver": olv, + "css": css, + } + head += Html("link", href=url, type="text/css", rel="stylesheet") + + src_js = ("https://openlayers.org/en/" "%(ver)s/%(bld)s/ol.js") % { + "ver": olv, + "bld": build, + } + head += Html("script", href=url, type="text/javascript", rel="stylesheet") + + # begin events division + with Html("div", class_="content", id="events") as taglist: + outerwrapper += taglist + # Individual List page message + taglist += Html("p", msg, id="description") + + # this is the style element where the Map is held in the CSS... + with Html("div", id="map_canvas") as canvas: + outerwrapper += canvas + + # add div for popups. + with Html("div", id="popup", class_="ol-popup", inline=True) as popup: + outerwrapper += popup + popup += Html("a", href="#", id="popup-closer", class_="ol-popup-closer") + popup += Html("div", id="popup-title", class_="ol-popup-title") + popup += Html("div", id="popup-content", class_="ol-popup-content") + with Html("div", id="tooltip", class_="ol-popup", inline=True) as tooltip: + outerwrapper += tooltip + tooltip += Html("div", id="tooltip-content") + + start_zoom = self.report.options["start_zoom"] + + # Collect heatmap coordinates + heatmap_data = [place.to_list() for place in self.places] + + # Substitute the HTML template source code with heatmap report data + if self.count: + start_lat = self.vallat / self.count + start_lon = self.vallon / self.count + else: + start_lat = 0.0 + start_lon = 0.0 + + # begin javascript inline code... + with Html( + "script", + deter="deter", + style="width =100%; height =100%;", + type="text/javascript", + indent=False, + ) as jsc: + outerwrapper += jsc + + # Link to Gramps marker + fname = "/".join(["images", "marker.png"]) + marker_path = self.report.build_url_image( + "marker.png", "images", self.uplink + ) + + latitude = start_lat + longitude = start_lon + jsc += MARKER_PATH % marker_path + # we are using OpenStreetMap + if midy_ is None: + jsc += OSM_MARKERS % ( + "heatmap", + tracelife, + longitude, + latitude, + start_zoom, + self.radius, + self.blur, + ) + else: + jsc += OSM_MARKERS % ( + "heatmap", + tracelife, + midy_, + midx_, + start_zoom, + self.radius, + self.blur, + ) + jsc += OPENLAYER + + # create footer section + footer = self.write_footer(date) + outerwrapper += (FULLCLEAR, footer) + + # send page out for processing + # and close the file + self.xhtml_writer(heatmappage, output_file, sio, date) + + def get_all_events(self, event_type): + """Get all relevant events from the current filter.""" + if self.lookingfor == self._("All Events"): + for event_hdl in sorted(self.report.obj_dict[Event]): + event = self.r_db.get_event_from_handle(event_hdl) + self.select_event(event, evt=None) + else: + for index, name in enumerate(self.event_types): + for event_hdl in sorted(self.report.obj_dict[Event]): + event = self.r_db.get_event_from_handle(event_hdl) + self.select_event(event, evt=event_type) + + def get_tag_events(self, tag): + """Get all relevant tags.""" + selected = [] + for handle in self.report.obj_dict[Person]: + person = self.r_db.get_person_from_handle(handle) + if person: + tags_list = person.get_tag_list() + if tag[1] in tags_list: + selected.append((Person, handle)) + self.get_place(person.get_event_ref_list()) + for handle in self.report.obj_dict[Family]: + family = self.r_db.get_family_from_handle(handle) + if family: + tags_list = family.get_tag_list() + if tag[1] in tags_list: + selected.append((Family, handle)) + fam_evt_ref_list = family.get_event_ref_list() + if fam_evt_ref_list: + self.get_place(fam_evt_ref_list) + for handle in self.report.obj_dict[Event]: + event = self.r_db.get_event_from_handle(handle) + if event: + tags_list = event.get_tag_list() + if tag[1] in tags_list: + selected.append((Event, handle)) + self.get_place([event]) + return selected + + def get_surname_events(self, person_handle): + selected_surname = [] + for handle in person_handle: + if handle not in self.report.obj_dict[Person]: + continue + selected_surname.append(handle) + events = self.get_events(handle) + if events: + self.get_place(events) + return selected_surname + + def select_event(self, event, evt=None): + if evt: + if event.get_type().xml_str() == evt: + handle = event.get_place_handle() + if handle: + place = self.r_db.get_place_from_handle(handle) + self.check_place(place, event) + else: + handle = event.get_place_handle() + if handle: + place = self.r_db.get_place_from_handle(handle) + self.check_place(place, event) + + def get_events(self, person_h, force=False): + """Get all relevant events of a person.""" + person = self.r_db.get_person_from_handle(person_h) + event_list = [] + for event_ref in person.get_event_ref_list(): + event = self.r_db.get_event_from_handle(event_ref.ref) + event_list.append(event_ref) + # select all events for the associated families + fam_handle_list = person.get_family_handle_list() + if fam_handle_list: + for family_handle in fam_handle_list: + family = self.r_db.get_family_from_handle(family_handle) + if family: + fam_evt_ref_list = family.get_event_ref_list() + if fam_evt_ref_list: + for evt_ref in fam_evt_ref_list: + event_list.append(evt_ref) + return event_list + + def get_place(self, events): + """Get an event places and call check_place.""" + for event_ref in events: + if not isinstance(event_ref, Event): + event = self.r_db.get_event_from_handle(event_ref.ref) + else: + event = event_ref + handle = event.get_place_handle() + if handle: + place = self.r_db.get_place_from_handle(handle) + self.check_place(place, event) + + def check_place(self, place, event): + """Check the place for latitude and longitude.""" + lat, lon = conv_lat_lon(place.get_latitude(), place.get_longitude(), "D.D8") + name = place.get_gramps_id() + placetitle = _pd.display(self.r_db, place, fmt=0) + eid = event.get_gramps_id() + + if lat and lon: + for placex in self.places: + if name == placex.name: + placex.count += 1 + if not (eid in placex.events): + placex.add_event(event.get_gramps_id()) + self.vallat += float(lat) + self.vallon += float(lon) + self.count += 1 + found = None + for placex in self.places: + if name == placex.name: + found = placex + if not found: + self.places.append(HeatmapPlace(name, lat, lon, 1, placetitle)) + for placex in self.places: + if name == placex.name: + self.count += 1 + placex.add_event(event.get_gramps_id()) + self.vallat += float(lat) + self.vallon += float(lon) + else: + for place_ref in place.get_placeref_list(): + place_new = self.r_db.get_place_from_handle(place_ref.ref) + self.check_place(place_new, event) + + def create_tracelife(self, places, handles): + number_markers = len(places) + places_list = [] + for place in places: + places_list.append(place.get_all()) + sorted_places = sorted(places_list, key=lambda x: x[0]) + placetitle = "unknown" + tracelife = "[" + if number_markers > 0: + for index in range(0, number_markers): + (name, latitude, longitude, count, placetitle, events) = sorted_places[ + index + ] + debug = 0 + links = "''" + for event_ID in events: + event = self.r_db.get_event_from_gramps_id(event_ID) + handle = event.get_place_handle() + if handle: + place = self.r_db.get_place_from_handle(handle) + placename = _pd.display(self.r_db, place, fmt=0) + p_fname = ( + self.report.build_url_fname(handle, "plc", self.uplink) + + self.ext + ) + placetitle = ' %s' % (p_fname, placename) + + bkref_list = self.report.bkref_dict[Event][event.handle] + for ref in bkref_list: + (bkref_class, bkref_hdle, role) = ref + if bkref_class == Person: + person = self.r_db.get_person_from_handle(bkref_hdle) + links = self.__create_links_tracelife( + links, + person, + placetitle, + latitude, + longitude, + ref, + event, + ) + debug = 0 + tracelife = self._create_family_tracelife( + tracelife, placetitle, latitude, longitude, links + ) + tracelife += "]" + else: + tracelife += "]" + return tracelife + + def _create_family_tracelife( + self, tracelife, placetitle, latitude, longitude, links + ): + """ + creates individual family tracelife map events + + @param: tracelife -- The family event list + @param: placetitle -- The place title to add to the event list + @param: latitude -- The latitude for this place + @param: longitude -- The longitude for this place + @param: links -- Used to add links in the popup html page + """ + tracelife += """[%f, %f, \'%s\', %s],""" % ( + float(longitude), + float(latitude), + placetitle.replace("'", "\\'"), + links, + ) + return tracelife + + def __create_links_tracelife( + self, links, person, placetitle, latitude, longitude, ref, event + ): + """ + creates individual family events for a marker + + @param: links -- Used to add links in the popup html page + @param: person -- family for this person + @param: placetitle -- The place title to add to the event list + @param: latitude -- The latitude for this place + @param: longitude -- The longitude for this place + @param: event -- The event for which we are working + @param: ref -- The back reference and role for the event + """ + if not person: + return links + url_fct = self.report.build_url_fname_html + ppl_fct = self.r_db.get_person_from_handle + ln_str = "%s" + ppl_lnk = "" + (bkref_class, bkref_hdle, role) = ref + if role == "Marriage" or role == "Divorce" or role == "Family": + url = url_fct(bkref_hdle, "fam", self.uplink) + fam_fct = self.r_db.get_family_from_handle + fam = fam_fct(bkref_hdle) + fam_name = self.report.get_family_name(fam) + ppl_lnk = ln_str % (url, fam.get_gramps_id(), fam_name) + if "

" in links: + links += ' + "
%s"' % ppl_lnk + else: + links = '"

%s"' % ppl_lnk + elif bkref_class == Person: + pers = ppl_fct(bkref_hdle) + url = url_fct(bkref_hdle, "ppl", self.uplink) + ppl_lnk = ln_str % ( + url, + pers.get_gramps_id(), + self.get_name(pers).replace('"', ""), + ) + evt_type = self._(event.get_type().xml_str()) + evt_date = self.rlocale.get_date(event.get_date_object()) + if self.report.options["inc_events"]: + url = url_fct(event.get_handle(), "evt", self.uplink) + evt_lnk = ln_str % (url, evt_date, evt_type) + else: + evt_lnk = evt_type + if evt_date: + evt_lnk += " (" + evt_date + ")" + if "

" in links: + links += ' + "
%s"' % (ppl_lnk + self._(":") + " " + evt_lnk) + else: + links = '"

%s"' % (ppl_lnk + self._(":") + " " + evt_lnk) + return links diff --git a/gramps/plugins/webreport/multiselect.py b/gramps/plugins/webreport/multiselect.py new file mode 100644 index 00000000000..a672db4db5b --- /dev/null +++ b/gramps/plugins/webreport/multiselect.py @@ -0,0 +1,334 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2021 Matthias Kemmer +# Copyright (C) 2024- Serge Noiraud +# +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +"""Collection of classes creating a multi-select listbox for menu options.""" + + +# ------------------------------------------------------------------------- +# +# GTK Modules +# +# ------------------------------------------------------------------------- +from collections import defaultdict +from gi.repository import Gtk # type: ignore +from gramps.plugins.webreport.common import get_surname_from_person + +# ------------------------------------------------------------------------ +# +# GRAMPS modules +# +# ------------------------------------------------------------------------ +from gramps.gen.lib import EventType # type: ignore +from gramps.gen.plug.menu import Option as PlugOption # type: ignore +from gramps.gui.widgets.multitreeview import MultiTreeView +from gramps.gen.const import GRAMPS_LOCALE as glocale + +try: + _trans = glocale.get_addon_translator(__file__) +except ValueError: + _trans = glocale.translation +_ = _trans.gettext + + +# ------------------------------------------------------------------------ +# +# MultiSelectOption Class for events +# +# ------------------------------------------------------------------------ +class MultiSelectEvents(PlugOption): + """Extending gramps.gen.plug.menu._option.Option""" + + def __init__(self, label, value): + PlugOption.__init__(self, label, value) + + +# ------------------------------------------------------------------------ +# +# MultiSelectOption Class for surnames +# +# ------------------------------------------------------------------------ +class MultiSelectSurnames(PlugOption): + """Extending gramps.gen.plug.menu._option.Option""" + + def __init__(self, label, value): + PlugOption.__init__(self, label, value) + + +# ------------------------------------------------------------------------ +# +# MultiSelectOption Class for tags +# +# ------------------------------------------------------------------------ +class MultiSelectTags(PlugOption): + """Extending gramps.gen.plug.menu._option.Option""" + + def __init__(self, label, value): + PlugOption.__init__(self, label, value) + + +# ------------------------------------------------------------------------ +# +# HeatmapScrolled Class for events +# +# ------------------------------------------------------------------------ +class HeatmapEventsScrolled(Gtk.ScrolledWindow): + """Extending Gtk.ScrolledWindow.""" + + def __init__(self, option, dbstate, uistate, track, override=False): + Gtk.ScrolledWindow.__init__(self) + self.set_min_content_height(60) # Real size of the scrolled window + self.add(HeatmapEventsMultiTreeView(dbstate, option)) + + +# ------------------------------------------------------------------------ +# +# HeatmapScrolled Class for surnames +# +# ------------------------------------------------------------------------ +class HeatmapSurnamesScrolled(Gtk.ScrolledWindow): + """Extending Gtk.ScrolledWindow.""" + + def __init__(self, option, dbstate, uistate, track, override=False): + Gtk.ScrolledWindow.__init__(self) + self.set_min_content_height(60) # Real size of the scrolled window + self.add(HeatmapSurnamesMultiTreeView(dbstate, option)) + + +# ------------------------------------------------------------------------ +# +# HeatmapScrolled Class for tags +# +# ------------------------------------------------------------------------ +class HeatmapTagsScrolled(Gtk.ScrolledWindow): + """Extending Gtk.ScrolledWindow.""" + + def __init__(self, option, dbstate, uistate, track, override=False): + Gtk.ScrolledWindow.__init__(self) + self.set_min_content_height(60) # Real size of the scrolled window + self.add(HeatmapTagsMultiTreeView(dbstate, option)) + + +# ------------------------------------------------------------------------ +# +# HeatmapMultiTreeView Class for events +# +# ------------------------------------------------------------------------ +class HeatmapEventsMultiTreeView(MultiTreeView): + """Extending gramps.gui.widgets.multitreeview.""" + + def __init__(self, dbstate, option): + MultiTreeView.__init__(self) + self.db = dbstate.db + self.option = option + self.get_selection().set_mode(Gtk.SelectionMode.NONE) + self.selected_rows = list() + + # Event types data + default_types = [name[1] for name in EventType._DATAMAP] + custom_types = [name for name in self.db.get_event_types()] + self.data = sorted([*default_types, *custom_types]) + + # Setup columns + model = Gtk.ListStore(bool, str) + self.set_model(model) + + toggle_renderer = Gtk.CellRendererToggle() + toggle_renderer.set_property("activatable", True) + toggle_renderer.connect("toggled", self.toggle, 0) + col_check = Gtk.TreeViewColumn("", toggle_renderer) + col_check.add_attribute(toggle_renderer, "active", 0) + self.append_column(col_check) + + col_name = Gtk.TreeViewColumn(_("Event type"), Gtk.CellRendererText(), text=1) + self.append_column(col_name) + + # Fill columns with data + for item in self.data: + model.append([False, item]) + + self.load_last_values() + + def load_last_values(self): + for row in self.option.get_value(): + self.get_model()[row][0] = True + + def toggle(self, _, row, col): + is_activated = self.get_model()[row][col] + values = self.option.get_value() + + if is_activated: + values.remove(row) + else: + values.append(row) + + self.option.set_value(values) + + # Invert the checkbox value + self.get_model()[row][col] = not self.get_model()[row][col] + + +# ------------------------------------------------------------------------ +# +# HeatmapMultiTreeView Class for surnames +# +# ------------------------------------------------------------------------ +class HeatmapSurnamesMultiTreeView(MultiTreeView): + """Extending gramps.gui.widgets.multitreeview.""" + + def __init__(self, dbstate, option): + MultiTreeView.__init__(self) + self.db = dbstate.db + self.option = ( + option # should contains the list of people like [self.obj_dict[Person]] + ) + self.get_selection().set_mode(Gtk.SelectionMode.NONE) + self.selected_rows = list() + + # Surnames list + data = get_surnames_list(self.db) + + # Setup columns + model = Gtk.ListStore(bool, str) + self.set_model(model) + + toggle_renderer = Gtk.CellRendererToggle() + toggle_renderer.set_property("activatable", True) + toggle_renderer.connect("toggled", self.toggle, 0) + col_check = Gtk.TreeViewColumn("", toggle_renderer) + col_check.add_attribute(toggle_renderer, "active", 0) + self.append_column(col_check) + + col_name = Gtk.TreeViewColumn(_("Event type"), Gtk.CellRendererText(), text=1) + self.append_column(col_name) + + # Fill columns with data + index = 1 + for item in sorted(data, key=lambda x: x[1], reverse=True): + if index > 10: # Show only the 10 largest group of people + break + model.append([False, item[0] + " (" + str(item[1]) + ")"]) + index += 1 + + self.load_last_values() + + def load_last_values(self): + for row in self.option.get_value(): + self.get_model()[row][0] = True + + def toggle(self, _, row, col): + is_activated = self.get_model()[row][col] + values = self.option.get_value() + + if is_activated: + values.remove(row) + else: + values.append(row) + + self.option.set_value(values) + + # Invert the checkbox value + self.get_model()[row][col] = not self.get_model()[row][col] + + +def get_surnames_list(db): + # Assemble all the handles for each surname into a dictionary + # We don't call sort_people because we don't care about sorting + # individuals, only surnames + iter_persons = ( + db.iter_person_handles() + ) # All persons in the database. no filtering. + surname_handle_dict = defaultdict(list) + for person_handle in iter_persons: + person = db.get_person_from_handle(person_handle) + surname = get_surname_from_person(db, person) + surname_handle_dict[surname].append(person_handle) + # count_ppl_handle_dict = {} + # count_ppl_handle_dict = defaultdict(list) + count_ppl_handle = [] + for surname, data_list in surname_handle_dict.items(): + # count_ppl_handle_dict[len(data_list)].append( + count_ppl_handle.append((surname, len(data_list), data_list)) + return count_ppl_handle + + +# ------------------------------------------------------------------------ +# +# HeatmapMultiTreeView Class for tags +# +# ------------------------------------------------------------------------ +class HeatmapTagsMultiTreeView(MultiTreeView): + """Extending gramps.gui.widgets.multitreeview.""" + + def __init__(self, dbstate, option): + MultiTreeView.__init__(self) + self.db = dbstate.db + self.option = option + self.get_selection().set_mode(Gtk.SelectionMode.NONE) + self.selected_rows = list() + + # Tags + data = get_tags_list(self.db) + + # Setup columns + model = Gtk.ListStore(bool, str) + self.set_model(model) + + toggle_renderer = Gtk.CellRendererToggle() + toggle_renderer.set_property("activatable", True) + toggle_renderer.connect("toggled", self.toggle, 0) + col_check = Gtk.TreeViewColumn("", toggle_renderer) + col_check.add_attribute(toggle_renderer, "active", 0) + self.append_column(col_check) + + col_name = Gtk.TreeViewColumn(_("Tag"), Gtk.CellRendererText(), text=1) + self.append_column(col_name) + + # Fill columns with data + for item in data: + model.append([False, item[0]]) + + self.load_last_values() + + def load_last_values(self): + for row in self.option.get_value(): + self.get_model()[row][0] = True + + def toggle(self, _, row, col): + is_activated = self.get_model()[row][col] + values = self.option.get_value() + + if is_activated: + values.remove(row) + else: + values.append(row) + + self.option.set_value(values) + + # Invert the checkbox value + self.get_model()[row][col] = not self.get_model()[row][col] + + +def get_tags_list(db): + tag_list = [] + for handle in db.get_tag_handles(sort_handles=True): + tag = db.get_tag_from_handle(handle) + tag_list.append((tag.get_name(), tag.get_handle())) + data = sorted([*tag_list]) + return data diff --git a/gramps/plugins/webreport/narrativeweb.py b/gramps/plugins/webreport/narrativeweb.py index e07090b03e2..fa95daa4e7f 100644 --- a/gramps/plugins/webreport/narrativeweb.py +++ b/gramps/plugins/webreport/narrativeweb.py @@ -89,6 +89,7 @@ MediaOption, DestinationOption, ) +from gramps.gen.plug import BasePluginManager from gramps.gen.plug.report import Report from gramps.gen.plug.report import utils from gramps.gen.plug.report import MenuReportOptions @@ -127,6 +128,15 @@ from gramps.plugins.webreport.addressbook import AddressBookPage from gramps.plugins.webreport.addressbooklist import AddressBookListPage from gramps.plugins.webreport.calendar import CalendarPage +from gramps.plugins.webreport.heatmap import HeatmapPage +from gramps.plugins.webreport.multiselect import ( + MultiSelectEvents, + HeatmapEventsScrolled, + MultiSelectSurnames, + HeatmapSurnamesScrolled, + MultiSelectTags, + HeatmapTagsScrolled +) from gramps.plugins.webreport.common import ( get_gendex_data, @@ -246,6 +256,7 @@ def __init__(self, database, options, user): self.use_contact = self.opts["contactnote"] or self.opts["contactimg"] self.inc_stats = self.opts["inc_stats"] self.inc_updates = self.opts["updates"] + self.inc_heatmaps = self.opts["heatmaps"] self.create_unused_media = self.opts["unused"] # Do we need to include this in a CMS? @@ -265,6 +276,9 @@ def __init__(self, database, options, user): # Do we need to include news and updates page? self.inc_updates = self.options["updates"] + # Do we need to include heatmap pages? + self.inc_heatmaps = self.options["heatmaps"] + # either include the gender graphics or not? self.ancestortree = self.options["ancestortree"] @@ -582,6 +596,10 @@ def write_report(self): if self.inc_updates: self.updates_preview_page() + # build Heatmaps + if self.inc_heatmaps: + self.heatmap_pages(self.filter.get_name(self.rlocale)) + # copy all of the necessary files self.copy_narrated_files() @@ -1426,6 +1444,12 @@ def updates_preview_page(self): with self.user.progress(pgr_title, _("Creating updates page..."), 1): UpdatesPage(self, self.the_lang, self.the_title) + def heatmap_pages(self, filter): + """ + creates the statistics preview page + """ + HeatmapPage(self, self.the_lang, self.the_title, filter) + def addressbook_pages(self, ind_list): """ Create a webpage with a list of address availability for each person @@ -2001,6 +2025,7 @@ def __init__(self, name, dbase): self.__css = None self.__gallery = None self.__updates = None + self.__heatmaps = None self.__maxdays = None self.__maxupdates = None self.__unused = None @@ -2051,6 +2076,7 @@ def add_menu_options(self, menu): self.__add_advanced_options(menu) self.__add_advanced_options_2(menu) self.__add_place_map_options(menu) + self.__add_heatmap_options(menu) self.__add_others_options(menu) self.__add_translations(menu) self.__add_calendar_options(menu) @@ -3150,6 +3176,46 @@ def __add_calendar_options(self, menu): ) menu.add_option(category_name, "after_year", self.__after_year) + + def __add_heatmap_options(self, menu): + """ + Options on the "Heatmap Options" tab. + """ + pmgr = BasePluginManager.get_instance() + pmgr.register_option(MultiSelectEvents, HeatmapEventsScrolled) + pmgr.register_option(MultiSelectSurnames, HeatmapSurnamesScrolled) + pmgr.register_option(MultiSelectTags, HeatmapTagsScrolled) + + category_name = _("HeatMap Options") + addopt = partial(menu.add_option, category_name) + + self.__heatmaps = BooleanOption(_("Include heatmap pages"), True) + self.__heatmaps.set_help(_("Whether to include heatmap pages for events")) + addopt("heatmaps", self.__heatmaps) + + # ------------------- + # HeatMap options + # ------------------- + radius = NumberOption(_("Point size"), 8, 1, 50) + radius.set_help(_("Set the size of the heatmap points.\nDefault: 8")) + menu.add_option(category_name, "radius", radius) + + blur = NumberOption(_("Blur"), 15, 1, 50) + blur.set_help(_("Set the blur size.\nDefault: 15")) + menu.add_option(category_name, "blur", blur) + + zoom = NumberOption(_("Zoom"), 4, 1, 15) + menu.add_option(category_name, "start_zoom", zoom) + + selected_evts = MultiSelectEvents(_("Events"), []) + menu.add_option(category_name, "selected_evts", selected_evts) + + selected_surnames = MultiSelectSurnames(_("Surnames"), []) + menu.add_option(category_name, "selected_surnames", selected_surnames) + + selected_tags = MultiSelectTags(_("Tags"), []) + menu.add_option(category_name, "selected_tags", selected_tags) + def __usecal_changed(self): """ Do we need to choose calendar options ? diff --git a/gramps/plugins/webreport/person.py b/gramps/plugins/webreport/person.py index 331e155ed33..b1e94952bbd 100644 --- a/gramps/plugins/webreport/person.py +++ b/gramps/plugins/webreport/person.py @@ -1154,9 +1154,9 @@ def __create_family_map(self, person, place_lat_long): # we are using OpenStreetMap elif self.mapservice == "OpenStreetMap": if midy_ is None: - jsc += OSM_MARKERS % (tracelife, longitude, latitude, 10) + jsc += OSM_MARKERS % ("markers", tracelife, longitude, latitude, 10, 0, 0) else: - jsc += OSM_MARKERS % (tracelife, midy_, midx_, zoomlevel) + jsc += OSM_MARKERS % ("markers", tracelife, midy_, midx_, zoomlevel, 0, 0) jsc += OPENLAYER # we are using StamenMap elif self.mapservice == "StamenMap": diff --git a/gramps/plugins/webreport/place.py b/gramps/plugins/webreport/place.py index 44a4a8db5a4..f2ded6a8885 100644 --- a/gramps/plugins/webreport/place.py +++ b/gramps/plugins/webreport/place.py @@ -650,6 +650,7 @@ def placepage(self, report, the_lang, the_title, place_handle, place_name): elif self.mapservice == "OpenStreetMap": jsc += MARKER_PATH % marker_path jsc += OSM_MARKERS % ( + "markers", [ [ float(longitude), @@ -661,6 +662,8 @@ def placepage(self, report, the_lang, the_title, place_handle, place_name): longitude, latitude, 10, + 0, + 0, ) jsc += OPENLAYER else: # STAMEN