From fec5d532d1a691bec53c12f6ffff0eb7c4a3d40d Mon Sep 17 00:00:00 2001 From: Bastien Jacquet Date: Fri, 25 Nov 2016 16:15:25 +1100 Subject: [PATCH] Feature: Gep-030 FanChart2Way ...New FanChart consisting of both ascendants and descendants. It can be checked-out there : https://sourceforge.net/u/bubblegum00/gramps/ci/geps/gep-030-FanChart2Way/~/tree/ I find it quite handy, so please let me know if you have any comments or suggestions. NB: I heavily cleaned up the FanChart code on my way. Regards, Bastien Jacquet https://sourceforge.net/p/gramps/mailman/message/32908110/ ........................................................ Refactor fanchart for further modification Fix radial text pos_start radian alignment Simplify father's and mother's details getter Rename draw_gradient to draw_gradient_legend refactor prepare_background_box Add option not to flip names Add a display_format(self, person, num) function as proposed by Benny Malengier on 2012-12-13 It just returns name_display.name_formats[num][_F_FN](person.get_primary_name()) Add option to show the name on two lines Allow for variable radius depending on generation Switch to WORD_CHAR wrapping of name (ie word, and char if 0-length word) Move rescaling tentative inside wrap_truncate_layout Fix person_under_cursor bugs Refactor root angle computation Refactor code positionning the fan Refactor personpos_at_angle move implementation of person_under_cursor outside of FanChartBaseWidget class Change draw_person to take angles in radians Use same structure for innerring as for outerring Uses cursor_to_polar and cursor_on_tranlation_dot Slightly change person_under_cursor logic to return an "address" in the fan Uses radian_in_bounds to compare angles modulo 2 PI Fixup test on cursor over inner ring Fix Center size for FanchartDesc Fixup fanchart check up to last generation Give same signature to draw_person Refactor the common code of self.draw_person in a single function in Base class Fix center box comment Refactoring inside celladdress Remove manual central box drawing since done with draw_person Fixup draw_person color for duplicates Use draw_person for central person too Make __compute_angle and __rec_fill_data public for use in FanChart2Way Add 2Way View Rewrite create_map_rect_to_sector to allow bottom-outside-oriented text-arc Allow to automatically right upside-up bottom arc-text Correct icons for Fanchart2Way Small code refactoring Refactor code of fanchartdesc to use self.rootangle_rad Rename change_slice to toggle_cell_state Fanchart2Way code formating and changes Small refactoring of fanchartdesc innerring fill data Remove the name from the local temporary data structure Remove the name from the local temporary data structure (in Fanchart2Way) Change background gradient to follow the user-selected gradient colors rename parentsroot to innerring Some renaming for clearer code Show last generation of partners in descendant fanchart Show last partner in Fanchart2Way Fanchart2Way : Add option to disable gradient on the background Fixup flipupsidedownname parameter for gramplet usage of fancharts Fixup twolinename parameter for gramplet usage of fancharts Add FanChart2Way in available gramplets Tentative fix for last view on Fanchart2Way Show step-sibling in Fancharts context-menu Fix overestimation of descendant halfdist (SM) Trailing White spaces removed (SM) Fix config box Table Grid (SM) Move Icons gramps-fanchart2way to new location (SM) Add Copyright for Bastien Jacquet (SM) Fix BSDDB AttributeError NoneType object has no attr (SM) Update patch to account for bug 9771; fix missing right-click menu items (Nick Hall/eno93) Fix set_text method takes the length of the utf-8, not the length of the unicode as the second parameter ((Gramps.py:3697): Pango-WARNING **: Invalid UTF-8 string passed to pango_layout_set_text()) --- gramps/gen/display/name.py | 18 + gramps/gui/widgets/fanchart.py | 915 +++++----- gramps/gui/widgets/fanchart2way.py | 625 +++++++ gramps/gui/widgets/fanchartdesc.py | 580 +++--- .../plugins/gramplet/fanchart2waygramplet.py | 100 ++ .../plugins/gramplet/fanchartdescgramplet.py | 2 + gramps/plugins/gramplet/fanchartgramplet.py | 2 + gramps/plugins/gramplet/gramplet.gpr.py | 17 + gramps/plugins/view/fanchart2wayview.py | 572 ++++++ gramps/plugins/view/fanchartdescview.py | 36 +- gramps/plugins/view/fanchartview.py | 34 +- gramps/plugins/view/view.gpr.py | 15 + .../16x16/actions/gramps-fanchart2way.png | Bin 0 -> 980 bytes .../22x22/actions/gramps-fanchart2way.png | Bin 0 -> 1461 bytes .../48x48/actions/gramps-fanchart2way.png | Bin 0 -> 3760 bytes .../scalable/actions/gramps-fanchart2way.svg | 1599 +++++++++++++++++ 16 files changed, 3700 insertions(+), 815 deletions(-) create mode 100644 gramps/gui/widgets/fanchart2way.py create mode 100644 gramps/plugins/gramplet/fanchart2waygramplet.py create mode 100644 gramps/plugins/view/fanchart2wayview.py create mode 100644 images/hicolor/16x16/actions/gramps-fanchart2way.png create mode 100644 images/hicolor/22x22/actions/gramps-fanchart2way.png create mode 100644 images/hicolor/48x48/actions/gramps-fanchart2way.png create mode 100644 images/hicolor/scalable/actions/gramps-fanchart2way.svg diff --git a/gramps/gen/display/name.py b/gramps/gen/display/name.py index 1c6dcf787..ffa4b94a1 100644 --- a/gramps/gen/display/name.py +++ b/gramps/gen/display/name.py @@ -892,6 +892,24 @@ class NameDisplay: name = person.get_primary_name() return self.display_name(name) + def display_format(self, person, num): + """ + Return a text string representing the L{gen.lib.Person} instance's + L{Name} using num format. + + @param person: L{gen.lib.Person} instance that contains the + L{Name} that is to be displayed. The primary name is used for + the display. + @type person: L{gen.lib.Person} + @param num: num of the format to be used, as return by + name_displayer.add_name_format('name','format') + @type num: int + @returns: Returns the L{gen.lib.Person} instance's name + @rtype: str + """ + name = person.get_primary_name() + return self.name_formats[num][_F_FN](name) + def display_formal(self, person): """ Return a text string representing the :class:`~.person.Person` diff --git a/gramps/gui/widgets/fanchart.py b/gramps/gui/widgets/fanchart.py index 6efb17523..67abe69d1 100644 --- a/gramps/gui/widgets/fanchart.py +++ b/gramps/gui/widgets/fanchart.py @@ -93,20 +93,6 @@ from gramps.gen.const import ( _ = glocale.translation.gettext from gramps.gui.utilscairo import warpPath -#------------------------------------------------------------------------- -# -# Functions -# -#------------------------------------------------------------------------- -def gender_code(is_male): - """ - Given boolean is_male (means position in FanChart) return code. - """ - if is_male: - return Person.MALE - else: - return Person.FEMALE - #------------------------------------------------------------------------- # # FanChartBaseWidget @@ -115,7 +101,7 @@ def gender_code(is_male): class FanChartBaseWidget(Gtk.DrawingArea): """ a base widget for fancharts""" - CENTER = 50 # pixel radius of center, changes per fanchart + CENTER = 60 # pixel radius of center, changes per fanchart def __init__(self, dbstate, uistate, callback_popup=None): Gtk.DrawingArea.__init__(self) @@ -134,6 +120,8 @@ class FanChartBaseWidget(Gtk.DrawingArea): self.last_x, self.last_y = None, None self.fontdescr = "Sans" self.fontsize = 8 + self.twolineformat_nums=(name_displayer.add_name_format('fanchart_name_line1', '%l'), + name_displayer.add_name_format('fanchart_name_line2', '%f %s')) self.connect("button_release_event", self.on_mouse_up) self.connect("motion_notify_event", self.on_mouse_move) self.connect("button-press-event", self.on_mouse_down) @@ -175,15 +163,18 @@ class FanChartBaseWidget(Gtk.DrawingArea): self._mouse_click = False self.rotate_value = 90 # degrees, initially, 1st gen male on right half - self.center_xy = [0, 0] # distance from center (x, y) - self.center_x = 0 - self.center_y = 0 + self.center_delta_xy = [0, 0] # translation of the center of the fan wrt canonical center + self.center_xy = [0, 0] # coord of the center of the fan self.mouse_x = 0 self.mouse_y = 0 #(re)compute everything self.reset() self.set_size_request(120, 120) + def __del__(self): + for num in self.twolineformat_nums: + name_displayer.del_name_format(num) + def reset(self): """ Reset the fan chart. This should trigger computation of all data @@ -195,7 +186,7 @@ class FanChartBaseWidget(Gtk.DrawingArea): self._fill_data_structures() # prepare the colors for the boxes - self.prepare_background_box() + self.prepare_background_box(self.generations) def _fill_data_structures(self): """ @@ -245,6 +236,9 @@ class FanChartBaseWidget(Gtk.DrawingArea): """ raise NotImplementedError + def get_radiusinout_for_generation(self,generation): + raise NotImplementedError + def on_draw(self, widget, cr, scale=1.): """ callback to draw the fanchart @@ -296,12 +290,11 @@ class FanChartBaseWidget(Gtk.DrawingArea): ) userdata.append((agecol[0]*255, agecol[1]*255, agecol[2]*255)) - def prepare_background_box(self): + def prepare_background_box(self, maxgen): """ Method that is called every reset of the chart, to precomputed values needed for the background of the boxes """ - maxgen = self.generations cstart = hex_to_rgb(self.grad_start) cend = hex_to_rgb(self.grad_end) self.cstart_hsv = colorsys.rgb_to_hsv(cstart[0]/255, cstart[1]/255, @@ -462,6 +455,9 @@ class FanChartBaseWidget(Gtk.DrawingArea): def draw_radbox(self, cr, radiusin, radiusout, start_rad, stop_rad, color, thick=False): + """ + Procedure to draw a person box in the outter ring position + """ cr.move_to(radiusout * math.cos(start_rad), radiusout * math.sin(start_rad)) cr.arc(0, 0, radiusout, start_rad, stop_rad) cr.line_to(radiusin * math.cos(stop_rad), radiusin * math.sin(stop_rad)) @@ -473,9 +469,13 @@ class FanChartBaseWidget(Gtk.DrawingArea): #and again for the border cr.move_to(radiusout * math.cos(start_rad), radiusout * math.sin(start_rad)) cr.arc(0, 0, radiusout, start_rad, stop_rad) - cr.line_to(radiusin * math.cos(stop_rad), radiusin * math.sin(stop_rad)) + if (start_rad - stop_rad) % (2 * math.pi) > 1e-5: + radial_motion_type = cr.line_to + else: + radial_motion_type = cr.move_to + radial_motion_type(radiusin * math.cos(stop_rad), radiusin * math.sin(stop_rad)) cr.arc_negative(0, 0, radiusin, stop_rad, start_rad) - cr.close_path() + radial_motion_type(radiusout * math.cos(start_rad), radiusout * math.sin(start_rad)) ##cr.append_path(path) # not working correct cr.set_source_rgb(0, 0, 0) # black if thick: @@ -520,12 +520,98 @@ class FanChartBaseWidget(Gtk.DrawingArea): cr.set_source_rgba(r/255., g/255., b/255., a) cr.fill() - def wrap_truncate_layout(self, layout, font, width_pixels): - """Uses the layout to wrap and truncate its text to given width + def draw_person(self, cr, person, radiusin, radiusout, start_rad, stop_rad, + generation, dup, userdata, thick=False, has_moregen_indicator = False, + is_central_person=False): + """ + Display the piece of pie for a given person. start_rad and stop_rad + are in radians. + """ + cr.save() + # If we need an indicator of more generations: + if has_moregen_indicator: + # draw an indicator + color=(1.0, 1.0, 1.0, 1.0) # white + self.draw_radbox(cr, radiusout, radiusout + BORDER_EDGE_WIDTH, start_rad, stop_rad, color, thick=False) + # get the color of the background + if not person: + # if called on None, let's make a transparent box + r, g, b, a = (255, 255, 255, 0) + elif dup: + r, g, b = self.dupcolor #duplicate color + a = 1.0 + else: + r, g, b, a = self.background_box(person, generation, userdata) + color=(r/255., g/255., b/255., a) + + # now draw the person + if not is_central_person: + self.draw_radbox(cr, radiusin, radiusout, start_rad, stop_rad, color, thick) + else: + #special box for centrer pers + cr.arc(0, 0, radiusout, 0, 2 * math.pi) + if self.childring and len(self.childrenroot)>0: + cr.arc_negative(0, 0, radiusin, 2 * math.pi, 0) + cr.close_path() + cr.set_source_rgba(*color) + cr.fill() + + if self.last_x is None or self.last_y is None: + #we are not in a move, so draw text + radial = False + if self.radialtext: ## and generation >= 6: + space_arc_text = (radiusin+radiusout)/2 * (stop_rad-start_rad) + # is there more space to print it radial ? + radial= (space_arc_text < (radiusout-radiusin) * 1.1) + self.draw_person_text(cr, person, radiusin, radiusout, start_rad, stop_rad, + radial, self.fontcolor(r, g, b, a), self.fontbold(a), can_flip=not is_central_person) + cr.restore() + + def draw_person_text(self, cr, person, radiusin, radiusout, start, stop, + radial=False, fontcolor=(0, 0, 0), bold=False, can_flip = True): + if not person: return + draw_radial = radial and self.radialtext + if not self.twolinename: + name=name_displayer.display(person) + self.draw_text(cr, name, radiusin, radiusout, start, stop, draw_radial, + fontcolor, bold) + else: + text=name_displayer.display(person) + text_line1=name_displayer.display_format(person,self.twolineformat_nums[0]) + text_line2=name_displayer.display_format(person,self.twolineformat_nums[1]) + if draw_radial: + split_frac_line1=0.5 + flipped = can_flip and ((math.degrees((start+stop)/2.0) + self.rotate_value - 90) % 360 < 179 and self.flipupsidedownname) + if flipped: + middle=(start*split_frac_line1+stop*(1.0-split_frac_line1)) + (a11,a12,a21,a22)=(middle,stop,start,middle) + else: + middle=(start*(1.0-split_frac_line1)+stop*split_frac_line1) + (a11,a12,a21,a22)=(start,middle,middle,stop) + written_textwidth=self.draw_text(cr, text_line1, radiusin, radiusout, a11, a12, draw_radial, fontcolor, bold=1, flipped=flipped) + if written_textwidth == 0 and text_line1 != "": + #Not enought space for 2 line, fallback to 1 line + written_textwidth=self.draw_text(cr, text_line1, radiusin, radiusout, start, stop, draw_radial, fontcolor, bold=1, flipped=flipped) + self.draw_text(cr, text_line2, radiusin+written_textwidth+PAD_TEXT, radiusout, start, stop, draw_radial, fontcolor, bold, flipped) + else: + self.draw_text(cr, text_line2, radiusin, radiusout, a21, a22, draw_radial, fontcolor, bold, flipped) + else: + middle=(radiusin*.5+radiusout*.5) + flipped = can_flip and ((math.degrees((start+stop)/2.0) + self.rotate_value) % 360 < 179 and self.flipupsidedownname) + if flipped: + self.draw_text(cr, text_line2, middle, radiusout, start, stop, draw_radial, fontcolor, bold=0, flipped=flipped) + self.draw_text(cr, text_line1, radiusin, middle, start, stop, draw_radial, fontcolor, bold=1, flipped=flipped) + else: + self.draw_text(cr, text_line1, middle, radiusout, start, stop, draw_radial, fontcolor, bold=1, flipped=flipped) + self.draw_text(cr, text_line2, radiusin, middle, start, stop, draw_radial, fontcolor, bold=0, flipped=flipped) + + def wrap_truncate_layout(self, layout, font, width_pixels, height_pixels, tryrescale=True): + """ + Uses the layout to wrap and truncate its text to given width Returns: (w,h) as returned by layout.get_pixel_size() """ - + all_text_backup = layout.get_text() layout.set_font_description(font) layout.set_width(Pango.SCALE * width_pixels) @@ -534,166 +620,138 @@ class FanChartBaseWidget(Gtk.DrawingArea): if layout.get_line_count() > 1: layout.set_text(layout.get_text(), layout.get_line(0).length) + #2. we check if height is ok + w, h = layout.get_pixel_size() + if h > height_pixels: + if tryrescale: + #try to reduce the height + fontsize = max(height_pixels / h * font.get_size() /1.1, font.get_size()/2.0) + font.set_size(fontsize) + layout.set_text(all_text_backup, len(all_text_backup.encode('utf-8'))) # reducing the height allows for more characters + layout.set_font_description(font) + if layout.get_line_count() > 1: + layout.set_text(layout.get_text(), layout.get_line(0).length) + w, h = layout.get_pixel_size() + # we check again if height is ok + if h > height_pixels: + #we could not fix it, no text + layout.set_text("",0) + layout.context_changed() return layout.get_pixel_size() - def draw_text(self, cr, text, radius, start, stop, - height=PIXELS_PER_GENERATION, radial=False, - fontcolor=(0, 0, 0), bold=False): + def draw_text(self, cr, text, radiusin, radiusout, start_rad, stop_rad, + radial=False, + fontcolor=(0, 0, 0), bold=False, flipped = False): """ - Display text at a particular radius, between start and stop - degrees. + Display text at a particular radius, between start_rad and stop_rad + radians. """ - cr.save() font = Pango.FontDescription(self.fontdescr) fontsize = self.fontsize font.set_size(fontsize * Pango.SCALE) if bold: font.set_weight(Pango.Weight.BOLD) + cr.save() cr.set_source_rgb(*fontcolor) if radial and self.radialtext: - cr.save() - layout = self.create_pango_layout(text) - layout.set_font_description(font) - layout.set_wrap(Pango.WrapMode.CHAR) - - # NOTE: for radial text, the sector radius height is the text width - w, h = self.wrap_truncate_layout(layout, font, height - 2*PAD_TEXT) - - w = w + 5 # 5 pixel padding - h = h + 4 # 4 pixel padding - #first we check if height is ok - degneedheight = math.degrees(h / radius) - degavailheight = stop-start - degoffsetheight = 0 - if degneedheight > degavailheight: - #reduce height - fontsize = degavailheight / degneedheight * fontsize / 2 - font.set_size(fontsize * Pango.SCALE) - w, h = self.wrap_truncate_layout(layout, font, height - 2*PAD_TEXT) - w = w + 5 # 5 pixel padding - h = h + 4 # 4 pixel padding - #first we check if height is ok - degneedheight = math.degrees(h / radius) - degavailheight = stop-start - if degneedheight > degavailheight: - #we could not fix it, no text - text = "" - if text: - #spread rest - degoffsetheight = (degavailheight - degneedheight) / 2 - # offset for cairo-font system is 90 - rotval = self.rotate_value % 360 - 90 - if (start + rotval) % 360 > 179: - pos = start + degoffsetheight + 90 - 90 - else: - pos = stop - degoffsetheight + 180 - cr.rotate(math.radians(pos)) - layout.context_changed() - if (start + rotval) % 360 > 179: - cr.move_to(radius + PAD_TEXT, 0) - else: - cr.move_to(-radius - height + PAD_TEXT, 0) - PangoCairo.show_layout(cr, layout) - cr.restore() + self.draw_radial_text(cr, text, radiusin, radiusout, start_rad, stop_rad, font, flipped) else: - self.draw_arc_text(cr, text, radius, start, stop, font) + self.draw_arc_text(cr, text, radiusin, radiusout, start_rad, stop_rad, font, flipped) cr.restore() - def draw_arc_text(self, cr, text, radius, start, stop, font): + def draw_radial_text(self, cr, text, radiusin, radiusout, start_rad, stop_rad, font, flipped): + layout = self.create_pango_layout(text) + layout.set_font_description(font) + layout.set_wrap(Pango.WrapMode.WORD_CHAR) + + # compute available text space + # NOTE: for radial text, the sector radius height is the text width + avail_height = (stop_rad - start_rad) * radiusin - 2.0 * PAD_TEXT + avail_width = radiusout - radiusin - 2.0 * PAD_TEXT + + w, h = self.wrap_truncate_layout(layout, font, avail_width, avail_height, tryrescale=True) + + # 2. now draw this text + # offset for cairo-font system is 90 + if flipped: + angle = (start_rad + stop_rad)/2 + (h / radiusin / 2) + math.pi + start_pos = -radiusout + PAD_TEXT + else: + angle = (start_rad + stop_rad)/2 - (h / radiusin / 2) + start_pos = radiusin + PAD_TEXT + cr.rotate(angle) + layout.context_changed() + cr.move_to(start_pos, 0) + PangoCairo.show_layout(cr, layout) + + def draw_arc_text(self, cr, text, radiusin, radiusout, start_rad, stop_rad, font, bottom_is_outside): """ Display text at a particular radius, between start and stop degrees, setting it up along the arc, center-justified. - Text not fitting a single line will be word-wrapped away. + Text not fitting a single line will be char-wrapped away. """ - - # 1. determine the spread of text we can draw, in radians - degpadding = math.degrees(PAD_TEXT / radius) - # offset for cairo-font system is 90, padding used is 5: - pos = start + 90 + degpadding/2 - cr.save() - cr.rotate(math.radians(pos)) - cr.new_path() - cr.move_to(0, -radius) - rad_spread = math.radians(stop - start - degpadding) - - # 2. Use Pango.Layout to set up the text for us, and do - # the hard work in CTL text handling and line wrapping. - # Clip to the top line only so the text looks nice - # all around the circle at the same radius. layout = self.create_pango_layout(text) - layout.set_wrap(Pango.WrapMode.WORD) - w, h = self.wrap_truncate_layout(layout, font, radius * rad_spread) + layout.set_font_description(font) + layout.set_wrap(Pango.WrapMode.WORD_CHAR) + + # get height of text: + textheight=layout.get_size()[1]/Pango.SCALE + radius_text=(radiusin+radiusout)/2.0 + + # 1. compute available text space + avail_height = radiusout - radiusin - 2.0 * PAD_TEXT + avail_width = (stop_rad - start_rad) * radius_text - 2.0 * PAD_TEXT + + w, h = self.wrap_truncate_layout(layout, font, avail_width, avail_height, tryrescale=True) + + # 2. Compute text position start angle + mid_rad = (stop_rad + start_rad)/2 + pos_rad = mid_rad - (w/2/radius_text) + end_rad = pos_rad + (w/radius_text) # 3. Use the layout to provide us the metrics of the text box + cr.new_path() PangoCairo.layout_path(cr, layout) - #le = layout.get_line(0).get_pixel_extents()[0] - pe = cr.path_extents() - if pe == (0.0, 0.0, 0.0, 0.0): - # 7710: When scrolling the path extents are zero on Ubuntu 14.04 - return - arc_used_ratio = w / (radius * rad_spread) - rad_mid = math.radians(pos) + rad_spread/2 # 4. The moment of truth: map the text box onto the sector, and render! warpPath(cr, \ - self.create_map_rect_to_sector(radius, pe, \ - arc_used_ratio, rad_mid - rad_spread/2, rad_mid + rad_spread/2)) + self.create_map_rect_to_sector(radius_text, pos_rad, end_rad, textheight, bottom_is_outside )) cr.fill() - cr.restore() @staticmethod - def create_map_rect_to_sector(radius, rect, arc_used_ratio, start_rad, stop_rad): + def create_map_rect_to_sector(radius, start_rad, stop_rad, textheight, bottom_is_outside = False): """ Create a 2D-transform, mapping a rectangle onto a circle sector. :param radius: average radius of the target sector - :param rect: (x1, y1, x2, y2) - :param arc_used_ratio: From 0.0 to 1.0. Rather than stretching onto the - whole sector, only the middle arc_used_ratio part - will be mapped onto. :param start_rad: start radial angle of the sector, in radians :param stop_rad: stop radial angle of the sector, in radians + :param textheight height of the text + :param bottom_is_outside flag defining if we write with the bottom toward outside :returns: a lambda (x,y)|->(xNew,yNew) to feed to warpPath. """ - - x0, y0, w, h = rect[0], rect[1], rect[2]-rect[0], rect[3]-rect[1] - - radiusin = radius - h/2 - radiusout = radius + h/2 - drho = h - dphi = (stop_rad - start_rad) - - # There has to be a clearer way to express this transform, - # by stacking a set of transforms on cr around using this function - # and doing a mapping between unit squares of rectangular and polar - # coordinates. - - def phi(x): - return (x - x0) * dphi * arc_used_ratio / w \ - + (1 - arc_used_ratio) * dphi / 2 \ - - math.pi/2 - def rho(y): - return (y - y0) * (radiusin - radiusout)/h + radiusout - - # In (user coordinates units - pixels): - # x from x0 to x0 + w - # y from y0 to y0 + h - # Out: - # (x, y) within the arc_used_ratio of a box like drawn by draw_radbox + if bottom_is_outside: + def phi(x): + return -x/radius + stop_rad + def rho(y): + return radius + (y - textheight/2.0) + else: + def phi(x): + return x/radius + start_rad + def rho(y): + return radius - ( y - textheight/2.0) return lambda x, y: \ (rho(y) * math.cos(phi(x)), rho(y) * math.sin(phi(x))) - def draw_gradient(self, cr, widget, halfdist): + def draw_gradient_legend(self, cr, widget, halfdist): gradwidth = 10 gradheight = 10 starth = 15 startw = 5 - alloc = self.get_allocation() - x, y, w, h = alloc.x, alloc.y, alloc.width, alloc.height cr.save() - cr.translate(-self.center_x, -self.center_y) + cr.translate(-self.center_xy[0], -self.center_xy[1]) font = Pango.FontDescription(self.fontdescr) fontsize = self.fontsize @@ -711,75 +769,55 @@ class FanChartBaseWidget(Gtk.DrawingArea): starth = starth+gradheight cr.restore() - def person_under_cursor(self, curx, cury): + def cursor_on_tranlation_dot(self, curx, cury): + """ + Determine if the cursor at position x and y is + on the translation dot + """ + fanxy = curx - self.center_xy[0], cury - self.center_xy[1] + radius = math.sqrt((fanxy[0]) ** 2 + (fanxy[1]) ** 2) + return radius < TRANSLATE_PX + + def cursor_to_polar(self, curx, cury, get_raw_rads = False): + # compute angle, radius in unrotated fan + fanxy = curx - self.center_xy[0], cury - self.center_xy[1] + radius = math.sqrt((fanxy[0]) ** 2 + (fanxy[1]) ** 2) + #angle before rotation: + #children are in cairo angle (clockwise) from pi to 3 pi + #rads however is clock 0 to 2 pi + raw_rads = math.atan2( fanxy[1], fanxy[0]) % (2 * math.pi) + rads = (raw_rads - math.radians(self.rotate_value) ) % (2 * math.pi) + if get_raw_rads: + return radius, rads, raw_rads + else: + return radius, rads + + def radian_in_bounds(self, start_rad, rads, stop_rad): + assert(start_rad <= stop_rad) + # we compare (rads - start_rad) % (2.0 * math.pi) and (stop_rad - start_rad) + slice = stop_rad - start_rad + dist_rads_to_start_rads = (rads - start_rad) % (2.0 * math.pi) + #print start_rad, rads, stop_rad, ". (rads-start), slice :", dist_rads_to_start_rads, slice + return dist_rads_to_start_rads < slice + + def cell_address_under_cursor(self, curx, cury): """ Determine the generation and the position in the generation at position x and y, as well as the type of box. generation = -1 on center black dot generation >= self.generations outside of diagram """ - # compute angle, radius, find out who would be there (rotated) + raise NotImplementedError - # center coordinate - cx = self.center_x - cy = self.center_y - radius = math.sqrt((curx - cx) ** 2 + (cury - cy) ** 2) - if radius < TRANSLATE_PX: - generation = -1 - elif (self.childring and self.angle[-2] and - radius < TRANSLATE_PX + CHILDRING_WIDTH): - generation = -2 # indication of one of the children - elif radius < self.CENTER: - generation = 0 - else: - generation = int((radius - self.CENTER)/self.gen_pixels()) + 1 - btype = self.boxtype(radius) - - rads = math.atan2( (cury - cy), (curx - cx) ) - if rads < 0: # second half of unit circle - rads = math.pi + (math.pi + rads) - #angle before rotation: - pos = ((rads/(math.pi * 2) - self.rotate_value/360.) * 360.0) % 360 - #children are in cairo angle (clockwise) from pi to 3 pi - #rads however is clock 0 to 2 pi - if rads < math.pi: - rads += 2 * math.pi - # if generation is in expand zone: - # FIXME: add a way of expanding - # find what person is in this position: - selected = None - if (0 <= generation < self.generations): - selected = self.personpos_at_angle(generation, pos, btype) - elif generation == -2: - for p in range(len(self.angle[generation])): - start, stop, state = self.angle[generation][p] - if start <= rads <= stop: - selected = p - break - - return generation, selected, btype - - def boxtype(self, radius): + def person_at(self, cell_address): """ - default is only one type of box type - """ - return TYPE_BOX_NORMAL - - def personpos_at_angle(self, generation, angledeg, btype): - """ - returns the person in generation generation at angle of type btype. + returns the person at cell_address """ raise NotImplementedError - def person_at(self, generation, pos, btype): + def family_at(self, cell_address): """ - returns the person at generation, pos, btype - """ - raise NotImplementedError - - def family_at(self, generation, pos, btype): - """ - returns the family at generation, pos, btype + returns the family at cell_address """ raise NotImplementedError @@ -799,12 +837,10 @@ class FanChartBaseWidget(Gtk.DrawingArea): """grab key press """ if self.mouse_x and self.mouse_y: - generation, selected, btype = self.person_under_cursor(self.mouse_x, - self.mouse_y) - if selected is None: + cell_address = self.cell_address_under_cursor(self.mouse_x, self.mouse_y) + if cell_address is None: return False - person = self.person_at(generation, selected, btype) - family = self.family_at(generation, selected, btype) + person, family = self.person_at(cell_address), self.family_at(cell_address) if person and (Gdk.keyval_name(eventkey.keyval) == 'e'): # we edit the person self.edit_person_cb(None, person.handle) @@ -818,22 +854,22 @@ class FanChartBaseWidget(Gtk.DrawingArea): def on_mouse_down(self, widget, event): self.translating = False # keep track of up/down/left/right movement - generation, selected, btype = self.person_under_cursor(event.x, event.y) if event.button == 1: #we grab the focus to enable to see key_press events self.grab_focus() # left mouse on center dot, we translate on left click - if generation == -1: + if self.cursor_on_tranlation_dot(event.x, event.y): if event.button == 1: # left mouse # save the mouse location for movements self.translating = True self.last_x, self.last_y = event.x, event.y return True + cell_address = self.cell_address_under_cursor(event.x, event.y) #click in open area, prepare for a rotate - if selected is None: + if cell_address is None: # save the mouse location for movements self.last_x, self.last_y = event.x, event.y return True @@ -841,16 +877,13 @@ class FanChartBaseWidget(Gtk.DrawingArea): #left click on person, prepare for expand/collapse or drag if event.button == 1: self._mouse_click = True - self._mouse_click_gen = generation - self._mouse_click_sel = selected - self._mouse_click_btype = btype + self._mouse_click_cell_address = cell_address return False #right click on person, context menu # Do things based on state, event.get_state(), or button, event.button if is_right_click(event): - person = self.person_at(generation, selected, btype) - family = self.family_at(generation, selected, btype) + person, family = self.person_at(cell_address), self.family_at(cell_address) fhandle = None if family: fhandle = family.handle @@ -864,36 +897,24 @@ class FanChartBaseWidget(Gtk.DrawingArea): self._mouse_click = False if self.last_x is None or self.last_y is None: # while mouse is moving, we must update the tooltip based on person - generation, selected, btype = self.person_under_cursor(event.x, event.y) - self.mouse_x = event.x - self.mouse_y = event.y + cell_address = self.cell_address_under_cursor(event.x, event.y) + self.mouse_x, self.mouse_y = event.x, event.y tooltip = "" - person = self.person_at(generation, selected, btype) - if person: + if cell_address: + person = self.person_at(cell_address) tooltip = self.format_helper.format_person(person, 11) self.set_tooltip_text(tooltip) return False #translate or rotate should happen - alloc = self.get_allocation() - x, y, w, h = alloc.x, alloc.y, alloc.width, alloc.height if self.translating: - if self.form == FORM_CIRCLE: - self.center_xy = w/2 - event.x, h/2 - event.y - elif self.form == FORM_HALFCIRCLE: - self.center_xy = w/2 - event.x, h - self.CENTER - PAD_PX - event.y - elif self.form == FORM_QUADRANT: - self.center_xy = self.CENTER + PAD_PX - event.x, h - self.CENTER - PAD_PX - event.y + canonical_center = self.center_xy_from_delta([0,0]) + self.center_delta_xy = canonical_center[0]-event.x,canonical_center[1]-event.y + self.center_xy = self.center_xy_from_delta() else: - cx = w/2 - self.center_xy[0] - cy = h/2 - self.center_xy[1] # get the angles of the two points from the center: - start_angle = math.atan2(event.y - cy, event.x - cx) - end_angle = math.atan2(self.last_y - cy, self.last_x - cx) - if start_angle < 0: # second half of unit circle - start_angle = math.pi + (math.pi + start_angle) - if end_angle < 0: # second half of unit circle - end_angle = math.pi + (math.pi + end_angle) + start_angle = math.atan2(event.y - self.center_xy[1], event.x - self.center_xy[0]) + end_angle = math.atan2(self.last_y - self.center_xy[1], self.last_x - self.center_xy[0]) # now look at change in angle: diff_angle = (end_angle - start_angle) % (math.pi * 2.0) self.rotate_value -= math.degrees(diff_angle) @@ -901,6 +922,18 @@ class FanChartBaseWidget(Gtk.DrawingArea): self.queue_draw() return True + def center_xy_from_delta(self, delta=None): + alloc = self.get_allocation() + x, y, w, h = alloc.x, alloc.y, alloc.width, alloc.height + if delta is None: delta = self.center_delta_xy + if self.form == FORM_CIRCLE: + canvas_xy = w/2 - delta[0], h/2 - delta[1] + elif self.form == FORM_HALFCIRCLE: + canvas_xy = w/2 - delta[0], h - self.CENTER - PAD_PX - delta[1] + elif self.form == FORM_QUADRANT: + canvas_xy = self.CENTER + PAD_PX - delta[0], h - self.CENTER - PAD_PX - delta[1] + return canvas_xy + def do_mouse_click(self): """ action to take on left mouse click @@ -916,15 +949,6 @@ class FanChartBaseWidget(Gtk.DrawingArea): return True if self.translating: self.translating = False - alloc = self.get_allocation() - x, y, w, h = alloc.x, alloc.y, alloc.width, alloc.height - if self.form == FORM_CIRCLE: - self.center_xy = w/2 - event.x, h/2 - event.y - self.center_xy = w/2 - event.x, h/2 - event.y - elif self.form == FORM_HALFCIRCLE: - self.center_xy = w/2 - event.x, h - self.CENTER - PAD_PX - event.y - elif self.form == FORM_QUADRANT: - self.center_xy = self.CENTER + PAD_PX - event.x, h - self.CENTER - PAD_PX - event.y self.last_x, self.last_y = None, None self.queue_draw() @@ -945,14 +969,14 @@ class FanChartBaseWidget(Gtk.DrawingArea): Specified for 'person-link', for others return text info about person. """ tgs = [x.name() for x in context.list_targets()] - person = self.person_at(self._mouse_click_gen, self._mouse_click_sel, - self._mouse_click_btype) - if info == DdTargets.PERSON_LINK.app_id: - data = (DdTargets.PERSON_LINK.drag_type, - id(self), person.get_handle(), 0) - sel_data.set(sel_data.get_target(), 8, pickle.dumps(data)) - elif ('TEXT' in tgs or 'text/plain' in tgs) and info == 0: - sel_data.set_text(self.format_helper.format_person(person, 11), -1) + person = self.person_at(self._mouse_click_cell_address) + if person: + if info == DdTargets.PERSON_LINK.app_id: + data = (DdTargets.PERSON_LINK.drag_type, + id(self), person.get_handle(), 0) + sel_data.set(sel_data.get_target(), 8, pickle.dumps(data)) + elif ('TEXT' in tgs or 'text/plain' in tgs) and info == 0: + sel_data.set_text(self.format_helper.format_person(person, 11), -1) def on_drag_data_received(self, widget, context, x, y, sel_data, info, time): """ @@ -960,8 +984,9 @@ class FanChartBaseWidget(Gtk.DrawingArea): If the selection data is defined, extract the value from sel_data.data """ - gen, persatcurs, btype = self.person_under_cursor(x, y) - if gen == -1 or gen == 0: + radius, rads = self.cursor_to_polar(x, y) + + if radius < self.CENTER: if sel_data and sel_data.get_data(): (drag_type, idval, handle, val) = pickle.loads(sel_data.get_data()) self.goto(self, handle) @@ -1002,13 +1027,13 @@ class FanChartWidget(FanChartBaseWidget): Fan Chart Widget. Handles visualization of data in self.data. See main() of FanChartGramplet for example of model format. """ - self.set_values(None, 9, BACKGROUND_GRAD_GEN, True, True, 'Sans', '#0000FF', + self.set_values(None, 9, BACKGROUND_GRAD_GEN, True, True, True, True, 'Sans', '#0000FF', '#FF0000', None, 0.5, FORM_CIRCLE) FanChartBaseWidget.__init__(self, dbstate, uistate, callback_popup) def set_values(self, root_person_handle, maxgen, background, childring, - radialtext, fontdescr, grad_start, grad_end, - filter, alpha_filter, form): + flipupsidedownname, twolinename, radialtext, fontdescr, + grad_start, grad_end, filter, alpha_filter, form): """ Reset the values to be used: @@ -1017,6 +1042,8 @@ class FanChartWidget(FanChartBaseWidget): :param background: config setting of which background procedure to use :type background: int :param childring: to show the center ring with children or not + :param twolinename: uses two lines for the display of person's name + :param flipupsidedownname: flip name on the left of the fanchart for the display of person's name :param radialtext: try to use radial text or not :param fontdescr: string describing the font to use :param grad_start: colors to use for background procedure @@ -1030,6 +1057,8 @@ class FanChartWidget(FanChartBaseWidget): self.generations = maxgen self.radialtext = radialtext self.childring = childring + self.twolinename = twolinename + self.flipupsidedownname = flipupsidedownname self.background = background self.fontdescr = fontdescr self.grad_start = grad_start @@ -1047,21 +1076,19 @@ class FanChartWidget(FanChartBaseWidget): self.angle[-2] = [] self.data = {} self.childrenroot = [] + self.rootangle_rad = [math.radians(0), math.radians(360)] + if self.form == FORM_HALFCIRCLE: + self.rootangle_rad = [math.radians(90), math.radians(270)] + elif self.form == FORM_QUADRANT: + self.rootangle_rad = [math.radians(180), math.radians(270)] for i in range(self.generations): - # name, person, parents?, children? - self.data[i] = [(None,) * 5] * 2 ** i + # person, parents?, children? + self.data[i] = [(None,) * 4] * 2 ** i self.angle[i] = [] - factor = 1 - angle = 0 - if self.form == FORM_HALFCIRCLE: - factor = 1/2 - angle = 90 - elif self.form == FORM_QUADRANT: - angle = 180 - factor = 1/4 - slice = 360.0 / (2 ** i) * factor + angle = self.rootangle_rad[0] + slice = 1/ (2 ** i) * (self.rootangle_rad[1] - self.rootangle_rad[0]) for count in range(len(self.data[i])): - # start, stop, male, state + # start, stop, state self.angle[i].append([angle, angle + slice, NORMAL]) angle += slice @@ -1070,16 +1097,11 @@ class FanChartWidget(FanChartBaseWidget): if not self.rootpersonh: return person = self.dbstate.db.get_person_from_handle(self.rootpersonh) - if not person: - #nothing to do, just return - return - else: - name = name_displayer.display(person) parents = self._have_parents(person) child = self._have_children(person) # our data structure is the text, the person object, parents, child and # list for userdata which we might fill in later. - self.data[0][0] = (name, person, parents, child, []) + self.data[0][0] = (person, parents, child, []) self.childrenroot = [] if child: childlist = find_children(self.dbstate.db, person) @@ -1088,42 +1110,23 @@ class FanChartWidget(FanChartBaseWidget): if not child: continue else: - self.childrenroot.append((child_handle, child.get_gender(), + self.childrenroot.append((child, True, self._have_children(child), [])) for current in range(1, self.generations): parent = 0 - # name, person, parents, children - for (n, p, q, c, d) in self.data[current - 1]: - # Get father's details: - person = self._get_parent(p, True) - if person: - name = name_displayer.display(person) - else: - name = None - if current == self.generations - 1: - parents = self._have_parents(person) - else: - parents = None - self.data[current][parent] = (name, person, parents, None, []) - if person is None: - # start,stop,male/right,state - self.angle[current][parent][2] = COLLAPSED - parent += 1 - # Get mother's details: - person = self._get_parent(p, False) - if person: - name = name_displayer.display(person) - else: - name = None - if current == self.generations - 1: - parents = self._have_parents(person) - else: - parents = None - self.data[current][parent] = (name, person, parents, None, []) - if person is None: - # start,stop,male/right,state - self.angle[current][parent][2] = COLLAPSED - parent += 1 + # person, parents, children + for (p, q, c, d) in self.data[current - 1]: + # Get father's and mother's details: + for person in [self._get_parent(p, True), self._get_parent(p, False)]: + if current == self.generations - 1: + parents = self._have_parents(person) + else: + parents = None + self.data[current][parent] = (person, parents, None, []) + if person is None: + # start,stop,male/right,state + self.angle[current][parent][2] = COLLAPSED + parent += 1 def _have_parents(self, person): """ @@ -1162,25 +1165,25 @@ class FanChartWidget(FanChartBaseWidget): def nrgen(self): #compute the number of generations present - nrgen = None for generation in range(self.generations - 1, 0, -1): for p in range(len(self.data[generation])): - (text, person, parents, child, userdata) = self.data[generation][p] + (person, parents, child, userdata) = self.data[generation][p] if person: - nrgen = generation - break - if nrgen is not None: - break - if nrgen is None: - nrgen = 1 - return nrgen + return generation + return 1 def halfdist(self): """ Compute the half radius of the circle """ - nrgen = self.nrgen() - return PIXELS_PER_GENERATION * nrgen + self.CENTER + BORDER_EDGE_WIDTH + return PIXELS_PER_GENERATION * self.nrgen() + self.CENTER + BORDER_EDGE_WIDTH + + def get_radiusinout_for_generation(self,generation): + outerradius=generation * PIXELS_PER_GENERATION + self.CENTER + innerradius=(generation-1) * PIXELS_PER_GENERATION + self.CENTER + if generation==0: + innerradius= CHILDRING_WIDTH + TRANSLATE_PX + return (innerradius,outerradius) def people_generator(self): """ @@ -1188,7 +1191,7 @@ class FanChartWidget(FanChartBaseWidget): """ for generation in range(self.generations): for p in range(len(self.data[generation])): - (text, person, parents, child, userdata) = self.data[generation][p] + (person, parents, child, userdata) = self.data[generation][p] yield (person, userdata) def innerpeople_generator(self): @@ -1196,9 +1199,8 @@ class FanChartWidget(FanChartBaseWidget): a generator over all people inside of the core person """ for childdata in self.childrenroot: - child_handle, child_gender, has_child, userdata = childdata - child = self.dbstate.db.get_person_from_handle(child_handle) - yield (child, userdata) + (person, parents, child, userdata) = childdata + yield (person, userdata) def on_draw(self, widget, cr, scale=1.): """ @@ -1216,62 +1218,33 @@ class FanChartWidget(FanChartBaseWidget): elif self.form == FORM_QUADRANT: self.set_size_request(halfdist + self.CENTER + PAD_PX, halfdist + self.CENTER + PAD_PX) - #obtain the allocation - alloc = self.get_allocation() - x, y, w, h = alloc.x, alloc.y, alloc.width, alloc.height - cr.scale(scale, scale) - # when printing, we need not recalculate if widget: - if self.form == FORM_CIRCLE: - self.center_x = w/2 - self.center_xy[0] - self.center_y = h/2 - self.center_xy[1] - elif self.form == FORM_HALFCIRCLE: - self.center_x = w/2. - self.center_xy[0] - self.center_y = h - self.CENTER - PAD_PX- self.center_xy[1] - elif self.form == FORM_QUADRANT: - self.center_x = self.CENTER + PAD_PX - self.center_xy[0] - self.center_y = h - self.CENTER - PAD_PX - self.center_xy[1] - cr.translate(self.center_x, self.center_y) + self.center_xy = self.center_xy_from_delta() + cr.translate(*self.center_xy) cr.save() cr.rotate(math.radians(self.rotate_value)) for generation in range(self.generations - 1, 0, -1): for p in range(len(self.data[generation])): - (text, person, parents, child, userdata) = self.data[generation][p] + (person, parents, child, userdata) = self.data[generation][p] if person: start, stop, state = self.angle[generation][p] if state in [NORMAL, EXPANDED]: - self.draw_person(cr, gender_code(p%2 == 0), - text, start, stop, - generation, state, parents, child, - person, userdata) - cr.set_source_rgb(1, 1, 1) # white - cr.move_to(0,0) - cr.arc(0, 0, self.CENTER, 0, 2 * math.pi) - cr.fill() - cr.set_source_rgb(0, 0, 0) # black - cr.arc(0, 0, self.CENTER, 0, 2 * math.pi) - cr.stroke() + radiusin,radiusout = self.get_radiusinout_for_generation(generation) + dup = False + self.draw_person(cr, person, radiusin, radiusout, start, stop, + generation, dup, userdata, thick=(state == EXPANDED), + has_moregen_indicator = (generation == self.generations - 1 and parents) ) cr.restore() # Draw center person: - (text, person, parents, child, userdata) = self.data[0][0] + (person, parents, child, userdata) = self.data[0][0] if person: - r, g, b, a = self.background_box(person, 0, userdata) - cr.arc(0, 0, self.CENTER, 0, 2 * math.pi) - if self.childring and child: - cr.arc_negative(0, 0, TRANSLATE_PX + CHILDRING_WIDTH, 2 * math.pi, 0) - cr.close_path() - cr.set_source_rgba(r/255, g/255, b/255, a) - cr.fill() - cr.save() - name = name_displayer.display(person) - self.draw_text(cr, name, self.CENTER - - (self.CENTER - (CHILDRING_WIDTH + TRANSLATE_PX))/2, 95, 455, - 10, False, - self.fontcolor(r, g, b, a), self.fontbold(a)) - cr.restore() - #draw center to move chart + radiusin, radiusout = self.get_radiusinout_for_generation(0) + if not child: radiusin = TRANSLATE_PX + self.draw_person(cr, person, radiusin, radiusout, math.pi/2, math.pi/2 + 2*math.pi, + 0, False, userdata, thick = False, has_moregen_indicator = False, is_central_person = True) + #draw center disk 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) @@ -1282,80 +1255,7 @@ class FanChartWidget(FanChartBaseWidget): if child and self.childring: self.draw_childring(cr) if self.background in [BACKGROUND_GRAD_AGE, BACKGROUND_GRAD_PERIOD]: - self.draw_gradient(cr, widget, halfdist) - - def draw_person(self, cr, gender, name, start, stop, generation, - state, parents, child, person, userdata): - """ - Display the piece of pie for a given person. start and stop - are in degrees. Gender is indication of father position or mother - position in the chart - """ - cr.save() - start_rad = math.radians(start) - stop_rad = math.radians(stop) - r, g, b, a = self.background_box(person, generation, userdata) - radius = generation * PIXELS_PER_GENERATION + self.CENTER - # If max generation, and they have parents: - if generation == self.generations - 1 and parents: - # draw an indicator - radmax = radius + BORDER_EDGE_WIDTH - cr.move_to(radmax*math.cos(start_rad), radmax*math.sin(start_rad)) - cr.arc(0, 0, radius + BORDER_EDGE_WIDTH, start_rad, stop_rad) - cr.line_to(radius*math.cos(stop_rad), radius*math.sin(stop_rad)) - cr.arc_negative(0, 0, radius, stop_rad, start_rad) - cr.close_path() - ##path = cr.copy_path() # not working correct - cr.set_source_rgb(255, 255, 255) # white - cr.fill() - #and again for the border - cr.move_to(radmax*math.cos(start_rad), radmax*math.sin(start_rad)) - cr.arc(0, 0, radius + BORDER_EDGE_WIDTH, start_rad, stop_rad) - cr.line_to(radius*math.cos(stop_rad), radius*math.sin(stop_rad)) - cr.arc_negative(0, 0, radius, stop_rad, start_rad) - cr.close_path() - ##cr.append_path(path) # not working correct - cr.set_source_rgb(0, 0, 0) # black - cr.stroke() - # now draw the person - cr.move_to(radius * math.cos(start_rad), radius * math.sin(start_rad)) - cr.arc(0, 0, radius, start_rad, stop_rad) - radmin = radius - PIXELS_PER_GENERATION - cr.line_to(radmin * math.cos(stop_rad), radmin * math.sin(stop_rad)) - cr.arc_negative(0, 0, radmin, stop_rad, start_rad) - cr.close_path() - ##path = cr.copy_path() # not working correct - cr.set_source_rgba(r/255., g/255., b/255., a) - cr.fill() - #and again for the border - cr.move_to(radius * math.cos(start_rad), radius * math.sin(start_rad)) - cr.arc(0, 0, radius, start_rad, stop_rad) - radmin = radius - PIXELS_PER_GENERATION - cr.line_to(radmin * math.cos(stop_rad), radmin * math.sin(stop_rad)) - cr.arc_negative(0, 0, radmin, stop_rad, start_rad) - cr.close_path() - ##cr.append_path(path) # not working correct - cr.set_source_rgb(0, 0, 0) # black - if state == NORMAL: # normal - cr.set_line_width(1) - else: # EXPANDED - cr.set_line_width(3) - cr.stroke() - cr.set_line_width(1) - if self.last_x is None or self.last_y is None: - #we are not in a move, so draw text - radial = False - radstart = radius - PIXELS_PER_GENERATION/2 - if self.radialtext: ## and generation >= 6: - spacepolartext = radstart * math.radians(stop-start) - if spacepolartext < PIXELS_PER_GENERATION * 1.1: - # more space to print it radial - radial = True - radstart = radius - PIXELS_PER_GENERATION - self.draw_text(cr, name, radstart, start, stop, - PIXELS_PER_GENERATION, radial, - self.fontcolor(r, g, b, a), self.fontbold(a)) - cr.restore() + self.draw_gradient_legend(cr, widget, halfdist) def draw_childring(self, cr): cr.move_to(TRANSLATE_PX + CHILDRING_WIDTH, 0) @@ -1371,9 +1271,8 @@ class FanChartWidget(FanChartBaseWidget): else: angleinc = 2 * math.pi / nrchild for childdata in self.childrenroot: - child_handle, child_gender, has_child, userdata = childdata - child = self.dbstate.db.get_person_from_handle(child_handle) - self.draw_innerring(cr, child, userdata, startangle, angleinc) + (person, parents, child, userdata) = childdata + self.draw_innerring(cr, person, userdata, startangle, angleinc) startangle += angleinc def expand_parents(self, generation, selected, current): @@ -1433,7 +1332,8 @@ class FanChartWidget(FanChartBaseWidget): state] self.shrink_parents(generation + 1, selected+1, current) - def change_slice(self, generation, selected): + def toggle_cell_state(self, cell_address): + generation, selected = cell_address if generation < 1: return gstart, gstop, gstate = self.angle[generation][selected] @@ -1476,7 +1376,45 @@ class FanChartWidget(FanChartBaseWidget): NORMAL] self.show_parents(generation+1, selected-1, start, slice/2.0) - def personpos_at_angle(self, generation, angledeg, btype): + 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) + + # find out the generation + if radius < TRANSLATE_PX: + return None + elif (self.childring and self.angle[-2] and + radius < TRANSLATE_PX + CHILDRING_WIDTH): + generation = -2 # indication of one of the children + elif radius < self.CENTER: + generation = 0 + else: + generation = None + for gen in range(self.generations): + radiusin,radiusout = self.get_radiusinout_for_generation(gen) + if radiusin <= radius <= radiusout: + generation = gen + break + + # find what person at this angle: + selected = None + if not (generation is None) and 0 <= generation: + selected = self.personpos_at_angle(generation, rads) + 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 + + def personpos_at_angle(self, generation, rads): """ returns the person in generation generation at angle. """ @@ -1484,30 +1422,28 @@ class FanChartWidget(FanChartBaseWidget): return 0 selected = None for p in range(len(self.angle[generation])): - if self.data[generation][p][1]: # there is a person there + if self.data[generation][p][0]: # there is a person there start, stop, state = self.angle[generation][p] if state == COLLAPSED: continue - if start <= angledeg <= stop: + if self.radian_in_bounds(start, rads, stop): selected = p break return selected - def person_at(self, generation, pos, btype): + def person_at(self, cell_address): """ - returns the person at generation, pos, btype + returns the person at cell_address """ - if pos is None: - return None + generation, pos = cell_address if generation == -2: - child_handle = self.childrenroot[pos][0] - person = self.dbstate.db.get_person_from_handle(child_handle) + person = self.childrenroot[pos][0] else: - person = self.data[generation][pos][1] + person = self.data[generation][pos][0] return person - def family_at(self, generation, pos, btype): + def family_at(self, cell_address): """ - returns the family at generation, pos, btype + returns the family at cell_address Difficult here, we would need to go to child, and then obtain the first parent family, as that is the family that is shown. """ @@ -1515,7 +1451,7 @@ class FanChartWidget(FanChartBaseWidget): def do_mouse_click(self): # no drag occured, expand or collapse the section - self.change_slice(self._mouse_click_gen, self._mouse_click_sel) + self.toggle_cell_state(self._mouse_click_cell_address) self._mouse_click = False self.queue_draw() @@ -1547,7 +1483,7 @@ class FanChartGrampsGUI: """ root_person_handle = self.get_active('Person') self.fan.set_values(root_person_handle, self.maxgen, self.background, - self.childring, self.radialtext, self.fonttype, + self.childring, self.flipupsidedownname, self.twolinename, self.radialtext, self.fonttype, self.grad_start, self.grad_end, self.generic_filter, self.alpha_filter, self.form) self.fan.reset() @@ -1576,6 +1512,7 @@ class FanChartGrampsGUI: edit_item.connect("activate", self.edit_person_cb, person_handle) edit_item.show() menu.append(edit_item) + # action related to the clicked family (when there is one) if family_handle: family = self.dbstate.db.get_family_from_handle(family_handle) edit_fam_item = Gtk.MenuItem() @@ -1647,7 +1584,8 @@ class FanChartGrampsGUI: # Go over siblings and build their menu item = Gtk.MenuItem(label=_("Siblings")) pfam_list = person.get_parent_family_handle_list() - no_siblings = 1 + siblings = [] + step_siblings = [] for f in pfam_list: fam = self.dbstate.db.get_family_from_handle(f) sib_list = fam.get_child_ref_list() @@ -1655,32 +1593,49 @@ class FanChartGrampsGUI: sib_id = sib_ref.ref if sib_id == person.get_handle(): continue - sib = self.dbstate.db.get_person_from_handle(sib_id) - if not sib: - continue + siblings.append(sib_id) + # Collect a list of per-step-family step-siblings + for parent_h in [fam.get_father_handle(), fam.get_mother_handle()]: + parent = self.dbstate.db.get_person_from_handle(parent_h) + other_families = [self.dbstate.db.get_family_from_handle(fam_id) + for fam_id in parent.get_family_handle_list() + if fam_id not in pfam_list] + for step_fam in other_families: + fam_stepsiblings = [sib_ref.ref + for sib_ref in step_fam.get_child_ref_list() + if not (sib_ref.ref == person.get_handle())] + if fam_stepsiblings: + step_siblings.append(fam_stepsiblings) - if no_siblings: - no_siblings = 0 - item.set_submenu(Gtk.Menu()) - sib_menu = item.get_submenu() - sib_menu.set_reserve_toggle_size(False) - - if find_children(self.dbstate.db,sib): - label = Gtk.Label(label='%s' % escape(name_displayer.display(sib))) - else: - label = Gtk.Label(label=escape(name_displayer.display(sib))) - - sib_item = Gtk.MenuItem() - label.set_use_markup(True) - label.show() - label.set_halign(Gtk.Align.START) - sib_item.add(label) - linked_persons.append(sib_id) - sib_item.connect("activate", self.on_childmenu_changed, sib_id) - sib_item.show() - sib_menu.append(sib_item) - - if no_siblings: + # Add siblings sub-menu with a bar between each siblings group + if siblings or step_siblings: + item.set_submenu(Gtk.Menu()) + sib_menu = item.get_submenu() + sib_menu.set_reserve_toggle_size(False) + sibs = [siblings]+step_siblings + for sib_group in sibs: + for sib_id in sib_group: + sib = self.dbstate.db.get_person_from_handle(sib_id) + if not sib: + continue + if find_children(self.dbstate.db,sib): + label = Gtk.Label(label='%s' % escape(name_displayer.display(sib))) + else: + label = Gtk.Label(label=escape(name_displayer.display(sib))) + sib_item = Gtk.MenuItem() + label.set_use_markup(True) + label.show() + label.set_alignment(0,0) + sib_item.add(label) + linked_persons.append(sib_id) + sib_item.connect("activate", self.on_childmenu_changed, sib_id) + sib_item.show() + sib_menu.append(sib_item) + if sibs.index(sib_group) < len(sibs)-1: + sep = Gtk.SeparatorMenuItem.new() + sep.show() + sib_menu.append(sep) + else: item.set_sensitive(0) item.show() menu.append(item) diff --git a/gramps/gui/widgets/fanchart2way.py b/gramps/gui/widgets/fanchart2way.py new file mode 100644 index 000000000..954d4a206 --- /dev/null +++ b/gramps/gui/widgets/fanchart2way.py @@ -0,0 +1,625 @@ +# +# 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 +# Copyright (C) 2014 Bastien Jacquet +# +# 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 sys +import pickle +from cgi 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 * +from .fanchartdesc import * + +#------------------------------------------------------------------------- +# +# Constants +# +#------------------------------------------------------------------------- + +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_ASCENDANCE = 0 +TYPE_DESCENDANCE = 1 + +#------------------------------------------------------------------------- +# +# FanChart2WayWidget +# +#------------------------------------------------------------------------- + +class FanChart2WayWidget(FanChartWidget, FanChartDescWidget): + """ + Interactive Fan Chart Widget. + """ + CENTER = 50 # we require a larger center + + 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, 6, 5, True, True, BACKGROUND_GRAD_GEN, True, 'Sans', '#0000FF', + '#FF0000', None, 0.5, ANGLE_WEIGHT, '#888a85') + FanChartBaseWidget.__init__(self, dbstate, uistate, callback_popup) + + def reset(self): + """ + Reset the fan chart. This should trigger computation of all data + structures needed + """ + self.cache_fontcolor = {} + + # fill the data structure + self._fill_data_structures() + + # prepare the colors for the boxes + self.prepare_background_box(self.generations_asc + self.generations_desc - 1) + + def set_values(self, root_person_handle, maxgen_asc, maxgen_desc, flipupsidedownname, twolinename, background, + background_gradient, fontdescr, grad_start, grad_end, + filter, alpha_filter, angle_algo, dupcolor): + """ + Reset the values to be used: + + :param root_person_handle: person to show + :param maxgen_asc: maximum of ascendant generations to show + :param maxgen_desc: maximum of descendant 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 background_gradient: option to add an overall gradient for distinguishing Asc/Desc + :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 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_asc = maxgen_asc + self.generations_desc = maxgen_desc + self.background = background + self.background_gradient = background_gradient + self.fontdescr = fontdescr + self.grad_start = grad_start + self.grad_end = grad_end + self.filter = filter + self.form = FORM_CIRCLE + self.alpha_filter = alpha_filter + 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. + """ + self.rootangle_rad_desc = [math.radians(275), math.radians(275 + 170)] + self.rootangle_rad_asc = [math.radians(90), math.radians(270)] + + self.handle2desc = {} + self.famhandle2desc = {} + self.handle2fam = {} + self.gen2people = {} + self.gen2fam = {} + self.gen2people[0] = [(None, False, 0, 2 * math.pi, 0, 0, [], NORMAL)] # no center person + self.gen2fam[0] = [] # no families + for i in range(1, self.generations_desc): + self.gen2fam[i] = [] + self.gen2people[i] = [] + self.gen2people[self.generations_desc] = [] # indication of more children + + # Ascendance part + self.angle = {} + self.data = {} + for i in range(self.generations_asc): + # name, person, parents?, children? + self.data[i] = [(None,) * 4] * 2 ** i + self.angle[i] = [] + angle = self.rootangle_rad_asc[0] + slice = 1 / (2 ** i) * (self.rootangle_rad_asc[1] - self.rootangle_rad_asc[0]) + for count in range(len(self.data[i])): + # start, stop, state + self.angle[i].append([angle, angle + slice, NORMAL]) + angle += slice + + 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 + + # Descendance part + # person, duplicate or not, start angle, slice size, + # text, parent pos in fam, nrfam, userdata, status + self.gen2people[0] = [[person, False, 0, 2 * math.pi, 0, 0, [], NORMAL]] + self.handle2desc[self.rootpersonh] = 0 + # recursively fill in the datastructures: + nrdesc = self._rec_fill_data(0, person, 0, self.generations_desc) + self.handle2desc[person.handle] += nrdesc + self._compute_angles(*self.rootangle_rad_desc) + + # Ascendance part + parents = self._have_parents(person) + child = self._have_children(person) + # Ascendance data structure is the person object, parents, child and + # list for userdata which we might fill in later. + self.data[0][0] = (person, parents, child, []) + for current in range(1, self.generations_asc): + parent = 0 + # name, person, parents, children + for (p, q, c, d) in self.data[current - 1]: + # Get father's and mother's details: + for person in [self._get_parent(p, True), self._get_parent(p, False)]: + if current == self.generations_asc - 1: + parents = self._have_parents(person) + else: + parents = None + self.data[current][parent] = (person, parents, None, []) + if person is None: + # start,stop,male/right,state + self.angle[current][parent][2] = COLLAPSED + parent += 1 + + def nrgen_desc(self): + # compute the number of generations present + for gen in range(self.generations_desc - 1, 0, -1): + if len(self.gen2people[gen]) > 0: + return gen + 1 + return 1 + + def nrgen_asc(self): + # compute the number of generations present + for generation in range(self.generations_asc - 1, 0, -1): + for p in range(len(self.data[generation])): + (person, parents, child, userdata) = self.data[generation][p] + if person: + return generation + return 1 + + def maxradius_asc(self, generation): + """ + Compute the current half radius of the ascendant circle + """ + radiusin, radius_asc = self.get_radiusinout_for_generation_asc(generation) + return radius_asc + BORDER_EDGE_WIDTH + + def maxradius_desc(self,generation): + """ + Compute the current radius of the descendant circle + """ + radiusin_pers, radiusout_pers, radiusin_partner, radius_desc = self.get_radiusinout_for_generation_pair(generation-1) + return radius_desc + BORDER_EDGE_WIDTH + + def halfdist(self): + """ + Compute the current max half radius of the circle + """ + return max(self.maxradius_desc(self.nrgen_desc()), self.maxradius_asc(self.nrgen_asc())) + + def get_radiusinout_for_generation_desc(self, generation): + """ + Get the in and out radius for descendant generation (starting with center pers = 0) + """ + 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_asc(self, generation): + """ + Get the in and out radius for ascendant generation (starting with center pers = 0) + """ + radiusin, radius_first_gen = self.get_radiusinout_for_generation_desc(0) + outerradius = generation * PIXELS_PER_GENERATION + radius_first_gen + innerradius = (generation - 1) * PIXELS_PER_GENERATION + radius_first_gen + if generation == 0: + innerradius = CHILDRING_WIDTH + TRANSLATE_PX + return (innerradius, outerradius) + + def get_radiusinout_for_generation_pair(self, generation): + """ + Get the in and out radius for descendant generation pair (starting with center pers = 0) + :return: (radiusin_pers, radiusout_pers, radiusin_partner, radiusout_partner) + """ + radiusin, radiusout = self.get_radiusinout_for_generation_desc(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_desc): + for data in self.gen2people[generation]: + yield (data[0], data[6]) + for generation in range(self.generations_desc): + for data in self.gen2fam[generation]: + yield (data[7], data[6]) + for generation in range(self.generations_asc): + for p in range(len(self.data[generation])): + (person, parents, child, userdata) = self.data[generation][p] + yield (person, userdata) + + def innerpeople_generator(self): + """ + a generator over all people inside of the core person + """ + if False: + yield + + def draw_background(self, cr): + cr.save() + + cr.rotate(math.radians(self.rotate_value)) + delta = (self.rootangle_rad_asc[0] - self.rootangle_rad_desc[1]) / 2.0 % math.pi + + cr.move_to(0, 0) + radius_gradient_asc = 1.5 * self.maxradius_asc(self.generations_asc) + gradient_asc = cairo.RadialGradient(0, 0, self.CENTER, 0, 0, radius_gradient_asc) + color = hex_to_rgb(self.grad_end) + gradient_asc.add_color_stop_rgba(0.0, color[0]/255, color[1]/255, color[2]/255, 0.5) + gradient_asc.add_color_stop_rgba(1.0, 1, 1, 1, 0.0) + start_rad, stop_rad = self.rootangle_rad_asc[0] - delta, self.rootangle_rad_asc[1] + delta + cr.set_source(gradient_asc) + cr.arc(0, 0, radius_gradient_asc, start_rad, stop_rad) + cr.fill() + + cr.move_to(0, 0) + radius_gradient_desc = 1.5 * self.maxradius_desc(self.generations_desc) + gradient_desc = cairo.RadialGradient(0, 0, self.CENTER, 0, 0, radius_gradient_desc) + color = hex_to_rgb(self.grad_start) + gradient_desc.add_color_stop_rgba(0.0, color[0]/255, color[1]/255, color[2]/255, 0.5) + gradient_desc.add_color_stop_rgba(1.0, 1, 1, 1, 0.0) + start_rad, stop_rad = self.rootangle_rad_desc[0] - delta, self.rootangle_rad_desc[1] + delta + cr.set_source(gradient_desc) + cr.arc(0, 0, radius_gradient_desc, start_rad, stop_rad) + cr.fill() + cr.restore() + + + 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: + self.set_size_request(2 * halfdist, 2 * halfdist) + + cr.scale(scale, scale) + if widget: + self.center_xy = self.center_xy_from_delta() + cr.translate(*self.center_xy) + + cr.save() + # Draw background + if self.background_gradient: + self.draw_background(cr) + # Draw center person: + (person, dup, start, slice, parentfampos, nrfam, userdata, status) \ + = self.gen2people[0][0] + if not person: + return + gen_remapped = self.generations_desc - 1 # remapped generation + if gen_remapped == 0: gen_remapped = (self.generations_desc + self.generations_asc - 1) # remapped generation + radiusin_pers, radiusout_pers, radiusin_partner, radiusout_partner = \ + self.get_radiusinout_for_generation_pair(0) + radiusin = TRANSLATE_PX + radiusout = radiusout_pers + self.draw_person(cr, person, radiusin, radiusout, math.pi / 2, math.pi / 2 + 2 * math.pi, + gen_remapped, 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) + cr.fill() + + cr.rotate(math.radians(self.rotate_value)) + # Ascendance + for generation in range(self.generations_asc - 1, 0, -1): + for p in range(len(self.data[generation])): + (person, parents, child, userdata) = self.data[generation][p] + if person: + start, stop, state = self.angle[generation][p] + if state in [NORMAL, EXPANDED]: + radiusin, radiusout = self.get_radiusinout_for_generation_asc(generation) + dup = False + gen_remapped = generation + self.generations_desc - 1 # remapped generation + self.draw_person(cr, person, radiusin, radiusout, start, stop, + gen_remapped, dup, userdata, thick=(state == EXPANDED), + has_moregen_indicator=(generation == self.generations_asc - 1 and parents)) + + # Descendance + for gen in range(self.generations_desc): + radiusin_pers, radiusout_pers, radiusin_partner, radiusout_partner = \ + self.get_radiusinout_for_generation_pair(gen) + gen_remapped = (self.generations_desc - gen - 1) + if gen_remapped == 0: gen_remapped = (self.generations_desc + self.generations_asc - 1) # remapped generation + 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_remapped, dup, userdata, + thick=status != NORMAL) + #if gen < self.generations_desc - 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_desc - 1 + and len(fam.get_child_ref_list()) > 0) + self.draw_person(cr, partner, radiusin_partner, radiusout_partner, start, start + slice, + gen_remapped, 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) + + if radius < TRANSLATE_PX: + return None + radius_parents = self.get_radiusinout_for_generation_asc(0)[1] + if (radius < radius_parents) or \ + (self.radian_in_bounds(self.rootangle_rad_desc[0], rads, self.rootangle_rad_desc[1])): + cell_address = self.cell_address_under_cursor_desc(rads, radius) + if cell_address is not None: + return (TYPE_DESCENDANCE,) + cell_address + elif self.radian_in_bounds(self.rootangle_rad_asc[0], rads, self.rootangle_rad_asc[1]): + cell_address = self.cell_address_under_cursor_asc(rads, radius) + if cell_address and cell_address[0]==0: return None # There is a gap before first parents + if cell_address is not None: + return (TYPE_ASCENDANCE,) + cell_address + + return None + + def cell_address_under_cursor_desc(self, rads, radius): + """ + Determine the cell address in the fan under the cursor + position x and y. + None if outside of diagram + """ + generation, selected, btype = None, None, TYPE_BOX_NORMAL + for gen in range(self.generations_desc): + 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: + if not (generation is None) and 0 <= generation: + selected = FanChartDescWidget.personpos_at_angle(self, generation, rads, btype) + + if (generation is None or selected is None): + return None + + return generation, selected, btype + + def cell_address_under_cursor_asc(self, rads, radius): + """ + Determine the cell address in the fan under the cursor + position x and y. + None if outside of diagram + """ + + generation, selected = None, None + for gen in range(self.generations_asc): + radiusin, radiusout = self.get_radiusinout_for_generation_asc(gen) + if radiusin <= radius <= radiusout: + generation = gen + break + + # find what person is in this position: + if not (generation is None) and 0 <= generation: + selected = FanChartWidget.personpos_at_angle(self, generation, rads) + if (generation is None or selected is None): + return None + return generation, selected + + def person_at(self, cell_address): + """ + returns the person at radius_first_gen + """ + direction = cell_address[0] + if direction == TYPE_ASCENDANCE: + return FanChartWidget.person_at(self, cell_address[1:]) + elif direction == TYPE_DESCENDANCE: + return FanChartDescWidget.person_at(self, cell_address[1:]) + return None + + def family_at(self, cell_address): + """ + returns the family at cell_address + """ + direction = cell_address[0] + if direction == TYPE_ASCENDANCE: + return None + elif direction == TYPE_DESCENDANCE: + return FanChartDescWidget.family_at(self, cell_address[1:]) + return None + + def do_mouse_click(self): + # no drag occured, expand or collapse the section + self.toggle_cell_state(self._mouse_click_cell_address) + self._mouse_click = False + self.queue_draw() + + def expand_parents(self, generation, selected, current): + if generation >= self.generations_asc: return + selected = 2 * selected + start, stop, state = self.angle[generation][selected] + if state in [NORMAL, EXPANDED]: + slice = (stop - start) * 2.0 + self.angle[generation][selected] = [current, current + slice, state] + self.expand_parents(generation + 1, selected, current) + current += slice + start, stop, state = self.angle[generation][selected + 1] + if state in [NORMAL, EXPANDED]: + slice = (stop - start) * 2.0 + self.angle[generation][selected + 1] = [current, current + slice, + state] + self.expand_parents(generation + 1, selected + 1, current) + + def show_parents(self, generation, selected, angle, slice): + if generation >= self.generations_asc: return + selected *= 2 + self.angle[generation][selected][0] = angle + self.angle[generation][selected][1] = angle + slice + self.angle[generation][selected][2] = NORMAL + self.show_parents(generation + 1, selected, angle, slice / 2.0) + self.angle[generation][selected + 1][0] = angle + slice + self.angle[generation][selected + 1][1] = angle + slice + slice + self.angle[generation][selected + 1][2] = NORMAL + self.show_parents(generation + 1, selected + 1, angle + slice, slice / 2.0) + + def hide_parents(self, generation, selected, angle): + if generation >= self.generations_asc: return + selected = 2 * selected + self.angle[generation][selected][0] = angle + self.angle[generation][selected][1] = angle + self.angle[generation][selected][2] = COLLAPSED + self.hide_parents(generation + 1, selected, angle) + self.angle[generation][selected + 1][0] = angle + self.angle[generation][selected + 1][1] = angle + self.angle[generation][selected + 1][2] = COLLAPSED + self.hide_parents(generation + 1, selected + 1, angle) + + def shrink_parents(self, generation, selected, current): + if generation >= self.generations_asc: return + selected = 2 * selected + start, stop, state = self.angle[generation][selected] + if state in [NORMAL, EXPANDED]: + slice = (stop - start) / 2.0 + self.angle[generation][selected] = [current, current + slice, + state] + self.shrink_parents(generation + 1, selected, current) + current += slice + start, stop, state = self.angle[generation][selected + 1] + if state in [NORMAL, EXPANDED]: + slice = (stop - start) / 2.0 + self.angle[generation][selected + 1] = [current, current + slice, + state] + self.shrink_parents(generation + 1, selected + 1, current) + + def toggle_cell_state(self, cell_address): + direction = cell_address[0] + if direction == TYPE_ASCENDANCE: + FanChartWidget.toggle_cell_state(self, cell_address[1:]) + elif direction == TYPE_DESCENDANCE: + FanChartDescWidget.toggle_cell_state(self, cell_address[1:]) + self._compute_angles(*self.rootangle_rad_desc) + +class FanChart2WayGrampsGUI(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.generations_asc, self.generations_desc, self.flipupsidedownname, self.twolinename, self.background, + self.background_gradient, self.fonttype, self.grad_start, self.grad_end, + self.generic_filter, self.alpha_filter, + self.angle_algo, self.dupcolor) + self.fan.reset() + self.fan.queue_draw() diff --git a/gramps/gui/widgets/fanchartdesc.py b/gramps/gui/widgets/fanchartdesc.py index bf8d14cba..0c250f3ed 100644 --- a/gramps/gui/widgets/fanchartdesc.py +++ b/gramps/gui/widgets/fanchartdesc.py @@ -68,14 +68,21 @@ from .fanchart import * #------------------------------------------------------------------------- pi = math.pi -PIXELS_PER_GENPERSON = 30 # size of radius for generation of children -PIXELS_PER_GENFAMILY = 20 # size of radius for family +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 #------------------------------------------------------------------------- # @@ -87,18 +94,18 @@ class FanChartDescWidget(FanChartBaseWidget): """ Interactive Fan Chart Widget. """ - CENTER = 60 # we require a larger center + 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, BACKGROUND_GRAD_GEN, 'Sans', '#0000FF', + 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, background, + def set_values(self, root_person_handle, maxgen, flipupsidedownname, twolinename, background, fontdescr, grad_start, grad_end, filter, alpha_filter, form, angle_algo, dupcolor): """ @@ -106,6 +113,7 @@ class FanChartDescWidget(FanChartBaseWidget): :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 @@ -131,24 +139,28 @@ class FanChartDescWidget(FanChartBaseWidget): self.anglealgo = angle_algo self.dupcolor = hex_to_rgb(dupcolor) self.childring = False - - def gen_pixels(self): - """ - how many pixels a generation takes up in the fanchart - """ - return PIXELS_PER_GENPERSON + PIXELS_PER_GENFAMILY + 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.parentsroot = [] - self.gen2people[0] = [(None, False, 0, 2*pi, '', 0, 0, [], NORMAL)] #no center person + 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] = [] @@ -156,14 +168,6 @@ class FanChartDescWidget(FanChartBaseWidget): self.gen2fam[i] = [] self.gen2people[i] = [] self.gen2people[self.generations] = [] #indication of more children - self.rotfactor = 1 - self.rotstartangle = 0 - if self.form == FORM_HALFCIRCLE: - self.rotfactor = 1/2 - self.rotangle = 90 - elif self.form == FORM_QUADRANT: - self.rotangle = 180 - self.rotfactor = 1/4 def _fill_data_structures(self): self.set_generations() @@ -173,15 +177,13 @@ class FanChartDescWidget(FanChartBaseWidget): if not person: #nothing to do, just return return - else: - name = name_displayer.display(person) # person, duplicate or not, start angle, slice size, # text, parent pos in fam, nrfam, userdata, status - self.gen2people[0] = [[person, False, 0, 2*pi, name, 0, 0, [], NORMAL]] + self.gen2people[0] = [[person, False, 0, 2*pi, 0, 0, [], NORMAL]] self.handle2desc[self.rootpersonh] = 0 # fill in data for the parents - self.parentsroot = [] + self.innerring = [] handleparents = [] family_handle_list = person.get_parent_family_handle_list() if family_handle_list: @@ -189,173 +191,145 @@ class FanChartDescWidget(FanChartBaseWidget): family = self.dbstate.db.get_family_from_handle(family_handle) if not family: continue - hfather = family.get_father_handle() - if hfather and hfather not in handleparents: - father = self.dbstate.db.get_person_from_handle(hfather) - if father: - self.parentsroot.append((father, [])) - handleparents.append(hfather) - hmother = family.get_mother_handle() - if hmother and hmother not in handleparents: - mother = self.dbstate.db.get_person_from_handle(hmother) - if mother: - self.parentsroot.append((mother, [])) - handleparents.append(hmother) + 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) + nrdesc = self._rec_fill_data(0, person, 0, self.generations) self.handle2desc[person.handle] += nrdesc - self.__compute_angles() + self._compute_angles(*self.rootangle_rad) - def __rec_fill_data(self, gen, person, pos): + def _rec_fill_data(self, gen, person, pos, maxgen): """ Recursively fill in the data """ totdesc = 0 - nrfam = len(person.get_family_handle_list()) - self.gen2people[gen][pos][6] = nrfam - for family_handle in person.get_family_handle_list(): + 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) - spname = name_displayer.display(spouse) else: - spname = '' spouse = None - if family_handle in self.famhandle2desc: - #family occurs via father and via mother in the chart, only - #first to show and count. - famdup = True - else: - famdup = False + # 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, - # text, spouse pos in gen, nrchildren, userdata, parnter, status - self.gen2fam[gen].append([family, famdup, 0, 0, spname, pos, 0, [], - spouse, NORMAL]) + # 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 famdup: + if not fam_duplicate and gen < maxgen-1: nrchild = len(family.get_child_ref_list()) - self.gen2fam[gen][-1][6] = nrchild + 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) - chname = name_displayer.display(child) - if child_ref.ref in self.handle2desc: - dup = True - else: - dup = False - self.handle2desc[child_ref.ref] = 0 + 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, - # text, parent pos in fam, nrfam, userdata, status - self.gen2people[gen+1].append([child, dup, 0, 0, chname, - posfam, 0, [], NORMAL]) + # 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 dup and not(self.generations == gen+2): - nrdesc = self.__rec_fill_data(gen+1, child, pospers) + 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): + def _compute_angles(self, start_rad, stop_rad): """ Compute the angles of the boxes """ #first we compute the size of the slice. - nrgen = self.nrgen() #set angles root person - if self.form == FORM_CIRCLE: - slice = 2*pi - start = 0. - elif self.form == FORM_HALFCIRCLE: - slice = pi - start = pi/2 - elif self.form == FORM_QUADRANT: - slice = pi/2 - start = pi + 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(1, nrgen): - nrpeople = len(self.gen2people[gen]) + for gen in range(0, nr_gen): prevpartnerdatahandle = None offset = 0 - for data in self.gen2fam[gen-1]: - #obtain start and stop of partner - partnerdata = self.gen2people[gen-1][data[5]] - dupfam = data[1] + 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 the descendants here, but in the first - # occurrence of the family - nrdescfam = 0 - nrdescpartner = self.handle2desc[partnerdata[0].handle] - nrfam = partnerdata[6] + # we don't show again the descendants here nrdescfam = 0 else: - nrdescfam = self.famhandle2desc[data[0].handle] - nrdescpartner = self.handle2desc[partnerdata[0].handle] - nrfam = partnerdata[6] - partstart = partnerdata[2] - partslice = partnerdata[3] - if prevpartnerdatahandle != partnerdata[0].handle: - #reset the offset + 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 = partnerdata[0].handle - slice = partslice/(nrdescpartner+nrfam)*(nrdescfam+1) - if data[9] == COLLAPSED: + prevpartnerdatahandle = persondata[0].handle + slice = personslice/(nrdescperson+nrfam)*(nrdescfam+1) + if data_fam[8] == COLLAPSED: slice = 0 - elif data[9] == EXPANDED: - slice = partslice + elif data_fam[8] == EXPANDED: + slice = personslice - data[2] = partstart + offset - data[3] = slice + data_fam[2] = personstart + offset + data_fam[3] = slice offset += slice -## if nrdescpartner == 0: +## if nrdescperson == 0: ## #no offspring, draw as large as fraction of ## #nr families -## nrfam = partnerdata[6] -## slice = partslice/nrfam -## data[2] = partstart + offset -## data[3] = slice +## 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 = partnerdata[6] -## slice = partslice/(nrdescpartner + nrfam - 1)*(nrdescfam+1) -## data[2] = partstart + offset -## data[3] = slice +## 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 = partnerdata[6] -## slice = partslice/(nrdescpartner + nrfam - 1)*(nrdescfam+1) -## data[2] = partstart + offset -## data[3] = slice +## 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 data in self.gen2people[gen]: + 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-1][data[5]] + parentfamdata = self.gen2fam[gen][persondata[4]] nrdescfam = 0 if not parentfamdata[1]: nrdescfam = self.famhandle2desc[parentfamdata[0].handle] nrdesc = 0 - if not data[1]: - nrdesc = self.handle2desc[data[0].handle] + if not persondata[1]: + nrdesc = self.handle2desc[persondata[0].handle] famstart = parentfamdata[2] famslice = parentfamdata[3] - nrchild = parentfamdata[6] + nrchild = parentfamdata[5] #now we divide this slice to the weight of children, #adding one for every child if self.anglealgo == ANGLE_CHEQUI: @@ -368,32 +342,48 @@ class FanChartDescWidget(FanChartBaseWidget): #reset the offset offset = 0 prevfamdatahandle = parentfamdata[0].handle - if data[8] == COLLAPSED: + if persondata[7] == COLLAPSED: slice = 0 - elif data[8] == EXPANDED: + elif persondata[7] == EXPANDED: slice = famslice - data[2] = famstart + offset - data[3] = slice + persondata[2] = famstart + offset + persondata[3] = slice offset += slice def nrgen(self): #compute the number of generations present - nrgen = None for gen in range(self.generations - 1, 0, -1): if len(self.gen2people[gen]) > 0: - nrgen = gen + 1 - break - if nrgen is None: - nrgen = 1 - return nrgen + return gen + 1 + return 1 def halfdist(self): """ Compute the half radius of the circle """ - nrgen = self.nrgen() - ringpxs = (PIXELS_PER_GENPERSON + PIXELS_PER_GENFAMILY) * (nrgen - 1) - return ringpxs + self.CENTER + BORDER_EDGE_WIDTH + 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): """ @@ -401,16 +391,16 @@ class FanChartDescWidget(FanChartBaseWidget): """ for generation in range(self.generations): for data in self.gen2people[generation]: - yield (data[0], data[7]) - for generation in range(self.generations-1): + yield (data[0], data[6]) + for generation in range(self.generations): for data in self.gen2fam[generation]: - yield (data[8], data[7]) + yield (data[7], data[6]) def innerpeople_generator(self): """ a generator over all people inside of the core person """ - for parentdata in self.parentsroot: + for parentdata in self.innerring: parent, userdata = parentdata yield (parent, userdata) @@ -432,184 +422,112 @@ class FanChartDescWidget(FanChartBaseWidget): self.set_size_request(halfdist + self.CENTER + PAD_PX, halfdist + self.CENTER + PAD_PX) - #obtain the allocation - alloc = self.get_allocation() - x, y, w, h = alloc.x, alloc.y, alloc.width, alloc.height - cr.scale(scale, scale) # when printing, we need not recalculate if widget: - if self.form == FORM_CIRCLE: - self.center_x = w/2 - self.center_xy[0] - self.center_y = h/2 - self.center_xy[1] - elif self.form == FORM_HALFCIRCLE: - self.center_x = w/2. - self.center_xy[0] - self.center_y = h - self.CENTER - PAD_PX- self.center_xy[1] - elif self.form == FORM_QUADRANT: - self.center_x = self.CENTER + PAD_PX - self.center_xy[0] - self.center_y = h - self.CENTER - PAD_PX - self.center_xy[1] - cr.translate(self.center_x, self.center_y) + self.center_xy = self.center_xy_from_delta() + cr.translate(*self.center_xy) cr.save() - #draw center - cr.set_source_rgb(1, 1, 1) # white - cr.move_to(0,0) - cr.arc(0, 0, self.CENTER-PIXELS_PER_GENFAMILY, 0, 2 * math.pi) - cr.fill() - cr.set_source_rgb(0, 0, 0) # black - cr.arc(0, 0, self.CENTER-PIXELS_PER_GENFAMILY, 0, 2 * math.pi) - cr.stroke() - cr.restore() # Draw center person: - (person, dup, start, slice, text, parentfampos, nrfam, userdata, status) \ + (person, dup, start, slice, parentfampos, nrfam, userdata, status) \ = self.gen2people[0][0] if person: r, g, b, a = self.background_box(person, 0, userdata) - cr.arc(0, 0, self.CENTER-PIXELS_PER_GENFAMILY, 0, 2 * math.pi) - if self.parentsroot: - cr.arc_negative(0, 0, TRANSLATE_PX + CHILDRING_WIDTH, - 2 * math.pi, 0) - cr.close_path() - cr.set_source_rgba(r/255, g/255, b/255, a) - cr.fill() - cr.save() - name = name_displayer.display(person) - self.draw_text(cr, name, self.CENTER - PIXELS_PER_GENFAMILY - - (self.CENTER - PIXELS_PER_GENFAMILY - - (CHILDRING_WIDTH + TRANSLATE_PX))/2, - 95, 455, 10, False, - self.fontcolor(r, g, b, a), self.fontbold(a)) - cr.restore() + 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.parentsroot: # has at least one parent + if self.innerring: # has at least one parent cr.fill() - self.draw_parentring(cr) + self.draw_innerring_people(cr) else: cr.stroke() #now write all the families and children - cr.save() cr.rotate(self.rotate_value * math.pi/180) - radstart = self.CENTER - PIXELS_PER_GENFAMILY - PIXELS_PER_GENPERSON - for gen in range(self.generations-1): - radstart += PIXELS_PER_GENPERSON + 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, - # text, spouse pos in gen, nrchildren, userdata, status - fam, dup, start, slice, text, posfam, nrchild, userdata,\ + # spouse pos in gen, nrchildren, userdata, status + fam, dup, start, slice, posfam, nrchild, userdata,\ partner, status = famdata if status != COLLAPSED: - self.draw_person(cr, text, start, slice, radstart, - radstart + PIXELS_PER_GENFAMILY, gen, dup, - partner, userdata, family=True, thick=status != NORMAL) - radstart += PIXELS_PER_GENFAMILY - for pdata in self.gen2people[gen+1]: - # person, duplicate or not, start angle, slice size, - # text, parent pos in fam, nrfam, userdata, status - pers, dup, start, slice, text, pospar, nrfam, userdata, status = \ - pdata - if status != COLLAPSED: - self.draw_person(cr, text, start, slice, radstart, - radstart + PIXELS_PER_GENPERSON, gen+1, dup, - pers, userdata, thick=status != NORMAL) + 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(cr, widget, halfdist) + self.draw_gradient_legend(cr, widget, halfdist) - def draw_person(self, cr, name, start_rad, slice, radius, radiusend, - generation, dup, person, userdata, family=False, thick=False): + def cell_address_under_cursor(self, curx, cury): """ - Display the piece of pie for a given person. start_rad and slice - are in radial. + Determine the cell address in the fan under the cursor + position x and y. + None if outside of diagram """ - if slice == 0: - return - cr.save() - full = False - if abs(slice - 2*pi) < 1e-6: - full = True - stop_rad = start_rad + slice - if not person: - #an family with partner not set. Don't have a color for this, - # let's make it transparent - r, g, b, a = (255, 255, 255, 0) - elif not dup: - r, g, b, a = self.background_box(person, generation, userdata) + 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: - #duplicate color - a = 1 - r, g, b = self.dupcolor #(136, 138, 133) - # If max generation, and they have children: - if (not family and generation == self.generations - 1 - and self._have_children(person)): - # draw an indicator - radmax = radiusend + BORDER_EDGE_WIDTH - cr.move_to(radmax*math.cos(start_rad), radmax*math.sin(start_rad)) - cr.arc(0, 0, radmax, start_rad, stop_rad) - cr.line_to(radiusend*math.cos(stop_rad), radiusend*math.sin(stop_rad)) - cr.arc_negative(0, 0, radiusend, stop_rad, start_rad) - cr.close_path() - ##path = cr.copy_path() # not working correct - cr.set_source_rgb(1, 1, 1) # white - cr.fill() - #and again for the border - cr.move_to(radmax*math.cos(start_rad), radmax*math.sin(start_rad)) - cr.arc(0, 0, radmax, start_rad, stop_rad) - cr.line_to(radiusend*math.cos(stop_rad), radiusend*math.sin(stop_rad)) - cr.arc_negative(0, 0, radiusend, stop_rad, start_rad) - cr.close_path() - ##cr.append_path(path) # not working correct - cr.set_source_rgb(0, 0, 0) # black - cr.stroke() - # now draw the person - self.draw_radbox(cr, radius, radiusend, start_rad, stop_rad, - (r/255, g/255, b/255, a), thick) - if self.last_x is None or self.last_y is None: - #we are not in a move, so draw text - radial = False - width = radiusend-radius - radstart = radius + width/2 - spacepolartext = radstart * (stop_rad-start_rad) - if spacepolartext < width * 1.1: - # more space to print it radial - radial = True - radstart = radius - self.draw_text(cr, name, radstart, start_rad/ math.pi*180, - stop_rad/ math.pi*180, width, radial, - self.fontcolor(r, g, b, a), self.fontbold(a)) - cr.restore() + 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 - def boxtype(self, radius): - """ - default is only one type of box type - """ - if radius <= self.CENTER: - if radius >= self.CENTER - PIXELS_PER_GENFAMILY: - return TYPE_BOX_FAMILY - else: - return TYPE_BOX_NORMAL - else: - gen = int((radius - self.CENTER)/self.gen_pixels()) + 1 - radius = (radius - self.CENTER) % PIXELS_PER_GENERATION - if radius >= PIXELS_PER_GENPERSON: - if gen < self.generations - 1: - return TYPE_BOX_FAMILY - else: - # the last generation has no family boxes - None - else: - return TYPE_BOX_NORMAL + # 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_parentring(self, cr): + 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.parentsroot) + nrparent = len(self.innerring) #Y axis is downward. positve angles are hence clockwise startangle = math.pi if nrparent <= 2: @@ -618,104 +536,100 @@ class FanChartDescWidget(FanChartBaseWidget): angleinc = math.pi/2 else: angleinc = 2 * math.pi / nrchild - for data in self.parentsroot: + for data in self.innerring: self.draw_innerring(cr, data[0], data[1], startangle, angleinc) startangle += angleinc - def personpos_at_angle(self, generation, angledeg, btype): + def personpos_at_angle(self, generation, rads, btype): """ returns the person in generation generation at angle. """ - angle = angledeg / 360 * 2 * pi selected = None + datas = None if btype == TYPE_BOX_NORMAL: - for p, pdata in enumerate(self.gen2people[generation]): - # person, duplicate or not, start angle, slice size, - # text, parent pos in fam, nrfam, userdata, status - start = pdata[2] - stop = start + pdata[3] - if start <= angle <= stop: - selected = p - break + if generation==0: + return 0 # central person is always ok ! + datas = self.gen2people[generation] elif btype == TYPE_BOX_FAMILY: - for p, pdata in enumerate(self.gen2fam[generation]): - # person, duplicate or not, start angle, slice size, - # text, parent pos in fam, nrfam, userdata, status - start = pdata[2] - stop = start + pdata[3] - if start <= angle <= stop: - selected = p - break + 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, generation, pos, btype): + def person_at(self, cell_address): """ returns the person at generation, pos, btype """ - if pos is None: - return None + generation, pos, btype = cell_address if generation == -2: - person, userdata = self.parentsroot[pos] + person, userdata = self.innerring[pos] elif btype == TYPE_BOX_NORMAL: # person, duplicate or not, start angle, slice size, - # text, parent pos in fam, nrfam, userdata, status + # 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, - # text, spouse pos in gen, nrchildren, userdata, person, status - person = self.gen2fam[generation][pos][8] + # spouse pos in gen, nrchildren, userdata, person, status + person = self.gen2fam[generation][pos][7] return person - def family_at(self, generation, pos, btype): + 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.change_slice(self._mouse_click_gen, self._mouse_click_sel, - self._mouse_click_btype) + self.toggle_cell_state(self._mouse_click_cell_address) + self._compute_angles(*self.rootangle_rad) self._mouse_click = False self.queue_draw() - def change_slice(self, generation, selected, btype): + 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[5] - status = data[8] + parpos = data[4] + status = data[7] if status == NORMAL: #should be expanded, rest collapsed for entry in self.gen2people[generation]: - if entry[5] == parpos: + 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.gen2people[generation]: - if entry[5] == parpos: + for entry in self.gen2fam[generation]: + if entry[4] == parpos: entry[8] = NORMAL - if btype == TYPE_BOX_FAMILY: - data = self.gen2fam[generation][selected] - parpos = data[5] - status = data[9] - if status == NORMAL: - #should be expanded, rest collapsed - for entry in self.gen2fam[generation]: - if entry[5] == parpos: - entry[9] = COLLAPSED - data[9] = EXPANDED - else: - #is expanded, set back to normal - for entry in self.gen2fam[generation]: - if entry[5] == parpos: - entry[9] = NORMAL - - self.__compute_angles() class FanChartDescGrampsGUI(FanChartGrampsGUI): """ class for functions fanchart GUI elements will need in Gramps @@ -727,7 +641,7 @@ class FanChartDescGrampsGUI(FanChartGrampsGUI): data. """ root_person_handle = self.get_active('Person') - self.fan.set_values(root_person_handle, self.maxgen, self.background, + 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) diff --git a/gramps/plugins/gramplet/fanchart2waygramplet.py b/gramps/plugins/gramplet/fanchart2waygramplet.py new file mode 100644 index 000000000..06680de2b --- /dev/null +++ b/gramps/plugins/gramplet/fanchart2waygramplet.py @@ -0,0 +1,100 @@ +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2001-2007 Donald N. Allingham, Martin Hawlisch +# Copyright (C) 2009 Douglas S. Blank +# +# 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. + +# $Id$ + +## Based on the normal fanchart + +#------------------------------------------------------------------------- +# +# Python modules +# +#------------------------------------------------------------------------- +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Pango +from gi.repository import Gtk +import math +from gi.repository import Gdk +try: + import cairo +except ImportError: + pass + +#------------------------------------------------------------------------- +# +# GRAMPS modules +# +#------------------------------------------------------------------------- +from gramps.gen.const import GRAMPS_LOCALE as glocale +_ = glocale.translation.gettext +from gramps.gen.plug import Gramplet +from gramps.gen.errors import WindowActiveError +from gramps.gui.editors import EditPerson +from gramps.gui.widgets.fanchart2way import (FanChart2WayWidget, FanChart2WayGrampsGUI, + ANGLE_WEIGHT) +from gramps.gui.widgets.fanchart import FORM_HALFCIRCLE, BACKGROUND_SCHEME1 + +class FanChart2WayGramplet(FanChart2WayGrampsGUI, Gramplet): + """ + The Gramplet code that realizes the FanChartWidget. + """ + + def __init__(self, gui, nav_group=0): + Gramplet.__init__(self, gui, nav_group) + FanChart2WayGrampsGUI.__init__(self, self.on_childmenu_changed) + self.generations_asc = 5 + self.generations_desc = 4 + self.background = BACKGROUND_SCHEME1 + self.fonttype = 'Sans' + self.grad_start = '#FF0000' + self.grad_end = '#0000FF' + self.dupcolor = '#888A85' #light grey + self.generic_filter = None + self.alpha_filter = 0.2 + self.form = FORM_HALFCIRCLE + self.angle_algo = ANGLE_WEIGHT + self.flipupsidedownname = True + self.twolinename = True + self.childring = False + self.background_gradient = True + #self.filter = filter + + self.set_fan(FanChart2WayWidget(self.dbstate, self.uistate, self.on_popup)) + # Replace the standard textview with the fan chart widget: + self.gui.get_container_widget().remove(self.gui.textview) + self.gui.get_container_widget().add_with_viewport(self.fan) + # Make sure it is visible: + self.fan.show() + + def init(self): + self.set_tooltip(_("Click to expand/contract person\nRight-click for options\nClick and drag in open area to rotate")) + + def active_changed(self, handle): + """ + Method called when active person changes. + """ + # Reset everything but rotation angle (leave it as is) + self.update() + + def on_childmenu_changed(self, obj, person_handle): + """Callback for the pulldown menu selection, changing to the person + attached with menu item.""" + self.set_active('Person', person_handle) + return True diff --git a/gramps/plugins/gramplet/fanchartdescgramplet.py b/gramps/plugins/gramplet/fanchartdescgramplet.py index 89afc099f..ecb791a08 100644 --- a/gramps/plugins/gramplet/fanchartdescgramplet.py +++ b/gramps/plugins/gramplet/fanchartdescgramplet.py @@ -50,6 +50,8 @@ class FanChartDescGramplet(FanChartDescGrampsGUI, Gramplet): self.alpha_filter = 0.2 self.form = FORM_HALFCIRCLE self.angle_algo = ANGLE_WEIGHT + self.flipupsidedownname = True + self.twolinename = True self.set_fan(FanChartDescWidget(self.dbstate, self.uistate, self.on_popup)) # Replace the standard textview with the fan chart widget: diff --git a/gramps/plugins/gramplet/fanchartgramplet.py b/gramps/plugins/gramplet/fanchartgramplet.py index 6395b62ad..27a7b75c2 100644 --- a/gramps/plugins/gramplet/fanchartgramplet.py +++ b/gramps/plugins/gramplet/fanchartgramplet.py @@ -47,6 +47,8 @@ class FanChartGramplet(FanChartGrampsGUI, Gramplet): self.maxgen = 6 self.background = BACKGROUND_SCHEME1 self.childring = True + self.flipupsidedownname = True + self.twolinename = True self.radialtext = True self.fonttype = 'Sans' self.grad_start = '#0000FF' diff --git a/gramps/plugins/gramplet/gramplet.gpr.py b/gramps/plugins/gramplet/gramplet.gpr.py index 2a274a3ad..386b7c9cf 100644 --- a/gramps/plugins/gramplet/gramplet.gpr.py +++ b/gramps/plugins/gramplet/gramplet.gpr.py @@ -147,6 +147,23 @@ register(GRAMPLET, navtypes=["Person"], ) +register(GRAMPLET, + id= "2-Way Fan Chart", + name=_("2-Way Fan Chart"), + description = _("Gramplet showing active person's direct ancestors and descendants as a fanchart"), + status = STABLE, + fname="fanchart2waygramplet.py", + height=300, + expand=True, + gramplet = 'FanChart2WayGramplet', + detached_height =300, + detached_width = 300, + gramplet_title=_("2-Way Fan"), + version="1.0.0", + gramps_target_version=MODULE_VERSION, + navtypes=["Person"], + ) + register(GRAMPLET, id="FAQ", name=_("FAQ"), diff --git a/gramps/plugins/view/fanchart2wayview.py b/gramps/plugins/view/fanchart2wayview.py new file mode 100644 index 000000000..6684a489d --- /dev/null +++ b/gramps/plugins/view/fanchart2wayview.py @@ -0,0 +1,572 @@ +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2001-2007 Donald N. Allingham, Martin Hawlisch +# Copyright (C) 2009 Douglas S. Blank +# Copyright (C) 2014 Bastien Jacquet +# +# 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 Gdk +from gi.repository import Gtk +import cairo +from gramps.gen.const import GRAMPS_LOCALE as glocale +_ = glocale.translation.gettext + +#------------------------------------------------------------------------- +# +# Gramps modules +# +#------------------------------------------------------------------------- +import gramps.gui.widgets.fanchart as fanchart +import gramps.gui.widgets.fanchart2way as fanchart2way +from gramps.gui.views.navigationview import NavigationView +from gramps.gui.views.bookmarks import PersonBookmarks +from gramps.gui.utils import SystemFonts + +# the print settings to remember between print sessions +PRINT_SETTINGS = None + +class FanChart2WayView(fanchart2way.FanChart2WayGrampsGUI, NavigationView): + """ + The Gramplet code that realizes the FanChartWidget. + """ + #settings in the config file + CONFIGSETTINGS = ( + ('interface.fanview-maxgen-asc', 4), + ('interface.fanview-maxgen-desc', 4), + ('interface.fanview-background', fanchart.BACKGROUND_GRAD_GEN), + ('interface.fanview-background-gradient', True), + ('interface.fanview-radialtext', True), + ('interface.fanview-twolinename', True), + ('interface.fanview-flipupsidedownname', True), + ('interface.fanview-font', 'Sans'), + ('interface.fanview-form', fanchart.FORM_CIRCLE), + ('interface.color-start-grad', '#ef2929'), + ('interface.color-end-grad', '#3d37e9'), + ('interface.angle-algorithm', fanchart2way.ANGLE_WEIGHT), + ('interface.duplicate-color', '#888a85') + ) + def __init__(self, pdata, dbstate, uistate, nav_group=0): + self.dbstate = dbstate + self.uistate = uistate + + NavigationView.__init__(self, _('2-Way Fan Chart'), + pdata, dbstate, uistate, + PersonBookmarks, + nav_group) + fanchart2way.FanChart2WayGrampsGUI.__init__(self, self.on_childmenu_changed) + #set needed values + self.generations_asc = self._config.get('interface.fanview-maxgen-asc') + self.generations_desc = self._config.get('interface.fanview-maxgen-desc') + self.background = self._config.get('interface.fanview-background') + self.background_gradient = self._config.get('interface.fanview-background-gradient') + self.radialtext = self._config.get('interface.fanview-radialtext') + self.twolinename = self._config.get('interface.fanview-twolinename') + self.flipupsidedownname = self._config.get('interface.fanview-flipupsidedownname') + self.fonttype = self._config.get('interface.fanview-font') + + self.grad_start = self._config.get('interface.color-start-grad') + self.grad_end = self._config.get('interface.color-end-grad') + self.form = fanchart.FORM_CIRCLE + self.angle_algo = self._config.get('interface.angle-algorithm') + self.dupcolor = self._config.get('interface.duplicate-color') + self.generic_filter = None + self.alpha_filter = 0.2 + + dbstate.connect('active-changed', self.active_changed) + dbstate.connect('database-changed', self.change_db) + + self.additional_uis.append(self.additional_ui()) + self.allfonts = [x for x in enumerate(SystemFonts().get_system_fonts())] + + def navigation_type(self): + return 'Person' + + def build_widget(self): + self.set_fan(fanchart2way.FanChart2WayWidget(self.dbstate, self.uistate, + self.on_popup)) + self.scrolledwindow = Gtk.ScrolledWindow(hadjustment=None, + vadjustment=None) + self.scrolledwindow.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + self.fan.show_all() + self.scrolledwindow.add_with_viewport(self.fan) + + return self.scrolledwindow + + def get_stock(self): + """ + The category stock icon + """ + return 'gramps-pedigree' + + def get_viewtype_stock(self): + """Type of view in category + """ + return 'gramps-fanchart' + + def additional_ui(self): + return ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' + + def define_actions(self): + """ + Required define_actions function for PageView. Builds the action + group information required. + """ + NavigationView.define_actions(self) + + self._add_action('PrintView', Gtk.STOCK_PRINT, _("_Print..."), + accel="P", + tip=_("Print or save the Fan Chart View"), + callback=self.printview) + def build_tree(self): + """ + Generic method called by PageView to construct the view. + Here the tree builds when active person changes or db changes or on + callbacks like person_rebuild, so build will be double sometimes. + However, change in generic filter also triggers build_tree ! So we + need to reset. + """ + self.update() + + def active_changed(self, handle): + """ + Method called when active person changes. + """ + # Reset everything but rotation angle (leave it as is) + self.update() + + def _connect_db_signals(self): + """ + Connect database signals. + """ + self._add_db_signal('person-add', self.person_rebuild) + self._add_db_signal('person-update', self.person_rebuild) + self._add_db_signal('person-delete', self.person_rebuild) + self._add_db_signal('person-rebuild', self.person_rebuild_bm) + self._add_db_signal('family-update', self.person_rebuild) + self._add_db_signal('family-add', self.person_rebuild) + self._add_db_signal('family-delete', self.person_rebuild) + self._add_db_signal('family-rebuild', self.person_rebuild) + + def change_db(self, db): + self._change_db(db) + if self.active: + self.bookmarks.redraw() + self.update() + + def update(self): + self.main() + + def goto_handle(self, handle): + self.change_active(handle) + self.main() + + def get_active(self, object): + """overrule get_active, to support call as in Gramplets + """ + return NavigationView.get_active(self) + + def person_rebuild(self, *args): + self.update() + + def person_rebuild_bm(self, *args): + """Large change to person database""" + self.person_rebuild() + if self.active: + self.bookmarks.redraw() + + def printview(self, obj): + """ + Print or save the view that is currently shown + """ + widthpx = 2 * self.fan.halfdist() + heightpx = widthpx + + prt = CairoPrintSave(widthpx, heightpx, self.fan.on_draw, self.uistate.window) + prt.run() + + def on_childmenu_changed(self, obj, person_handle): + """Callback for the pulldown menu selection, changing to the person + attached with menu item.""" + self.change_active(person_handle) + return True + + def can_configure(self): + """ + See :class:`~gui.views.pageview.PageView + :return: bool + """ + return True + + def _get_configure_page_funcs(self): + """ + Return a list of functions that create gtk elements to use in the + notebook pages of the Configure dialog + + :return: list of functions + """ + return [self.config_panel] + + def config_panel(self, configdialog): + """ + Function that builds the widget in the configuration dialog + """ + nrentry = 9 + grid = Gtk.Grid() + grid.set_border_width(12) + grid.set_column_spacing(6) + grid.set_row_spacing(6) + + configdialog.add_spinner(grid, _("Max ancestor generations"), 0, + 'interface.fanview-maxgen-asc', (1, 11), + callback=self.cb_update_maxgen) + configdialog.add_spinner(grid, _("Max descendant generations"), 1, + 'interface.fanview-maxgen-desc', (1, 11), + callback=self.cb_update_maxgen) + configdialog.add_combo(grid, + _('Text Font'), + 2, 'interface.fanview-font', + self.allfonts, callback=self.cb_update_font, valueactive=True) + backgrvals = ( + (fanchart.BACKGROUND_GENDER, _('Gender colors')), + (fanchart.BACKGROUND_GRAD_GEN, _('Generation based gradient')), + (fanchart.BACKGROUND_GRAD_AGE, _('Age (0-100) based gradient')), + (fanchart.BACKGROUND_SINGLE_COLOR, + _('Single main (filter) color')), + (fanchart.BACKGROUND_GRAD_PERIOD, _('Time period based gradient')), + (fanchart.BACKGROUND_WHITE, _('White')), + (fanchart.BACKGROUND_SCHEME1, _('Color scheme classic report')), + (fanchart.BACKGROUND_SCHEME2, _('Color scheme classic view')), + ) + curval = self._config.get('interface.fanview-background') + nrval = 0 + for nr, val in backgrvals: + if curval == nr: + break + nrval += 1 + configdialog.add_combo(grid, + _('Background'), + 3, 'interface.fanview-background', + backgrvals, + callback=self.cb_update_background, valueactive=False, + setactive=nrval + ) + + # show names one two line + configdialog.add_checkbox(grid, + _('Add global background colored gradient'), + 4, 'interface.fanview-background-gradient') + + #colors, stored as hex values + configdialog.add_color(grid, _('Start gradient/Main color'), 5, + 'interface.color-start-grad', col=1) + configdialog.add_color(grid, _('End gradient/2nd color'), 6, + 'interface.color-end-grad', col=1) + configdialog.add_color(grid, _('Color for duplicates'), 7, + 'interface.duplicate-color', col=1) + # algo for the fan angle distribution + configdialog.add_combo(grid, _('Fan chart distribution'), 8, + 'interface.angle-algorithm', + ((fanchart2way.ANGLE_CHEQUI, + _('Homogeneous children distribution')), + (fanchart2way.ANGLE_WEIGHT, + _('Size proportional to number of descendants')), + ), + callback=self.cb_update_anglealgo) + + # show names one two line + configdialog.add_checkbox(grid, + _('Show names on two lines'), + 9, 'interface.fanview-twolinename') + + # Flip names + configdialog.add_checkbox(grid, + _('Flip name on the left of the fan'), + 10, 'interface.fanview-flipupsidedownname') + + return _('Layout'), grid + + def config_connect(self): + """ + Overwriten from :class:`~gui.views.pageview.PageView method + This method will be called after the ini file is initialized, + use it to monitor changes in the ini file + """ + self._config.connect('interface.color-start-grad', + self.cb_update_color) + self._config.connect('interface.color-end-grad', + self.cb_update_color) + self._config.connect('interface.duplicate-color', + self.cb_update_color) + self._config.connect('interface.fanview-flipupsidedownname', + self.cb_update_flipupsidedownname) + self._config.connect('interface.fanview-twolinename', + self.cb_update_twolinename) + self._config.connect('interface.fanview-background-gradient', + self.cb_update_background_gradient) + + def cb_update_maxgen(self, spinbtn, constant): + self._config.set(constant, spinbtn.get_value_as_int()) + self.generations_asc = int(self._config.get('interface.fanview-maxgen-asc')) + self.generations_desc = int(self._config.get('interface.fanview-maxgen-desc')) + self.update() + + def cb_update_twolinename(self, client, cnxn_id, entry, data): + """ + Called when the configuration menu changes the twolinename setting. + """ + self.twolinename = (entry == 'True') + self.update() + + def cb_update_background(self, obj, constant): + entry = obj.get_active() + Gtk.TreePath.new_from_string('%d' % entry) + val = int(obj.get_model().get_value( + obj.get_model().get_iter_from_string('%d' % entry), 0)) + self._config.set(constant, val) + self.background = val + self.update() + + def cb_update_background_gradient(self, client, cnxn_id, entry, data): + """ + Called when the configuration menu changes the twolinename setting. + """ + self.background_gradient = (entry == 'True') + self.update() + + def cb_update_form(self, obj, constant): + entry = obj.get_active() + self._config.set(constant, entry) + self.form = entry + self.update() + + def cb_update_anglealgo(self, obj, constant): + entry = obj.get_active() + self._config.set(constant, entry) + self.angle_algo = entry + self.update() + + def cb_update_color(self, client, cnxn_id, entry, data): + """ + Called when the configuration menu changes the childrenring setting. + """ + self.grad_start = self._config.get('interface.color-start-grad') + self.grad_end = self._config.get('interface.color-end-grad') + self.dupcolor = self._config.get('interface.duplicate-color') + self.update() + + def cb_update_flipupsidedownname(self, client, cnxn_id, entry, data): + """ + Called when the configuration menu changes the flipupsidedownname setting. + """ + self.flipupsidedownname = (entry == 'True') + self.update() + + def cb_update_font(self, obj, constant): + entry = obj.get_active() + self._config.set(constant, self.allfonts[entry][1]) + self.fonttype = self.allfonts[entry][1] + self.update() + + def get_default_gramplets(self): + """ + Define the default gramplets for the sidebar and bottombar. + """ + return (("Person Filter",), + ()) + +#------------------------------------------------------------------------ +# +# CairoPrintSave class +# +#------------------------------------------------------------------------ +class CairoPrintSave(): + """Act as an abstract document that can render onto a cairo context. + + It can render the model onto cairo context pages, according to the received + page style. + + """ + + def __init__(self, widthpx, heightpx, drawfunc, parent): + """ + This class provides the things needed so as to dump a cairo drawing on + a context to output + """ + self.widthpx = widthpx + self.heightpx = heightpx + self.drawfunc = drawfunc + self.parent = parent + + def run(self): + """Create the physical output from the meta document. + + """ + global PRINT_SETTINGS + + # set up a print operation + operation = Gtk.PrintOperation() + operation.connect("draw_page", self.on_draw_page) + operation.connect("preview", self.on_preview) + operation.connect("paginate", self.on_paginate) + operation.set_n_pages(1) + #paper_size = Gtk.PaperSize.new(name="iso_a4") + ## WHY no Gtk.Unit.PIXEL ?? Is there a better way to convert + ## Pixels to MM ?? + paper_size = Gtk.PaperSize.new_custom("custom", + "Custom Size", + round(self.widthpx * 0.2646), + round(self.heightpx * 0.2646), + Gtk.Unit.MM) + page_setup = Gtk.PageSetup() + page_setup.set_paper_size(paper_size) + #page_setup.set_orientation(Gtk.PageOrientation.PORTRAIT) + operation.set_default_page_setup(page_setup) + #operation.set_use_full_page(True) + + if PRINT_SETTINGS is not None: + operation.set_print_settings(PRINT_SETTINGS) + + # run print dialog + while True: + self.preview = None + res = operation.run(Gtk.PrintOperationAction.PRINT_DIALOG, self.parent) + if self.preview is None: # cancel or print + break + # set up printing again; can't reuse PrintOperation? + operation = Gtk.PrintOperation() + operation.set_default_page_setup(page_setup) + operation.connect("draw_page", self.on_draw_page) + operation.connect("preview", self.on_preview) + operation.connect("paginate", self.on_paginate) + # set print settings if it was stored previously + if PRINT_SETTINGS is not None: + operation.set_print_settings(PRINT_SETTINGS) + + # store print settings if printing was successful + if res == Gtk.PrintOperationResult.APPLY: + PRINT_SETTINGS = operation.get_print_settings() + + def on_draw_page(self, operation, context, page_nr): + """Draw a page on a Cairo context. + """ + cr = context.get_cairo_context() + pxwidth = round(context.get_width()) + pxheight = round(context.get_height()) + scale = min(pxwidth/self.widthpx, pxheight/self.heightpx) + if scale > 1: + scale = 1 + self.drawfunc(None, cr, scale=scale) + + def on_paginate(self, operation, context): + """Paginate the whole document in chunks. + We don't need this as there is only one page, however, + we provide a dummy holder here, because on_preview crashes if no + default application is set with gir 3.3.2 (typically evince not installed)! + It will provide the start of the preview dialog, which cannot be + started in on_preview + """ + finished = True + # update page number + operation.set_n_pages(1) + + # start preview if needed + if self.preview: + self.preview.run() + + return finished + + def on_preview(self, operation, preview, context, parent): + """Implement custom print preview functionality. + We provide a dummy holder here, because on_preview crashes if no + default application is set with gir 3.3.2 (typically evince not installed)! + """ + dlg = Gtk.MessageDialog(parent, + flags=Gtk.DialogFlags.MODAL, + type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.CLOSE, + message_format=_('No preview available')) + self.preview = dlg + self.previewopr = operation + #dlg.format_secondary_markup(msg2) + dlg.set_title("Fan Chart Preview - Gramps") + dlg.connect('response', self.previewdestroy) + + # give a dummy cairo context to Gtk.PrintContext, + try: + width = int(round(context.get_width())) + except ValueError: + width = 0 + try: + height = int(round(context.get_height())) + except ValueError: + height = 0 + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + cr = cairo.Context(surface) + context.set_cairo_context(cr, 72.0, 72.0) + + return True + + def previewdestroy(self, dlg, res): + self.preview.destroy() + self.previewopr.end_preview() diff --git a/gramps/plugins/view/fanchartdescview.py b/gramps/plugins/view/fanchartdescview.py index b92976db0..57adde49f 100644 --- a/gramps/plugins/view/fanchartdescview.py +++ b/gramps/plugins/view/fanchartdescview.py @@ -58,6 +58,9 @@ class FanChartDescView(fanchartdesc.FanChartDescGrampsGUI, NavigationView): CONFIGSETTINGS = ( ('interface.fanview-maxgen', 9), ('interface.fanview-background', fanchart.BACKGROUND_GRAD_GEN), + ('interface.fanview-radialtext', True), + ('interface.fanview-twolinename', True), + ('interface.fanview-flipupsidedownname', True), ('interface.fanview-font', 'Sans'), ('interface.fanview-form', fanchart.FORM_CIRCLE), ('interface.color-start-grad', '#ef2929'), @@ -77,6 +80,9 @@ class FanChartDescView(fanchartdesc.FanChartDescGrampsGUI, NavigationView): #set needed values self.maxgen = self._config.get('interface.fanview-maxgen') self.background = self._config.get('interface.fanview-background') + self.radialtext = self._config.get('interface.fanview-radialtext') + self.twolinename = self._config.get('interface.fanview-twolinename') + self.flipupsidedownname = self._config.get('interface.fanview-flipupsidedownname') self.fonttype = self._config.get('interface.fanview-font') self.grad_start = self._config.get('interface.color-start-grad') @@ -265,7 +271,7 @@ class FanChartDescView(fanchartdesc.FanChartDescGrampsGUI, NavigationView): """ Function that builds the widget in the configuration dialog """ - nrentry = 8 + nrentry = 9 grid = Gtk.Grid() grid.set_border_width(12) grid.set_column_spacing(6) @@ -326,6 +332,16 @@ class FanChartDescView(fanchartdesc.FanChartDescGrampsGUI, NavigationView): ), callback=self.cb_update_anglealgo) + # show names one two line + configdialog.add_checkbox(grid, + _('Show names on two lines'), + 8, 'interface.fanview-twolinename') + + # Flip names + configdialog.add_checkbox(grid, + _('Flip name on the left of the fan'), + 9, 'interface.fanview-flipupsidedownname') + return _('Layout'), grid def config_connect(self): @@ -340,12 +356,23 @@ class FanChartDescView(fanchartdesc.FanChartDescGrampsGUI, NavigationView): self.cb_update_color) self._config.connect('interface.duplicate-color', self.cb_update_color) + self._config.connect('interface.fanview-flipupsidedownname', + self.cb_update_flipupsidedownname) + self._config.connect('interface.fanview-twolinename', + self.cb_update_twolinename) def cb_update_maxgen(self, spinbtn, constant): self.maxgen = spinbtn.get_value_as_int() self._config.set(constant, self.maxgen) self.update() + def cb_update_twolinename(self, client, cnxn_id, entry, data): + """ + Called when the configuration menu changes the twolinename setting. + """ + self.twolinename = (entry == 'True') + self.update() + def cb_update_background(self, obj, constant): entry = obj.get_active() Gtk.TreePath.new_from_string('%d' % entry) @@ -376,6 +403,13 @@ class FanChartDescView(fanchartdesc.FanChartDescGrampsGUI, NavigationView): self.dupcolor = self._config.get('interface.duplicate-color') self.update() + def cb_update_flipupsidedownname(self, client, cnxn_id, entry, data): + """ + Called when the configuration menu changes the flipupsidedownname setting. + """ + self.flipupsidedownname = (entry == 'True') + self.update() + def cb_update_font(self, obj, constant): entry = obj.get_active() self._config.set(constant, self.allfonts[entry][1]) diff --git a/gramps/plugins/view/fanchartview.py b/gramps/plugins/view/fanchartview.py index 08418b492..aad0b2174 100644 --- a/gramps/plugins/view/fanchartview.py +++ b/gramps/plugins/view/fanchartview.py @@ -59,6 +59,8 @@ class FanChartView(fanchart.FanChartGrampsGUI, NavigationView): ('interface.fanview-background', fanchart.BACKGROUND_GRAD_GEN), ('interface.fanview-childrenring', True), ('interface.fanview-radialtext', True), + ('interface.fanview-twolinename', True), + ('interface.fanview-flipupsidedownname', True), ('interface.fanview-font', 'Sans'), ('interface.fanview-form', fanchart.FORM_CIRCLE), ('interface.color-start-grad', '#ef2929'), @@ -78,6 +80,8 @@ class FanChartView(fanchart.FanChartGrampsGUI, NavigationView): self.background = self._config.get('interface.fanview-background') self.childring = self._config.get('interface.fanview-childrenring') self.radialtext = self._config.get('interface.fanview-radialtext') + self.twolinename = self._config.get('interface.fanview-twolinename') + self.flipupsidedownname = self._config.get('interface.fanview-flipupsidedownname') self.fonttype = self._config.get('interface.fanview-font') self.grad_start = self._config.get('interface.color-start-grad') @@ -263,7 +267,7 @@ class FanChartView(fanchart.FanChartGrampsGUI, NavigationView): """ Function that builds the widget in the configuration dialog """ - nrentry = 7 + nrentry = 9 grid = Gtk.Grid() grid.set_border_width(12) grid.set_column_spacing(6) @@ -311,6 +315,16 @@ class FanChartView(fanchart.FanChartGrampsGUI, NavigationView): (2, _('Quadrant'))), callback=self.cb_update_form) + # show names one two line + configdialog.add_checkbox(grid, + _('Show names on two lines'), + 6, 'interface.fanview-twolinename') + + # Flip names + configdialog.add_checkbox(grid, + _('Flip name on the left of the fan'), + 7, 'interface.fanview-flipupsidedownname') + # options users should not change: configdialog.add_checkbox(grid, _('Show children ring'), @@ -330,6 +344,10 @@ class FanChartView(fanchart.FanChartGrampsGUI, NavigationView): """ self._config.connect('interface.fanview-childrenring', self.cb_update_childrenring) + self._config.connect('interface.fanview-twolinename', + self.cb_update_twolinename) + self._config.connect('interface.fanview-flipupsidedownname', + self.cb_update_flipupsidedownname) self._config.connect('interface.fanview-radialtext', self.cb_update_radialtext) self._config.connect('interface.color-start-grad', @@ -385,6 +403,20 @@ class FanChartView(fanchart.FanChartGrampsGUI, NavigationView): self.grad_end = self._config.get('interface.color-end-grad') self.update() + def cb_update_twolinename(self, client, cnxn_id, entry, data): + """ + Called when the configuration menu changes the twolinename setting. + """ + self.twolinename = (entry == 'True') + self.update() + + def cb_update_flipupsidedownname(self, client, cnxn_id, entry, data): + """ + Called when the configuration menu changes the flipupsidedownname setting. + """ + self.flipupsidedownname = (entry == 'True') + self.update() + def cb_update_font(self, obj, constant): entry = obj.get_active() self._config.set(constant, self.allfonts[entry][1]) diff --git a/gramps/plugins/view/view.gpr.py b/gramps/plugins/view/view.gpr.py index 639742e14..cda4edfc0 100644 --- a/gramps/plugins/view/view.gpr.py +++ b/gramps/plugins/view/view.gpr.py @@ -166,6 +166,21 @@ viewclass = 'FanChartDescView', stock_icon = 'gramps-fanchartdesc', ) +register(VIEW, +id = 'fanchart2wayview', +name = _("2-Way Fan"), +category = ("Ancestry", _("Charts")), +description = _("Showing ascendants and descendants through a fanchart"), +version = '1.0', +gramps_target_version = MODULE_VERSION, +status = STABLE, +fname = 'fanchart2wayview.py', +authors = ["B. Jacquet"], +authors_email = ["bastien.jacquet_dev@m4x.org"], +viewclass = 'FanChart2WayView', +stock_icon = 'gramps-fanchart2way', + ) + register(VIEW, id = 'personview', name = _("Grouped People"), diff --git a/images/hicolor/16x16/actions/gramps-fanchart2way.png b/images/hicolor/16x16/actions/gramps-fanchart2way.png new file mode 100644 index 0000000000000000000000000000000000000000..6da92274a56af5cbbd9692622a62300d4a2bb27c GIT binary patch literal 980 zcmV;_11tQAP)4UAw$1Lw-6Yf5>HeF^{Cggj(w~g$x{e?SkfK07W-fMalrDXL(@*C8kP2MwUssajB?dq|c_=aWSWZ8#>w||> zE51KHGQ&#E2JT7>PSc?AYKyX>6y7rP_fP%!+uN3pJb*O-1%C0B$@A04W?qkKF>aNM zFjs$v)5&5qK7wJVflB^bm`zMk+i!cH{FVRO^05h^1)u}K6`vay{!FA&FLlaGvDo%i zs7eD>YaLE=8-d4A-zY7OC9+ZB)Qafy!%ANLBh=`j4=Nt?SNf`!!dp#r-BJy{Cf$t{g zSI7mBeg9eIDLrLhT(y@q@Hq)$8FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H101R|RSad;kbZBpK075PxN?}x~p=1C61sX|2K~y-6eN|g*6lE0t z{+WMgc4xQSZI^bJ?Ur7kEJ%S+je$fT8a2ek7fB>U2_!xcA4Fq9ReZrFni!)&1nY|d zqt(boATf~u+Gq(DDU?gGC~OzDm+fV{yPcic%goNq??Y!xk;apJ4>NPl_n+@SU(OK5 z7#54xt(}5qNSlaTc$NxuB{R$o`7|sr+ypbDJTrgeA^v{%=iS<3^<**PmY4T$6}_%b z!RK1Zi$i^W(;p#T_53HTT+sQY6!twuIP!=JZ8opTF^G(p77@7M-F7Nl+p1DmauL$Tk$& z%(+7)-8%44$7{EBZCTnB`PM19rFd*G&H1UD8W00+A0jPH004P5i~jy0Cb%mI02I_b z@&&-qHHG9{BGa7$;WdSGreC^ue$QF{#1IiYzWO7T{@Mlr@Kps|>co_}KGH(Nb-@Q$ zQPeP=NHQvj1OOa@1ENTv%Z9@$c<7u&DuOdte|PBm5elw$hd&s8qD)}uI_g6~(dY95 z0Cc^Cfxe4rIhn}I*&GqvewQz_yo%!~;VFfyO5oJI0cP6VrHaQ>{^=Juhtsu&r*yuv zqB?`-<#lt17zTblb#!#_?Ns%-gc)i9h#aPx-e#0=G<(&E(qS@0+;Y$7QcxT z1+jkNd>sxBgv`0(#^uYIXir!CowqyI_V)Hx_w@Am4jlL_GJEr=JewHKlxsNgKOQk@ z17$#qt`z~sU;!`+1yStIk=do+oF0QMqt8Pr5$mvVM zl^h5moJj~@0%NvK32C9}>G3xc|NK#{$&;0)rc`m%6}hTRl2Rcft#ziGS2c#(GMNn8 z+S;J&I`a8ELeN~&*y+(Lch2`S(U*02Ld-O>QJz+$cxPos$mhPH3q(tfZi;O7CLAS;-N_oJBRAd0Vm=N%BK1J5k0$Nl6r z=AjNYC(>fQiGktc_tlF4asUbdiZ)Dc=jp`Z(Oc2WV8F=r0iJFmvS0+0ou+Q+BQtOe^b)*aF{JD7FaNp$AwUZ;tp8ym9vJGVcIVb?cPEGEw+Tq?KG_qO% z`0EZ2sZjs`cxWZogo8-Urtsq>`Rv!j@)-cSEnT%cE|aIr+mZsnKE{lLZKPEcT~V)B z^Kvx;cnXyAK(!-mTa!TKk+p}T+jp6>JI&gp^P6#gZSEdm$>G7my4a{gEARf|LN*)@74m6>UfTyDWKIIdDa8bAVo$r!U1t1ZXBkg{WZQRzar P00000NkvXXu0mjfpDVN& literal 0 HcmV?d00001 diff --git a/images/hicolor/48x48/actions/gramps-fanchart2way.png b/images/hicolor/48x48/actions/gramps-fanchart2way.png new file mode 100644 index 0000000000000000000000000000000000000000..376a7f2325713e76494b68b328e08fd6f19cbd51 GIT binary patch literal 3760 zcmV;h4o~rkP)6vvr< zx_f4KH2a}trIl6^LJ}Y$Y(N-f^Xb?G=P+O=clYBc$K^Xa<)U0vu0oO@SLG^~jjt-P zkJu(DQi<=Xu8t5Aa(8u=@>Oi?oDF9T*a-6}z64l{guYiR?Z@ow?96oE5A903(yRm| zb+4+`(EZNz^Ss^t_WSmT2qEyN3Lzj3+uvMADOMB8S_u^kg}{7p)N&gZgct%L0}vu2 zgy`Zn|FHL?mplI~W8d#f$eum4>9n$u$Ydu0*(8@MitJZ{)Wtw$2IgYW?u=)&C{+aY z1HuM@4G3F+r=vs|2EhMFg>|5NjeT6a`YQi_A+TxF9;wGy_Cql8U01oHDwW=#?3YTlw@R({`#@&b*Luec zElS;`2uukFwc`oG(*wlH?(F{Pl~3;$*If~4+P*hTWc*h4x|e$w1S+V^?&{@QPt4Fp z*p7gbW( ze{Ytz4GUJ<1UD^YqiJqtIOXsHgh3D&K+ zW}>sEaq+^w$aTxxFptRP%B;JVXKff!4OZL$BNUaPdGM$zQppqs21ej2^MG46%xo5% zb8u1yPCWqR(%`vnQ+o_{yqXbWgL;OqdHk8S&VPS3Tc|g~9EvFpgc}ymFAs*GdVJE& z%V#X5)@Lb|-Wj^W<}e0RFvoN>w=|({Wi>n=b+W(vRxgHzVo-hlDYCW=Bcnr4M8Syy zoB9D1+lO$?CRQAFJj@^{1Q>)7Sg0k&4+7Xxs9UrL7XRhm9p3VJn*$Y9E&!0-3YM%` zCmDljk?xC|g}PQMi{!Nt)CDTBW$R;TS<+MlzLZeLQ0DO>7!JeZlOfr|AVQx4ZzpYP zCBVmP7{nAjZg^e7c5i9i`r~bm1jwE}lo0l2U1LkINI_y~ZCfkzgpFkPs0jeZXd0QG zI5uu<$Ain7AxX2Xuo8Zn5R`em2vmh2i#P~Rg4l7JS}E}HFmrNXTo0BL(02%#>YF6c zbk_6jU}eob${fHDg68E9%Py{v_?1D9SQeX~SdT!!KT~%){5kp=lMtw`hMVc2TqAa( zhKW+%moo@ct*U?WN8c=n`Gb`~CY5$^LU8omyo&W3RzWFq&pJpdl}69)e%$Hp z7u>RtNo%l8bJk}FA@EmKf_cUOV$Z^WL0DzM`X~SKQ6T4qBw2bfSXC34ZGhNNKf>V( z%nOzd3lfPGP8~mOW>N_wljx77qqpM_ATEEE*Bz*v$J{>EAF7dMm$LyFlc4&&(98tv z)Vz}8h^(iANZPjn9M7A4m7;n}leR6yhVEeVH`bTb>AHH8U%h8=A}G@hmq@GZYL4h1=~$e}6w! z6eXEVCa(}CrK;NhZfRit@~|&h>FB=nWD=5M)C8c7 zkD|8DnV+s_aPeHHdFSN6bl8UGS5@_~=bwK*6pcpdz`y{DL?SGeN|D!Jf8FQtcpetE z6&^nGt802B)b7Ri|67DAY=iB5S6nLu(DaGYMEnSDyczOx(*2D z^?ECxd+xcSnSAT5xA59)uN6J>%rnmrS(XC;n2{V9x6Gp0Y*Ls+0b1If3eEt5T1gOi z(#bd`qEeAN_@`%Z>FU<7Y3KkUgn$SFLIpr9(*%*pgr#Q)$^Q;X zu*JJQU7gj!&Qfk$RM=U@ZIg1#V8S+;Ahd!?O3m<{8$_78WsLU?aoduGuv`$_or!g) zb8j{J`uZlPYWw!>IB?(qbX~`e9a8~he}6y96va+y8cz>hjYdJv5<;_}QkGC^xV<$2 zs(8x?k!;(_j&d_SNSVtbOtL{-7Lz0s%*_d5mVg`~LI@B-6aXFoYOXHz?-_0DLulSq zuReHTj06{d^F_R~Z{H-?L?QvxG*MAeG1+$T;6eQC<=x_2p-lRE+!{DDnvHbDafbsb z0BHbPL9NZvmdF!u-vw9|fX7AQd%7MQ9;xv?;+0u>U|v1K^H-9r9K!Pl58&8|&(PZ1 z>bPzbi3C=(w&InRWxTB-D8@2_=uJkO`(^ zhnfdvxE`&c4?R+=Yz;`#g353!!nJM0A6S6G)Aa8j+l1{~w&2k3euuSd*A{{A?(W8O zJ9gk}!H`%Tsul!5m0B;V)Ov}4Kr`dD3(L>e1l%9Q8kQ|q8XjnLpZ>2CXn9~e2${LLo?5XIUblj8ZQF+S_J^@%!+NNy zijET}@bS^3_|Bpw{F$XIM6p9A0Dx)p)K!(VD(4(mKRu0sE0@vS zl0{eNZ?I(fvzVAXXUU%}n30&}z~`)gcJR1)KBk}OOJg(#Je#ZY#llQ77LZG5((&Yv zyG~vhEd?ajKa7vx{S8>+95#Pl!-LCA)YX24>z58eh*@sMg+Os`OD28@L>~Ob;gi;x zk)bdD^@ec-fZ+gsdMP9^L11@>I5E`Sm$b~{xWqOsoO}N$F8<*?tbZhi)+HwLPN!vw z3Dq6JrL(_=WjRNc6DK#%fhM95I2SfLM^0H^j`Usp`L)dNbKvtOTTrHhHvt&=`>e_N z7q1?STOv-}bw zQ%gudpeJ#iAG`ft`oANQ5B5!L+DxvLBH=^?K*7bToMFEt@rflb0SIgqA3%jgpH?y`-VcTQLZ1%ak-$qQ#Be%oZ#a2^*{Rf2`$!bH*-cTua^{V4 zECMX5YnjnbKmZDksQ_&HY3f8|Itc?nMhM}YemXUNze9Yf>*U2_pGe#^fpi+^IL*+s z)&-|?YrL0YLAY^7yC49C;3YtpnuX~kbRmRu+6IT~^}KxV^X$LvUnf_*|GxB3E*Gcs z+g*?xzI7y{96?_-B!@dy0s=a*-Zd0Nwn2>`(E)8dex2oV9Jz?{UaajT_x;x@g|cT8k;*mv17q@cTrR1O5+ZX!s1O3Wp84 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + application + gramps + pedigree + + + + + B. Jacquet + + + + + + + rework of the work of B. Malengier which was based on original design of Don Allingham + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +