From f6069dfd83fa30a7bc120a0194bcc8b4ab373838 Mon Sep 17 00:00:00 2001 From: Daniel Viegas Date: Sat, 29 Nov 2025 22:10:25 +0100 Subject: [PATCH] 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 --- MyTimeline.py | 169 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 118 insertions(+), 51 deletions(-) diff --git a/MyTimeline.py b/MyTimeline.py index 5594870..805e471 100644 --- a/MyTimeline.py +++ b/MyTimeline.py @@ -28,6 +28,7 @@ MyTimeline View - A vertical timeline showing all events in the database # # ------------------------------------------------------------------------- import cairo +import colorsys import logging 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_WIDTH = 3.5 CONNECTION_VERTICAL_LINE_X = 5 # Left of year markers +CONNECTION_LINE_SPACING = 25 # Pixels between vertical lines for different persons # Marker State Colors 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.tooltip_timeout_id = 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_y = 0 @@ -493,6 +496,9 @@ class MyTimelineView(NavigationView): self.active_event_handle = None self.all_events = [] self.events = [] + # Clear selected persons and colors + self.selected_person_handles.clear() + self.person_colors.clear() # Connect to new database signals if db and db.is_open(): @@ -2124,6 +2130,56 @@ class MyTimelineView(NavigationView): """ 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, timeline_y_start: float, timeline_y_end: float) -> float: """ @@ -2350,18 +2406,26 @@ class MyTimelineView(NavigationView): if self.active_event_handle == event_handle: # Deselect if clicking same event self.active_event_handle = None - self.selected_person_handle = None else: # Select this event 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 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() return False @@ -2827,7 +2891,7 @@ class MyTimelineView(NavigationView): self._draw_events(context, events_with_y_pos, timeline_x) # 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, timeline_y_start, timeline_y_end) @@ -3201,7 +3265,8 @@ class MyTimelineView(NavigationView): timeline_x: float, timeline_y_start: float, 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: context: Cairo drawing context. @@ -3210,56 +3275,58 @@ class MyTimelineView(NavigationView): timeline_y_start: Y coordinate of the timeline start. timeline_y_end: Y coordinate of the timeline end. """ - if not self.selected_person_handle: + if not self.selected_person_handles: return - # Find all events for the selected person - person_events = [ - event_data for event_data in events_with_y_pos - if self._person_matches_handle(event_data.person, self.selected_person_handle) - ] - - if len(person_events) < 1: - return - - # Sort by Y position - person_events.sort(key=lambda x: x.y_pos) - context.save() - - # 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 - if len(person_events) > 1: - y_positions = [event_data.y_pos for event_data in person_events] - min_y = min(y_positions) - max_y = max(y_positions) - - # Draw vertical line connecting all events - 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.line_to(vertical_line_x, max_y) - context.stroke() + # Sort person handles for consistent ordering (affects X position) + sorted_person_handles = sorted(self.selected_person_handles) - # 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: - # Draw horizontal line from vertical line to event marker - context.move_to(vertical_line_x, event_data.y_pos) - context.line_to(timeline_x, event_data.y_pos) - context.stroke() + # 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 = [ + event_data for event_data in events_with_y_pos + if self._person_matches_handle(event_data.person, person_handle) + ] + + if len(person_events) < 1: + continue + + # Sort by Y position + person_events.sort(key=lambda x: x.y_pos) + + # Set color for this person's lines + context.set_source_rgba(*person_color) + + # Draw vertical connector line on the left side + if len(person_events) > 1: + y_positions = [event_data.y_pos for event_data in person_events] + min_y = min(y_positions) + max_y = max(y_positions) + + # Draw vertical line connecting all events + if max_y - min_y > EVENT_MARKER_SIZE * 2: + context.move_to(vertical_line_x, min_y) + context.line_to(vertical_line_x, max_y) + context.stroke() + + # Draw horizontal lines connecting vertical line to each event marker + for event_data in person_events: + # Draw horizontal line from vertical line to event marker + context.move_to(vertical_line_x, event_data.y_pos) + context.line_to(timeline_x, event_data.y_pos) + context.stroke() context.restore()