Add multiple person selection with colored connection lines

- Changed from single selected_person_handle to selected_person_handles set
- Added color generation using HSV color space for distinct person colors
- Added unique X positions for each person's vertical connection line
- Updated click handler to toggle persons in/out of selection set
- Refactored draw_person_connections to draw separate colored lines for each person
- Each selected person gets their own colored vertical line at unique X position
- Colors are consistent for each person based on handle hash
This commit is contained in:
Daniel Viegas 2025-11-29 22:10:25 +01:00
parent c9f6e7f8b8
commit f6069dfd83

View File

@ -28,6 +28,7 @@ MyTimeline View - A vertical timeline showing all events in the database
# #
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
import cairo import cairo
import colorsys
import logging import logging
logger = logging.getLogger("plugin.mytimeline") logger = logging.getLogger("plugin.mytimeline")
@ -109,6 +110,7 @@ BORDER_OPACITY = 0.3
CONNECTION_LINE_COLOR = (0.2, 0.5, 1.0, 0.75) # Brighter, more opaque blue CONNECTION_LINE_COLOR = (0.2, 0.5, 1.0, 0.75) # Brighter, more opaque blue
CONNECTION_LINE_WIDTH = 3.5 CONNECTION_LINE_WIDTH = 3.5
CONNECTION_VERTICAL_LINE_X = 5 # Left of year markers CONNECTION_VERTICAL_LINE_X = 5 # Left of year markers
CONNECTION_LINE_SPACING = 25 # Pixels between vertical lines for different persons
# Marker State Colors # Marker State Colors
SELECTED_MARKER_COLOR = (0.2, 0.4, 0.9) # Blue highlight for selected person's events SELECTED_MARKER_COLOR = (0.2, 0.4, 0.9) # Blue highlight for selected person's events
@ -381,7 +383,8 @@ class MyTimelineView(NavigationView):
self.hovered_event_index = None self.hovered_event_index = None
self.tooltip_timeout_id = None self.tooltip_timeout_id = None
self.tooltip_window = None self.tooltip_window = None
self.selected_person_handle = None self.selected_person_handles: Set[str] = set() # Set of selected person handles
self.person_colors: Dict[str, Tuple[float, float, float, float]] = {} # Person handle -> RGBA color
self.mouse_x = 0 self.mouse_x = 0
self.mouse_y = 0 self.mouse_y = 0
@ -493,6 +496,9 @@ class MyTimelineView(NavigationView):
self.active_event_handle = None self.active_event_handle = None
self.all_events = [] self.all_events = []
self.events = [] self.events = []
# Clear selected persons and colors
self.selected_person_handles.clear()
self.person_colors.clear()
# Connect to new database signals # Connect to new database signals
if db and db.is_open(): if db and db.is_open():
@ -2124,6 +2130,56 @@ class MyTimelineView(NavigationView):
""" """
return person is not None and person.get_handle() == handle return person is not None and person.get_handle() == handle
def _get_person_color(self, person_handle: str) -> Tuple[float, float, float, float]:
"""
Get or generate a distinct color for a person handle.
Uses HSL color space to generate evenly distributed colors.
Args:
person_handle: The handle of the person.
Returns:
Tuple[float, float, float, float]: RGBA color tuple (values 0-1).
"""
# If color already assigned, return it
if person_handle in self.person_colors:
return self.person_colors[person_handle]
# Generate a new color based on the person's position in selection
# Use hash of handle for consistent color assignment
num_selected = len(self.selected_person_handles)
if num_selected == 0:
# Default color if no selection (shouldn't happen)
color = (0.2, 0.5, 1.0, 0.75)
else:
# Generate color using HSL: vary hue, keep saturation and lightness constant
# Use hash of handle for consistent color even when selection order changes
handle_hash = hash(person_handle)
hue = (abs(handle_hash) % 360) / 360.0 # 0-1 range
saturation = 0.7 # High saturation for vibrant colors
lightness = 0.5 # Medium lightness for good visibility
# Convert HSV to RGB (HSV is Hue, Saturation, Value)
r, g, b = colorsys.hsv_to_rgb(hue, saturation, lightness)
alpha = 0.75 # Semi-transparent
color = (r, g, b, alpha)
# Store the color
self.person_colors[person_handle] = color
return color
def _get_person_vertical_line_x(self, person_index: int) -> float:
"""
Calculate the X position for a person's vertical connection line.
Args:
person_index: The index of the person in the sorted selection (0-based).
Returns:
float: The X coordinate for the vertical line.
"""
return CONNECTION_VERTICAL_LINE_X + (person_index * CONNECTION_LINE_SPACING)
def _calculate_y_position(self, date_sort: int, min_date: int, date_range: int, def _calculate_y_position(self, date_sort: int, min_date: int, date_range: int,
timeline_y_start: float, timeline_y_end: float) -> float: timeline_y_start: float, timeline_y_end: float) -> float:
""" """
@ -2350,18 +2406,26 @@ class MyTimelineView(NavigationView):
if self.active_event_handle == event_handle: if self.active_event_handle == event_handle:
# Deselect if clicking same event # Deselect if clicking same event
self.active_event_handle = None self.active_event_handle = None
self.selected_person_handle = None
else: else:
# Select this event # Select this event
self.active_event_handle = event_handle self.active_event_handle = event_handle
# Set selected person based on this event's person
if clicked_event_data.person:
self.selected_person_handle = clicked_event_data.person.get_handle()
else:
self.selected_person_handle = None
# Navigate to this event in the view # Navigate to this event in the view
self.uistate.set_active(event_handle, "Event") self.uistate.set_active(event_handle, "Event")
# Toggle person selection (always toggle, even if event is same)
if clicked_event_data.person:
person_handle = clicked_event_data.person.get_handle()
if person_handle in self.selected_person_handles:
# Remove from selection
self.selected_person_handles.remove(person_handle)
# Clean up color if no longer selected
if person_handle in self.person_colors:
del self.person_colors[person_handle]
else:
# Add to selection
self.selected_person_handles.add(person_handle)
# Color will be assigned when drawing
self.drawing_area.queue_draw() self.drawing_area.queue_draw()
return False return False
@ -2827,7 +2891,7 @@ class MyTimelineView(NavigationView):
self._draw_events(context, events_with_y_pos, timeline_x) self._draw_events(context, events_with_y_pos, timeline_x)
# Draw visual connections for selected person (from selected event) # Draw visual connections for selected person (from selected event)
if self.selected_person_handle is not None: if self.selected_person_handles:
self.draw_person_connections(context, events_with_y_pos, timeline_x, self.draw_person_connections(context, events_with_y_pos, timeline_x,
timeline_y_start, timeline_y_end) timeline_y_start, timeline_y_end)
@ -3201,7 +3265,8 @@ class MyTimelineView(NavigationView):
timeline_x: float, timeline_y_start: float, timeline_x: float, timeline_y_start: float,
timeline_y_end: float) -> None: timeline_y_end: float) -> None:
""" """
Draw visual connections between all events of the selected person. Draw visual connections between all events of selected persons.
Each person gets their own colored vertical line at a unique X position.
Args: Args:
context: Cairo drawing context. context: Cairo drawing context.
@ -3210,33 +3275,39 @@ class MyTimelineView(NavigationView):
timeline_y_start: Y coordinate of the timeline start. timeline_y_start: Y coordinate of the timeline start.
timeline_y_end: Y coordinate of the timeline end. timeline_y_end: Y coordinate of the timeline end.
""" """
if not self.selected_person_handle: if not self.selected_person_handles:
return return
# Find all events for the selected person context.save()
context.set_line_width(CONNECTION_LINE_WIDTH)
context.set_line_cap(cairo.LINE_CAP_ROUND)
context.set_line_join(cairo.LINE_JOIN_ROUND)
# Sort person handles for consistent ordering (affects X position)
sorted_person_handles = sorted(self.selected_person_handles)
# Draw connections for each selected person
for person_index, person_handle in enumerate(sorted_person_handles):
# Get color for this person
person_color = self._get_person_color(person_handle)
# Calculate X position for this person's vertical line
vertical_line_x = self._get_person_vertical_line_x(person_index)
# Find all events for this person
person_events = [ person_events = [
event_data for event_data in events_with_y_pos event_data for event_data in events_with_y_pos
if self._person_matches_handle(event_data.person, self.selected_person_handle) if self._person_matches_handle(event_data.person, person_handle)
] ]
if len(person_events) < 1: if len(person_events) < 1:
return continue
# Sort by Y position # Sort by Y position
person_events.sort(key=lambda x: x.y_pos) person_events.sort(key=lambda x: x.y_pos)
context.save() # Set color for this person's lines
context.set_source_rgba(*person_color)
# Position vertical line to the left of year markers
# Year labels are positioned at timeline_x - 20 - text_width (around x=90-130)
# Position vertical line at CONNECTION_VERTICAL_LINE_X to be clearly left of all year markers
vertical_line_x = CONNECTION_VERTICAL_LINE_X
# Draw connecting lines - more visible with brighter color and increased opacity
context.set_source_rgba(*CONNECTION_LINE_COLOR)
context.set_line_width(CONNECTION_LINE_WIDTH)
context.set_line_cap(cairo.LINE_CAP_ROUND)
context.set_line_join(cairo.LINE_JOIN_ROUND)
# Draw vertical connector line on the left side # Draw vertical connector line on the left side
if len(person_events) > 1: if len(person_events) > 1:
@ -3246,15 +3317,11 @@ class MyTimelineView(NavigationView):
# Draw vertical line connecting all events # Draw vertical line connecting all events
if max_y - min_y > EVENT_MARKER_SIZE * 2: if max_y - min_y > EVENT_MARKER_SIZE * 2:
context.set_source_rgba(*CONNECTION_LINE_COLOR)
context.set_line_width(CONNECTION_LINE_WIDTH)
context.move_to(vertical_line_x, min_y) context.move_to(vertical_line_x, min_y)
context.line_to(vertical_line_x, max_y) context.line_to(vertical_line_x, max_y)
context.stroke() context.stroke()
# Draw horizontal lines connecting vertical line to each event marker # Draw horizontal lines connecting vertical line to each event marker
context.set_source_rgba(*CONNECTION_LINE_COLOR)
context.set_line_width(CONNECTION_LINE_WIDTH)
for event_data in person_events: for event_data in person_events:
# Draw horizontal line from vertical line to event marker # Draw horizontal line from vertical line to event marker
context.move_to(vertical_line_x, event_data.y_pos) context.move_to(vertical_line_x, event_data.y_pos)