gramps/gramps/gui/widgets/fanchartdesc.py
2016-12-21 11:50:03 +01:00

651 lines
27 KiB
Python

#
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2001-2007 Donald N. Allingham, Martin Hawlisch
# Copyright (C) 2009 Douglas S. Blank
# Copyright (C) 2012 Benny Malengier
#
# 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.
#
## Based on the paper:
## http://www.cs.utah.edu/~draperg/research/fanchart/draperg_FHT08.pdf
## and the applet:
## http://www.cs.utah.edu/~draperg/research/fanchart/demo/
## Found by redwood:
## http://www.gramps-project.org/bugs/view.php?id=2611
#-------------------------------------------------------------------------
#
# Python modules
#
#-------------------------------------------------------------------------
from gi.repository import Pango
from gi.repository import GObject
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import PangoCairo
import cairo
import math
import colorsys
import pickle
from html import escape
#-------------------------------------------------------------------------
#
# Gramps modules
#
#-------------------------------------------------------------------------
from gramps.gen.display.name import displayer as name_displayer
from gramps.gen.errors import WindowActiveError
from ..editors import EditPerson, EditFamily
from ..utils import hex_to_rgb
from ..ddtargets import DdTargets
from gramps.gen.utils.alive import probably_alive
from gramps.gen.utils.libformatting import FormattingHelper
from gramps.gen.utils.db import (find_children, find_parents, find_witnessed_people,
get_age, get_timeperiod)
from gramps.gen.plug.report.utils import find_spouse
from .fanchart import *
#-------------------------------------------------------------------------
#
# Constants
#
#-------------------------------------------------------------------------
pi = math.pi
PIXELS_PER_GENPERSON_RATIO = 0.55 # ratio of generation radius for person (rest for partner)
PIXELS_PER_GEN_SMALL = 80
PIXELS_PER_GEN_LARGE = 160
N_GEN_SMALL = 4
PIXELS_PER_GENFAMILY = 25 # size of radius for family
PIXELS_PER_RECLAIM = 4 # size of the radius of pixels taken from family to reclaim space
PIXELS_PARTNER_GAP = 0 # Padding between someone and his partner
PIXELS_CHILDREN_GAP = 5 # Padding between generations
PARENTRING_WIDTH = 12 # width of the parent ring inside the person
ANGLE_CHEQUI = 0 #Algorithm with homogeneous children distribution
ANGLE_WEIGHT = 1 #Algorithm for angle computation based on nr of descendants
TYPE_BOX_NORMAL = 0
TYPE_BOX_FAMILY = 1
#-------------------------------------------------------------------------
#
# FanChartDescWidget
#
#-------------------------------------------------------------------------
class FanChartDescWidget(FanChartBaseWidget):
"""
Interactive Fan Chart Widget.
"""
CENTER = 50 # we require a larger center as CENTER includes the 1st partner
def __init__(self, dbstate, uistate, callback_popup=None):
"""
Fan Chart Widget. Handles visualization of data in self.data.
See main() of FanChartGramplet for example of model format.
"""
self.set_values(None, 9, True, True, BACKGROUND_GRAD_GEN, 'Sans', '#0000FF',
'#FF0000', None, 0.5, FORM_CIRCLE, ANGLE_WEIGHT, '#888a85')
FanChartBaseWidget.__init__(self, dbstate, uistate, callback_popup)
def set_values(self, root_person_handle, maxgen, flipupsidedownname, twolinename, background,
fontdescr, grad_start, grad_end,
filter, alpha_filter, form, angle_algo, dupcolor):
"""
Reset the values to be used:
:param root_person_handle: person to show
:param maxgen: maximum generations to show
:param flipupsidedownname: flip name on the left of the fanchart for the display of person's name
:param background: config setting of which background procedure to use
:type background: int
:param fontdescr: string describing the font to use
:param grad_start: colors to use for background procedure
:param grad_end: colors to use for background procedure
:param filter: the person filter to apply to the people in the chart
:param alpha_filter: the alpha transparency value (0-1) to apply to
filtered out data
:param form: the ``FORM_`` constant for the fanchart
:param angle_algo: alorithm to use to calculate the sizes of the boxes
:param dupcolor: color to use for people or families that occur a second
or more time
"""
self.rootpersonh = root_person_handle
self.generations = maxgen
self.background = background
self.fontdescr = fontdescr
self.grad_start = grad_start
self.grad_end = grad_end
self.filter = filter
self.alpha_filter = alpha_filter
self.form = form
self.anglealgo = angle_algo
self.dupcolor = hex_to_rgb(dupcolor)
self.childring = False
self.flipupsidedownname = flipupsidedownname
self.twolinename = twolinename
def set_generations(self):
"""
Set the generations to max, and fill data structures with initial data.
"""
if self.form == FORM_CIRCLE:
self.rootangle_rad = [math.radians(0), math.radians(360)]
elif self.form == FORM_HALFCIRCLE:
self.rootangle_rad = [math.radians(90), math.radians(90 + 180)]
elif self.form == FORM_QUADRANT:
self.rootangle_rad = [math.radians(90), math.radians(90 + 90)]
self.handle2desc = {}
self.famhandle2desc = {}
self.handle2fam = {}
self.gen2people = {}
self.gen2fam = {}
self.innerring = []
self.gen2people[0] = [(None, False, 0, 2*pi, 0, 0, [], NORMAL)] #no center person
self.gen2fam[0] = [] #no families
self.angle = {}
self.angle[-2] = []
for i in range(1, self.generations+1):
self.gen2fam[i] = []
self.gen2people[i] = []
self.gen2people[self.generations] = [] #indication of more children
def _fill_data_structures(self):
self.set_generations()
if not self.rootpersonh:
return
person = self.dbstate.db.get_person_from_handle(self.rootpersonh)
if not person:
#nothing to do, just return
return
# person, duplicate or not, start angle, slice size,
# text, parent pos in fam, nrfam, userdata, status
self.gen2people[0] = [[person, False, 0, 2*pi, 0, 0, [], NORMAL]]
self.handle2desc[self.rootpersonh] = 0
# fill in data for the parents
self.innerring = []
handleparents = []
family_handle_list = person.get_parent_family_handle_list()
if family_handle_list:
for family_handle in family_handle_list:
family = self.dbstate.db.get_family_from_handle(family_handle)
if not family:
continue
for hparent in [family.get_father_handle(), family.get_mother_handle()]:
if hparent and hparent not in handleparents:
parent = self.dbstate.db.get_person_from_handle(hparent)
if parent:
self.innerring.append((parent, []))
handleparents.append(hparent)
#recursively fill in the datastructures:
nrdesc = self._rec_fill_data(0, person, 0, self.generations)
self.handle2desc[person.handle] += nrdesc
self._compute_angles(*self.rootangle_rad)
def _rec_fill_data(self, gen, person, pos, maxgen):
"""
Recursively fill in the data
"""
totdesc = 0
marriage_handle_list = person.get_family_handle_list()
self.gen2people[gen][pos][5] = len(marriage_handle_list)
for family_handle in marriage_handle_list:
totdescfam = 0
family = self.dbstate.db.get_family_from_handle(family_handle)
spouse_handle = find_spouse(person, family)
if spouse_handle:
spouse = self.dbstate.db.get_person_from_handle(spouse_handle)
else:
spouse = None
# family may occur via father and via mother in the chart, only
# first to show and count.
fam_duplicate = family_handle in self.famhandle2desc
# family, duplicate or not, start angle, slice size,
# spouse pos in gen, nrchildren, userdata, parnter, status
self.gen2fam[gen].append([family, fam_duplicate, 0, 0, pos, 0, [], spouse, NORMAL])
posfam = len(self.gen2fam[gen]) - 1
if not fam_duplicate and gen < maxgen-1:
nrchild = len(family.get_child_ref_list())
self.gen2fam[gen][posfam][5] = nrchild
for child_ref in family.get_child_ref_list():
child = self.dbstate.db.get_person_from_handle(child_ref.ref)
child_dup = child_ref.ref in self.handle2desc
if not child_dup:
self.handle2desc[child_ref.ref] = 0 # mark this child as processed
# person, duplicate or not, start angle, slice size,
# parent pos in fam, nrfam, userdata, status
self.gen2people[gen+1].append([child, child_dup, 0, 0, posfam, 0, [], NORMAL])
totdescfam += 1 #add this person as descendant
pospers = len(self.gen2people[gen+1]) - 1
if not child_dup:
nrdesc = self._rec_fill_data(gen+1, child, pospers, maxgen)
self.handle2desc[child_ref.ref] += nrdesc
totdescfam += nrdesc # add children of him as descendants
if not fam_duplicate:
self.famhandle2desc[family_handle] = totdescfam
totdesc += totdescfam
return totdesc
def _compute_angles(self, start_rad, stop_rad):
"""
Compute the angles of the boxes
"""
#first we compute the size of the slice.
#set angles root person
start, slice = start_rad, stop_rad - start_rad
nr_gen = len(self.gen2people)-1
# Fill in central person angles
gen = 0
data = self.gen2people[gen][0]
data[2] = start
data[3] = slice
for gen in range(0, nr_gen):
prevpartnerdatahandle = None
offset = 0
for data_fam in self.gen2fam[gen]: # for each partner/fam of gen-1
#obtain start and stop from the people of this partner
persondata = self.gen2people[gen][data_fam[4]]
dupfam = data_fam[1]
if dupfam:
# we don't show again the descendants here
nrdescfam = 0
else:
nrdescfam = self.famhandle2desc[data_fam[0].handle]
nrdescperson = self.handle2desc[persondata[0].handle]
nrfam = persondata[5]
personstart, personslice = persondata[2:4]
if prevpartnerdatahandle != persondata[0].handle:
#partner of a new person: reset the offset
offset = 0
prevpartnerdatahandle = persondata[0].handle
slice = personslice/(nrdescperson+nrfam)*(nrdescfam+1)
if data_fam[8] == COLLAPSED:
slice = 0
elif data_fam[8] == EXPANDED:
slice = personslice
data_fam[2] = personstart + offset
data_fam[3] = slice
offset += slice
## if nrdescperson == 0:
## #no offspring, draw as large as fraction of
## #nr families
## nrfam = persondata[6]
## slice = personslice/nrfam
## data_fam[2] = personstart + offset
## data_fam[3] = slice
## offset += slice
## elif nrdescfam == 0:
## #no offspring this family, but there is another
## #family. We draw this as a weight of 1
## nrfam = persondata[6]
## slice = personslice/(nrdescperson + nrfam - 1)*(nrdescfam+1)
## data_fam[2] = personstart + offset
## data_fam[3] = slice
## offset += slice
## else:
## #this family has offspring. We give it space for it's
## #weight in offspring
## nrfam = persondata[6]
## slice = personslice/(nrdescperson + nrfam - 1)*(nrdescfam+1)
## data_fam[2] = personstart + offset
## data_fam[3] = slice
## offset += slice
prevfamdatahandle = None
offset = 0
for persondata in self.gen2people[gen+1] if gen < nr_gen else []:
#obtain start and stop of family this is child of
parentfamdata = self.gen2fam[gen][persondata[4]]
nrdescfam = 0
if not parentfamdata[1]:
nrdescfam = self.famhandle2desc[parentfamdata[0].handle]
nrdesc = 0
if not persondata[1]:
nrdesc = self.handle2desc[persondata[0].handle]
famstart = parentfamdata[2]
famslice = parentfamdata[3]
nrchild = parentfamdata[5]
#now we divide this slice to the weight of children,
#adding one for every child
if self.anglealgo == ANGLE_CHEQUI:
slice = famslice / nrchild
elif self.anglealgo == ANGLE_WEIGHT:
slice = famslice/(nrdescfam) * (nrdesc + 1)
else:
raise NotImplementedError('Unknown angle algorithm %d' % self.anglealgo)
if prevfamdatahandle != parentfamdata[0].handle:
#reset the offset
offset = 0
prevfamdatahandle = parentfamdata[0].handle
if persondata[7] == COLLAPSED:
slice = 0
elif persondata[7] == EXPANDED:
slice = famslice
persondata[2] = famstart + offset
persondata[3] = slice
offset += slice
def nrgen(self):
#compute the number of generations present
for gen in range(self.generations - 1, 0, -1):
if len(self.gen2people[gen]) > 0:
return gen + 1
return 1
def halfdist(self):
"""
Compute the half radius of the circle
"""
radius = PIXELS_PER_GEN_SMALL * N_GEN_SMALL + PIXELS_PER_GEN_LARGE \
* ( self.nrgen() - N_GEN_SMALL ) + self.CENTER
return radius
def get_radiusinout_for_generation(self,generation):
radius_first_gen = self.CENTER - (1-PIXELS_PER_GENPERSON_RATIO) * PIXELS_PER_GEN_SMALL
if generation < N_GEN_SMALL:
radius_start = PIXELS_PER_GEN_SMALL * generation + radius_first_gen
return (radius_start,radius_start + PIXELS_PER_GEN_SMALL)
else:
radius_start = PIXELS_PER_GEN_SMALL * N_GEN_SMALL + PIXELS_PER_GEN_LARGE \
* ( generation - N_GEN_SMALL ) + radius_first_gen
return (radius_start,radius_start + PIXELS_PER_GEN_LARGE)
def get_radiusinout_for_generation_pair(self,generation):
radiusin, radiusout = self.get_radiusinout_for_generation(generation)
radius_spread = radiusout - radiusin - PIXELS_CHILDREN_GAP - PIXELS_PARTNER_GAP
radiusin_pers = radiusin + PIXELS_CHILDREN_GAP
radiusout_pers = radiusin_pers + PIXELS_PER_GENPERSON_RATIO * radius_spread
radiusin_partner = radiusout_pers + PIXELS_PARTNER_GAP
radiusout_partner = radiusout
return (radiusin_pers,radiusout_pers,radiusin_partner,radiusout_partner)
def people_generator(self):
"""
a generator over all people outside of the core person
"""
for generation in range(self.generations):
for data in self.gen2people[generation]:
yield (data[0], data[6])
for generation in range(self.generations):
for data in self.gen2fam[generation]:
yield (data[7], data[6])
def innerpeople_generator(self):
"""
a generator over all people inside of the core person
"""
for parentdata in self.innerring:
parent, userdata = parentdata
yield (parent, userdata)
def on_draw(self, widget, cr, scale=1.):
"""
The main method to do the drawing.
If widget is given, we assume we draw in GTK3 and use the allocation.
To draw raw on the cairo context cr, set widget=None.
"""
# first do size request of what we will need
halfdist = self.halfdist()
if widget:
if self.form == FORM_CIRCLE:
self.set_size_request(2 * halfdist, 2 * halfdist)
elif self.form == FORM_HALFCIRCLE:
self.set_size_request(2 * halfdist, halfdist + self.CENTER
+ PAD_PX)
elif self.form == FORM_QUADRANT:
self.set_size_request(halfdist + self.CENTER + PAD_PX,
halfdist + self.CENTER + PAD_PX)
cr.scale(scale, scale)
# when printing, we need not recalculate
if widget:
self.center_xy = self.center_xy_from_delta()
cr.translate(*self.center_xy)
cr.save()
# Draw center person:
(person, dup, start, slice, parentfampos, nrfam, userdata, status) \
= self.gen2people[0][0]
if person:
r, g, b, a = self.background_box(person, 0, userdata)
radiusin_pers,radiusout_pers,radiusin_partner,radiusout_partner = \
self.get_radiusinout_for_generation_pair(0)
if not self.innerring: radiusin_pers = TRANSLATE_PX
self.draw_person(cr, person, radiusin_pers, radiusout_pers, math.pi/2, math.pi/2 + 2*math.pi,
0, False, userdata, is_central_person =True)
#draw center to move chart
cr.set_source_rgb(0, 0, 0) # black
cr.move_to(TRANSLATE_PX, 0)
cr.arc(0, 0, TRANSLATE_PX, 0, 2 * math.pi)
if self.innerring: # has at least one parent
cr.fill()
self.draw_innerring_people(cr)
else:
cr.stroke()
#now write all the families and children
cr.rotate(self.rotate_value * math.pi/180)
for gen in range(self.generations):
radiusin_pers,radiusout_pers,radiusin_partner,radiusout_partner = \
self.get_radiusinout_for_generation_pair(gen)
if gen > 0:
for pdata in self.gen2people[gen]:
# person, duplicate or not, start angle, slice size,
# parent pos in fam, nrfam, userdata, status
pers, dup, start, slice, pospar, nrfam, userdata, status = \
pdata
if status != COLLAPSED:
self.draw_person(cr, pers, radiusin_pers, radiusout_pers,
start, start + slice, gen, dup, userdata,
thick=status != NORMAL)
#if gen < self.generations-1:
for famdata in self.gen2fam[gen]:
# family, duplicate or not, start angle, slice size,
# spouse pos in gen, nrchildren, userdata, status
fam, dup, start, slice, posfam, nrchild, userdata,\
partner, status = famdata
if status != COLLAPSED:
more_pers_flag = (gen == self.generations - 1
and len(fam.get_child_ref_list()) > 0)
self.draw_person(cr, partner, radiusin_partner, radiusout_partner, start, start + slice,
gen, dup, userdata, thick = (status != NORMAL), has_moregen_indicator = more_pers_flag )
cr.restore()
if self.background in [BACKGROUND_GRAD_AGE, BACKGROUND_GRAD_PERIOD]:
self.draw_gradient_legend(cr, widget, halfdist)
def cell_address_under_cursor(self, curx, cury):
"""
Determine the cell address in the fan under the cursor
position x and y.
None if outside of diagram
"""
radius, rads, raw_rads = self.cursor_to_polar(curx, cury, get_raw_rads=True)
btype = TYPE_BOX_NORMAL
if radius < TRANSLATE_PX:
return None
elif (self.innerring and self.angle[-2] and
radius < CHILDRING_WIDTH + TRANSLATE_PX):
generation = -2 # indication of one of the children
elif radius < self.CENTER:
generation = 0
else:
generation = None
for gen in range(self.generations):
radiusin_pers,radiusout_pers,radiusin_partner,radiusout_partner \
= self.get_radiusinout_for_generation_pair(gen)
if radiusin_pers <= radius <= radiusout_pers:
generation, btype = gen, TYPE_BOX_NORMAL
break
if radiusin_partner <= radius <= radiusout_partner:
generation, btype = gen, TYPE_BOX_FAMILY
break
# find what person is in this position:
selected = None
if not (generation is None) and 0 <= generation:
selected = self.personpos_at_angle(generation, rads, btype)
elif generation == -2:
for p in range(len(self.angle[generation])):
start, stop, state = self.angle[generation][p]
if self.radian_in_bounds(start, raw_rads, stop):
selected = p
break
if (generation is None or selected is None):
return None
return generation, selected, btype
def draw_innerring_people(self, cr):
cr.move_to(TRANSLATE_PX + CHILDRING_WIDTH, 0)
cr.set_source_rgb(0, 0, 0) # black
cr.set_line_width(1)
cr.arc(0, 0, TRANSLATE_PX + CHILDRING_WIDTH, 0, 2 * math.pi)
cr.stroke()
nrparent = len(self.innerring)
#Y axis is downward. positve angles are hence clockwise
startangle = math.pi
if nrparent <= 2:
angleinc = math.pi
elif nrparent <= 4:
angleinc = math.pi/2
else:
# FIXME: nrchild not set
angleinc = 2 * math.pi / nrchild
for data in self.innerring:
self.draw_innerring(cr, data[0], data[1], startangle, angleinc)
startangle += angleinc
def personpos_at_angle(self, generation, rads, btype):
"""
returns the person in generation generation at angle.
"""
selected = None
datas = None
if btype == TYPE_BOX_NORMAL:
if generation==0:
return 0 # central person is always ok !
datas = self.gen2people[generation]
elif btype == TYPE_BOX_FAMILY:
datas = self.gen2fam[generation]
else:
return None
for p, pdata in enumerate(datas):
# person, duplicate or not, start angle, slice size,
# parent pos in fam, nrfam, userdata, status
start, stop = pdata[2], pdata[2] + pdata[3]
if self.radian_in_bounds(start, rads, stop):
selected = p
break
return selected
def person_at(self, cell_address):
"""
returns the person at generation, pos, btype
"""
generation, pos, btype = cell_address
if generation == -2:
person, userdata = self.innerring[pos]
elif btype == TYPE_BOX_NORMAL:
# person, duplicate or not, start angle, slice size,
# parent pos in fam, nrfam, userdata, status
person = self.gen2people[generation][pos][0]
elif btype == TYPE_BOX_FAMILY:
# family, duplicate or not, start angle, slice size,
# spouse pos in gen, nrchildren, userdata, person, status
person = self.gen2fam[generation][pos][7]
return person
def family_at(self, cell_address):
"""
returns the family at generation, pos, btype
"""
generation, pos, btype = cell_address
if pos is None or btype == TYPE_BOX_NORMAL or generation < 0:
return None
return self.gen2fam[generation][pos][0]
def do_mouse_click(self):
# no drag occured, expand or collapse the section
self.toggle_cell_state(self._mouse_click_cell_address)
self._compute_angles(*self.rootangle_rad)
self._mouse_click = False
self.queue_draw()
def toggle_cell_state(self, cell_address):
generation, selected, btype = cell_address
if generation < 1:
return
if btype == TYPE_BOX_NORMAL:
data = self.gen2people[generation][selected]
parpos = data[4]
status = data[7]
if status == NORMAL:
#should be expanded, rest collapsed
for entry in self.gen2people[generation]:
if entry[4] == parpos:
entry[7] = COLLAPSED
data[7] = EXPANDED
else:
#is expanded, set back to normal
for entry in self.gen2people[generation]:
if entry[4] == parpos:
entry[7] = NORMAL
if btype == TYPE_BOX_FAMILY:
data = self.gen2fam[generation][selected]
parpos = data[4]
status = data[8]
if status == NORMAL:
#should be expanded, rest collapsed
for entry in self.gen2fam[generation]:
if entry[4] == parpos:
entry[8] = COLLAPSED
data[8] = EXPANDED
else:
#is expanded, set back to normal
for entry in self.gen2fam[generation]:
if entry[4] == parpos:
entry[8] = NORMAL
class FanChartDescGrampsGUI(FanChartGrampsGUI):
""" class for functions fanchart GUI elements will need in Gramps
"""
def main(self):
"""
Fill the data structures with the active data. This initializes all
data.
"""
root_person_handle = self.get_active('Person')
self.fan.set_values(root_person_handle, self.maxgen, self.flipupsidedownname, self.twolinename, self.background,
self.fonttype, self.grad_start, self.grad_end,
self.generic_filter, self.alpha_filter, self.form,
self.angle_algo, self.dupcolor)
self.fan.reset()
self.fan.queue_draw()