diff --git a/src/components/MiniGraph.astro b/src/components/MiniGraph.astro index 28eaa7a..c872e1c 100644 --- a/src/components/MiniGraph.astro +++ b/src/components/MiniGraph.astro @@ -8,6 +8,8 @@ interface Props { title: string; // Current post title tags?: string[]; // Current post tags category?: string; // Current post category + relatedPosts?: any[]; // Related posts data + allPosts?: any[]; // All posts for second level relationships } // Extract props with defaults @@ -15,34 +17,127 @@ const { slug, title, tags = [], - category = "Uncategorized" + category = "Uncategorized", + relatedPosts = [], + allPosts = [] } = Astro.props; // Generate unique ID for the graph container const graphId = `graph-${Math.random().toString(36).substring(2, 8)}`; -// Prepare simple graph data for just the post and its tags +// Get all unique tags from related posts (Level 1 tags) +const relatedPostsTags = relatedPosts + .flatMap(post => post.data.tags || []) + .filter(tag => !tags.includes(tag)); // Exclude current post tags to avoid duplicates + +// Get Level 2 posts: posts related to Level 1 tags (excluding current post and Level 1 posts) +const level2PostIds = new Set(); +const level2Posts = []; + +// For each Level 1 tag, find related posts +tags.forEach(tag => { + allPosts.forEach(post => { + // Skip if post is current post or already in related posts + if (post.slug === slug || relatedPosts.some(rp => rp.slug === post.slug)) { + return; + } + + // If post has the tag and isn't already added + if (post.data.tags?.includes(tag) && !level2PostIds.has(post.slug)) { + level2PostIds.add(post.slug); + level2Posts.push(post); + } + }); +}); + +// Get Level 2 tags from Level 2 posts +const level2Tags = new Set(); +level2Posts.forEach(post => { + (post.data.tags || []).forEach(tag => { + // Only add if not already in Level 0 or Level 1 tags + if (!tags.includes(tag) && !relatedPostsTags.includes(tag)) { + level2Tags.add(tag); + } + }); +}); + +// Prepare nodes data with different levels const nodes = [ - // Current post node + // Level 0: Current post node { id: slug, label: title, - type: "post" + type: "post", + level: 0 }, - // Tag nodes + // Level 1: Tag nodes ...tags.map(tag => ({ id: `tag-${tag}`, label: tag, - type: "tag" + type: "tag", + level: 1 + })), + // Level 1: Related post nodes + ...relatedPosts.map(post => ({ + id: post.slug, + label: post.data.title, + type: "post", + level: 1 + })), + // Level 2: Related tags nodes + ...relatedPostsTags.map(tag => ({ + id: `tag-${tag}`, + label: tag, + type: "tag", + level: 2 + })), + // Level 2: Posts related to tags + ...level2Posts.map(post => ({ + id: post.slug, + label: post.data.title, + type: "post", + level: 2 + })), + // Level 2: Tags from Level 2 posts + ...[...level2Tags].map(tag => ({ + id: `tag-${tag}`, + label: tag.toString(), + type: "tag", + level: 2 })) ]; -// Create edges connecting post to tags -const edges = tags.map(tag => ({ - source: slug, - target: `tag-${tag}`, - type: "post-tag" -})); +// Create edges connecting nodes +const edges = [ + // Level 0 to Level 1: Current post to its tags + ...tags.map(tag => ({ + source: slug, + target: `tag-${tag}`, + type: "post-tag" + })), + // Level 0 to Level 1: Current post to related posts + ...relatedPosts.map(post => ({ + source: slug, + target: post.slug, + type: "post-related" + })), + // Level 1 to Level 2: Related posts to their tags + ...relatedPosts.flatMap(post => + (post.data.tags || []).map(tag => ({ + source: post.slug, + target: `tag-${tag}`, + type: "post-tag" + })) + ), + // Level 1 to Level 2: Tags to related posts + ...level2Posts.flatMap(post => + tags.filter(tag => post.data.tags?.includes(tag)).map(tag => ({ + source: `tag-${tag}`, + target: post.slug, + type: "tag-post" + })) + ) +]; // Prepare graph data object const graphData = { nodes, edges }; @@ -69,7 +164,7 @@ const graphData = { nodes, edges }; .mini-graph-container { width: 100%; - height: 200px; + aspect-ratio: 1 / 1; /* Create square aspect ratio */ border-radius: 8px; overflow: hidden; border: 1px solid var(--card-border, rgba(56, 189, 248, 0.2)); @@ -125,7 +220,8 @@ const graphData = { nodes, edges }; data: { id: node.id, label: node.label, - type: node.type + type: node.type, + level: node.level } })), ...graphData.edges.map((edge, index) => ({ @@ -155,47 +251,104 @@ const graphData = { nodes, edges }; 'text-max-width': '60px' } }, - // Post node style + // Current post node style (Level 0) { - selector: 'node[type="post"]', + selector: 'node[type="post"][level=0]', style: { 'background-color': '#06B6D4', 'width': 30, 'height': 30, 'font-size': '9px', - 'text-max-width': '80px' + 'text-max-width': '80px', + 'border-width': 2, + 'border-color': '#38BDF8' } }, - // Tag node style + // Related post node style (Level 1) { - selector: 'node[type="tag"]', + selector: 'node[type="post"][level=1]', + style: { + 'background-color': '#3B82F6', + 'width': 25, + 'height': 25, + 'font-size': '8px' + } + }, + // Distant post node style (Level 2) + { + selector: 'node[type="post"][level=2]', + style: { + 'background-color': '#60A5FA', + 'width': 20, + 'height': 20, + 'font-size': '7px', + 'opacity': 0.8 + } + }, + // Primary tag node style (Level 1) + { + selector: 'node[type="tag"][level=1]', style: { 'background-color': '#10B981', 'shape': 'diamond', - 'width': 18, - 'height': 18 + 'width': 20, + 'height': 20 } }, - // Edge style + // Secondary tag node style (Level 2) { - selector: 'edge', + selector: 'node[type="tag"][level=2]', style: { - 'width': 1, - 'line-color': 'rgba(16, 185, 129, 0.6)', + 'background-color': '#34D399', + 'shape': 'diamond', + 'width': 15, + 'height': 15, + 'opacity': 0.8 + } + }, + // Direct edge style + { + selector: 'edge[type="post-tag"]', + style: { + 'width': 1.5, + 'line-color': 'rgba(16, 185, 129, 0.7)', + 'line-style': 'solid', + 'curve-style': 'bezier', + 'opacity': 0.8 + } + }, + // Related post edge style + { + selector: 'edge[type="post-related"]', + style: { + 'width': 1.5, + 'line-color': 'rgba(59, 130, 246, 0.7)', 'line-style': 'dashed', 'curve-style': 'bezier', - 'opacity': 0.7 + 'opacity': 0.8 + } + }, + // Secondary connections + { + selector: 'edge[type="tag-post"]', + style: { + 'width': 1, + 'line-color': 'rgba(16, 185, 129, 0.4)', + 'line-style': 'dotted', + 'curve-style': 'bezier', + 'opacity': 0.6 } } ], - // Simple layout for small space + // Improved layout for multi-level visualization layout: { name: 'concentric', concentric: function(node) { - return node.data('type') === 'post' ? 10 : 1; + // Use node level for concentric layout + return 10 - node.data('level') * 3; }, - levelWidth: function() { return 1; }, - minNodeSpacing: 50, + levelWidth: function() { return 2; }, + minNodeSpacing: 40, animate: false } }); @@ -207,6 +360,12 @@ const graphData = { nodes, edges }; window.location.href = `/tag/${tagName}`; }); + cy.on('tap', 'node[type="post"][level!=0]', function(evt) { + const node = evt.target; + const postSlug = node.id(); + window.location.href = `/posts/${postSlug}/`; + }); + // Fit graph to container cy.fit(undefined, 20); diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[slug].astro index 2c6c58c..2267068 100644 --- a/src/pages/posts/[slug].astro +++ b/src/pages/posts/[slug].astro @@ -2,7 +2,7 @@ // src/pages/posts/[slug].astro import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; -import MiniGraph from '../../components/MiniGraph.astro'; // Add import for MiniGraph +import MiniGraph from '../../components/MiniGraph.astro'; // Required getStaticPaths function for dynamic routes export async function getStaticPaths() { @@ -11,7 +11,7 @@ export async function getStaticPaths() { const allPosts = await getCollection('posts', ({ data }) => { return import.meta.env.PROD ? !data.draft : true; }); - + return allPosts.map(post => ({ params: { slug: post.slug }, props: { post, allPosts }, @@ -58,10 +58,10 @@ const getISODate = (date) => { // Find related posts by tags const getRelatedPosts = (currentPost, allPosts, maxPosts = 3) => { if (!currentPost || !allPosts) return []; - + // Get current post tags const postTags = currentPost.data.tags || []; - + // If no tags, just return recent posts if (postTags.length === 0) { return allPosts @@ -73,7 +73,7 @@ const getRelatedPosts = (currentPost, allPosts, maxPosts = 3) => { }) .slice(0, maxPosts); } - + // Score posts by matching tags const scoredPosts = allPosts .filter(p => p.slug !== currentPost.slug && !p.data.draft) @@ -86,20 +86,20 @@ const getRelatedPosts = (currentPost, allPosts, maxPosts = 3) => { .sort((a, b) => { // Sort by score first if (b.score !== a.score) return b.score - a.score; - + // If scores are equal, sort by date const dateA = a.post.data.pubDate ? new Date(a.post.data.pubDate) : new Date(0); const dateB = b.post.data.pubDate ? new Date(b.post.data.pubDate) : new Date(0); return dateB.getTime() - dateA.getTime(); }) .slice(0, maxPosts); - + // If we don't have enough related posts by tags, add recent posts if (scoredPosts.length < maxPosts) { const recentPosts = allPosts .filter(p => { - return p.slug !== currentPost.slug && - !p.data.draft && + return p.slug !== currentPost.slug && + !p.data.draft && !scoredPosts.some(sp => sp.post.slug === p.slug); }) .sort((a, b) => { @@ -108,10 +108,10 @@ const getRelatedPosts = (currentPost, allPosts, maxPosts = 3) => { return dateB.getTime() - dateA.getTime(); }) .slice(0, maxPosts - scoredPosts.length); - + return [...scoredPosts.map(sp => sp.post), ...recentPosts]; } - + return scoredPosts.map(sp => sp.post); }; @@ -119,7 +119,7 @@ const getRelatedPosts = (currentPost, allPosts, maxPosts = 3) => { const relatedPosts = getRelatedPosts(post, allPosts); // Check for explicitly related posts in frontmatter -const explicitRelatedPosts = post.data.related_posts +const explicitRelatedPosts = post.data.related_posts ? allPosts.filter(p => post.data.related_posts.includes(p.slug)) : []; @@ -144,51 +144,44 @@ const { Content } = await post.render(); {post.data.author &&
} - - {post.data.heroImage && ( -