From bdbe237e0ca270b1bf206c511cf0f66f0e391c68 Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Tue, 18 Nov 2025 10:33:20 -0500 Subject: [PATCH] feat: match Observable tangled tree styling with 90-degree connections - Reposition day nodes horizontally across the top (matching Observable) - Position session nodes vertically below each day - Implement 90-degree angle tree connections (straight down, then across) - Add curved tangled connections between related sessions - Update spacing: 200px between days, 30px between sessions - Place day labels above nodes, session labels to the right - Increase node sizes and improve hover effects - Match exact layout pattern from Observable notebook --- .../sessions/SessionTimelineView.tsx | 178 ++++++++++-------- 1 file changed, 96 insertions(+), 82 deletions(-) diff --git a/ui/desktop/src/components/sessions/SessionTimelineView.tsx b/ui/desktop/src/components/sessions/SessionTimelineView.tsx index 690f7734bf..120a702732 100644 --- a/ui/desktop/src/components/sessions/SessionTimelineView.tsx +++ b/ui/desktop/src/components/sessions/SessionTimelineView.tsx @@ -15,6 +15,8 @@ interface TangledNode { x: number; y: number; data: SessionData; + dayIndex?: number; + sessionIndex?: number; } interface TangledLink { @@ -51,7 +53,7 @@ const SessionTimelineView: React.FC = ({ // Sort sessions by creation date (newest first) const sortedSessions = [...sessions] .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .slice(0, 50); // Limit for performance + .slice(0, 100); // Limit for performance // Group sessions by day const sessionsByDay = new Map(); @@ -72,29 +74,46 @@ const SessionTimelineView: React.FC = ({ }); }); - // Create nodes array - const nodes: TangledNode[] = []; - const links: TangledLink[] = []; - // Sort days (newest first) const sortedDays = Array.from(sessionsByDay.keys()) .sort((a, b) => new Date(b).getTime() - new Date(a).getTime()); - let sessionIndex = 0; + // Create nodes array + const nodes: TangledNode[] = []; + const links: TangledLink[] = []; - // Create date nodes (level 0) and session nodes (level 1) + // Layout configuration - matching Observable example + const daySpacing = 200; // Horizontal spacing between days + const sessionSpacing = 30; // Vertical spacing between sessions + const dayToSessionOffset = 80; // Vertical offset from day to first session + + // Create day nodes (level 0) positioned horizontally across the top sortedDays.forEach((day, dayIndex) => { - const dayTitle = day === new Date().toDateString() ? 'Today' : - day === new Date(Date.now() - 24 * 60 * 60 * 1000).toDateString() ? 'Yesterday' : - formatDistanceToNow(new Date(day), { addSuffix: true }); + const dayDate = new Date(day); + const today = new Date(); + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + + let dayTitle; + if (day === today.toDateString()) { + dayTitle = 'Today'; + } else if (day === yesterday.toDateString()) { + dayTitle = 'Yesterday'; + } else { + dayTitle = dayDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: dayDate.getFullYear() !== today.getFullYear() ? 'numeric' : undefined + }); + } - // Create day node + // Position day nodes horizontally across the top const dayNode: TangledNode = { id: `day-${day}`, title: dayTitle, level: 0, - x: 100, // Will be positioned properly later - y: dayIndex * 120 + 60, + x: dayIndex * daySpacing + 100, + y: 50, // Top position + dayIndex, data: { id: `day-${day}`, title: dayTitle, @@ -107,31 +126,31 @@ const SessionTimelineView: React.FC = ({ }; nodes.push(dayNode); - // Create session nodes for this day + // Create session nodes for this day, positioned vertically below const daySessions = sessionsByDay.get(day)!; daySessions.forEach((session, sessionInDayIndex) => { const sessionNode: TangledNode = { id: session.id, title: session.title, level: 1, - x: 300, // Will be positioned properly later - y: dayIndex * 120 + 60 + (sessionInDayIndex - (daySessions.length - 1) / 2) * 25, + x: dayNode.x, // Same x as parent day + y: dayNode.y + dayToSessionOffset + (sessionInDayIndex * sessionSpacing), + dayIndex, + sessionIndex: sessionInDayIndex, data: session }; nodes.push(sessionNode); - // Create tree link from day to session + // Create tree link from day to session (90-degree angle) links.push({ source: dayNode, target: sessionNode, type: 'tree' }); - - sessionIndex++; }); }); - // Create tangled links between similar sessions + // Create tangled links between similar sessions across different days const sessionNodes = nodes.filter(n => n.level === 1); for (let i = 0; i < sessionNodes.length; i++) { @@ -140,17 +159,17 @@ const SessionTimelineView: React.FC = ({ const nodeB = sessionNodes[j]; // Skip if same day (already connected via tree) - if (nodeA.data.day === nodeB.data.day) continue; + if (nodeA.dayIndex === nodeB.dayIndex) continue; // Calculate similarity const titleSimilarity = calculateTitleSimilarity(nodeA.data.title, nodeB.data.title); - const typeSimilarity = nodeA.data.chat_type === nodeB.data.chat_type ? 0.3 : 0; + const typeSimilarity = nodeA.data.chat_type === nodeB.data.chat_type ? 0.4 : 0; const messageSimilarity = Math.min(nodeA.data.message_count, nodeB.data.message_count) / Math.max(nodeA.data.message_count, nodeB.data.message_count) * 0.2; const totalSimilarity = titleSimilarity + typeSimilarity + messageSimilarity; - if (totalSimilarity > 0.4 && links.filter(l => l.type === 'tangle').length < 15) { + if (totalSimilarity > 0.3 && links.filter(l => l.type === 'tangle').length < 20) { links.push({ source: nodeA, target: nodeB, @@ -162,6 +181,9 @@ const SessionTimelineView: React.FC = ({ console.log('SessionTimelineView: Created tangled tree', { nodeCount: nodes.length, + dayCount: sortedDays.length, + sessionCount: sortedSessions.length, + sessionsPerDay: Object.fromEntries(Array.from(sessionsByDay.entries()).map(([day, sessions]) => [day, sessions.length])), treeLinks: links.filter(l => l.type === 'tree').length, tangleLinks: links.filter(l => l.type === 'tangle').length }); @@ -178,39 +200,23 @@ const SessionTimelineView: React.FC = ({ // Clear previous content svg.selectAll("*").remove(); - // Set up dimensions + // Calculate dimensions based on layout const containerRect = container.getBoundingClientRect(); - const width = Math.max(800, containerRect.width - 40); - const height = Math.max(600, nodes.filter(n => n.level === 0).length * 120 + 100); - - svg.attr("width", width).attr("height", height); - - // Position nodes properly for tangled tree layout const dayNodes = nodes.filter(n => n.level === 0); const sessionNodes = nodes.filter(n => n.level === 1); + + const maxX = Math.max(...nodes.map(n => n.x)) + 150; + const maxY = Math.max(...nodes.map(n => n.y)) + 50; + + const width = Math.max(800, maxX, containerRect.width - 40); + const height = Math.max(400, maxY); - // Position day nodes vertically - dayNodes.forEach((node, index) => { - node.x = 100; - node.y = index * 120 + 60; - }); - - // Position session nodes - sessionNodes.forEach(node => { - const dayNode = dayNodes.find(d => d.id === `day-${node.data.day}`); - if (dayNode) { - const daySessionNodes = sessionNodes.filter(s => s.data.day === node.data.day); - const sessionIndex = daySessionNodes.indexOf(node); - - node.x = 300; - node.y = dayNode.y + (sessionIndex - (daySessionNodes.length - 1) / 2) * 25; - } - }); + svg.attr("width", width).attr("height", height); // Create container group const g = svg.append("g").attr("transform", "translate(20, 20)"); - // Draw tree links (day to sessions) + // Draw tree links (day to sessions) with 90-degree angles const treeLinks = links.filter(l => l.type === 'tree'); g.selectAll(".tree-link") .data(treeLinks) @@ -223,16 +229,15 @@ const SessionTimelineView: React.FC = ({ const tx = d.target.x; const ty = d.target.y; - // Horizontal tree connection - const mx = (sx + tx) / 2; - return `M${sx},${sy}C${mx},${sy} ${mx},${ty} ${tx},${ty}`; + // 90-degree angle connection: straight down from source, then straight to target + return `M${sx},${sy}L${sx},${ty}L${tx},${ty}`; }) .attr("fill", "none") .attr("stroke", "#6366f1") .attr("stroke-width", 2) - .attr("opacity", 0.6); + .attr("opacity", 0.7); - // Draw tangled links (session to session) + // Draw tangled links (session to session) with curved paths const tangleLinks = links.filter(l => l.type === 'tangle'); g.selectAll(".tangle-link") .data(tangleLinks) @@ -245,24 +250,17 @@ const SessionTimelineView: React.FC = ({ const tx = d.target.x; const ty = d.target.y; - // Curved tangled connection - const dx = tx - sx; - const dy = ty - sy; - const dr = Math.sqrt(dx * dx + dy * dy); + // Curved connection for tangled links + const midY = (sy + ty) / 2; + const controlOffset = Math.abs(tx - sx) * 0.3; - // Control points for more organic curves - const cx1 = sx + dx * 0.3 + dr * 0.1; - const cy1 = sy + dy * 0.3 - dr * 0.2; - const cx2 = sx + dx * 0.7 - dr * 0.1; - const cy2 = sy + dy * 0.7 + dr * 0.2; - - return `M${sx},${sy}C${cx1},${cy1} ${cx2},${cy2} ${tx},${ty}`; + return `M${sx},${sy}C${sx},${midY - controlOffset} ${tx},${midY - controlOffset} ${tx},${ty}`; }) .attr("fill", "none") .attr("stroke", "#f59e0b") .attr("stroke-width", 1.5) - .attr("stroke-dasharray", "4,4") - .attr("opacity", 0.5); + .attr("stroke-dasharray", "5,5") + .attr("opacity", 0.6); // Draw nodes const nodeGroups = g.selectAll(".node") @@ -275,11 +273,11 @@ const SessionTimelineView: React.FC = ({ // Add circles for nodes nodeGroups.append("circle") .attr("r", d => { - if (d.level === 0) return 8; // Day nodes - return Math.max(4, Math.min(10, Math.sqrt(d.data.message_count) + 2)); // Session nodes + if (d.level === 0) return 10; // Day nodes - larger + return Math.max(5, Math.min(12, Math.sqrt(d.data.message_count) + 3)); // Session nodes }) .attr("fill", d => { - if (d.level === 0) return "#4f46e5"; // Day nodes + if (d.level === 0) return "#4f46e5"; // Day nodes - blue // Session nodes colored by chat type switch (d.data.chat_type) { @@ -299,26 +297,42 @@ const SessionTimelineView: React.FC = ({ }) .on("mouseover", function(event, d) { if (d.level === 1) { - d3.select(this).attr("stroke-width", 3); + d3.select(this) + .attr("stroke-width", 3) + .attr("r", Math.max(6, Math.min(14, Math.sqrt(d.data.message_count) + 4))); } }) .on("mouseout", function(event, d) { if (d.level === 1) { - d3.select(this).attr("stroke-width", 2); + d3.select(this) + .attr("stroke-width", 2) + .attr("r", Math.max(5, Math.min(12, Math.sqrt(d.data.message_count) + 3))); } }); - // Add labels - nodeGroups.append("text") - .attr("dx", d => d.level === 0 ? -8 : 15) + // Add labels for day nodes (above the circle) + nodeGroups.filter(d => d.level === 0) + .append("text") + .attr("dx", 0) + .attr("dy", -15) + .attr("text-anchor", "middle") + .style("font-size", "12px") + .style("font-weight", "bold") + .style("fill", "#374151") + .text(d => d.title); + + // Add labels for session nodes (to the right) + nodeGroups.filter(d => d.level === 1) + .append("text") + .attr("dx", 18) .attr("dy", 4) - .attr("text-anchor", d => d.level === 0 ? "end" : "start") - .style("font-size", d => d.level === 0 ? "12px" : "10px") - .style("font-weight", d => d.level === 0 ? "bold" : "normal") + .attr("text-anchor", "start") + .style("font-size", "10px") + .style("font-weight", "normal") .style("fill", "#374151") .text(d => { - if (d.level === 1 && d.title.length > 25) { - return d.title.substring(0, 25) + "..."; + if (d.title.length > 30) { + return d.title.substring(0, 30) + "..."; } return d.title; }); @@ -326,7 +340,7 @@ const SessionTimelineView: React.FC = ({ // Add message count for session nodes nodeGroups.filter(d => d.level === 1) .append("text") - .attr("dx", 15) + .attr("dx", 18) .attr("dy", 16) .style("font-size", "8px") .style("fill", "#6b7280") @@ -373,7 +387,7 @@ const SessionTimelineView: React.FC = ({

Tangled Tree Timeline

- Dates on the left, sessions branching out, with tangled connections showing relationships + Days across the top, sessions flowing down with 90° connections, tangled links show relationships

@@ -381,7 +395,7 @@ const SessionTimelineView: React.FC = ({