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
This commit is contained in:
spencrmartin
2025-11-18 10:33:20 -05:00
parent 7ca50bf1d3
commit bdbe237e0c
@@ -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<SessionTimelineViewProps> = ({
// 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<string, SessionData[]>();
@@ -72,29 +74,46 @@ const SessionTimelineView: React.FC<SessionTimelineViewProps> = ({
});
});
// 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<SessionTimelineViewProps> = ({
};
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<SessionTimelineViewProps> = ({
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<SessionTimelineViewProps> = ({
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<SessionTimelineViewProps> = ({
// 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<SessionTimelineViewProps> = ({
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<SessionTimelineViewProps> = ({
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<SessionTimelineViewProps> = ({
// 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<SessionTimelineViewProps> = ({
})
.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<SessionTimelineViewProps> = ({
// 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<SessionTimelineViewProps> = ({
<div className="p-4 border-b bg-gray-50">
<h3 className="text-lg font-semibold text-gray-900">Tangled Tree Timeline</h3>
<p className="text-sm text-gray-600">
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
</p>
</div>
@@ -381,7 +395,7 @@ const SessionTimelineView: React.FC<SessionTimelineViewProps> = ({
<svg
ref={svgRef}
className="w-full"
style={{ minHeight: '600px' }}
style={{ minHeight: '400px' }}
/>
</div>
</div>