mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user