Add text overlap prevention, enhanced tooltips, and person selection with visual connections
- Implement detect_label_overlaps() to prevent text label overlaps - Automatically adjust Y positions while maintaining chronological order - Enhanced tooltips to show all events for a person, not just hovered event - Added person selection on event marker click - Implement draw_person_connections() to visually connect selected person's events - Selected person's events highlighted with blue color and connecting lines - Click on marker selects person, click on label expands event details
This commit is contained in:
parent
f13f5e1141
commit
8be124f5e0
244
MyTimeline.py
244
MyTimeline.py
@ -244,6 +244,7 @@ class MyTimelineView(NavigationView):
|
|||||||
self.tooltip_timeout_id = None
|
self.tooltip_timeout_id = None
|
||||||
self.tooltip_window = None
|
self.tooltip_window = None
|
||||||
self.expanded_event_index = None
|
self.expanded_event_index = None
|
||||||
|
self.selected_person_handle = None
|
||||||
self.mouse_x = 0
|
self.mouse_x = 0
|
||||||
self.mouse_y = 0
|
self.mouse_y = 0
|
||||||
|
|
||||||
@ -512,6 +513,53 @@ class MyTimelineView(NavigationView):
|
|||||||
if self.drawing_area:
|
if self.drawing_area:
|
||||||
self.drawing_area.set_size_request(800, self.timeline_height)
|
self.drawing_area.set_size_request(800, self.timeline_height)
|
||||||
|
|
||||||
|
def detect_label_overlaps(self, context, events_with_y_pos, timeline_y_start, timeline_y_end):
|
||||||
|
"""Detect and adjust Y positions to prevent label overlaps."""
|
||||||
|
if not events_with_y_pos:
|
||||||
|
return events_with_y_pos
|
||||||
|
|
||||||
|
# Create a temporary layout to measure text
|
||||||
|
layout = PangoCairo.create_layout(context)
|
||||||
|
layout.set_font_description(Pango.font_description_from_string("Sans 11"))
|
||||||
|
|
||||||
|
adjusted_events = []
|
||||||
|
min_spacing = 30 # Minimum spacing between labels in pixels
|
||||||
|
|
||||||
|
for i, event_data in enumerate(events_with_y_pos):
|
||||||
|
date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data
|
||||||
|
|
||||||
|
# Calculate label height
|
||||||
|
if person:
|
||||||
|
person_name = name_displayer.display(person)
|
||||||
|
date_str = get_date(event)
|
||||||
|
event_type_str = str(event_type)
|
||||||
|
label_text = f"{date_str} - {event_type_str} - {person_name}"
|
||||||
|
else:
|
||||||
|
date_str = get_date(event)
|
||||||
|
event_type_str = str(event_type)
|
||||||
|
label_text = f"{date_str} - {event_type_str}"
|
||||||
|
|
||||||
|
layout.set_text(label_text, -1)
|
||||||
|
text_width, text_height = layout.get_pixel_size()
|
||||||
|
label_height = text_height + 16 # Add padding
|
||||||
|
|
||||||
|
# Check for overlap with previous events
|
||||||
|
adjusted_y = y_pos
|
||||||
|
for prev_data in adjusted_events:
|
||||||
|
prev_y_pos = prev_data[6] # Get y_pos from previous event
|
||||||
|
# Check if labels would overlap
|
||||||
|
if abs(adjusted_y - prev_y_pos) < min_spacing:
|
||||||
|
# Adjust downward
|
||||||
|
adjusted_y = prev_y_pos + min_spacing
|
||||||
|
|
||||||
|
# Ensure adjusted position is within bounds
|
||||||
|
adjusted_y = max(timeline_y_start, min(adjusted_y, timeline_y_end))
|
||||||
|
|
||||||
|
# Create new event data with adjusted Y position
|
||||||
|
adjusted_events.append((date_sort, date_obj, event, person, event_type, expanded, adjusted_y))
|
||||||
|
|
||||||
|
return adjusted_events
|
||||||
|
|
||||||
def on_zoom_in(self, widget):
|
def on_zoom_in(self, widget):
|
||||||
"""Zoom in."""
|
"""Zoom in."""
|
||||||
if self.zoom_level < self.max_zoom:
|
if self.zoom_level < self.max_zoom:
|
||||||
@ -559,11 +607,33 @@ class MyTimelineView(NavigationView):
|
|||||||
# Find which event was clicked
|
# Find which event was clicked
|
||||||
clicked_index = self.find_event_at_position(event.x, event.y)
|
clicked_index = self.find_event_at_position(event.x, event.y)
|
||||||
if clicked_index is not None:
|
if clicked_index is not None:
|
||||||
# Toggle expansion
|
# Check if clicking on marker (for person selection) or label (for expansion)
|
||||||
if self.expanded_event_index == clicked_index:
|
date_sort, date_obj, clicked_event, clicked_person, event_type, expanded, _ = self.events[clicked_index]
|
||||||
self.expanded_event_index = None
|
|
||||||
|
# Check if click is on marker area (left side) or label area (right side)
|
||||||
|
timeline_x = TIMELINE_MARGIN_LEFT
|
||||||
|
marker_area_width = EVENT_MARKER_SIZE * self.zoom_level + 20
|
||||||
|
|
||||||
|
if event.x < timeline_x + marker_area_width:
|
||||||
|
# Click on marker - toggle person selection
|
||||||
|
if clicked_person:
|
||||||
|
person_handle = clicked_person.get_handle()
|
||||||
|
if self.selected_person_handle == person_handle:
|
||||||
|
# Deselect if clicking same person
|
||||||
|
self.selected_person_handle = None
|
||||||
|
else:
|
||||||
|
# Select this person
|
||||||
|
self.selected_person_handle = person_handle
|
||||||
|
else:
|
||||||
|
# No person for this event, deselect
|
||||||
|
self.selected_person_handle = None
|
||||||
else:
|
else:
|
||||||
self.expanded_event_index = clicked_index
|
# Click on label - toggle expansion
|
||||||
|
if self.expanded_event_index == clicked_index:
|
||||||
|
self.expanded_event_index = None
|
||||||
|
else:
|
||||||
|
self.expanded_event_index = clicked_index
|
||||||
|
|
||||||
self.drawing_area.queue_draw()
|
self.drawing_area.queue_draw()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -649,37 +719,69 @@ class MyTimelineView(NavigationView):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def show_tooltip(self, event_index, x_root, y_root):
|
def show_tooltip(self, event_index, x_root, y_root):
|
||||||
"""Show tooltip for an event."""
|
"""Show tooltip for an event, including all events for that person."""
|
||||||
if event_index is None or event_index >= len(self.events):
|
if event_index is None or event_index >= len(self.events):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
date_sort, date_obj, event, person, event_type, expanded, y_pos = self.events[event_index]
|
date_sort, date_obj, event, person, event_type, expanded, y_pos = self.events[event_index]
|
||||||
|
|
||||||
# Build tooltip text
|
# If event has a person, show all events for that person
|
||||||
date_str = get_date(event)
|
|
||||||
event_type_str = str(event_type)
|
|
||||||
|
|
||||||
tooltip_text = f"<b>{event_type_str}</b>\n{date_str}"
|
|
||||||
|
|
||||||
if person:
|
if person:
|
||||||
|
person_handle = person.get_handle()
|
||||||
person_name = name_displayer.display(person)
|
person_name = name_displayer.display(person)
|
||||||
tooltip_text += f"\n{person_name}"
|
|
||||||
|
# Find all events for this person
|
||||||
# Get place information
|
person_events = []
|
||||||
place_handle = event.get_place_handle()
|
for evt_data in self.events:
|
||||||
if place_handle:
|
evt_date_sort, evt_date_obj, evt_event, evt_person, evt_event_type, evt_expanded, evt_y_pos = evt_data
|
||||||
try:
|
if evt_person and evt_person.get_handle() == person_handle:
|
||||||
place = self.dbstate.db.get_place_from_handle(place_handle)
|
person_events.append((evt_date_sort, evt_date_obj, evt_event, evt_event_type))
|
||||||
if place:
|
|
||||||
place_name = place.get_title()
|
# Sort by date
|
||||||
tooltip_text += f"\n{place_name}"
|
person_events.sort(key=lambda x: x[0])
|
||||||
except:
|
|
||||||
pass
|
# Build tooltip text with person name as header
|
||||||
|
tooltip_text = f"<b>{person_name}</b>\n"
|
||||||
# Get description
|
tooltip_text += "─" * 30 + "\n"
|
||||||
description = event.get_description()
|
|
||||||
if description:
|
# List all events for this person
|
||||||
tooltip_text += f"\n{description}"
|
for evt_date_sort, evt_date_obj, evt_event, evt_event_type in person_events:
|
||||||
|
evt_date_str = get_date(evt_event)
|
||||||
|
evt_event_type_str = str(evt_event_type)
|
||||||
|
tooltip_text += f"{evt_event_type_str} - {evt_date_str}\n"
|
||||||
|
|
||||||
|
# Add place if available
|
||||||
|
evt_place_handle = evt_event.get_place_handle()
|
||||||
|
if evt_place_handle:
|
||||||
|
try:
|
||||||
|
evt_place = self.dbstate.db.get_place_from_handle(evt_place_handle)
|
||||||
|
if evt_place:
|
||||||
|
evt_place_name = evt_place.get_title()
|
||||||
|
tooltip_text += f" 📍 {evt_place_name}\n"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Family event (no person) - show single event info
|
||||||
|
date_str = get_date(event)
|
||||||
|
event_type_str = str(event_type)
|
||||||
|
|
||||||
|
tooltip_text = f"<b>{event_type_str}</b>\n{date_str}"
|
||||||
|
|
||||||
|
# Get place information
|
||||||
|
place_handle = event.get_place_handle()
|
||||||
|
if place_handle:
|
||||||
|
try:
|
||||||
|
place = self.dbstate.db.get_place_from_handle(place_handle)
|
||||||
|
if place:
|
||||||
|
place_name = place.get_title()
|
||||||
|
tooltip_text += f"\n📍 {place_name}"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get description
|
||||||
|
description = event.get_description()
|
||||||
|
if description:
|
||||||
|
tooltip_text += f"\n{description}"
|
||||||
|
|
||||||
# Create tooltip window
|
# Create tooltip window
|
||||||
self.tooltip_window = Gtk.Window(type=Gtk.WindowType.POPUP)
|
self.tooltip_window = Gtk.Window(type=Gtk.WindowType.POPUP)
|
||||||
@ -772,24 +874,35 @@ class MyTimelineView(NavigationView):
|
|||||||
context.line_to(timeline_x, timeline_y_end)
|
context.line_to(timeline_x, timeline_y_end)
|
||||||
context.stroke()
|
context.stroke()
|
||||||
|
|
||||||
# Draw events
|
# Calculate initial Y positions based on dates
|
||||||
|
events_with_y_pos = []
|
||||||
for i, event_data in enumerate(self.events):
|
for i, event_data in enumerate(self.events):
|
||||||
date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data
|
date_sort, date_obj, event, person, event_type, expanded, _ = event_data
|
||||||
|
|
||||||
# Calculate Y position based on date
|
# Calculate Y position based on date
|
||||||
y_pos = TIMELINE_MARGIN_TOP + (
|
y_pos = TIMELINE_MARGIN_TOP + (
|
||||||
(date_sort - min_date) / date_range
|
(date_sort - min_date) / date_range
|
||||||
) * (timeline_y_end - timeline_y_start)
|
) * (timeline_y_end - timeline_y_start)
|
||||||
|
|
||||||
# Update y_pos in event data
|
events_with_y_pos.append((date_sort, date_obj, event, person, event_type, expanded, y_pos))
|
||||||
self.events[i] = event_data[:6] + (y_pos,)
|
|
||||||
|
# Detect and fix label overlaps
|
||||||
|
events_with_y_pos = self.detect_label_overlaps(context, events_with_y_pos, timeline_y_start, timeline_y_end)
|
||||||
|
|
||||||
|
# Draw events
|
||||||
|
for i, event_data in enumerate(events_with_y_pos):
|
||||||
|
date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data
|
||||||
|
|
||||||
# Check if this event is hovered or expanded
|
# Check if this event is hovered or expanded
|
||||||
is_hovered = (i == self.hovered_event_index)
|
is_hovered = (i == self.hovered_event_index)
|
||||||
is_expanded = (i == self.expanded_event_index)
|
is_expanded = (i == self.expanded_event_index)
|
||||||
|
|
||||||
|
# Check if this event belongs to selected person
|
||||||
|
is_selected = (self.selected_person_handle is not None and
|
||||||
|
person and person.get_handle() == self.selected_person_handle)
|
||||||
|
|
||||||
# Draw event marker with modern styling
|
# Draw event marker with modern styling
|
||||||
self.draw_event_marker(context, timeline_x, y_pos, event_type, is_hovered)
|
self.draw_event_marker(context, timeline_x, y_pos, event_type, is_hovered, is_selected)
|
||||||
|
|
||||||
# Draw event label
|
# Draw event label
|
||||||
label_x = timeline_x + 25
|
label_x = timeline_x + 25
|
||||||
@ -797,12 +910,16 @@ class MyTimelineView(NavigationView):
|
|||||||
context, label_x, y_pos, date_obj, event, person, event_type, is_hovered, is_expanded
|
context, label_x, y_pos, date_obj, event, person, event_type, is_hovered, is_expanded
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Draw visual connections for selected person
|
||||||
|
if self.selected_person_handle is not None:
|
||||||
|
self.draw_person_connections(context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end)
|
||||||
|
|
||||||
# Draw year markers on the left
|
# Draw year markers on the left
|
||||||
self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end, min_date, max_date)
|
self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end, min_date, max_date)
|
||||||
|
|
||||||
context.restore()
|
context.restore()
|
||||||
|
|
||||||
def draw_event_marker(self, context, x, y, event_type, is_hovered=False):
|
def draw_event_marker(self, context, x, y, event_type, is_hovered=False, is_selected=False):
|
||||||
"""Draw a marker for an event with modern styling."""
|
"""Draw a marker for an event with modern styling."""
|
||||||
context.save()
|
context.save()
|
||||||
|
|
||||||
@ -814,9 +931,15 @@ class MyTimelineView(NavigationView):
|
|||||||
shape = EVENT_SHAPES.get(event_type_value, 'square')
|
shape = EVENT_SHAPES.get(event_type_value, 'square')
|
||||||
marker_size = EVENT_MARKER_SIZE
|
marker_size = EVENT_MARKER_SIZE
|
||||||
|
|
||||||
# Increase size if hovered
|
# Increase size if hovered or selected
|
||||||
if is_hovered:
|
if is_hovered:
|
||||||
marker_size *= 1.3
|
marker_size *= 1.3
|
||||||
|
elif is_selected:
|
||||||
|
marker_size *= 1.2
|
||||||
|
|
||||||
|
# Use highlight color if selected
|
||||||
|
if is_selected:
|
||||||
|
color = (0.2, 0.4, 0.9) # Blue highlight for selected person's events
|
||||||
|
|
||||||
# Draw shadow
|
# Draw shadow
|
||||||
context.set_source_rgba(0.0, 0.0, 0.0, 0.3)
|
context.set_source_rgba(0.0, 0.0, 0.0, 0.3)
|
||||||
@ -1064,6 +1187,57 @@ class MyTimelineView(NavigationView):
|
|||||||
|
|
||||||
context.restore()
|
context.restore()
|
||||||
|
|
||||||
|
def draw_person_connections(self, context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end):
|
||||||
|
"""Draw visual connections between all events of the selected person."""
|
||||||
|
if not self.selected_person_handle:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find all events for the selected person
|
||||||
|
person_events = []
|
||||||
|
for event_data in events_with_y_pos:
|
||||||
|
date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data
|
||||||
|
if person and person.get_handle() == self.selected_person_handle:
|
||||||
|
person_events.append((y_pos, event_data))
|
||||||
|
|
||||||
|
if len(person_events) < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sort by Y position
|
||||||
|
person_events.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
context.save()
|
||||||
|
|
||||||
|
# Draw connecting lines
|
||||||
|
context.set_source_rgba(0.2, 0.4, 0.9, 0.5) # Semi-transparent blue
|
||||||
|
context.set_line_width(2)
|
||||||
|
context.set_line_cap(cairo.LINE_CAP_ROUND)
|
||||||
|
context.set_line_join(cairo.LINE_JOIN_ROUND)
|
||||||
|
|
||||||
|
# Draw lines from timeline axis to each event marker
|
||||||
|
for y_pos, event_data in person_events:
|
||||||
|
context.move_to(timeline_x, y_pos)
|
||||||
|
context.line_to(timeline_x + EVENT_MARKER_SIZE * 1.5, y_pos)
|
||||||
|
context.stroke()
|
||||||
|
|
||||||
|
# Draw curved path connecting all events (optional - can be commented out if too cluttered)
|
||||||
|
if len(person_events) > 1:
|
||||||
|
# Draw a subtle curved path connecting all markers
|
||||||
|
context.set_source_rgba(0.2, 0.4, 0.9, 0.3) # More transparent
|
||||||
|
context.set_line_width(1.5)
|
||||||
|
|
||||||
|
# Create a smooth curve through all points
|
||||||
|
y_positions = [y for y, _ in person_events]
|
||||||
|
min_y = min(y_positions)
|
||||||
|
max_y = max(y_positions)
|
||||||
|
|
||||||
|
# Draw a vertical line on the left side connecting the range
|
||||||
|
if max_y - min_y > EVENT_MARKER_SIZE * 2:
|
||||||
|
context.move_to(timeline_x - 15, min_y)
|
||||||
|
context.line_to(timeline_x - 15, max_y)
|
||||||
|
context.stroke()
|
||||||
|
|
||||||
|
context.restore()
|
||||||
|
|
||||||
def get_stock(self):
|
def get_stock(self):
|
||||||
"""Return the stock icon name."""
|
"""Return the stock icon name."""
|
||||||
return "gramps-family"
|
return "gramps-family"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user