- {post.updatedAt && post.updatedAt !== post.createdAt && (
-
-
-
-
-
- Last updated:{' '}
-
- {new Date(post.updatedAt).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- })}
-
-
+ )}
+
+ {/* Content container with optimal reading width */}
+
+ {/* Article metadata for SEO */}
+
+ {post.updatedAt && post.updatedAt !== post.createdAt && (
+
+
+
+
+
+ Last updated:{' '}
+
+ {new Date(post.updatedAt).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })}
+
+
+
+ )}
+
+
+ {/* Main article content */}
+
+
+
+
+ {/* Article tags/keywords */}
+ {post.categories && post.categories.length > 0 && (
+
+
+ Topics Covered
+
+
+ {post.categories.map((category, index) => {
+ if (typeof category === 'object' && category !== null) {
+ return (
+
+ {category.title}
+
+ );
+ }
+ return null;
+ })}
+
)}
- {/* Main article content */}
-
-
-
-
- {/* Article tags/keywords */}
- {post.categories && post.categories.length > 0 && (
-
-
- Topics Covered
-
-
- {post.categories.map((category, index) => {
- if (typeof category === 'object' && category !== null) {
+ {/* Author bio section */}
+ {post.populatedAuthors && post.populatedAuthors.length > 0 && (
+
+
+
About the Author
+ {post.populatedAuthors.map((author, index) => {
+ if (author?.name) {
return (
-
- {category.title}
-
+
+
+ {author.name.charAt(0).toUpperCase()}
+
+
+
+
+ {author.name}
+
+
Contributor at Decentralizard
+
+
);
}
return null;
@@ -253,97 +311,63 @@ export default async function Post({ params: paramsPromise }: Args) {
)}
-
- {/* Author bio section */}
- {post.populatedAuthors && post.populatedAuthors.length > 0 && (
-
-
-
About the Author
- {post.populatedAuthors.map((author, index) => {
- if (author?.name) {
- return (
-
-
-
- {author.name.charAt(0).toUpperCase()}
-
-
-
-
- {author.name}
-
-
Contributor at Decentralizard
+ {/* Category-based related posts */}
+ {categoryBasedPosts.length > 0 && (
+
+
+ More in{' '}
+ {typeof firstCategory === 'object' ? firstCategory?.title : 'This Category'}
+
+
+ {categoryBasedPosts.map((relatedPost) => (
+
+ {relatedPost.heroImage && typeof relatedPost.heroImage === 'object' && (
+
+
+ )}
+
+
+ {relatedPost.title}
+
+ {relatedPost.shortDescription && (
+
+ {relatedPost.shortDescription}
+
+ )}
- );
- }
- return null;
- })}
+
+ ))}
+
-
- )}
-
- {/* Category-based related posts */}
- {categoryBasedPosts.length > 0 && (
-
-
- More in{' '}
- {typeof firstCategory === 'object' ? firstCategory?.title : 'This Category'}
-
-
- {categoryBasedPosts.map((relatedPost) => (
-
- {relatedPost.heroImage && typeof relatedPost.heroImage === 'object' && (
-
-
-
- )}
-
-
- {relatedPost.title}
-
- {relatedPost.shortDescription && (
-
- {relatedPost.shortDescription}
-
- )}
-
-
- ))}
+ )}
+
+ {/* Manually curated related posts */}
+ {post.relatedPosts && post.relatedPosts.length > 0 && (
+
+
+ Related Articles
+
+ typeof post === 'object')}
+ />
-
- )}
-
- {/* Manually curated related posts */}
- {post.relatedPosts && post.relatedPosts.length > 0 && (
-
-
- Related Articles
-
- typeof post === 'object')}
- />
-
- )}
+ )}
+
-
-
+
+
>
);
}
diff --git a/src/base/accordion.tsx b/src/base/accordion.tsx
new file mode 100644
index 0000000..8f89550
--- /dev/null
+++ b/src/base/accordion.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import * as React from 'react';
+import * as AccordionPrimitive from '@radix-ui/react-accordion';
+import { ChevronDown } from 'lucide-react';
+
+import { cn } from '@/utilities/ui';
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = 'AccordionItem';
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180',
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/src/base/slider.tsx b/src/base/slider.tsx
new file mode 100644
index 0000000..0ce5b1c
--- /dev/null
+++ b/src/base/slider.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import * as React from 'react';
+import * as SliderPrimitive from '@radix-ui/react-slider';
+
+import { cn } from '@/utilities/ui';
+
+const Slider = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+
+));
+Slider.displayName = SliderPrimitive.Root.displayName;
+
+export { Slider };
diff --git a/src/components/ArticleAnalyser/ArticleTreeMap.tsx b/src/components/ArticleAnalyser/ArticleTreeMap.tsx
new file mode 100644
index 0000000..1d64da7
--- /dev/null
+++ b/src/components/ArticleAnalyser/ArticleTreeMap.tsx
@@ -0,0 +1,739 @@
+import React, { FC, useState, useEffect, useMemo, useRef } from 'react';
+import { useRouter } from 'next/navigation';
+import * as d3 from 'd3';
+import { Post } from '@/payload-types';
+import { useCanvasStore } from '@/stores/useCanvasStore';
+import { useFilteredPosts } from '@/stores/useFilterStore';
+import { useZoomStore } from '@/stores/useZoomStore';
+import { SizeMetric, PostWithMetrics, HierarchyNode, ViewType } from './types';
+import {
+ getShade,
+ enrichPostsWithMetrics,
+ CATEGORY_PALETTE,
+ PREDEFINED_CATEGORY_COLORS,
+} from './utils';
+import { FilterSidebar } from './FilterSidebar';
+import { cn } from '@/utilities/ui';
+
+const TARGET_NODE_COUNT = 40; // Target number of articles to display at any zoom level
+
+interface ArticleTreeMapProps {
+ posts: Post[];
+ activeView?: ViewType;
+ onViewChange?: (view: ViewType) => void;
+}
+
+export const ArticleTreeMap: FC = ({ posts, activeView, onViewChange }) => {
+ const router = useRouter();
+ const canvasRef = useRef(null);
+ const containerRef = useRef(null);
+ const tooltipRef = useRef(null);
+ const zoomBehaviorRef = useRef | null>(null);
+ const [sizeMetric] = useState('clicks');
+ const [postsWithMetrics, setPostsWithMetrics] = useState([]);
+ const [isNavigating, setIsNavigating] = useState(false);
+ const [clickedArticle, setClickedArticle] = useState(null);
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true);
+
+ // Use Zustand store for filtered posts
+ const filteredPosts = useFilteredPosts(posts);
+
+ // Use Zustand store for zoom level
+ const { zoomLevel, setZoomLevel } = useZoomStore();
+
+ // Store the current zoom state (using zoomLevel from store as scale)
+ const [zoomState, setZoomState] = useState({
+ scale: zoomLevel,
+ centerX: 0,
+ centerY: 0,
+ visibleRegion: { x: 0, y: 0, width: 0, height: 0 },
+ });
+
+ // Cache the treemap nodes after layout computation
+ const treemapNodesRef = useRef[]>([]);
+ const allNodesRef = useRef[]>([]);
+
+ // For hover interactions
+ const [hoveredNode, setHoveredNode] = useState | null>(
+ null
+ );
+ const { dimensions, setDimensions } = useCanvasStore();
+ const [isTransitioning, setIsTransitioning] = useState(false);
+
+ // Track the full hierarchy data
+ const hierarchyRootRef = useRef | null>(null);
+
+ // Set up canvas dimensions based on container
+ useEffect(() => {
+ const handleResize = () => {
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect();
+ setDimensions({
+ width: rect.width,
+ height: rect.height,
+ });
+ }
+ };
+ handleResize();
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, [setDimensions]);
+
+ // Enrich posts with deterministic metrics (consistent across SSR and client)
+ useEffect(() => {
+ setPostsWithMetrics(enrichPostsWithMetrics(filteredPosts));
+ }, [filteredPosts]);
+
+ // Build hierarchical data for the treemap
+ const hierarchyData: HierarchyNode = useMemo(() => {
+ const categorized: Record = {};
+ postsWithMetrics.forEach((post) => {
+ const category =
+ post.category_titles && post.category_titles.length > 0
+ ? post.category_titles[0]
+ : 'Uncategorized';
+ if (!categorized[category]) categorized[category] = [];
+ categorized[category].push(post);
+ });
+ return {
+ name: 'Articles',
+ children: Object.keys(categorized).map((category) => ({
+ name: category,
+ children: categorized[category].map((post) => ({
+ name: post.title.replace(/\n\s*/g, ' '),
+ clicks: post.clicks,
+ uniqueClicks: post.uniqueClicks,
+ clickRate: post.clickRate,
+ slug: post.slug,
+ id: post.id,
+ })),
+ })),
+ };
+ }, [postsWithMetrics]);
+
+ // Limit articles to target density
+ const limitArticlesToTargetDensity = (
+ nodes: d3.HierarchyRectangularNode[],
+ targetCount: number,
+ zoomScale: number
+ ) => {
+ // If we have fewer nodes than target, return all of them
+ if (nodes.length <= targetCount) return nodes;
+
+ // Filter for leaf nodes (articles) and category nodes
+ const categoryNodes = nodes.filter((n) => n.depth === 1);
+ const leafNodes = nodes.filter((n) => !n.children && n.depth > 1);
+
+ if (leafNodes.length <= targetCount) {
+ // If we have fewer leaf nodes than target, return all categories and leaves
+ return [...categoryNodes, ...leafNodes];
+ }
+
+ // When highly zoomed in, prioritize visible articles with higher values
+ if (zoomScale > 2) {
+ const sortedLeaves = [...leafNodes].sort((a, b) => (b.value || 0) - (a.value || 0));
+ return [...categoryNodes, ...sortedLeaves.slice(0, targetCount - categoryNodes.length)];
+ }
+
+ // When moderately zoomed, group smaller articles by their parent category
+ const topLeaves = leafNodes
+ .sort((a, b) => (b.value || 0) - (a.value || 0))
+ .slice(0, Math.floor(targetCount * 0.7));
+
+ // Return the categories, top leaves
+ return [...categoryNodes, ...topLeaves];
+ };
+
+ // Calculate which nodes to display based on visible region
+ const getNodesInVisibleRegion = (zoomState: {
+ scale: number;
+ centerX: number;
+ centerY: number;
+ visibleRegion: { x: number; y: number; width: number; height: number };
+ }) => {
+ if (!allNodesRef.current.length) return [];
+
+ const { x, y, width, height } = zoomState.visibleRegion;
+
+ // Find nodes that intersect with the visible region
+ const visibleNodes = allNodesRef.current.filter((node) => {
+ return node.x0 < x + width && node.x1 > x && node.y0 < y + height && node.y1 > y;
+ });
+
+ // Limit the number of nodes to a reasonable density
+ return limitArticlesToTargetDensity(visibleNodes, TARGET_NODE_COUNT, zoomState.scale);
+ };
+
+ // Compute treemap layout for visible nodes
+ const computeVisibleTreemap = (visibleNodes: d3.HierarchyRectangularNode[]) => {
+ if (!visibleNodes.length) return;
+
+ // Get category nodes and leaf nodes
+ const categoryNodes = visibleNodes.filter((n) => n.depth === 1);
+ const leafNodes = visibleNodes.filter((n) => !n.children && n.depth > 1);
+
+ // Create a new temporary hierarchy for layout
+ const tempHierarchy: HierarchyNode = {
+ name: 'Visible Articles',
+ children: categoryNodes
+ .map((catNode) => ({
+ name: catNode.data.name,
+ children: leafNodes
+ .filter((leaf) => leaf.parent?.data.name === catNode.data.name)
+ .map((leaf) => leaf.data),
+ }))
+ .filter((cat) => cat.children && cat.children.length > 0),
+ };
+
+ // Create a new hierarchy and compute the treemap
+ const root = d3
+ .hierarchy(tempHierarchy)
+ .sum((d) => (!d.children ? d[sizeMetric] || 0 : 0))
+ .sort((a, b) => (b.value || 0) - (a.value || 0));
+
+ const treemapLayout = d3
+ .treemap()
+ .size([dimensions.width, dimensions.height])
+ .paddingOuter(3)
+ .paddingTop(22)
+ .paddingInner(1)
+ .round(true);
+
+ treemapLayout(root);
+
+ // Store the new layout
+ treemapNodesRef.current = root.descendants() as d3.HierarchyRectangularNode[];
+ };
+
+ // Initial computation of the full treemap
+ useEffect(() => {
+ if (!hierarchyData.children || hierarchyData.children.length === 0) return;
+
+ // Create the full hierarchy
+ const root = d3
+ .hierarchy(hierarchyData)
+ .sum((d) => (!d.children ? d[sizeMetric] || 0 : 0))
+ .sort((a, b) => (b.value || 0) - (a.value || 0));
+
+ hierarchyRootRef.current = root;
+
+ // Compute the initial treemap for all nodes
+ const treemapLayout = d3
+ .treemap()
+ .size([dimensions.width, dimensions.height])
+ .paddingOuter(3)
+ .paddingTop(22)
+ .paddingInner(1)
+ .round(true);
+
+ treemapLayout(root);
+
+ // Store all nodes for reference
+ allNodesRef.current = root.descendants() as d3.HierarchyRectangularNode[];
+ treemapNodesRef.current = allNodesRef.current;
+
+ // Update visible region based on dimensions
+ setZoomState((prev) => ({
+ ...prev,
+ visibleRegion: {
+ x: 0,
+ y: 0,
+ width: dimensions.width,
+ height: dimensions.height,
+ },
+ }));
+
+ // After computing the layout, draw the treemap
+ draw();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hierarchyData, sizeMetric, dimensions]);
+
+ // When zoom state changes, recalculate visible nodes
+ useEffect(() => {
+ if (allNodesRef.current.length === 0) return;
+
+ const visibleNodes = getNodesInVisibleRegion(zoomState);
+
+ if (zoomState.scale > 1.2) {
+ // When zoomed in enough, compute a new treemap layout for just the visible nodes
+ computeVisibleTreemap(visibleNodes);
+ } else {
+ // When at default zoom, use the full treemap
+ treemapNodesRef.current = allNodesRef.current;
+ }
+
+ draw();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [zoomState]);
+
+ const renderConstants = useMemo(() => {
+ if (!treemapNodesRef.current.length) {
+ return null;
+ }
+ const categoryNodes = treemapNodesRef.current.filter((d) => d.depth === 1);
+ const allCategories = categoryNodes.map((d) => d.data.name);
+ const otherCategories = allCategories.filter((cat) => !(cat in PREDEFINED_CATEGORY_COLORS));
+ const ordinal = d3
+ .scaleOrdinal()
+ .domain(otherCategories)
+ .range(CATEGORY_PALETTE.slice(2));
+ const categoryMax: Record = {};
+ categoryNodes.forEach((catNode) => {
+ let maxVal = 0;
+ catNode.leaves().forEach((leaf) => {
+ maxVal = Math.max(maxVal, leaf.data[sizeMetric] || 0);
+ });
+ categoryMax[catNode.data.name] = maxVal;
+ });
+ return { categoryNodes, ordinal, categoryMax };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [treemapNodesRef.current.length, sizeMetric]);
+
+ // Optimize the canvas setup
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = dimensions.width * dpr;
+ canvas.height = dimensions.height * dpr;
+ canvas.style.width = `${dimensions.width}px`;
+ canvas.style.height = `${dimensions.height}px`;
+
+ const context = canvas.getContext('2d');
+ if (context) {
+ context.scale(dpr, dpr);
+ }
+ }, [dimensions]);
+
+ // Draw the treemap
+ const draw = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const context = canvas.getContext('2d');
+ if (!context) return;
+
+ context.save();
+ context.clearRect(0, 0, canvas.width, canvas.height);
+
+ const rc = renderConstants;
+ if (!rc) {
+ context.restore();
+ return;
+ }
+ const { ordinal, categoryMax } = rc;
+
+ treemapNodesRef.current.forEach((d) => {
+ const x = d.x0,
+ y = d.y0,
+ w = d.x1 - d.x0,
+ h = d.y1 - d.y0;
+ if (w <= 0 || h <= 0) return;
+
+ // Use different fills based on node type
+ if (d.children) {
+ // Category node with a colored background
+ const categoryName = d.data.name;
+ const baseColor =
+ PREDEFINED_CATEGORY_COLORS[categoryName] || ordinal(categoryName) || '#333333';
+ context.fillStyle = d3.color(baseColor)?.copy({ opacity: 0.7 })?.toString() || baseColor;
+ } else {
+ // Article node
+ const cat = d.parent?.data.name || 'Uncategorized';
+ const baseColor = PREDEFINED_CATEGORY_COLORS[cat] || ordinal(cat);
+ const maxVal = categoryMax[cat] || 1;
+ context.fillStyle = getShade(baseColor, d.data[sizeMetric] || 0, maxVal);
+ }
+
+ context.strokeStyle = '#fff';
+ context.lineWidth = 1;
+ context.beginPath();
+ context.rect(x, y, w, h);
+ context.fill();
+ context.stroke();
+
+ if (hoveredNode === d) {
+ context.save();
+ const lighterFill =
+ d3.color(context.fillStyle)?.brighter(0.8).formatHex() || context.fillStyle;
+ context.fillStyle = lighterFill;
+ context.fillRect(x, y, w, h);
+ context.restore();
+ }
+
+ // Draw text based on available space
+ if (d.children) {
+ // Category nodes
+ if (w > 50 && h > 25) {
+ context.font = 'bold 14px "Rubik"';
+ context.textBaseline = 'top';
+ context.fillStyle = '#fff';
+
+ let catText = d.data.name || '';
+ while (context.measureText(catText).width > w - 10 && catText.length > 1) {
+ catText = catText.slice(0, -1);
+ }
+ if (catText !== d.data.name) catText += '...';
+ context.fillText(catText, x + 5, y + 5);
+ }
+ } else {
+ // Article nodes - size-based rendering
+ if (w > 80 && h > 45) {
+ // Large articles - show title and value
+ context.font = '12px "Rubik"';
+ context.textBaseline = 'top';
+ context.fillStyle = '#fff';
+
+ let text = d.data.name || '';
+ while (context.measureText(text).width > w - 10 && text.length > 1) {
+ text = text.slice(0, -1);
+ }
+ if (text !== d.data.name) text += '...';
+ context.fillText(text, x + 5, y + 5);
+
+ context.font = '10px "Rubik"';
+ const value = d.data[sizeMetric];
+ const valueText =
+ sizeMetric === 'clickRate' ? `${value}%` : value?.toLocaleString() || '';
+ context.fillText(valueText, x + 5, y + 20);
+ } else if (w > 40 && h > 25) {
+ // Medium articles - just show title
+ context.font = '11px "Rubik"';
+ context.textBaseline = 'top';
+ context.fillStyle = '#fff';
+
+ let text = d.data.name || '';
+ while (context.measureText(text).width > w - 8 && text.length > 1) {
+ text = text.slice(0, -1);
+ }
+ if (text !== d.data.name) text += '...';
+ context.fillText(text, x + 4, y + 4);
+ } else if (w > 20 && h > 15) {
+ // Small articles - just show a truncated title
+ context.font = '9px "Rubik"';
+ context.textBaseline = 'top';
+ context.fillStyle = '#fff';
+
+ let text = d.data.name?.substring(0, 5) || '';
+ if (text.length < d.data.name?.length) text += '...';
+ context.fillText(text, x + 3, y + 3);
+ } else if (w > 10 && h > 10) {
+ // Tiny articles - just show a dot
+ context.fillStyle = '#fff';
+ context.beginPath();
+ context.arc(x + w / 2, y + h / 2, 2, 0, 2 * Math.PI);
+ context.fill();
+ }
+ // For very tiny articles, just the colored rectangle is visible
+ }
+ });
+
+ context.restore();
+ };
+
+ // Handle semantic zooming
+ const handleZoom = (event: d3.D3ZoomEvent) => {
+ if (isTransitioning) return;
+
+ // Throttle zoom events to reduce frequency
+ if (!throttleZoom()) return;
+ setIsTransitioning(true);
+
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ setIsTransitioning(false);
+ return;
+ }
+
+ // Get mouse position in canvas coordinates
+ const [pointerX, pointerY] = d3.pointer(event, canvas);
+
+ // Calculate the new scale and transform
+ const newScale = event.transform.k;
+
+ // Update the zoom store
+ setZoomLevel(newScale);
+
+ // Calculate visible region in the original treemap coordinates
+ const visibleWidth = dimensions.width / newScale;
+ const visibleHeight = dimensions.height / newScale;
+
+ // Center on mouse position
+ const visibleX = pointerX - visibleWidth / 2;
+ const visibleY = pointerY - visibleHeight / 2;
+
+ // Update zoom state
+ setZoomState({
+ scale: newScale,
+ centerX: pointerX,
+ centerY: pointerY,
+ visibleRegion: {
+ x: visibleX,
+ y: visibleY,
+ width: visibleWidth,
+ height: visibleHeight,
+ },
+ });
+
+ setTimeout(() => setIsTransitioning(false), 300);
+ };
+
+ const throttleZoom = (() => {
+ let lastCall = 0;
+ const threshold = 25; // ms between zoom updates
+ return () => {
+ const now = Date.now();
+ if (now - lastCall < threshold) return false;
+ lastCall = now;
+ return true;
+ };
+ })();
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const zoom = d3
+ .zoom()
+ .scaleExtent([1, 8])
+ .wheelDelta((event) => {
+ // Reduce scroll sensitivity
+ return -event.deltaY * 0.004;
+ })
+ .filter((event) => {
+ // IMPORTANT: Allow pinch events by removing the filter on ctrlKey
+ // This was blocking pinch gestures on touch devices
+ return !(event.button && event.type === 'mousedown');
+ })
+ .interpolate(d3.interpolateZoom)
+ .on('zoom', function (event) {
+ // Round scale to nearest 0.25 increment
+ const k = Math.round(event.transform.k * 4) / 4;
+ const transform = d3.zoomIdentity.scale(k);
+ handleZoom({ ...event, transform });
+ });
+
+ zoomBehaviorRef.current = zoom;
+
+ d3.select(canvas)
+ .call(zoom)
+ .on('dblclick.zoom', null)
+ // Use touchstart with { passive: false } to prevent scrolling
+ // when performing touch gestures
+ .on(
+ 'touchstart',
+ function (event) {
+ event.preventDefault();
+ },
+ { passive: false }
+ );
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dimensions, isTransitioning]);
+
+ // Sync d3 zoom when zoomLevel changes from store (e.g., slider)
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas || !zoomBehaviorRef.current) return;
+
+ // Only update if the zoom level from store differs from current zoom state
+ if (Math.abs(zoomLevel - zoomState.scale) > 0.01) {
+ // Calculate visible region centered on the canvas
+ const centerX = dimensions.width / 2;
+ const centerY = dimensions.height / 2;
+ const visibleWidth = dimensions.width / zoomLevel;
+ const visibleHeight = dimensions.height / zoomLevel;
+ const visibleX = centerX - visibleWidth / 2;
+ const visibleY = centerY - visibleHeight / 2;
+
+ // Update zoom state directly (without triggering d3 zoom event)
+ setZoomState({
+ scale: zoomLevel,
+ centerX,
+ centerY,
+ visibleRegion: {
+ x: visibleX,
+ y: visibleY,
+ width: visibleWidth,
+ height: visibleHeight,
+ },
+ });
+
+ // Update d3 transform without triggering zoom event
+ const transform = d3.zoomIdentity.scale(zoomLevel);
+ d3.select(canvas).property('__zoom', transform);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [zoomLevel, dimensions.width, dimensions.height]);
+
+ // Find node at position
+ const findNodeAtPosition = (x: number, y: number) => {
+ for (const node of treemapNodesRef.current) {
+ if (x >= node.x0 && x <= node.x1 && y >= node.y0 && y <= node.y1) {
+ if (!node.children) {
+ return node;
+ }
+ }
+ }
+ return null;
+ };
+
+ // Add mouse event listeners
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const handleMouseMove = (event: MouseEvent) => {
+ const rect = canvas.getBoundingClientRect();
+ const mouseX = event.clientX - rect.left;
+ const mouseY = event.clientY - rect.top;
+
+ const hit = findNodeAtPosition(mouseX, mouseY);
+ setHoveredNode(hit);
+
+ // Set cursor style
+ canvas.style.cursor = hit ? 'pointer' : 'default';
+
+ if (hit && tooltipRef.current) {
+ tooltipRef.current.style.opacity = '1';
+ tooltipRef.current.style.left = `${event.pageX + 10}px`;
+ tooltipRef.current.style.top = `${event.pageY + 10}px`;
+ tooltipRef.current.innerHTML = `
+ ${hit.data.name}
+
+ Total Clicks:
+ ${hit.data.clicks?.toLocaleString() || ''}
+
+
+ Unique Clicks:
+ ${hit.data.uniqueClicks?.toLocaleString() || ''}
+
+
+ Click Rate:
+ ${hit.data.clickRate ? hit.data.clickRate + '%' : ''}
+
+ `;
+ } else if (tooltipRef.current) {
+ tooltipRef.current.style.opacity = '0';
+ }
+ };
+
+ const handleClick = (event: MouseEvent) => {
+ const rect = canvas.getBoundingClientRect();
+ const mouseX = event.clientX - rect.left;
+ const mouseY = event.clientY - rect.top;
+
+ const hit = findNodeAtPosition(mouseX, mouseY);
+ if (hit && !hit.children && hit.data.slug) {
+ setIsNavigating(true);
+ setClickedArticle(hit.data.name);
+ window.gtag('event', 'article_click', {
+ event_category: hit.parent?.data.name || 'Uncategorized',
+ event_label: hit.data.name,
+ event_link: `/posts/${hit.data.slug}`,
+ transport_type: 'beacon',
+ });
+ router.push(`/posts/${hit.data.slug}`);
+ }
+ };
+
+ canvas.addEventListener('mousemove', handleMouseMove);
+ canvas.addEventListener('click', handleClick);
+ return () => {
+ canvas.removeEventListener('mousemove', handleMouseMove);
+ canvas.removeEventListener('click', handleClick);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Re-draw when hovered node changes
+ useEffect(() => {
+ draw();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hoveredNode]);
+
+ return (
+
+
setIsSidebarOpen(!isSidebarOpen)}
+ activeView={activeView}
+ onViewChange={onViewChange}
+ />
+
+
+ {/* Loading overlay */}
+ {isNavigating && (
+
+
+
+
+
Loading Article
+ {clickedArticle && (
+
+ {clickedArticle}
+
+ )}
+
+
+
+ )}
+
+ {/* SEO and accessibility block */}
+
+ {postsWithMetrics.map((post) => (
+
+ {post.title.replace(/\n\s*/g, ' ')}
+ {post.shortDescription ?? ''}
+ {post.meta?.description ?? ''}
+
+ {(
+ post.content?.root?.children?.[0]?.children?.[0] as {
+ text: string;
+ }
+ )?.text ?? ''}
+
+
+ {post.category_titles?.map((category) => (
+ {category}
+ ))}
+
+ {post.slug && Read more }
+
+ ))}
+
+
+ {/* The interactive treemap */}
+
+
+
+ );
+};
diff --git a/src/components/ArticleAnalyser/CardMobileView.tsx b/src/components/ArticleAnalyser/CardMobileView.tsx
new file mode 100644
index 0000000..f9d67f2
--- /dev/null
+++ b/src/components/ArticleAnalyser/CardMobileView.tsx
@@ -0,0 +1,128 @@
+import React, { FC } from 'react';
+import Link from 'next/link';
+import Image from 'next/image';
+import { Post } from '@/payload-types';
+import { CategoryPill } from '@/components/CateoryPill';
+import { SearchInput } from './SearchInput';
+import { useCategories, useArticleNavigation } from './hooks';
+import { getImageUrl, getMockStats } from './utils';
+import { useFilterStore, useFilteredPosts } from '@/stores/useFilterStore';
+
+interface CardMobileViewProps {
+ posts: Post[];
+}
+
+export const CardMobileView: FC = ({ posts }) => {
+ // Use Zustand store for filter state and filtered posts
+ const { selectedCategories, searchQuery, toggleCategory, setSearchQuery } = useFilterStore();
+
+ const mockStats = getMockStats();
+ const categories = useCategories(posts);
+ const filteredPosts = useFilteredPosts(posts);
+ const { loadingSlug, handleArticleClick } = useArticleNavigation();
+
+ return (
+
+ {/* Stats Ticker */}
+
+
+
+ GRID OUTPUT:
+ {mockStats.gridOutput}
+
+
+
+ AQI:
+
+ {mockStats.aqi} ({mockStats.aqiStatus})
+
+
+
+
+ NEW NODES:
+ {mockStats.newNodes}
+
+
+
+ {/* Search Bar */}
+
+
+
+
+ {/* Category Filter Tags */}
+
+
+ {categories.map((category) => (
+
+ ))}
+
+
+
+
+ {filteredPosts.map((post) => {
+ const imageUrl = getImageUrl(post);
+ return (
+
handleArticleClick(post, e)}
+ className="group relative overflow-hidden rounded-xl bg-neutral-850 no-underline transition-all hover:bg-neutral-800"
+ >
+ {loadingSlug === post.slug && (
+
+ )}
+
+ {/* Card Image */}
+
+
+
+
+ {/* Category Badges */}
+ {post.category_titles && post.category_titles.length > 0 && (
+
+ {post.category_titles.map((category) => (
+
+ ))}
+
+ )}
+
+
+ {/* Card Content */}
+
+ {/* Title */}
+
+ {post.title.replace(/\n\s*/g, ' ')}
+
+
+ {/* Description */}
+ {post.shortDescription && (
+
{post.shortDescription}
+ )}
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/components/ArticleAnalyser/DesktopGridView.tsx b/src/components/ArticleAnalyser/DesktopGridView.tsx
new file mode 100644
index 0000000..66e44a2
--- /dev/null
+++ b/src/components/ArticleAnalyser/DesktopGridView.tsx
@@ -0,0 +1,127 @@
+import React, { FC, useState } from 'react';
+import Link from 'next/link';
+import Image from 'next/image';
+import { Post } from '@/payload-types';
+import { CategoryPill } from '@/components/CateoryPill';
+import { useArticleNavigation } from './hooks';
+import { getImageUrl } from './utils';
+import { ViewType } from './types';
+import { useFilterStore, useFilteredPosts } from '@/stores/useFilterStore';
+import { FilterSidebar } from './FilterSidebar';
+import { Hero } from '@/components/Header';
+import { cn } from '@/utilities/ui';
+
+interface DesktopGridViewProps {
+ posts: Post[];
+ activeView?: ViewType;
+ onViewChange?: (view: ViewType) => void;
+}
+
+export const DesktopGridView: FC = ({ posts, activeView, onViewChange }) => {
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true);
+
+ // Use Zustand store for all filter state and filtered posts
+ const { selectedCategories, toggleCategory } = useFilterStore();
+ const filteredPosts = useFilteredPosts(posts);
+ const { loadingSlug, handleArticleClick } = useArticleNavigation();
+
+ return (
+
+
setIsSidebarOpen(!isSidebarOpen)}
+ activeView={activeView}
+ onViewChange={onViewChange}
+ />
+
+ {/* Main Content Area - Add left margin when sidebar is open */}
+
+ {/* Hero Section */}
+
console.log('New idea essay clicked')} />
+
+ {/* Category Filter Pills - show selected categories */}
+ {selectedCategories.length > 0 && (
+
+
+ {selectedCategories.map((category) => (
+
+ ))}
+
+
+ )}
+
+ {/* Article Grid */}
+
+
+ {filteredPosts.map((post) => {
+ const imageUrl = getImageUrl(post);
+ return (
+
handleArticleClick(post, e)}
+ className="group relative flex flex-col overflow-hidden rounded-xl bg-neutral-850 no-underline transition-all hover:bg-neutral-800 hover:shadow-xl"
+ >
+ {loadingSlug === post.slug && (
+
+ )}
+
+ {/* Card Image */}
+
+
+
+
+ {/* Category Badges */}
+ {post.category_titles && post.category_titles.length > 0 && (
+
+ {post.category_titles.map((category) => (
+
+ ))}
+
+ )}
+
+
+ {/* Card Content */}
+
+ {/* Title */}
+
+ {post.title.replace(/\n\s*/g, ' ')}
+
+
+ {/* Description */}
+ {post.shortDescription && (
+
+ {post.shortDescription}
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ );
+};
diff --git a/src/components/ArticleAnalyser/FilterSidebar.tsx b/src/components/ArticleAnalyser/FilterSidebar.tsx
new file mode 100644
index 0000000..46f1fb6
--- /dev/null
+++ b/src/components/ArticleAnalyser/FilterSidebar.tsx
@@ -0,0 +1,190 @@
+import React, { FC } from 'react';
+import { cn } from '@/utilities/ui';
+import { Post } from '@/payload-types';
+import { ViewToggle } from './ViewToggle';
+import { ViewType } from './types';
+import { SliderFilter } from '@/components/SliderFilter';
+import { SearchInput } from './SearchInput';
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/base/accordion';
+import { Checkbox } from '@/base/checkbox';
+import { Label } from '@/base/label';
+import { useFilterStore, TimeFilter } from '@/stores/useFilterStore';
+import { useZoomStore } from '@/stores/useZoomStore';
+import { useCategories } from './hooks';
+
+interface FilterSidebarProps {
+ posts: Post[];
+ isOpen: boolean;
+ onToggle: () => void;
+ activeView?: ViewType;
+ onViewChange?: (view: ViewType) => void;
+}
+
+export const FilterSidebar: FC = ({
+ posts,
+ isOpen,
+ onToggle,
+ activeView,
+ onViewChange,
+}) => {
+ const {
+ selectedCategories,
+ timeFilter,
+ searchQuery,
+ contentBalance,
+ toggleCategory,
+ setTimeFilter,
+ setSearchQuery,
+ setContentBalance,
+ } = useFilterStore();
+ const { zoomLevel, setZoomLevel } = useZoomStore();
+ const categories = useCategories(posts);
+
+ return (
+ <>
+ {/* Collapsible Sidebar - Fixed position */}
+
+ {isOpen && (
+
+
+
+
+
+ {/* View Toggle */}
+ {activeView && onViewChange && (
+
+
+ View Mode
+
+
+
+ )}
+
+ {/* Slider Filter */}
+
+ {activeView === 'wba' ? (
+ <>
+
+ Zoom Level
+
+ setZoomLevel(value[0])}
+ />
+ >
+ ) : (
+ <>
+
+ Content Balance
+
+ setContentBalance(value[0])}
+ />
+ >
+ )}
+
+
+ {/* Time Filter */}
+
+
+ Time Period
+
+
+ {[
+ { value: 'all', label: 'All' },
+ { value: 'today', label: 'Today' },
+ { value: 'week', label: 'This Week' },
+ { value: 'month', label: 'This Month' },
+ ].map((option) => (
+
+ setTimeFilter(e.target.value as TimeFilter)}
+ className="h-4 w-4 border-neutral-700 bg-neutral-900 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
+ />
+ {option.label}
+
+ ))}
+
+
+
+ {/* Category Filter Accordion */}
+
+
+
+
+ Categories ({selectedCategories.length} selected)
+
+
+
+ {categories.map((category) => (
+
+ toggleCategory(category)}
+ className="border-neutral-700 bg-neutral-900 data-[state=checked]:border-cyan-500 data-[state=checked]:bg-cyan-500"
+ />
+
+ {category}
+
+
+ ))}
+
+
+
+
+
+
+ )}
+
+
+ {/* Toggle Sidebar Button */}
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/ArticleAnalyser/SearchInput.tsx b/src/components/ArticleAnalyser/SearchInput.tsx
new file mode 100644
index 0000000..a43ffcd
--- /dev/null
+++ b/src/components/ArticleAnalyser/SearchInput.tsx
@@ -0,0 +1,101 @@
+import React, { FC } from 'react';
+import { SearchVariant } from './types';
+
+const searchVariantStyles: Record<
+ SearchVariant,
+ { container: string; icon: string; input: string; filterBtn: string }
+> = {
+ light: {
+ container: 'relative',
+ icon: 'h-5 w-5 text-neutral-400',
+ input:
+ 'w-full rounded-lg border-2 border-neutral-300 bg-neutral-white py-2.5 pl-10 pr-4 text-base text-neutral-900 transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500 placeholder:text-neutral-400',
+ filterBtn: 'hidden',
+ },
+ dark: {
+ container: 'flex gap-3 rounded-xl px-4 py-3 bg-border bg-input',
+ icon: 'h-5 w-5 text-neutral-500',
+ input:
+ 'flex-1 bg-transparent text-sm text-neutral-white placeholder:text-neutral-300 focus:outline-none',
+ filterBtn: 'rounded-lg bg-[#2d333b] p-2 hover:bg-[#3d434b] transition-colors',
+ },
+};
+
+interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ variant?: SearchVariant;
+ placeholder?: string;
+}
+
+export const SearchInput: FC = ({
+ value,
+ onChange,
+ variant = 'light',
+ placeholder = 'Search articles by title...',
+}) => {
+ const styles = searchVariantStyles[variant];
+
+ if (variant === 'dark') {
+ return (
+
+
+
+
+
onChange(e.target.value)}
+ className={styles.input}
+ aria-label={placeholder}
+ />
+
+ );
+ }
+
+ // Light variant (original style)
+ return (
+
+
+ Search articles
+
+
+
onChange(e.target.value)}
+ className={styles.input}
+ aria-label={placeholder}
+ />
+
+ );
+};
diff --git a/src/components/ArticleAnalyser/ViewToggle.tsx b/src/components/ArticleAnalyser/ViewToggle.tsx
new file mode 100644
index 0000000..77a4603
--- /dev/null
+++ b/src/components/ArticleAnalyser/ViewToggle.tsx
@@ -0,0 +1,68 @@
+import React, { FC } from 'react';
+import { cn } from '@/utilities/ui';
+import { ViewType } from './types';
+
+interface ViewToggleProps {
+ activeView: ViewType;
+ onViewChange: (view: ViewType) => void;
+}
+
+export const ViewToggle: FC = ({ activeView, onViewChange }) => {
+ return (
+
+
onViewChange('default')}
+ className={cn(
+ 'flex h-8 items-center justify-center gap-1 rounded-md px-3 transition-all',
+ activeView === 'default' ? 'bg-neutral-700' : 'bg-transparent hover:bg-neutral-700'
+ )}
+ aria-label="Default view"
+ >
+
+
+
onViewChange('wba')}
+ className={cn(
+ 'flex h-8 items-center justify-center gap-1 rounded-md px-3 transition-all',
+ activeView === 'wba' ? 'bg-neutral-700' : 'bg-transparent hover:bg-neutral-700'
+ )}
+ aria-label="WBA view"
+ >
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/ArticleAnalyser/WBAMobileView.tsx b/src/components/ArticleAnalyser/WBAMobileView.tsx
new file mode 100644
index 0000000..13d5fd2
--- /dev/null
+++ b/src/components/ArticleAnalyser/WBAMobileView.tsx
@@ -0,0 +1,95 @@
+import React, { FC, useMemo } from 'react';
+import Link from 'next/link';
+import { Post } from '@/payload-types';
+import { PostWithMetrics } from './types';
+import { getShade, getCategoryColor as getColor, enrichPostsWithMetrics } from './utils';
+import { useArticleNavigation } from './hooks';
+
+interface WBAMobileViewProps {
+ posts: Post[];
+}
+
+export const WBAMobileView: FC = ({ posts }) => {
+ const { loadingSlug, handleArticleClick } = useArticleNavigation();
+
+ const postsWithMetrics = useMemo(() => enrichPostsWithMetrics(posts), [posts]);
+
+ // Group posts by category and sort by clicks
+ const categorizedPosts = useMemo(() => {
+ const categorized: Record = {};
+ postsWithMetrics.forEach((post) => {
+ const category =
+ post.category_titles && post.category_titles.length > 0
+ ? post.category_titles[0]
+ : 'Uncategorized';
+ if (!categorized[category]) categorized[category] = [];
+ categorized[category].push(post);
+ });
+ // Sort articles within each category by clicks (descending)
+ Object.keys(categorized).forEach((category) => {
+ categorized[category].sort((a, b) => b.clicks - a.clicks);
+ });
+ return categorized;
+ }, [postsWithMetrics]);
+
+ const allCategories = useMemo(() => Object.keys(categorizedPosts), [categorizedPosts]);
+
+ const getCategoryColor = (category: string) => getColor(category, allCategories);
+
+ // Calculate max clicks per category for color gradient
+ const categoryMaxClicks = useMemo(() => {
+ const maxClicks: Record = {};
+ Object.entries(categorizedPosts).forEach(([category, posts]) => {
+ maxClicks[category] = Math.max(...posts.map((p) => p.clicks));
+ });
+ return maxClicks;
+ }, [categorizedPosts]);
+
+ // Get shade color for article based on clicks
+ const getArticleColor = (category: string, clicks: number) => {
+ const baseColor = getCategoryColor(category);
+ const maxClicks = categoryMaxClicks[category] || 1;
+ return getShade(baseColor, clicks, maxClicks);
+ };
+
+ return (
+
+ {Object.entries(categorizedPosts).map(([category, categoryPosts]) => (
+
+ {/* Category Header */}
+
+ {category} ({categoryPosts.length})
+
+
+ {/* Articles in this category */}
+
+ {categoryPosts.map((post) => (
+
handleArticleClick(post, e)}
+ >
+ {loadingSlug === post.slug && (
+
+ )}
+
+ {post.title.replace(/\n\s*/g, ' ')}
+
+
+ {post.clicks?.toLocaleString()} clicks
+
+
+ ))}
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/ArticleAnalyser/hooks.ts b/src/components/ArticleAnalyser/hooks.ts
new file mode 100644
index 0000000..386e8a7
--- /dev/null
+++ b/src/components/ArticleAnalyser/hooks.ts
@@ -0,0 +1,42 @@
+import { useState, useMemo } from 'react';
+import { useRouter } from 'next/navigation';
+import { Post } from '@/payload-types';
+
+/**
+ * Hook for getting unique categories from posts
+ */
+export const useCategories = (posts: Post[]) => {
+ return useMemo(() => {
+ const cats = new Set();
+ posts.forEach((post) => {
+ if (post.category_titles && post.category_titles.length > 0) {
+ post.category_titles.forEach((cat) => cats.add(cat));
+ }
+ });
+ return Array.from(cats).sort();
+ }, [posts]);
+};
+
+/**
+ * Hook for handling article navigation with loading state
+ */
+export const useArticleNavigation = () => {
+ const router = useRouter();
+ const [loadingSlug, setLoadingSlug] = useState(null);
+
+ const handleArticleClick = (post: Post, e: React.MouseEvent) => {
+ if (post.slug) {
+ e.preventDefault();
+ setLoadingSlug(post.slug);
+ window.gtag?.('event', 'article_click', {
+ event_category: post.category_titles?.[0] || 'Uncategorized',
+ event_label: post.title,
+ event_link: `/posts/${post.slug}`,
+ transport_type: 'beacon',
+ });
+ router.push(`/posts/${post.slug}`);
+ }
+ };
+
+ return { loadingSlug, handleArticleClick };
+};
diff --git a/src/components/ArticleAnalyser/index.tsx b/src/components/ArticleAnalyser/index.tsx
index df59070..61e330e 100644
--- a/src/components/ArticleAnalyser/index.tsx
+++ b/src/components/ArticleAnalyser/index.tsx
@@ -1,1486 +1,60 @@
'use client';
-import React, { useState, useEffect, useMemo, useRef, FC } from 'react';
-import * as d3 from 'd3';
-import Image from 'next/image';
-import Link from 'next/link';
-import { useRouter } from 'next/navigation';
-
-import { Post, Media as MediaType } from '@/payload-types';
-import { PostWithMetrics, HierarchyNode, SizeMetric } from './types';
-import { useCanvasStore } from '@/store/useCanvasStore';
-import { cn } from '@/utilities/ui';
-// Media component available if needed for future use
-import { getClientSideURL } from '@/utilities/getURL';
-import { CategoryPill } from '../CateoryPill';
-
-const headerHeight = 60;
-
-type MobileViewType = 'default' | 'wba';
-const TARGET_NODE_COUNT = 40; // Target number of articles to display at any zoom level
-
-// Simple seeded random number generator for consistent SSR/client results
-const seededRandom = (seed: number) => {
- const x = Math.sin(seed) * 10000;
- return x - Math.floor(x);
-};
-
-/**
- * A helper function that returns a shade of the base color.
- * The variation controls how much we brighten/darken.
- */
-function getShade(baseColor: string, value: number, max: number, variation: number = 2): string {
- const t = max > 0 ? value / max : 0;
- const brighter = d3.color(baseColor)?.brighter(variation).formatHex() || baseColor;
- const darker = d3.color(baseColor)?.darker(variation).formatHex() || baseColor;
- return d3.interpolateLab(brighter, darker)(t);
+import React, { FC } from 'react';
+import { Post } from '@/payload-types';
+import { DesktopGridView } from './DesktopGridView';
+import { CardMobileView } from './CardMobileView';
+import { WBAMobileView } from './WBAMobileView';
+import { ArticleTreeMap } from './ArticleTreeMap';
+import { ViewToggle } from './ViewToggle';
+import { Header } from '@/components/Header';
+import { useViewStore } from '@/stores/useViewStore';
+
+interface ArticleAnalyserProps {
+ posts: Post[];
}
-/*––––––––––––––––––––––––––––––––––––––
- SEARCH INPUT COMPONENT
-–––––––––––––––––––––––––––––––––––––––––*/
-
-/**
- * A simple search input that accepts text and calls onChange.
- * Supports 'light' and 'dark' variants for different themes.
- */
-type SearchVariant = 'light' | 'dark';
-
-const searchVariantStyles: Record<
- SearchVariant,
- { container: string; icon: string; input: string; filterBtn: string }
-> = {
- light: {
- container: 'relative',
- icon: 'h-5 w-5 text-neutral-400',
- input:
- 'w-full rounded-lg border-2 border-neutral-300 bg-neutral-white py-2.5 pl-10 pr-4 text-base text-neutral-900 transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500 placeholder:text-neutral-400',
- filterBtn: 'hidden',
- },
- dark: {
- container: 'flex items-center gap-3 rounded-xl px-4 py-3 bg-border bg-input',
- icon: 'h-5 w-5 text-neutral-500',
- input:
- 'flex-1 bg-transparent text-sm text-neutral-white placeholder:text-neutral-300 focus:outline-none',
- filterBtn: 'rounded-lg bg-[#2d333b] p-2 hover:bg-[#3d434b] transition-colors',
- },
-};
-
-const SearchInput: FC<{
- value: string;
- onChange: (value: string) => void;
- variant?: SearchVariant;
- placeholder?: string;
-}> = ({ value, onChange, variant = 'light', placeholder = 'Search articles by title...' }) => {
- const styles = searchVariantStyles[variant];
-
- if (variant === 'dark') {
- return (
-
-
-
-
-
onChange(e.target.value)}
- className={styles.input}
- aria-label={placeholder}
- />
- {/*
-
-
-
- */}
-
- );
- }
-
- // Light variant (original style)
- return (
-
-
- Search articles
-
-
-
onChange(e.target.value)}
- className={styles.input}
- aria-label={placeholder}
- />
-
- );
-};
-
-/*––––––––––––––––––––––––––––––––––––––
- VIEW TOGGLE COMPONENT
-–––––––––––––––––––––––––––––––––––––––––*/
-
-const ViewToggle: FC<{
- activeView: MobileViewType;
- onViewChange: (view: MobileViewType) => void;
-}> = ({ activeView, onViewChange }) => {
- return (
-
-
onViewChange('default')}
- className={cn(
- 'flex h-8 items-center justify-center gap-1 rounded-md px-3 transition-all',
- activeView === 'default' ? 'bg-neutral-700' : 'bg-transparent hover:bg-neutral-700'
- )}
- aria-label="Default view"
- >
-
-
-
onViewChange('wba')}
- className={cn(
- 'flex h-8 items-center justify-center gap-1 rounded-md px-3 transition-all',
- activeView === 'wba' ? 'bg-neutral-700' : 'bg-transparent hover:bg-neutral-700'
- )}
- aria-label="WBA view"
- >
-
-
-
-
-
-
-
-
- );
-};
-
-/*––––––––––––––––––––––––––––––––––––––
- NEW CARD-BASED MOBILE VIEW (Default)
-–––––––––––––––––––––––––––––––––––––––––*/
-
-const CardMobileView: FC<{ posts: Post[] }> = ({ posts }) => {
- const router = useRouter();
- const [loadingSlug, setLoadingSlug] = useState(null);
- const [selectedCategory, setSelectedCategory] = useState('All Feeds');
- const [searchQuery, setSearchQuery] = useState('');
-
- // Mock data for stats ticker
- const mockStats = {
- gridOutput: '+12%',
- aqi: 42,
- aqiStatus: 'GOOD',
- newNodes: 8,
- };
-
- // Get unique categories from posts
- const categories = useMemo(() => {
- const cats = new Set();
- posts.forEach((post) => {
- if (post.category_titles && post.category_titles.length > 0) {
- post.category_titles.forEach((cat) => cats.add(cat));
- }
- });
- return ['All Feeds', ...Array.from(cats)];
- }, [posts]);
-
- // Filter posts by category and search query
- const filteredPosts = useMemo(() => {
- let result = posts;
-
- // Filter by category
- if (selectedCategory !== 'All Feeds') {
- result = result.filter(
- (post) => post.category_titles && post.category_titles.includes(selectedCategory)
- );
- }
-
- // Filter by search query
- if (searchQuery.trim()) {
- const query = searchQuery.toLowerCase();
- result = result.filter(
- (post) =>
- post.title.toLowerCase().includes(query) ||
- post.shortDescription?.toLowerCase().includes(query)
- );
- }
-
- return result;
- }, [posts, selectedCategory, searchQuery]);
-
- const handleArticleClick = (post: Post, e: React.MouseEvent) => {
- if (post.slug) {
- e.preventDefault();
- setLoadingSlug(post.slug);
- window.gtag?.('event', 'article_click', {
- event_category: post.category_titles?.[0] || 'Uncategorized',
- event_label: post.title,
- event_link: `/posts/${post.slug}`,
- transport_type: 'beacon',
- });
- router.push(`/posts/${post.slug}`);
- }
- };
+export const ArticleAnalyser: FC = ({ posts }) => {
+ // Use persisted view store
+ const { view, setView } = useViewStore();
- const getImageUrl = (post: Post): string | null => {
- if (post.heroImage && typeof post.heroImage === 'object') {
- const media = post.heroImage as MediaType;
- if (media.url) {
- return `${getClientSideURL()}${media.url}`;
- }
- }
- // Use placeholder if no hero image
- return '/images/future1.webp';
- };
+ // Note: Filtering is now handled by individual view components using useFilterStore
+ // This keeps the code DRY and centralized in the Zustand store
return (
- {/* Stats Ticker */}
-
-
-
- GRID OUTPUT:
- {mockStats.gridOutput}
-
-
-
- AQI:
-
- {mockStats.aqi} ({mockStats.aqiStatus})
-
-
-
-
- NEW NODES:
- {mockStats.newNodes}
-
-
-
- {/* Search Bar */}
-
-
-
+ {/* Header - Fixed at top */}
+
- {/* Hero Section - Global Sentiment Heatmap */}
- {/*
-
-
-
-
-
-
- Live Data
-
-
-
-
-
-
-
-
+ {/* Content with padding for fixed header */}
+
+
+
-
-
Global Sentiment Heatmap
-
- Visualizing ecological restoration progress...
-
-
+ {/* Mobile view with toggle - shown on screens < 768px */}
+
+ {view === 'default' ? : }
-
*/}
- {/* Category Filter Tags - Fixed visibility */}
-
-
- {categories.map((category) => (
-
- ))}
+ {/* Desktop view with toggle - shown on screens >= 768px */}
+
+ {view === 'default' ? (
+
+ ) : (
+
+ )}
-
- {/* Latest Transmissions Header */}
-
-
Latest Transmissions
-
-
- {/* Article Cards */}
-
- {filteredPosts.map((post) => {
- const imageUrl = getImageUrl(post);
- return (
-
handleArticleClick(post, e)}
- className="group relative overflow-hidden rounded-xl bg-neutral-850 no-underline transition-all hover:bg-neutral-800"
- >
- {loadingSlug === post.slug && (
-
- )}
-
- {/* Card Image */}
-
-
-
-
- {/* Category Badges - positioned 10px from bottom of image */}
- {post.category_titles && post.category_titles.length > 0 && (
-
- {post.category_titles.map((category) => (
-
- ))}
-
- )}
-
-
- {/* Card Content */}
-
- {/* Title */}
-
- {post.title.replace(/\n\s*/g, ' ')}
-
-
- {/* Description */}
- {post.shortDescription && (
-
{post.shortDescription}
- )}
-
-
- );
- })}
-
-
- );
-};
-
-/*––––––––––––––––––––––––––––––––––––––
- ARTICLE ANALYZER – TREEMAP (Snippet 2)
-–––––––––––––––––––––––––––––––––––––––––*/
-
-export const ArticleTreeMap: FC<{ posts: Post[] }> = ({ posts }) => {
- const router = useRouter();
- const canvasRef = useRef
(null);
- const tooltipRef = useRef(null);
- const [sizeMetric] = useState('clicks');
- const [postsWithMetrics, setPostsWithMetrics] = useState([]);
- const [isNavigating, setIsNavigating] = useState(false);
- const [clickedArticle, setClickedArticle] = useState(null);
-
- // Store the current zoom state
- const [zoomState, setZoomState] = useState({
- scale: 1,
- centerX: 0,
- centerY: 0,
- visibleRegion: { x: 0, y: 0, width: 0, height: 0 },
- });
-
- // Cache the treemap nodes after layout computation
- const treemapNodesRef = useRef[]>([]);
- const allNodesRef = useRef[]>([]);
-
- // For hover interactions
- const [hoveredNode, setHoveredNode] = useState | null>(
- null
- );
- const { dimensions, setDimensions } = useCanvasStore();
- const [isTransitioning, setIsTransitioning] = useState(false);
-
- // Track the full hierarchy data
- const hierarchyRootRef = useRef | null>(null);
-
- // Set up canvas dimensions
- useEffect(() => {
- const handleResize = () => {
- setDimensions({
- width: window.innerWidth,
- height: window.innerHeight - headerHeight,
- });
- };
- handleResize();
- window.addEventListener('resize', handleResize);
- return () => window.removeEventListener('resize', handleResize);
- }, [setDimensions]);
-
- // Enrich posts with deterministic metrics (consistent across SSR and client)
- useEffect(() => {
- const enriched = posts.map((post) => {
- // Use post ID as seed for consistent random values
- const seed = typeof post.id === 'number' ? post.id : parseInt(String(post.id), 10) || 0;
- const clicks = Math.floor(seededRandom(seed) * 2000) + 100;
- const uniqueClicks = Math.floor(clicks * (0.7 + seededRandom(seed + 1) * 0.2));
- const clickRate = +((uniqueClicks / clicks) * 10 + seededRandom(seed + 2) * 2).toFixed(1);
- return { ...post, clicks, uniqueClicks, clickRate };
- });
- setPostsWithMetrics(enriched);
- }, [posts]);
-
- // Build hierarchical data for the treemap
- const hierarchyData: HierarchyNode = useMemo(() => {
- const categorized: Record = {};
- postsWithMetrics.forEach((post) => {
- const category =
- post.category_titles && post.category_titles.length > 0
- ? post.category_titles[0]
- : 'Uncategorized';
- if (!categorized[category]) categorized[category] = [];
- categorized[category].push(post);
- });
- return {
- name: 'Articles',
- children: Object.keys(categorized).map((category) => ({
- name: category,
- children: categorized[category].map((post) => ({
- name: post.title.replace(/\n\s*/g, ' '),
- clicks: post.clicks,
- uniqueClicks: post.uniqueClicks,
- clickRate: post.clickRate,
- slug: post.slug,
- id: post.id,
- })),
- })),
- };
- }, [postsWithMetrics]);
-
- // Limit articles to target density
- const limitArticlesToTargetDensity = (
- nodes: d3.HierarchyRectangularNode[],
- targetCount: number,
- zoomScale: number
- ) => {
- // If we have fewer nodes than target, return all of them
- if (nodes.length <= targetCount) return nodes;
-
- // Filter for leaf nodes (articles) and category nodes
- const categoryNodes = nodes.filter((n) => n.depth === 1);
- const leafNodes = nodes.filter((n) => !n.children && n.depth > 1);
-
- if (leafNodes.length <= targetCount) {
- // If we have fewer leaf nodes than target, return all categories and leaves
- return [...categoryNodes, ...leafNodes];
- }
-
- // When highly zoomed in, prioritize visible articles with higher values
- if (zoomScale > 2) {
- const sortedLeaves = [...leafNodes].sort((a, b) => (b.value || 0) - (a.value || 0));
- return [...categoryNodes, ...sortedLeaves.slice(0, targetCount - categoryNodes.length)];
- }
-
- // When moderately zoomed, group smaller articles by their parent category
- const topLeaves = leafNodes
- .sort((a, b) => (b.value || 0) - (a.value || 0))
- .slice(0, Math.floor(targetCount * 0.7));
-
- // The rest can be represented as aggregated nodes
- const remainingLeaves = leafNodes
- .sort((a, b) => (b.value || 0) - (a.value || 0))
- .slice(Math.floor(targetCount * 0.7));
-
- // Group by parent category
- const groupedByCat: Record[]> = {};
- remainingLeaves.forEach((node) => {
- const category = node.parent?.data.name || 'Uncategorized';
- if (!groupedByCat[category]) groupedByCat[category] = [];
- groupedByCat[category].push(node);
- });
-
- // Return the categories, top leaves, and grouped nodes
- return [...categoryNodes, ...topLeaves];
- };
-
- // Calculate which nodes to display based on visible region
- const getNodesInVisibleRegion = (zoomState: {
- scale: number;
- centerX: number;
- centerY: number;
- visibleRegion: { x: number; y: number; width: number; height: number };
- }) => {
- if (!allNodesRef.current.length) return [];
-
- const { x, y, width, height } = zoomState.visibleRegion;
-
- // Find nodes that intersect with the visible region
- const visibleNodes = allNodesRef.current.filter((node) => {
- return node.x0 < x + width && node.x1 > x && node.y0 < y + height && node.y1 > y;
- });
-
- // Limit the number of nodes to a reasonable density
- return limitArticlesToTargetDensity(visibleNodes, TARGET_NODE_COUNT, zoomState.scale);
- };
-
- // Compute treemap layout for visible nodes
- const computeVisibleTreemap = (visibleNodes: d3.HierarchyRectangularNode[]) => {
- if (!visibleNodes.length) return;
-
- // Get category nodes and leaf nodes
- const categoryNodes = visibleNodes.filter((n) => n.depth === 1);
- const leafNodes = visibleNodes.filter((n) => !n.children && n.depth > 1);
-
- // Create a new temporary hierarchy for layout
- const tempHierarchy: HierarchyNode = {
- name: 'Visible Articles',
- children: categoryNodes
- .map((catNode) => ({
- name: catNode.data.name,
- children: leafNodes
- .filter((leaf) => leaf.parent?.data.name === catNode.data.name)
- .map((leaf) => leaf.data),
- }))
- .filter((cat) => cat.children && cat.children.length > 0),
- };
-
- // Create a new hierarchy and compute the treemap
- const root = d3
- .hierarchy(tempHierarchy)
- .sum((d) => (!d.children ? d[sizeMetric] || 0 : 0))
- .sort((a, b) => (b.value || 0) - (a.value || 0));
-
- const treemapLayout = d3
- .treemap()
- .size([dimensions.width, dimensions.height])
- .paddingOuter(3)
- .paddingTop(22)
- .paddingInner(1)
- .round(true);
-
- treemapLayout(root);
-
- // Store the new layout
- treemapNodesRef.current = root.descendants() as d3.HierarchyRectangularNode[];
- };
-
- // Initial computation of the full treemap
- useEffect(() => {
- if (!hierarchyData.children || hierarchyData.children.length === 0) return;
-
- // Create the full hierarchy
- const root = d3
- .hierarchy(hierarchyData)
- .sum((d) => (!d.children ? d[sizeMetric] || 0 : 0))
- .sort((a, b) => (b.value || 0) - (a.value || 0));
-
- hierarchyRootRef.current = root;
-
- // Compute the initial treemap for all nodes
- const treemapLayout = d3
- .treemap()
- .size([dimensions.width, dimensions.height])
- .paddingOuter(3)
- .paddingTop(22)
- .paddingInner(1)
- .round(true);
-
- treemapLayout(root);
-
- // Store all nodes for reference
- allNodesRef.current = root.descendants() as d3.HierarchyRectangularNode[];
- treemapNodesRef.current = allNodesRef.current;
-
- // Update visible region based on dimensions
- setZoomState((prev) => ({
- ...prev,
- visibleRegion: {
- x: 0,
- y: 0,
- width: dimensions.width,
- height: dimensions.height,
- },
- }));
-
- // After computing the layout, draw the treemap
- draw();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [hierarchyData, sizeMetric, dimensions]);
-
- // When zoom state changes, recalculate visible nodes
- useEffect(() => {
- if (allNodesRef.current.length === 0) return;
-
- const visibleNodes = getNodesInVisibleRegion(zoomState);
-
- if (zoomState.scale > 1.2) {
- // When zoomed in enough, compute a new treemap layout for just the visible nodes
- computeVisibleTreemap(visibleNodes);
- } else {
- // When at default zoom, use the full treemap
- treemapNodesRef.current = allNodesRef.current;
- }
-
- draw();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [zoomState]);
-
- const renderConstants = useMemo(() => {
- if (!treemapNodesRef.current.length) {
- return null;
- }
- const palette = ['#6A0DAD', '#228B22', '#FF5733', '#3498DB', '#F1C40F', '#9B59B6'];
- const predefinedColors: Record = {
- AI: palette[0],
- Earth: palette[1],
- };
- const categoryNodes = treemapNodesRef.current.filter((d) => d.depth === 1);
- const allCategories = categoryNodes.map((d) => d.data.name);
- const otherCategories = allCategories.filter((cat) => !(cat in predefinedColors));
- const ordinal = d3
- .scaleOrdinal()
- .domain(otherCategories)
- .range(palette.slice(2));
- const categoryMax: Record = {};
- categoryNodes.forEach((catNode) => {
- let maxVal = 0;
- catNode.leaves().forEach((leaf) => {
- maxVal = Math.max(maxVal, leaf.data[sizeMetric] || 0);
- });
- categoryMax[catNode.data.name] = maxVal;
- });
- return { palette, predefinedColors, categoryNodes, ordinal, categoryMax };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [treemapNodesRef.current.length, sizeMetric]);
-
- // Optimize the canvas setup
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const dpr = window.devicePixelRatio || 1;
- canvas.width = dimensions.width * dpr;
- canvas.height = dimensions.height * dpr;
- canvas.style.width = `${dimensions.width}px`;
- canvas.style.height = `${dimensions.height}px`;
-
- const context = canvas.getContext('2d');
- if (context) {
- context.scale(dpr, dpr);
- }
- }, [dimensions]);
-
- // Draw the treemap
- const draw = () => {
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const context = canvas.getContext('2d');
- if (!context) return;
-
- context.save();
- context.clearRect(0, 0, canvas.width, canvas.height);
-
- const rc = renderConstants;
- if (!rc) {
- context.restore();
- return;
- }
- const { predefinedColors, ordinal, categoryMax } = rc;
-
- treemapNodesRef.current.forEach((d) => {
- const x = d.x0,
- y = d.y0,
- w = d.x1 - d.x0,
- h = d.y1 - d.y0;
- if (w <= 0 || h <= 0) return;
-
- // Use different fills based on node type
- if (d.children) {
- // Category node with a colored background
- const categoryName = d.data.name;
- const baseColor = predefinedColors[categoryName] || ordinal(categoryName) || '#333333';
- context.fillStyle = d3.color(baseColor)?.copy({ opacity: 0.7 })?.toString() || baseColor;
- } else {
- // Article node
- const cat = d.parent?.data.name || 'Uncategorized';
- const baseColor = predefinedColors[cat] || ordinal(cat);
- const maxVal = categoryMax[cat] || 1;
- context.fillStyle = getShade(baseColor, d.data[sizeMetric] || 0, maxVal);
- }
-
- context.strokeStyle = '#fff';
- context.lineWidth = 1;
- context.beginPath();
- context.rect(x, y, w, h);
- context.fill();
- context.stroke();
-
- if (hoveredNode === d) {
- context.save();
- const lighterFill =
- d3.color(context.fillStyle)?.brighter(0.8).formatHex() || context.fillStyle;
- context.fillStyle = lighterFill;
- context.fillRect(x, y, w, h);
- context.restore();
- }
-
- // Draw text based on available space
- if (d.children) {
- // Category nodes
- if (w > 50 && h > 25) {
- context.font = 'bold 14px "Rubik"';
- context.textBaseline = 'top';
- context.fillStyle = '#fff';
-
- let catText = d.data.name || '';
- while (context.measureText(catText).width > w - 10 && catText.length > 1) {
- catText = catText.slice(0, -1);
- }
- if (catText !== d.data.name) catText += '...';
- context.fillText(catText, x + 5, y + 5);
- }
- } else {
- // Article nodes - size-based rendering
- if (w > 80 && h > 45) {
- // Large articles - show title and value
- context.font = '12px "Rubik"';
- context.textBaseline = 'top';
- context.fillStyle = '#fff';
-
- let text = d.data.name || '';
- while (context.measureText(text).width > w - 10 && text.length > 1) {
- text = text.slice(0, -1);
- }
- if (text !== d.data.name) text += '...';
- context.fillText(text, x + 5, y + 5);
-
- context.font = '10px "Rubik"';
- const value = d.data[sizeMetric];
- const valueText =
- sizeMetric === 'clickRate' ? `${value}%` : value?.toLocaleString() || '';
- context.fillText(valueText, x + 5, y + 20);
- } else if (w > 40 && h > 25) {
- // Medium articles - just show title
- context.font = '11px "Rubik"';
- context.textBaseline = 'top';
- context.fillStyle = '#fff';
-
- let text = d.data.name || '';
- while (context.measureText(text).width > w - 8 && text.length > 1) {
- text = text.slice(0, -1);
- }
- if (text !== d.data.name) text += '...';
- context.fillText(text, x + 4, y + 4);
- } else if (w > 20 && h > 15) {
- // Small articles - just show a truncated title
- context.font = '9px "Rubik"';
- context.textBaseline = 'top';
- context.fillStyle = '#fff';
-
- let text = d.data.name?.substring(0, 5) || '';
- if (text.length < d.data.name?.length) text += '...';
- context.fillText(text, x + 3, y + 3);
- } else if (w > 10 && h > 10) {
- // Tiny articles - just show a dot
- context.fillStyle = '#fff';
- context.beginPath();
- context.arc(x + w / 2, y + h / 2, 2, 0, 2 * Math.PI);
- context.fill();
- }
- // For very tiny articles, just the colored rectangle is visible
- }
- });
-
- context.restore();
- };
-
- // Handle semantic zooming
- const handleZoom = (event: d3.D3ZoomEvent) => {
- if (isTransitioning) return;
-
- // Throttle zoom events to reduce frequency
- if (!throttleZoom()) return;
- setIsTransitioning(true);
-
- const canvas = canvasRef.current;
- if (!canvas) {
- setIsTransitioning(false);
- return;
- }
-
- // Get mouse position in canvas coordinates
- const [pointerX, pointerY] = d3.pointer(event, canvas);
-
- // Calculate the new scale and transform
- const newScale = event.transform.k;
-
- // Calculate visible region in the original treemap coordinates
- const visibleWidth = dimensions.width / newScale;
- const visibleHeight = dimensions.height / newScale;
-
- // Center on mouse position
- const visibleX = pointerX - visibleWidth / 2;
- const visibleY = pointerY - visibleHeight / 2;
-
- // Update zoom state
- setZoomState({
- scale: newScale,
- centerX: pointerX,
- centerY: pointerY,
- visibleRegion: {
- x: visibleX,
- y: visibleY,
- width: visibleWidth,
- height: visibleHeight,
- },
- });
-
- setTimeout(() => setIsTransitioning(false), 300);
- };
-
- const throttleZoom = (() => {
- let lastCall = 0;
- const threshold = 25; // ms between zoom updates
- return () => {
- const now = Date.now();
- if (now - lastCall < threshold) return false;
- lastCall = now;
- return true;
- };
- })();
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const zoom = d3
- .zoom()
- .scaleExtent([1, 8])
- .wheelDelta((event) => {
- // Reduce scroll sensitivity
- return -event.deltaY * 0.004;
- })
- .filter((event) => {
- // IMPORTANT: Allow pinch events by removing the filter on ctrlKey
- // This was blocking pinch gestures on touch devices
- return !(event.button && event.type === 'mousedown');
- })
- .interpolate(d3.interpolateZoom)
- .on('zoom', function (event) {
- // Round scale to nearest 0.25 increment
- const k = Math.round(event.transform.k * 4) / 4;
- const transform = d3.zoomIdentity.scale(k);
- handleZoom({ ...event, transform });
- });
-
- d3.select(canvas)
- .call(zoom)
- .on('dblclick.zoom', null)
- // Use touchstart with { passive: false } to prevent scrolling
- // when performing touch gestures
- .on(
- 'touchstart',
- function (event) {
- event.preventDefault();
- },
- { passive: false }
- );
-
- // Optional: Set initial zoom transform if needed
- // d3.select(canvas).call(zoom.transform, d3.zoomIdentity)
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [dimensions, isTransitioning]);
-
- // Find node at position
- const findNodeAtPosition = (x: number, y: number) => {
- for (const node of treemapNodesRef.current) {
- if (x >= node.x0 && x <= node.x1 && y >= node.y0 && y <= node.y1) {
- if (!node.children) {
- return node;
- }
- }
- }
- return null;
- };
-
- // Add mouse event listeners
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const handleMouseMove = (event: MouseEvent) => {
- const rect = canvas.getBoundingClientRect();
- const mouseX = event.clientX - rect.left;
- const mouseY = event.clientY - rect.top;
-
- const hit = findNodeAtPosition(mouseX, mouseY);
- setHoveredNode(hit);
-
- // Set cursor style
- canvas.style.cursor = hit ? 'pointer' : 'default';
-
- if (hit && tooltipRef.current) {
- tooltipRef.current.style.opacity = '1';
- tooltipRef.current.style.left = `${event.pageX + 10}px`;
- tooltipRef.current.style.top = `${event.pageY + 10}px`;
- tooltipRef.current.innerHTML = `
- ${hit.data.name}
-
- Total Clicks:
- ${hit.data.clicks?.toLocaleString() || ''}
-
-
- Unique Clicks:
- ${hit.data.uniqueClicks?.toLocaleString() || ''}
-
-
- Click Rate:
- ${hit.data.clickRate ? hit.data.clickRate + '%' : ''}
-
- `;
- } else if (tooltipRef.current) {
- tooltipRef.current.style.opacity = '0';
- }
- };
-
- const handleClick = (event: MouseEvent) => {
- const rect = canvas.getBoundingClientRect();
- const mouseX = event.clientX - rect.left;
- const mouseY = event.clientY - rect.top;
-
- const hit = findNodeAtPosition(mouseX, mouseY);
- if (hit && !hit.children && hit.data.slug) {
- setIsNavigating(true);
- setClickedArticle(hit.data.name);
- window.gtag('event', 'article_click', {
- event_category: hit.parent?.data.name || 'Uncategorized',
- event_label: hit.data.name,
- event_link: `/posts/${hit.data.slug}`,
- transport_type: 'beacon',
- });
- router.push(`/posts/${hit.data.slug}`);
- }
- };
-
- canvas.addEventListener('mousemove', handleMouseMove);
- canvas.addEventListener('click', handleClick);
- return () => {
- canvas.removeEventListener('mousemove', handleMouseMove);
- canvas.removeEventListener('click', handleClick);
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // Reset zoom
- const handleResetZoom = () => {
- if (isTransitioning) return;
-
- setIsTransitioning(true);
- setZoomState({
- scale: 1,
- centerX: dimensions.width / 2,
- centerY: dimensions.height / 2,
- visibleRegion: {
- x: 0,
- y: 0,
- width: dimensions.width,
- height: dimensions.height,
- },
- });
-
- // Reset to the full treemap
- treemapNodesRef.current = allNodesRef.current;
- draw();
-
- setTimeout(() => setIsTransitioning(false), 300);
- };
-
- // Re-draw when hovered node changes
- useEffect(() => {
- draw();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [hoveredNode]);
-
- return (
-
- {/* Loading overlay */}
- {isNavigating && (
-
-
-
-
-
Loading Article
- {clickedArticle && (
-
{clickedArticle}
- )}
-
-
-
- )}
-
- {/* SEO and accessibility block */}
-
- {postsWithMetrics.map((post) => (
-
- {post.title.replace(/\n\s*/g, ' ')}
- {post.shortDescription ?? ''}
- {post.meta?.description ?? ''}
-
- {(
- post.content?.root?.children?.[0]?.children?.[0] as {
- text: string;
- }
- )?.text ?? ''}
-
-
- {post.category_titles?.map((category) => (
- {category}
- ))}
-
- {post.slug && Read more }
-
- ))}
-
-
- {/* The interactive treemap */}
-
-
-
-
- {/* Zoom controls */}
-
-
- {/* Zoom level indicator */}
-
-
- Zoom: {Math.round(zoomState.scale * 100)}%
-
-
-
-
- );
-};
-
-/**
- * WBA (color-coded category list) mobile view for articles
- */
-const WBAMobileView: FC<{ posts: Post[] }> = ({ posts }) => {
- const router = useRouter();
- const [loadingSlug, setLoadingSlug] = useState(null);
-
- const postsWithMetrics = useMemo(() => {
- return posts.map((post) => {
- // Use post ID as seed for consistent random values across SSR and client
- const seed = typeof post.id === 'number' ? post.id : parseInt(String(post.id), 10) || 0;
- const clicks = Math.floor(seededRandom(seed) * 2000) + 100;
- const uniqueClicks = Math.floor(clicks * (0.7 + seededRandom(seed + 1) * 0.2));
- const clickRate = +((uniqueClicks / clicks) * 10 + seededRandom(seed + 2) * 2).toFixed(1);
- return { ...post, clicks, uniqueClicks, clickRate };
- });
- }, [posts]);
-
- // Group posts by category and sort by clicks
- const categorizedPosts = useMemo(() => {
- const categorized: Record = {};
- postsWithMetrics.forEach((post) => {
- const category =
- post.category_titles && post.category_titles.length > 0
- ? post.category_titles[0]
- : 'Uncategorized';
- if (!categorized[category]) categorized[category] = [];
- categorized[category].push(post);
- });
- // Sort articles within each category by clicks (descending)
- Object.keys(categorized).forEach((category) => {
- categorized[category].sort((a, b) => b.clicks - a.clicks);
- });
- return categorized;
- }, [postsWithMetrics]);
-
- const palette = ['#6A0DAD', '#228B22', '#FF5733', '#3498DB', '#F1C40F', '#9B59B6'];
- const predefinedColors: Record = {
- AI: palette[0],
- Earth: palette[1],
- };
-
- const getCategoryColor = (category: string) => {
- if (predefinedColors[category]) return predefinedColors[category];
- const index = Object.keys(categorizedPosts).indexOf(category) % palette.length;
- return palette[index];
- };
-
- // Calculate max clicks per category for color gradient
- const categoryMaxClicks = useMemo(() => {
- const maxClicks: Record = {};
- Object.entries(categorizedPosts).forEach(([category, posts]) => {
- maxClicks[category] = Math.max(...posts.map((p) => p.clicks));
- });
- return maxClicks;
- }, [categorizedPosts]);
-
- // Get shade color for article based on clicks
- const getArticleColor = (category: string, clicks: number) => {
- const baseColor = getCategoryColor(category);
- const maxClicks = categoryMaxClicks[category] || 1;
- return getShade(baseColor, clicks, maxClicks);
- };
-
- return (
-
- {Object.entries(categorizedPosts).map(([category, categoryPosts]) => (
-
- {/* Category Header */}
-
- {category} ({categoryPosts.length})
-
-
- {/* Articles in this category */}
-
- {categoryPosts.map((post) => (
-
{
- if (post.slug) {
- e.preventDefault();
- setLoadingSlug(post.slug);
- window.gtag('event', 'article_click', {
- event_category: category,
- event_label: post.title,
- event_link: `/posts/${post.slug}`,
- transport_type: 'beacon',
- });
- router.push(`/posts/${post.slug}`);
- }
- }}
- >
- {loadingSlug === post.slug && (
-
- )}
-
- {post.title.replace(/\n\s*/g, ' ')}
-
-
- {post.clicks?.toLocaleString()} clicks
-
-
- ))}
-
-
- ))}
-
- );
-};
-
-type TimeFilter = 'all' | 'today' | 'week' | 'month' | 'year';
-
-/**
- * This parent component integrates the search input with the treemap.
- * It filters the posts (by title in this example) and passes the filtered
- * posts to the ArticleAnalyser.
- */
-export const ArticleAnalyser: FC<{
- posts: Post[];
-}> = ({ posts }) => {
- const [searchString, _setSearchString] = useState('');
- const [timeFilter, _setTimeFilter] = useState('all');
- const [filteredPosts, setFilteredPosts] = useState(posts);
- const [isLoading, setIsLoading] = useState(false);
- const [noResults, setNoResults] = useState(false);
- const [mobileView, setMobileView] = useState('default');
-
- // Apply time filtering
- const timeFilteredPosts = useMemo(() => {
- if (timeFilter === 'all') return posts;
-
- const now = new Date();
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
-
- return posts.filter((post) => {
- if (!post.publishedAt) return false;
- const publishedDate = new Date(post.publishedAt);
-
- switch (timeFilter) {
- case 'today':
- return publishedDate >= today;
- case 'week': {
- const weekAgo = new Date(today);
- weekAgo.setDate(weekAgo.getDate() - 7);
- return publishedDate >= weekAgo;
- }
- case 'month': {
- const monthAgo = new Date(today);
- monthAgo.setMonth(monthAgo.getMonth() - 1);
- return publishedDate >= monthAgo;
- }
- case 'year': {
- const yearAgo = new Date(today);
- yearAgo.setFullYear(yearAgo.getFullYear() - 1);
- return publishedDate >= yearAgo;
- }
- default:
- return true;
- }
- });
- }, [posts, timeFilter]);
-
- useEffect(() => {
- if (!searchString) {
- setFilteredPosts(timeFilteredPosts);
- setNoResults(false);
- return;
- }
- const lowerQuery = searchString.toLowerCase();
- const filtered = timeFilteredPosts.filter((post) =>
- post.title.toLowerCase().includes(lowerQuery)
- );
- // If the search term is a complete word and there are no matches
- if (filtered.length === 0 && searchString.trim().includes(' ') === false) {
- setNoResults(true);
- setIsLoading(true);
-
- // Mock API call delay
- const timer = setTimeout(() => {
- setIsLoading(false);
- }, 3000); // 3 seconds loading time
-
- return () => clearTimeout(timer);
- } else {
- setNoResults(false);
- setIsLoading(false);
- setFilteredPosts(filtered);
- }
- }, [searchString, timeFilteredPosts]);
-
- return (
-
- {/* Search and filter container - row on desktop, stack on mobile */}
- {/*
-
-
-
-
-
-
-
-
- Time Period
-
-
- {(['all', 'today', 'week', 'month', 'year'] as const).map((filter) => (
-
- setTimeFilter(e.target.value as TimeFilter)}
- className="sr-only"
- aria-label={filter === 'all' ? 'All articles' : filter === 'today' ? 'Articles from today' : filter === 'week' ? 'Articles from this week' : filter === 'month' ? 'Articles from this month' : 'Articles from this year'}
- />
-
- {filter === 'all' ? 'All' : filter === 'today' ? 'Today' : filter === 'week' ? 'This Week' : filter === 'month' ? 'This Month' : 'This Year'}
-
-
- ))}
-
-
-
-
-
- Showing {filteredPosts.length} {filteredPosts.length === 1 ? 'article' : 'articles'}
-
-
-
*/}
-
- {isLoading ? (
-
-
-
Searching with AI...
-
-
-
-
-
- ) : noResults ? (
-
-
-
- No articles found for "{searchString}"
-
-
-
- ) : (
- <>
- {/* Mobile view with toggle - shown on screens < 768px */}
-
- {/* Toggle positioned at top right */}
-
-
-
-
- {/* Render mobile view based on selection */}
- {mobileView === 'default' ? (
-
- ) : (
-
- )}
-
-
- {/* Desktop treemap view - shown on screens >= 768px */}
-
- >
- )}
);
};
diff --git a/src/components/ArticleAnalyser/types.ts b/src/components/ArticleAnalyser/types.ts
index b302e3b..6857064 100644
--- a/src/components/ArticleAnalyser/types.ts
+++ b/src/components/ArticleAnalyser/types.ts
@@ -1,13 +1,13 @@
-import { Post } from '@/payload-types'
+import { Post } from '@/payload-types';
/**
* Represents a post enriched with additional metrics.
* The base `Post` type is assumed to exist elsewhere.
*/
export interface PostWithMetrics extends Post {
- clicks: number
- uniqueClicks: number
- clickRate: number
+ clicks: number;
+ uniqueClicks: number;
+ clickRate: number;
}
/**
@@ -15,17 +15,49 @@ export interface PostWithMetrics extends Post {
* This type is used for both intermediate (category) nodes and leaves.
*/
export interface HierarchyNode {
- name: string
- children?: HierarchyNode[]
- clicks?: number
- uniqueClicks?: number
- clickRate?: number
- url?: string
- slug?: string | null
- id?: string | number
+ name: string;
+ children?: HierarchyNode[];
+ clicks?: number;
+ uniqueClicks?: number;
+ clickRate?: number;
+ url?: string;
+ slug?: string | null;
+ id?: string | number;
}
/**
* The metric used to size a treemap node.
*/
-export type SizeMetric = 'clicks' | 'clickRate' | 'uniqueClicks'
+export type SizeMetric = 'clicks' | 'clickRate' | 'uniqueClicks';
+
+/**
+ * View types for article display
+ * - 'default': Card/Grid view (CardMobileView on mobile, DesktopGridView on desktop)
+ * - 'wba': Alternative view (WBAMobileView on mobile, ArticleTreeMap on desktop)
+ */
+export type ViewType = 'default' | 'wba';
+
+/**
+ * @deprecated Use ViewType instead
+ */
+export type MobileViewType = ViewType;
+
+/**
+ * Time filter options for filtering posts by date
+ */
+export type TimeFilter = 'all' | 'today' | 'week' | 'month' | 'year';
+
+/**
+ * Search input variant styles
+ */
+export type SearchVariant = 'light' | 'dark';
+
+/**
+ * Mock statistics data structure
+ */
+export interface MockStats {
+ gridOutput: string;
+ aqi: number;
+ aqiStatus: string;
+ newNodes: number;
+}
diff --git a/src/components/ArticleAnalyser/utils.ts b/src/components/ArticleAnalyser/utils.ts
new file mode 100644
index 0000000..a370a09
--- /dev/null
+++ b/src/components/ArticleAnalyser/utils.ts
@@ -0,0 +1,89 @@
+import * as d3 from 'd3';
+import { Post, Media as MediaType } from '@/payload-types';
+import { getClientSideURL } from '@/utilities/getURL';
+
+/**
+ * Simple seeded random number generator for consistent SSR/client results
+ */
+export const seededRandom = (seed: number): number => {
+ const x = Math.sin(seed) * 10000;
+ return x - Math.floor(x);
+};
+
+/**
+ * Returns a shade of the base color based on value and max
+ * The variation controls how much we brighten/darken
+ */
+export function getShade(
+ baseColor: string,
+ value: number,
+ max: number,
+ variation: number = 2
+): string {
+ const t = max > 0 ? value / max : 0;
+ const brighter = d3.color(baseColor)?.brighter(variation).formatHex() || baseColor;
+ const darker = d3.color(baseColor)?.darker(variation).formatHex() || baseColor;
+ return d3.interpolateLab(brighter, darker)(t);
+}
+
+/**
+ * Get the image URL for a post, with fallback to future1.webp
+ */
+export const getImageUrl = (post: Post): string => {
+ if (post.heroImage && typeof post.heroImage === 'object') {
+ const media = post.heroImage as MediaType;
+ if (media.url) {
+ return `${getClientSideURL()}${media.url}`;
+ }
+ }
+ return '/images/future1.webp';
+};
+
+/**
+ * Mock statistics data
+ */
+export const getMockStats = () => ({
+ gridOutput: '+12%',
+ aqi: 42,
+ aqiStatus: 'GOOD',
+ newNodes: 8,
+});
+
+/**
+ * Category color palette - consistent across all visualizations
+ */
+export const CATEGORY_PALETTE = ['#6A0DAD', '#228B22', '#FF5733', '#3498DB', '#F1C40F', '#9B59B6'];
+
+/**
+ * Predefined colors for specific categories
+ */
+export const PREDEFINED_CATEGORY_COLORS: Record = {
+ AI: CATEGORY_PALETTE[0],
+ Earth: CATEGORY_PALETTE[1],
+};
+
+/**
+ * Get color for a category - uses predefined colors or palette rotation
+ */
+export const getCategoryColor = (category: string, allCategories: string[]): string => {
+ if (PREDEFINED_CATEGORY_COLORS[category]) {
+ return PREDEFINED_CATEGORY_COLORS[category];
+ }
+ const index = allCategories.indexOf(category) % CATEGORY_PALETTE.length;
+ return CATEGORY_PALETTE[index];
+};
+
+/**
+ * Enrich posts with deterministic metrics (consistent across SSR and client)
+ */
+export const enrichPostsWithMetrics = (
+ posts: Post[]
+): Array => {
+ return posts.map((post) => {
+ const seed = typeof post.id === 'number' ? post.id : parseInt(String(post.id), 10) || 0;
+ const clicks = Math.floor(seededRandom(seed) * 2000) + 100;
+ const uniqueClicks = Math.floor(clicks * (0.7 + seededRandom(seed + 1) * 0.2));
+ const clickRate = +((uniqueClicks / clicks) * 10 + seededRandom(seed + 2) * 2).toFixed(1);
+ return { ...post, clicks, uniqueClicks, clickRate };
+ });
+};
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
new file mode 100644
index 0000000..96b1e5a
--- /dev/null
+++ b/src/components/Header/Header.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import React, { FC } from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { cn } from '@/utilities/ui';
+
+interface HeaderProps {
+ className?: string;
+}
+
+export const Header: FC = ({ className }) => {
+ return (
+
+ );
+};
diff --git a/src/components/Header/Hero.tsx b/src/components/Header/Hero.tsx
new file mode 100644
index 0000000..c1cc44d
--- /dev/null
+++ b/src/components/Header/Hero.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import React, { FC } from 'react';
+import { cn } from '@/utilities/ui';
+
+interface HeroProps {
+ className?: string;
+ title?: string;
+ subtitle?: string;
+ systemStatus?: string;
+ onNewIdea?: () => void;
+}
+
+export const Hero: FC = ({
+ className,
+ title = 'Digital Laboratory Notebook',
+ subtitle = 'The intersection of algorithmic precision and creative intuition. Documenting the synthesis of worlds.',
+ onNewIdea,
+}) => {
+ return (
+
+ {/* Background gradient overlay */}
+
+
+
+ {/* Left: Content */}
+
+
+ {title}
+
+
+ {/* Subtitle */}
+
+ {subtitle}
+
+
+
+ {/* Right: CTA Button */}
+ {onNewIdea && (
+
+
+
+
+ New Idea Essay
+
+ )}
+
+
+ );
+};
diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts
new file mode 100644
index 0000000..3185d1c
--- /dev/null
+++ b/src/components/Header/index.ts
@@ -0,0 +1,3 @@
+export { Header } from './Header';
+export { Hero } from './Hero';
+export { SliderFilter } from '@/components/SliderFilter';
diff --git a/src/components/SliderFilter/index.tsx b/src/components/SliderFilter/index.tsx
new file mode 100644
index 0000000..c1d47ba
--- /dev/null
+++ b/src/components/SliderFilter/index.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import React from 'react';
+import { cn } from '@/utilities/ui';
+import { Slider } from '@/base/slider';
+
+interface SliderFilterProps {
+ className?: string;
+ leftLabel: string;
+ rightLabel: string;
+ leftColor?: string;
+ rightColor?: string;
+ value?: number[];
+ defaultValue?: number[];
+ min?: number;
+ max?: number;
+ step?: number;
+ onValueChange?: (value: number[]) => void;
+}
+
+export const SliderFilter = ({
+ className,
+ leftLabel,
+ rightLabel,
+ leftColor = 'text-cyan-400',
+ rightColor = 'text-purple-400',
+ value,
+ defaultValue = [50],
+ min = 0,
+ max = 100,
+ step = 1,
+ onValueChange,
+}: SliderFilterProps) => {
+ return (
+
+
+
+
+ {leftLabel}
+
+
+ {rightLabel}
+
+
+
+ );
+};
diff --git a/src/heros/PostHero/index.tsx b/src/heros/PostHero/index.tsx
index 7992046..23ca6f1 100644
--- a/src/heros/PostHero/index.tsx
+++ b/src/heros/PostHero/index.tsx
@@ -4,7 +4,6 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Post } from '@/payload-types';
-
import { Media } from '@/components/Media';
import { formatAuthors } from '@/utilities/formatAuthors';
import { CategoryPill } from '@/components/CateoryPill';
@@ -38,8 +37,9 @@ export const PostHero: React.FC<{
)}
diff --git a/src/store/useCanvasStore.tsx b/src/stores/useCanvasStore.tsx
similarity index 69%
rename from src/store/useCanvasStore.tsx
rename to src/stores/useCanvasStore.tsx
index 5cfb8a4..8be4ac5 100644
--- a/src/store/useCanvasStore.tsx
+++ b/src/stores/useCanvasStore.tsx
@@ -1,10 +1,10 @@
-import { create } from 'zustand'
+import { create } from 'zustand';
interface CanvasState {
- dimensions: { width: number; height: number }
- setDimensions: (dims: { width: number; height: number }) => void
- isHydrated: boolean
- markHydrated: () => void
+ dimensions: { width: number; height: number };
+ setDimensions: (dims: { width: number; height: number }) => void;
+ isHydrated: boolean;
+ markHydrated: () => void;
}
export const useCanvasStore = create((set) => ({
@@ -12,4 +12,4 @@ export const useCanvasStore = create((set) => ({
setDimensions: (dims) => set({ dimensions: dims }),
isHydrated: false,
markHydrated: () => set({ isHydrated: true }),
-}))
+}));
diff --git a/src/stores/useFilterStore.ts b/src/stores/useFilterStore.ts
new file mode 100644
index 0000000..fb0cc16
--- /dev/null
+++ b/src/stores/useFilterStore.ts
@@ -0,0 +1,234 @@
+import { create } from 'zustand';
+import { Post } from '@/payload-types';
+import React from 'react';
+
+export type TimeFilter = 'all' | 'today' | 'week' | 'month';
+
+/**
+ * Sanitize search query to prevent XSS and injection attacks
+ * Removes potentially dangerous characters and trims whitespace
+ */
+const sanitizeSearchQuery = (query: string): string => {
+ return query.replace(/[<>"']/g, '').trim();
+};
+
+interface FilterState {
+ // Filter values
+ selectedCategories: string[];
+ timeFilter: TimeFilter;
+ searchQuery: string;
+ contentBalance: number; // 0-100: 0=analytical, 50=balanced, 100=expressive
+
+ // Actions
+ toggleCategory: (category: string) => void;
+ setCategories: (categories: string[]) => void;
+ setTimeFilter: (filter: TimeFilter) => void;
+ setSearchQuery: (query: string) => void;
+ setContentBalance: (balance: number) => void;
+ resetFilters: () => void;
+}
+
+export const useFilterStore = create((set) => ({
+ // Initial state
+ selectedCategories: [],
+ timeFilter: 'all',
+ searchQuery: '',
+ contentBalance: 50, // Default to middle (show all)
+
+ // Actions
+ toggleCategory: (category) =>
+ set((state) => ({
+ selectedCategories: state.selectedCategories.includes(category)
+ ? state.selectedCategories.filter((c) => c !== category)
+ : [...state.selectedCategories, category],
+ })),
+
+ setCategories: (categories) => set({ selectedCategories: categories }),
+
+ setTimeFilter: (filter) => set({ timeFilter: filter }),
+
+ setSearchQuery: (query) => set({ searchQuery: query }),
+
+ setContentBalance: (balance) => set({ contentBalance: balance }),
+
+ resetFilters: () =>
+ set({
+ selectedCategories: [],
+ timeFilter: 'all',
+ searchQuery: '',
+ contentBalance: 50,
+ }),
+}));
+
+/**
+ * Maps categories to content balance scores (0-100)
+ * 0 = Pure Analytical/Science, 50 = Balanced, 100 = Pure Expressive/Arts
+ */
+const CATEGORY_SCORES: Record = {
+ // Analytical (0-30) - Tech, Science, Data
+ AI: 15,
+ 'Machine Learning': 10,
+ 'Data Science': 5,
+ Science: 10,
+ Technology: 20,
+ Engineering: 10,
+ Research: 5,
+ Mathematics: 0,
+ Physics: 5,
+ 'Computer Science': 15,
+ Blockchain: 20,
+ Web3: 25,
+ Crypto: 20,
+
+ // Balanced (30-70) - Interdisciplinary
+ Earth: 40,
+ Environment: 45,
+ Philosophy: 50,
+ Psychology: 45,
+ Education: 50,
+ Business: 40,
+ Economics: 35,
+ 'Social Science': 50,
+ Politics: 45,
+ History: 50,
+ Ethics: 55,
+ Society: 50,
+
+ // Expressive (70-100) - Arts, Creativity, Design
+ Art: 90,
+ Design: 80,
+ Music: 95,
+ Literature: 85,
+ 'Creative Writing': 90,
+ Film: 85,
+ Photography: 75,
+ Fashion: 85,
+ Culture: 70,
+};
+
+/**
+ * Calculate content balance score for a post (0-100)
+ * Returns 50 (balanced) if no recognizable categories
+ */
+const getPostContentScore = (post: Post): number => {
+ if (!post.category_titles || post.category_titles.length === 0) {
+ return 50; // Default to balanced if no categories
+ }
+
+ // Calculate average score from all categories
+ const scores = post.category_titles
+ .map((cat) => CATEGORY_SCORES[cat] ?? 50)
+ .filter((score) => score !== undefined);
+
+ if (scores.length === 0) return 50;
+
+ return scores.reduce((sum, score) => sum + score, 0) / scores.length;
+};
+
+/**
+ * Calculate relevance weight based on distance from target
+ * Uses a Gaussian-like curve for smooth falloff
+ */
+const getContentBalanceWeight = (
+ postScore: number,
+ targetScore: number,
+ tolerance: number = 30
+): number => {
+ const distance = Math.abs(postScore - targetScore);
+
+ // Gaussian falloff: weight = e^(-(distance^2) / (2 * tolerance^2))
+ const weight = Math.exp(-(distance * distance) / (2 * tolerance * tolerance));
+
+ return weight;
+};
+
+/**
+ * Calculate time filter dates once (not on every render)
+ */
+const getTimeFilterDates = (timeFilter: TimeFilter) => {
+ if (timeFilter === 'all') return null;
+
+ const now = new Date();
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+
+ switch (timeFilter) {
+ case 'today':
+ return startOfToday;
+ case 'week': {
+ const startOfWeek = new Date(startOfToday);
+ startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); // Sunday
+ return startOfWeek;
+ }
+ case 'month':
+ return new Date(now.getFullYear(), now.getMonth(), 1);
+ default:
+ return null;
+ }
+};
+
+/**
+ * Selector hook for filtering posts
+ * Centralizes all filtering logic in one place for efficiency
+ */
+export const useFilteredPosts = (posts: Post[]) => {
+ const { selectedCategories, timeFilter, searchQuery, contentBalance } = useFilterStore();
+
+ // Memoize filtered posts based on filter criteria
+ return React.useMemo(() => {
+ let result = posts;
+
+ // Filter by time period (optimized with pre-calculated dates)
+ if (timeFilter !== 'all') {
+ const startDate = getTimeFilterDates(timeFilter);
+ if (startDate) {
+ result = result.filter((post) => {
+ const publishedAt = post.publishedAt ? new Date(post.publishedAt) : null;
+ return publishedAt && publishedAt >= startDate;
+ });
+ }
+ }
+
+ // Filter by categories (if any selected, show posts that match ANY selected category)
+ if (selectedCategories.length > 0) {
+ result = result.filter(
+ (post) =>
+ post.category_titles &&
+ post.category_titles.some((cat) => selectedCategories.includes(cat))
+ );
+ }
+
+ // Filter by search query (with sanitization for security)
+ const sanitizedQuery = sanitizeSearchQuery(searchQuery);
+ if (sanitizedQuery) {
+ const query = sanitizedQuery.toLowerCase();
+ result = result.filter(
+ (post) =>
+ post.title.toLowerCase().includes(query) ||
+ post.shortDescription?.toLowerCase().includes(query)
+ );
+ }
+
+ // Apply content balance filtering with weighted scoring
+ // Early return: Skip expensive calculations when at default position
+ if (contentBalance !== 50) {
+ // Calculate score once per post (optimization)
+ const postsWithWeights = result.map((post) => {
+ const score = getPostContentScore(post);
+ return {
+ post,
+ score,
+ weight: getContentBalanceWeight(score, contentBalance),
+ };
+ });
+
+ // Sort by weight (highest relevance first)
+ postsWithWeights.sort((a, b) => b.weight - a.weight);
+
+ // Filter out posts with very low weights (< 0.1 means very far from target)
+ const threshold = 0.1;
+ result = postsWithWeights.filter((item) => item.weight >= threshold).map((item) => item.post);
+ }
+
+ return result;
+ }, [posts, selectedCategories, timeFilter, searchQuery, contentBalance]);
+};
diff --git a/src/stores/useViewStore.ts b/src/stores/useViewStore.ts
new file mode 100644
index 0000000..8e39bdf
--- /dev/null
+++ b/src/stores/useViewStore.ts
@@ -0,0 +1,20 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { ViewType } from '@/components/ArticleAnalyser/types';
+
+interface ViewState {
+ view: ViewType;
+ setView: (view: ViewType) => void;
+}
+
+export const useViewStore = create()(
+ persist(
+ (set) => ({
+ view: 'default',
+ setView: (view) => set({ view }),
+ }),
+ {
+ name: 'decentralizard-view-storage', // localStorage key
+ }
+ )
+);
diff --git a/src/stores/useZoomStore.ts b/src/stores/useZoomStore.ts
new file mode 100644
index 0000000..ee6d088
--- /dev/null
+++ b/src/stores/useZoomStore.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand';
+
+interface ZoomState {
+ zoomLevel: number; // 1 to 8 (100% to 800%)
+ setZoomLevel: (level: number) => void;
+ resetZoom: () => void;
+}
+
+export const useZoomStore = create((set) => ({
+ zoomLevel: 1,
+ setZoomLevel: (level) => set({ zoomLevel: Math.max(1, Math.min(8, level)) }),
+ resetZoom: () => set({ zoomLevel: 1 }),
+}));
diff --git a/yarn.lock b/yarn.lock
index 454dd2a..d3d5471 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3216,6 +3216,33 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-accordion@npm:^1.2.12":
+ version: 1.2.12
+ resolution: "@radix-ui/react-accordion@npm:1.2.12"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.3"
+ "@radix-ui/react-collapsible": "npm:1.1.12"
+ "@radix-ui/react-collection": "npm:1.1.7"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-direction": "npm:1.1.1"
+ "@radix-ui/react-id": "npm:1.1.1"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-controllable-state": "npm:1.2.2"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/c64a53ce766a1ef529cf6413ed7382598c94f78879b3a83ceda27cb1894ed6eb6e8ad61f6a550ca3c7fa813657045dadfc7328dbf1d736a37e1cf3c446db43de
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-arrow@npm:1.1.7":
version: 1.1.7
resolution: "@radix-ui/react-arrow@npm:1.1.7"
@@ -3261,6 +3288,32 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-collapsible@npm:1.1.12":
+ version: 1.1.12
+ resolution: "@radix-ui/react-collapsible@npm:1.1.12"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.3"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-id": "npm:1.1.1"
+ "@radix-ui/react-presence": "npm:1.1.5"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-controllable-state": "npm:1.2.2"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/777cced73fbbec9cfafe6325aa5605e90f49d889af2778f4c4a6be101c07cacd69ae817d0b41cc27e3181f49392e2c06db7f32d6b084db047a51805ec70729b3
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-collection@npm:1.1.7":
version: 1.1.7
resolution: "@radix-ui/react-collection@npm:1.1.7"
@@ -3558,6 +3611,35 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-slider@npm:^1.3.6":
+ version: 1.3.6
+ resolution: "@radix-ui/react-slider@npm:1.3.6"
+ dependencies:
+ "@radix-ui/number": "npm:1.1.1"
+ "@radix-ui/primitive": "npm:1.1.3"
+ "@radix-ui/react-collection": "npm:1.1.7"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-direction": "npm:1.1.1"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-controllable-state": "npm:1.2.2"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ "@radix-ui/react-use-previous": "npm:1.1.1"
+ "@radix-ui/react-use-size": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/a53d7854e28c5ef3d29b76c8d04cc3c723b982b643152cd5a8fefc7a8359180f8fd21753e5a08302a290bc837e7be04f2efad9d308b7a4a23326df6a6b1ac882
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-slot@npm:1.2.3":
version: 1.2.3
resolution: "@radix-ui/react-slot@npm:1.2.3"
@@ -7253,9 +7335,11 @@ __metadata:
"@payloadcms/plugin-seo": "npm:3.72.0"
"@payloadcms/richtext-lexical": "npm:3.72.0"
"@payloadcms/ui": "npm:3.72.0"
+ "@radix-ui/react-accordion": "npm:^1.2.12"
"@radix-ui/react-checkbox": "npm:^1.0.4"
"@radix-ui/react-label": "npm:^2.0.2"
"@radix-ui/react-select": "npm:^2.0.0"
+ "@radix-ui/react-slider": "npm:^1.3.6"
"@radix-ui/react-slot": "npm:^1.0.2"
"@radix-ui/react-switch": "npm:^1.2.6"
"@supabase/supabase-js": "npm:^2.49.1"