Skip to content

Commit 8a32cbc

Browse files
dsblankGaryGriffin
authored andcommitted
Add WordClouds gramplet
Adds three word-cloud gramplets (Given Name, Surname, Place) that render names/places as a size-weighted, click-to-navigate word cloud instead of the old plain-text link list, using a new Cairo/Pango WordCloudWidget with a spiral placement algorithm. Includes a slider+entry option widget (SliderOption/GuiSliderOption) registered via BasePluginManager.register_option so no changes to Gramps core are required. Based on ideas from ClmntPnd's gramps core PR gramps-project/gramps#2223, adapted here as a self-contained addon so it works with unmodified stable Gramps releases. Given Name/Surname/Place Word Cloud use new ids and class names distinct from the built-in Given Name Cloud / Surname Cloud gramplets to avoid any plugin registration collision.
1 parent d8c0f4f commit 8a32cbc

12 files changed

Lines changed: 1540 additions & 0 deletions

WordClouds/WordClouds.gpr.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2026 Douglas S. Blank <doug.blank@gmail.com>
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program; if not, write to the Free Software
18+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19+
#
20+
21+
register(
22+
GRAMPLET,
23+
id="Given Name Word Cloud",
24+
name=_("Given Name Word Cloud"),
25+
description=_("Gramplet showing all given names as a word cloud"),
26+
status=STABLE,
27+
version="1.0.0",
28+
fname="givennamewordcloudgramplet.py",
29+
height=300,
30+
expand=True,
31+
gramplet="GivenNameWordCloudGramplet",
32+
gramplet_title=_("Given Name Word Cloud"),
33+
gramps_target_version="6.1",
34+
help_url="WordClouds",
35+
)
36+
37+
register(
38+
GRAMPLET,
39+
id="Surname Word Cloud",
40+
name=_("Surname Word Cloud"),
41+
description=_("Gramplet showing all surnames as a word cloud"),
42+
status=STABLE,
43+
version="1.0.0",
44+
fname="surnamewordcloudgramplet.py",
45+
height=300,
46+
expand=True,
47+
gramplet="SurnameWordCloudGramplet",
48+
gramplet_title=_("Surname Word Cloud"),
49+
gramps_target_version="6.1",
50+
help_url="WordClouds",
51+
)
52+
53+
register(
54+
GRAMPLET,
55+
id="Place Word Cloud",
56+
name=_("Place Word Cloud"),
57+
description=_("Gramplet showing all places as a word cloud"),
58+
status=STABLE,
59+
version="1.0.0",
60+
fname="placewordcloudgramplet.py",
61+
height=300,
62+
expand=True,
63+
gramplet="PlaceWordCloudGramplet",
64+
gramplet_title=_("Place Word Cloud"),
65+
gramps_target_version="6.1",
66+
help_url="WordClouds",
67+
)

