gramps/src/plugins/gramplet/FanChartGramplet.py

619 lines
25 KiB
Python
Raw Normal View History

# Gramps - a GTK+/GNOME based genealogy program
#
# 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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
# $Id$
2009-01-19 22:27:43 +05:30
## 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
## TODO:
## 1) add arrows to show rotation ability (click on background)
## 2) add center popup to pick center's children
## 3) perhaps right-click shows choice to edit, or make active, quick views,
## etc
## 4) add animations
#-------------------------------------------------------------------------
#
# Python modules
#
#-------------------------------------------------------------------------
import pygtk
pygtk.require('2.0')
import pango
import gtk
import math
from gtk import gdk
try:
import cairo
except ImportError:
pass
if gtk.pygtk_version < (2,3,93):
raise Exception("PyGtk 2.3.93 or later required")
#-------------------------------------------------------------------------
#
# GRAMPS modules
#
#-------------------------------------------------------------------------
from BasicUtils import name_displayer
from gettext import gettext as _
from DataViews import Gramplet, register
import gen.lib
def gender_code(is_male):
if is_male: return 1
return 0
#-------------------------------------------------------------------------
#
# FanChartWidget
#
#-------------------------------------------------------------------------
class FanChartWidget(gtk.Widget):
2009-01-19 22:27:43 +05:30
"""
Interactive Fan Chart Widget.
2009-01-19 22:27:43 +05:30
"""
BORDER_WIDTH = 10
__gsignals__ = { 'realize': 'override',
'expose-event' : 'override',
'size-allocate': 'override',
'size-request': 'override',
}
2009-01-19 11:12:20 +05:30
GENCOLOR = ((229,191,252),
(191,191,252),
(191,222,252),
(183,219,197),
(206,246,209))
COLLAPSED = 0
NORMAL = 1
EXPANDED = 2
def __init__(self, generations, right_click_callback=None):
"""
Highly experimental... documents forthcoming...
"""
gtk.Widget.__init__(self)
self.last_x, self.last_y = None, None
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)
self.right_click_callback = right_click_callback
self.add_events(gdk.BUTTON_PRESS_MASK |
gdk.BUTTON_RELEASE_MASK |
gdk.POINTER_MOTION_MASK)
self.pixels_per_generation = 50 # size of radius for generation
## gotten from experiments with "sans serif 8":
self.degrees_per_radius = .80
## Other fonts will have different settings. Can you compute that
## from the font size? I have no idea.
self.generations = generations
self.rotate_value = 90 # degrees, initially, 1st gen male on right half
2009-01-19 11:12:20 +05:30
self.set_generations(self.generations)
self.center = 50 # pixel radius of center
self.layout = self.create_pango_layout('cairo')
self.layout.set_font_description(pango.FontDescription("sans serif 8"))
2009-01-19 11:12:20 +05:30
def reset_generations(self):
2009-01-19 22:27:43 +05:30
"""
Reset all of the data on where slices appear, and if they are expanded.
"""
2009-01-19 11:12:20 +05:30
self.set_generations(self.generations)
def set_generations(self, generations):
2009-01-19 22:27:43 +05:30
"""
Set the generations to max, and fill data structures with initial data.
"""
self.generations = generations
2009-01-19 11:12:20 +05:30
self.angle = {}
self.data = {}
for i in range(self.generations):
self.data[i] = [(None, None, None) for j in range(2 ** i)]
2009-01-19 11:12:20 +05:30
self.angle[i] = []
angle = 0
slice = 360.0 / (2 ** i)
gender = True
for a in range(len(self.data[i])):
# start, stop, male, state
self.angle[i].append([angle, angle + slice,gender,self.NORMAL])
2009-01-19 11:12:20 +05:30
angle += slice
gender = not gender
def do_realize(self):
2009-01-19 22:27:43 +05:30
"""
Overriden method to handle the realize event.
"""
self.set_flags(self.flags() | gtk.REALIZED)
self.window = gdk.Window(self.get_parent_window(),
width=self.allocation.width,
height=self.allocation.height,
window_type=gdk.WINDOW_CHILD,
wclass=gdk.INPUT_OUTPUT,
event_mask=self.get_events() | gdk.EXPOSURE_MASK)
if not hasattr(self.window, "cairo_create"):
self.draw_gc = gdk.GC(self.window,
line_width=5,
line_style=gdk.SOLID,
join_style=gdk.JOIN_ROUND)
self.window.set_user_data(self)
self.style.attach(self.window)
self.style.set_background(self.window, gtk.STATE_NORMAL)
self.window.move_resize(*self.allocation)
def do_size_request(self, requisition):
2009-01-19 22:27:43 +05:30
"""
Overridden method to handle size request events.
"""
width, height = self.layout.get_size()
requisition.width = (width // pango.SCALE + self.BORDER_WIDTH*4)* 1.45
requisition.height = (3 * height // pango.SCALE + self.BORDER_WIDTH*4) * 1.2
def do_size_allocate(self, allocation):
2009-01-19 22:27:43 +05:30
"""
Overridden method to handle size allocation events.
"""
self.allocation = allocation
if self.flags() & gtk.REALIZED:
self.window.move_resize(*allocation)
def _expose_gdk(self, event):
x, y, w, h = self.allocation
self.layout = self.create_pango_layout('no cairo')
fontw, fonth = self.layout.get_pixel_size()
self.style.paint_layout(self.window, self.state, False,
event.area, self, "label",
(w - fontw) / 2, (h - fonth) / 2,
self.layout)
def do_expose_event(self, event):
2009-01-19 22:27:43 +05:30
"""
Overridden method to handle expose events.
"""
try:
cr = self.window.cairo_create()
except AttributeError:
return self._expose_gdk(event)
return self._expose_cairo(event, cr)
def _expose_cairo(self, event, cr):
2009-01-19 22:27:43 +05:30
"""
The main method to do the drawing.
"""
x, y, w, h = self.allocation
cr.translate(w/2., h/2.)
2009-01-19 11:12:20 +05:30
cr.save()
cr.rotate(self.rotate_value * math.pi/180)
for generation in range(self.generations - 1, 0, -1):
2009-01-19 11:12:20 +05:30
for p in range(len(self.data[generation])):
(text, person, parents) = self.data[generation][p]
if person:
2009-01-19 11:12:20 +05:30
start, stop, male, state = self.angle[generation][p]
if state in [self.NORMAL, self.EXPANDED]:
self.draw_person(cr, gender_code(male),
text, start, stop,
generation, state, parents)
cr.set_source_rgb(1, 1, 1) # white
cr.move_to(0,0)
cr.arc(0, 0, self.center, 0, 2 * math.pi)
cr.move_to(0,0)
cr.fill()
cr.set_source_rgb(0, 0, 0) # black
cr.arc(0, 0, self.center, 0, 2 * math.pi)
cr.stroke()
2009-01-19 11:12:20 +05:30
# Draw center person:
(text, person, parents) = self.data[0][0]
2009-01-19 11:12:20 +05:30
cr.restore()
if person:
cr.save()
name = name_displayer.display(person)
2009-01-19 11:12:20 +05:30
self.draw_text(cr, name, self.center - 10, 95, 455)
cr.restore()
fontw, fonth = self.layout.get_pixel_size()
cr.move_to((w - fontw - 4), (h - fonth ))
cr.update_layout(self.layout)
cr.show_layout(self.layout)
def draw_person(self, cr, gender, name, start, stop, generation,
state, parents):
2009-01-19 11:12:20 +05:30
"""
Display the piece of pie for a given person. start and stop
are in degrees.
"""
x, y, w, h = self.allocation
start_rad = start * math.pi/180
2009-01-19 11:12:20 +05:30
stop_rad = stop * math.pi/180
r,g,b = self.GENCOLOR[generation % len(self.GENCOLOR)]
if gender == gen.lib.Person.MALE:
2009-01-19 11:12:20 +05:30
r -= r * .10
g -= g * .10
b -= b * .10
radius = generation * self.pixels_per_generation + self.center
# If max generation, and they have parents:
if generation == self.generations - 1 and parents:
# draw an indicator
cr.move_to(0, 0)
#cr.set_source_rgba(1, 0.2, 0.2, 0.6) # pink
cr.set_source_rgb(255, 255, 255) # white
cr.arc(0, 0, radius + 5, start_rad, stop_rad)
cr.fill()
cr.move_to(0, 0)
cr.set_source_rgb(0, 0, 0) # black
cr.arc(0, 0, radius + 5, start_rad, stop_rad)
cr.line_to(0, 0)
cr.stroke()
cr.set_source_rgb(r/255., g/255., b/255.)
cr.move_to(0, 0)
2009-01-19 11:12:20 +05:30
cr.arc(0, 0, radius, start_rad, stop_rad)
cr.move_to(0, 0)
cr.fill()
cr.set_source_rgb(0, 0, 0) # black
2009-01-19 11:12:20 +05:30
cr.arc(0, 0, radius, start_rad, stop_rad)
cr.line_to(0, 0)
2009-01-19 11:12:20 +05:30
cr.arc(0, 0, radius, start_rad, stop_rad)
cr.line_to(0, 0)
if state == self.NORMAL: # normal
2009-01-19 22:27:43 +05:30
cr.set_line_width(1)
else: # EXPANDED
2009-01-19 22:27:43 +05:30
cr.set_line_width(3)
cr.stroke()
cr.set_line_width(1)
if self.last_x == None or self.last_y == None:
self.draw_text(cr, name, radius - self.pixels_per_generation/2,
start, stop)
def text_degrees(self, text, radius):
2009-01-19 11:12:20 +05:30
"""
Returns the number of degrees of text at a given radius.
"""
return 360.0 * len(text)/(radius * self.degrees_per_radius)
2009-01-19 11:12:20 +05:30
def text_limit(self, text, degrees, radius):
2009-01-19 22:27:43 +05:30
"""
Trims the text to fit a given angle at a given radius. Probably
a better way to do this.
"""
2009-01-19 11:12:20 +05:30
while self.text_degrees(text, radius) > degrees:
text = text[:-1]
return text
def draw_text(self, cr, text, radius, start, stop):
"""
Display text at a particular radius, between start and stop
degrees.
"""
# trim to fit:
text = self.text_limit(text, stop - start, radius - 15)
# center text:
# offset for cairo-font system is 90:
pos = start + ((stop - start) - self.text_degrees(text,radius))/2.0 + 90
x, y, w, h = self.allocation
cr.save()
# Create a PangoLayout, set the font and text
# Draw the layout N_WORDS times in a circle
for i in range(len(text)):
cr.save()
layout = self.create_pango_layout(text[i])
layout.set_font_description(pango.FontDescription("sans serif 8"))
angle = 360.0 * i / (radius * self.degrees_per_radius) + pos
2009-01-19 11:12:20 +05:30
cr.set_source_rgb(0, 0, 0) # black
cr.rotate(angle * (math.pi / 180));
# Inform Pango to re-layout the text with the new transformation
cr.update_layout(layout)
width, height = layout.get_size()
cr.move_to(- (width / pango.SCALE) / 2.0, - radius)
cr.show_layout(layout)
cr.restore()
cr.restore()
2009-01-19 22:27:43 +05:30
def expand_parents(self, generation, selected, current):
2009-01-19 11:12:20 +05:30
if generation >= self.generations: return
selected = 2 * selected
start,stop,male,state = self.angle[generation][selected]
if state in [self.NORMAL, self.EXPANDED]:
2009-01-19 22:27:43 +05:30
slice = (stop - start) * 2.0
self.angle[generation][selected] = [current,current+slice,
male,state]
2009-01-19 22:27:43 +05:30
self.expand_parents(generation + 1, selected, current)
current += slice
start,stop,male,state = self.angle[generation][selected+1]
if state in [self.NORMAL, self.EXPANDED]:
2009-01-19 22:27:43 +05:30
slice = (stop - start) * 2.0
self.angle[generation][selected+1] = [current,current+slice,
male,state]
2009-01-19 22:27:43 +05:30
self.expand_parents(generation + 1, selected+1, current)
2009-01-19 12:41:48 +05:30
def show_parents(self, generation, selected, angle, slice):
2009-01-19 12:41:48 +05:30
if generation >= self.generations: return
selected = selected * 2
self.angle[generation][selected][0] = angle
self.angle[generation][selected][1] = angle + slice
self.angle[generation][selected][3] = self.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][3] = self.NORMAL
self.show_parents(generation+1, selected + 1, angle + slice, slice/2.0)
2009-01-19 12:41:48 +05:30
def hide_parents(self, generation, selected, angle):
if generation >= self.generations: return
selected = 2 * selected
self.angle[generation][selected][0] = angle
self.angle[generation][selected][1] = angle
self.angle[generation][selected][3] = self.COLLAPSED
2009-01-19 12:41:48 +05:30
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][3] = self.COLLAPSED
2009-01-19 12:41:48 +05:30
self.hide_parents(generation + 1, selected+1, angle)
2009-01-19 11:12:20 +05:30
2009-01-19 22:27:43 +05:30
def shrink_parents(self, generation, selected, current):
2009-01-19 12:41:48 +05:30
if generation >= self.generations: return
selected = 2 * selected
start,stop,male,state = self.angle[generation][selected]
if state in [self.NORMAL, self.EXPANDED]:
2009-01-19 22:27:43 +05:30
slice = (stop - start) / 2.0
self.angle[generation][selected] = [current, current + slice,
male,state]
2009-01-19 22:27:43 +05:30
self.shrink_parents(generation + 1, selected, current)
current = current + slice
start,stop,male,state = self.angle[generation][selected+1]
if state in [self.NORMAL, self.EXPANDED]:
2009-01-19 22:27:43 +05:30
slice = (stop - start) / 2.0
self.angle[generation][selected+1] = [current,current+slice,
male,state]
2009-01-19 22:27:43 +05:30
self.shrink_parents(generation + 1, selected+1, current)
2009-01-19 11:12:20 +05:30
def change_slice(self, generation, selected):
gstart, gstop, gmale, gstate = self.angle[generation][selected]
if gstate == self.NORMAL: # let's expand
2009-01-19 11:12:20 +05:30
if gmale:
# go to right
stop = gstop + (gstop - gstart)
self.angle[generation][selected] = [gstart,stop,gmale,
self.EXPANDED]
2009-01-19 22:27:43 +05:30
self.expand_parents(generation + 1, selected, gstart)
start,stop,male,state = self.angle[generation][selected+1]
self.angle[generation][selected+1] = [stop,stop,male,
self.COLLAPSED]
2009-01-19 12:41:48 +05:30
self.hide_parents(generation+1, selected+1, stop)
2009-01-19 11:12:20 +05:30
else:
# go to left
start = gstart - (gstop - gstart)
self.angle[generation][selected] = [start,gstop,gmale,
self.EXPANDED]
2009-01-19 22:27:43 +05:30
self.expand_parents(generation + 1, selected, start)
start,stop,male,state = self.angle[generation][selected-1]
self.angle[generation][selected-1] = [start,start,male,
self.COLLAPSED]
2009-01-19 12:41:48 +05:30
self.hide_parents(generation+1, selected-1, start)
elif gstate == self.EXPANDED: # let's shrink
2009-01-19 11:12:20 +05:30
if gmale:
# shrink from right
slice = (gstop - gstart)/2.0
stop = gstop - slice
self.angle[generation][selected] = [gstart,stop,gmale,
self.NORMAL]
2009-01-19 22:27:43 +05:30
self.shrink_parents(generation+1, selected, gstart)
self.angle[generation][selected+1][0] = stop # start
self.angle[generation][selected+1][1] = stop + slice # stop
self.angle[generation][selected+1][3] = self.NORMAL
self.show_parents(generation+1, selected+1, stop, slice/2.0)
2009-01-19 11:12:20 +05:30
else:
# shrink from left
slice = (gstop - gstart)/2.0
start = gstop - slice
self.angle[generation][selected] = [start,gstop,gmale,
self.NORMAL]
2009-01-19 22:27:43 +05:30
self.shrink_parents(generation+1, selected, start)
start,stop,male,state = self.angle[generation][selected-1]
self.angle[generation][selected-1] = [start,start+slice,male,
self.NORMAL]
self.show_parents(generation+1, selected-1, start, slice/2.0)
def on_mouse_up(self, widget, event):
# Done with mouse movement
if self.last_x == None or self.last_y == None: return True
self.queue_draw()
self.last_x, self.last_y = None, None
return True
def on_mouse_move(self, widget, event):
if self.last_x == None or self.last_y == None: return False
x, y, w, h = self.allocation
cx = w/2
cy = h/2
# 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)
# now look at change in angle:
diff_angle = (end_angle - start_angle) % (math.pi * 2.0)
self.rotate_value -= diff_angle * 180.0/ math.pi
self.queue_draw()
self.last_x, self.last_y = event.x, event.y
return True
def on_mouse_down(self, widget, event):
# compute angle, radius, find out who would be there (rotated)
x, y, w, h = self.allocation
cx = w/2
cy = h/2
radius = math.sqrt((event.x - cx) ** 2 + (event.y - cy) ** 2)
if radius < self.center:
generation = 0
else:
generation = int((radius - self.center) /
self.pixels_per_generation) + 1
rads = math.atan2( (event.y - cy), (event.x - cx) )
if rads < 0: # second half of unit circle
rads = math.pi + (math.pi + rads)
pos = ((rads/(math.pi * 2) - self.rotate_value/360.) * 360.0) % 360
2009-01-19 11:12:20 +05:30
# find what person is in this position:
selected = None
if (0 < generation < self.generations):
for p in range(len(self.angle[generation])):
if self.data[generation][p][1]: # there is a person there
start, stop, male, state = self.angle[generation][p]
if state == self.COLLAPSED: continue
if start <= pos <= stop:
selected = p
break
# Handle the click:
if selected == None: # clicked in open area
if radius < self.center:
print "TODO: select child, spouse"
self.queue_draw()
return True
else:
# save the mouse location for movements
self.last_x, self.last_y = event.x, event.y
return True
# Do things based on state, event.state, or button, event.button
if event.button == 1: # left mouse
2009-01-19 22:27:43 +05:30
self.change_slice(generation, selected)
elif event.button == 3: # right mouse
text, person, parents = self.data[generation][selected]
if person and self.right_click_callback:
self.right_click_callback(person)
self.queue_draw()
return True
class FanChartGramplet(Gramplet):
"""
The Gramplet code that realizes the FanChartWidget.
"""
def init(self):
self.set_tooltip("Click to expand/contract person\nRight-click to make person active")
self.generations = 6
self.gui.fan = FanChartWidget(self.generations,
right_click_callback=self.dbstate.change_active_person)
# 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.gui.fan)
# Make sure it is visible:
self.gui.fan.show()
def active_changed(self, handle):
"""
Method called when active person changes.
"""
# Reset everything but rotation angle (leave it as is)
self.update()
def have_parents(self, person):
"""
Returns True if a person has parents.
"""
if person:
m = self.get_parent(person, "female")
f = self.get_parent(person, "male")
return m != None or f != None
return False
def get_parent(self, person, gender):
"""
Get the father if gender == "male", or get mother otherwise.
"""
if person:
parent_handle_list = person.get_parent_family_handle_list()
if parent_handle_list:
family_id = parent_handle_list[0]
family = self.dbstate.db.get_family_from_handle(family_id)
if family:
if gender == "male":
person_handle = gen.lib.Family.get_father_handle(family)
else:
person_handle = gen.lib.Family.get_mother_handle(family)
if person_handle:
return self.dbstate.db.get_person_from_handle(person_handle)
return None
def main(self):
2009-01-19 22:27:43 +05:30
"""
Fill the data structures with the active data. This initializes all
data.
2009-01-19 22:27:43 +05:30
"""
self.gui.fan.reset_generations()
person = self.dbstate.get_active_person()
if not person:
name = None
else:
name = name_displayer.display(person)
parents = self.have_parents(person)
self.gui.fan.data[0][0] = (name, person, parents)
2009-01-18 09:11:44 +05:30
for current in range(1, self.generations):
parent = 0
# name, person, parents
for (n,p,q) in self.gui.fan.data[current - 1]:
# Get father's details:
person = self.get_parent(p, "male")
if person:
name = name_displayer.display(person)
else:
name = None
if current == self.generations - 1:
parents = self.have_parents(person)
else:
parents = None
self.gui.fan.data[current][parent] = (name, person, parents)
if person is None:
# start,stop,male/right,state
self.gui.fan.angle[current][parent][3] = self.gui.fan.COLLAPSED
parent += 1
# Get mother's details:
person = self.get_parent(p, "female")
if person:
name = name_displayer.display(person)
else:
name = None
parents = self.have_parents(person)
self.gui.fan.data[current][parent] = (name, person, parents)
if person is None:
# start,stop,male/right,state
self.gui.fan.angle[current][parent][3] = self.gui.fan.COLLAPSED
parent += 1
self.gui.fan.queue_draw()
2009-01-19 22:27:43 +05:30
#-------------------------------------------------------------------------
#
# Register the Gramplet
#
#-------------------------------------------------------------------------
register(type="gramplet",
name= "Fan Chart Gramplet",
tname=_("Fan Chart Gramplet"),
height=430,
expand=True,
content = FanChartGramplet,
detached_height = 550,
detached_width = 475,
title=_("Fan Chart"),
)