WordClouds/cloudgramplet.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2007-2009 Douglas S. Blank <doug.blank@gmail.com>
5+
# Copyright (C) 2026 Douglas S. Blank <doug.blank@gmail.com>
6+
#
7+
# This program is free software; you can redistribute it and/or modify
8+
# it under the terms of the GNU General Public License as published by
9+
# the Free Software Foundation; either version 2 of the License, or
10+
# (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License along
18+
# with this program; if not, see <https://www.gnu.org/licenses/>.
19+
20+
# ------------------------------------------------------------------------
21+
#
22+
# Python modules
23+
#
24+
# ------------------------------------------------------------------------
25+
from abc import abstractmethod
26+
27+
# ------------------------------------------------------------------------
28+
#
29+
# Gramps modules
30+
#
31+
# ------------------------------------------------------------------------
32+
from gramps.gen.plug import Gramplet, BasePluginManager
33+
from gramps.gen.config import config
34+
from gramps.gen.const import GRAMPS_LOCALE as glocale
35+
36+
try:
37+
_trans = glocale.get_addon_translator(__file__)
38+
except ValueError:
39+
_trans = glocale.translation
40+
_ = _trans.sgettext
41+
42+
from wordcloudwidget import WordCloudWidget
43+
from slideroption import SliderOption, GuiSliderOption
44+
45+
# ------------------------------------------------------------------------
46+
#
47+
# Constants
48+
#
49+
# ------------------------------------------------------------------------
50+
51+
_YIELD_INTERVAL = 350
52+
53+
_DEFAULT_COLOR_LOW = "#99ccff" # (0.6, 0.8, 1.0)
54+
_DEFAULT_COLOR_HIGH = "#003399" # (0.0, 0.2, 0.6)
55+
_DEFAULT_COLOR_HOVER = "#cc0000" # (0.8, 0.0, 0.0)
56+
_DEFAULT_QUALITY = 0.0
57+
58+
59+
def _hex_to_rgb(hex_color):
60+
h = hex_color.lstrip("#")
61+
return tuple(int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
62+
63+
64+
# ------------------------------------------------------------------------
65+
#
66+
# CloudGramplet class
67+
#
68+
# ------------------------------------------------------------------------
69+
class CloudGramplet(Gramplet):
70+
"""A gramplet that displays a word cloud where word size reflects frequency."""
71+
72+
def init(self):
73+
pmgr = BasePluginManager.get_instance()
74+
pmgr.register_option(SliderOption, GuiSliderOption)
75+
76+
self.top_size = 150
77+
self.color_low = _DEFAULT_COLOR_LOW
78+
self.color_high = _DEFAULT_COLOR_HIGH
79+
self.color_hover = _DEFAULT_COLOR_HOVER
80+
self.quality = _DEFAULT_QUALITY
81+
self.filter_missing = True
82+
self.value_name = "default_value_name"
83+
self.preference_no_value = ""
84+
self._values_linked_data = {}
85+
86+
self.word_cloud = WordCloudWidget(
87+
[],
88+
on_click=self._on_word_clicked,
89+
color_low=_hex_to_rgb(self.color_low),
90+
color_high=_hex_to_rgb(self.color_high),
91+
color_hover=_hex_to_rgb(self.color_hover),
92+
quality=self.quality,
93+
)
94+
self.gui.get_container_widget().remove(self.gui.textview)
95+
self.gui.get_container_widget().add(self.word_cloud)
96+
self.word_cloud.show()
97+
98+
def set_value_name(self, value_name):
99+
"""What the cloud displays. For a name cloud, `value_name` is "name"."""
100+
self.value_name = _(value_name)
101+
102+
def set_preference_no_value(self, preference_no_value):
103+
"""Config key holding the default text to show when there are no values."""
104+
self.preference_no_value = preference_no_value
105+
106+
def _on_word_clicked(self, word):
107+
linked_data = self._values_linked_data.get(word)
108+
if linked_data is not None:
109+
self.on_item_clicked(word, linked_data)
110+
111+
def on_item_clicked(self, word, linked_data):
112+
"""Called when the user clicks a word. Subclasses override to navigate."""
113+
114+
@abstractmethod
115+
def db_changed(self):
116+
"""Connect the cloud with the database.
117+
See the example in surnamewordcloudgramplet.py.
118+
"""
119+
120+
@abstractmethod
121+
def get_items(self) -> list:
122+
"""How to access data in the cloud. Must return an iterator of
123+
(value, linked_data, count) triples.
124+
See the example in surnamewordcloudgramplet.py.
125+
"""
126+
127+
def on_load(self):
128+
data = self.gui.data
129+
if len(data) >= 1:
130+
self.top_size = int(data[0])
131+
if len(data) >= 5:
132+
self.color_low = data[1]
133+
self.color_high = data[2]
134+
self.color_hover = data[3]
135+
self.quality = float(data[4])
136+
if len(data) >= 6:
137+
self.filter_missing = bool(int(data[5]))
138+
self.word_cloud.set_colors(
139+
_hex_to_rgb(self.color_low),
140+
_hex_to_rgb(self.color_high),
141+
_hex_to_rgb(self.color_hover),
142+
)
143+
self.word_cloud.set_quality(self.quality)
144+
145+
def _read_options(self):
146+
self.top_size = int(
147+
self.get_option(_("Number of %s") % self.value_name).get_value()
148+
)
149+
self.color_low = self.get_option(_("Color (low)")).get_value()
150+
self.color_high = self.get_option(_("Color (high)")).get_value()
151+
self.color_hover = self.get_option(_("Hover color")).get_value()
152+
self.quality = float(self.get_option(_("Layout quality")).get_value())
153+
self.filter_missing = self.get_option(
154+
_("Filter missing/unknown words")
155+
).get_value()
156+
157+
def save_update_options(self, widget=None):
158+
self._read_options()
159+
self.gui.data = [
160+
self.top_size,
161+
self.color_low,
162+
self.color_high,
163+
self.color_hover,
164+
self.quality,
165+
int(self.filter_missing),
166+
]
167+
self.update()
168+
169+
def save_options(self):
170+
self._read_options()
171+
172+
def main(self):
173+
yield True
174+
175+
yield_counter = 0
176+
177+
values_counts = {}
178+
values_linked_data = {}
179+
180+
for value, linked_data, count in self.get_items():
181+
if value not in values_counts:
182+
values_linked_data[value] = linked_data
183+
values_counts[value] = count
184+
else:
185+
values_counts[value] += count
186+
187+
yield_counter += 1
188+
if not yield_counter % _YIELD_INTERVAL:
189+
yield True
190+
191+
# count order: [(value, count), ...]
192+
sorted_values = sorted(
193+
list(values_counts.items()), key=(lambda k: k[1]), reverse=True
194+
)
195+
196+
# limit to top_size distinct values
197+
selected_values = sorted_values[: self.top_size]
198+
199+
# Build words list for the widget, resolving empty-value display text
200+
self._values_linked_data = {}
201+
words = []
202+
for value, count in selected_values:
203+
if len(value) == 0:
204+
if self.preference_no_value != "":
205+
display = config.get(self.preference_no_value)
206+
else:
207+
display = _("[Missing %s]") % self.value_name
208+
else:
209+
display = value
210+
self._values_linked_data[display] = values_linked_data.get(value)
211+
words.append((display, count))
212+
213+
self.word_cloud.configure(
214+
quality=self.quality,
215+
color_low=_hex_to_rgb(self.color_low),
216+
color_high=_hex_to_rgb(self.color_high),
217+
color_hover=_hex_to_rgb(self.color_hover),
218+
)
219+
self.word_cloud.set_words(words)
220+
221+
def build_options(self):
222+
from gramps.gen.plug.menu import BooleanOption, ColorOption
223+
224+
self.add_option(
225+
SliderOption(_("Number of %s") % self.value_name, self.top_size, 1, 150)
226+
)
227+
self.add_option(ColorOption(_("Color (low)"), self.color_low))
228+
self.add_option(ColorOption(_("Color (high)"), self.color_high))
229+
self.add_option(ColorOption(_("Hover color"), self.color_hover))
230+
self.add_option(SliderOption(_("Layout quality"), self.quality, 0.0, 1.0, 0.1))
231+
self.add_option(
232+
BooleanOption(_("Filter missing/unknown words"), self.filter_missing)
233+
)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2009 Pander Musubi
5+
# Copyright (C) 2009 Douglas S. Blank
6+
# Copyright (C) 2026 Douglas S. Blank <doug.blank@gmail.com>
7+
#
8+
# This program is free software; you can redistribute it and/or modify
9+
# it under the terms of the GNU General Public License as published by
10+
# the Free Software Foundation; either version 2 of the License, or
11+
# (at your option) any later version.
12+
#
13+
# This program is distributed in the hope that it will be useful,
14+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
# GNU General Public License for more details.
17+
#
18+
# You should have received a copy of the GNU General Public License along
19+
# with this program; if not, see <https://www.gnu.org/licenses/>.
20+
21+
# -------------------------------------------------------------------------
22+
#
23+
# Gramps modules
24+
#
25+
# -------------------------------------------------------------------------
26+
from gramps.gen.const import GRAMPS_LOCALE as glocale
27+
from gramps.gui.plug.quick import run_quick_report_by_name
28+
29+
try:
30+
_trans = glocale.get_addon_translator(__file__)
31+
except ValueError:
32+
_trans = glocale.translation
33+
_ = _trans.sgettext
34+
35+
from cloudgramplet import CloudGramplet
36+
37+
38+
# -------------------------------------------------------------------------
39+
#
40+
# GivenNameWordCloudGramplet class
41+
#
42+
# -------------------------------------------------------------------------
43+
class GivenNameWordCloudGramplet(CloudGramplet):
44+
"""Implementation of a Cloud gramplet for given names."""
45+
46+
def init(self):
47+
CloudGramplet.init(self)
48+
self.set_value_name("given name")
49+
self.set_preference_no_value("preferences.no-given-text")
50+
self.set_tooltip(_("Click given name to view people with that given name"))
51+
52+
def on_item_clicked(self, word, linked_data):
53+
run_quick_report_by_name(
54+
self.dbstate, self.uistate, "samegivens_misc", linked_data
55+
)
56+
57+
def db_changed(self):
58+
self.connect(self.dbstate.db, "person-add", self.update)
59+
self.connect(self.dbstate.db, "person-delete", self.update)
60+
self.connect(self.dbstate.db, "person-update", self.update)
61+
self.connect(self.dbstate.db, "person-rebuild", self.update)
62+
self.connect(self.dbstate.db, "family-rebuild", self.update)
63+
64+
def get_items(self):
65+
counts = {}
66+
for person in self.dbstate.db.iter_people():
67+
allnames = [person.get_primary_name()] + person.get_alternate_names()
68+
for name in allnames:
69+
given_name = name.get_first_name().strip()
70+
if self.filter_missing and (not given_name or given_name == "?"):
71+
continue
72+
counts[given_name] = counts.get(given_name, 0) + 1
73+
return [(given_name, given_name, counts[given_name]) for given_name in counts]

0 commit comments

Comments
 (0)