/** * useNeighborGraph Hook * * Builds a navigation graph from page_links to determine which pages * are neighbors and should have their assets preloaded. */ import { useMemo } from 'react'; import { PRELOAD_CONFIG } from '../config/preload.config'; import type { PreloadPage, PreloadPageLink, PreloadElement, PreloadAssetInfo, PreloadNeighborInfo, } from '../types/preload'; interface UseNeighborGraphOptions { pages: PreloadPage[]; pageLinks: PreloadPageLink[]; elements: PreloadElement[]; maxDepth?: number; } interface NeighborGraphResult { /** * Get neighboring page IDs within maxDepth hops */ getNeighbors: ( currentPageId: string, maxDepth?: number, ) => PreloadNeighborInfo[]; /** * Get all assets that should be preloaded for given pages */ getAssetsForPages: (pageIds: string[]) => PreloadAssetInfo[]; /** * Get prioritized assets for preloading based on current page */ getPrioritizedAssets: ( currentPageId: string, maxDepth?: number, ) => PreloadAssetInfo[]; /** * Raw adjacency list for debugging */ adjacencyList: Map; } /** * Parse content_json to extract asset URLs */ function extractAssetsFromContent( contentJson: string | undefined, pageId: string, ): PreloadAssetInfo[] { if (!contentJson) return []; try { const content = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; const assets: PreloadAssetInfo[] = []; // Asset URL fields in element content_json const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[]; const checkObject = (obj: Record, depth = 0) => { if (depth > 5 || !obj || typeof obj !== 'object') return; for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string' && value && urlFields.includes(key)) { const assetType = key.toLowerCase().includes('video') ? 'video' : key.toLowerCase().includes('audio') ? 'audio' : 'image'; assets.push({ url: value, pageId, assetType, priority: 0, // Will be calculated later }); } else if (typeof value === 'object' && value !== null) { checkObject(value as Record, depth + 1); } } }; checkObject(content); return assets; } catch { return []; } } export function useNeighborGraph( options: UseNeighborGraphOptions, ): NeighborGraphResult { const { pages, pageLinks, elements, maxDepth = PRELOAD_CONFIG.neighborGraph.maxDepth, } = options; // Build adjacency list from page links const adjacencyList = useMemo(() => { const adj = new Map(); // Initialize all pages pages.forEach((page) => { adj.set(page.id, []); }); // Add edges from active page links const activeLinks = pageLinks.filter((link) => link.is_active !== false); activeLinks.forEach((link) => { if (link.from_pageId && link.to_pageId) { const neighbors = adj.get(link.from_pageId) || []; if (!neighbors.includes(link.to_pageId)) { neighbors.push(link.to_pageId); adj.set(link.from_pageId, neighbors); } } }); return adj; }, [pages, pageLinks]); // BFS to find neighbors within depth const getNeighbors = useMemo(() => { return (currentPageId: string, depth = maxDepth): PreloadNeighborInfo[] => { const visited = new Set(); const result: PreloadNeighborInfo[] = []; const queue: { pageId: string; distance: number }[] = [ { pageId: currentPageId, distance: 0 }, ]; visited.add(currentPageId); while (queue.length > 0) { const item = queue.shift(); if (!item) break; const { pageId, distance } = item; if (distance > 0) { result.push({ pageId, distance }); } if (distance < depth) { const neighbors = adjacencyList.get(pageId) || []; for (const neighborId of neighbors) { if (!visited.has(neighborId)) { visited.add(neighborId); queue.push({ pageId: neighborId, distance: distance + 1 }); } } } } // Sort by distance (closest first) return result.sort((a, b) => a.distance - b.distance); }; }, [adjacencyList, maxDepth]); // Get assets for a set of pages const getAssetsForPages = useMemo(() => { return (pageIds: string[]): PreloadAssetInfo[] => { const assets: PreloadAssetInfo[] = []; const seenUrls = new Set(); pageIds.forEach((pageId) => { // Get elements for this page const pageElements = elements.filter((el) => el.pageId === pageId); // Extract assets from element content pageElements.forEach((element) => { const elementAssets = extractAssetsFromContent( element.content_json, pageId, ); elementAssets.forEach((asset) => { if (!seenUrls.has(asset.url)) { seenUrls.add(asset.url); assets.push(asset); } }); }); }); // Add transition videos (transition is eagerly loaded in page_links) const matchingLinks = pageLinks.filter( (link) => link.is_active !== false && pageIds.includes(link.from_pageId || ''), ); matchingLinks.forEach((link) => { const videoUrl = link.transition?.video_url; if (videoUrl && !seenUrls.has(videoUrl)) { seenUrls.add(videoUrl); assets.push({ url: videoUrl, pageId: link.from_pageId || '', assetType: 'transition', priority: 0, }); } }); return assets; }; }, [elements, pageLinks]); // Get prioritized assets for preloading const getPrioritizedAssets = useMemo(() => { return (currentPageId: string, depth = maxDepth): PreloadAssetInfo[] => { // Get current page assets (highest priority) const currentPageAssets = getAssetsForPages([currentPageId]).map( (asset) => ({ ...asset, priority: PRELOAD_CONFIG.priority.currentPage + (PRELOAD_CONFIG.priority.assetType[asset.assetType] || 0), }), ); // Get neighbor page assets const neighbors = getNeighbors(currentPageId, depth); const neighborAssets: PreloadAssetInfo[] = []; neighbors.forEach(({ pageId, distance }) => { const assets = getAssetsForPages([pageId]); assets.forEach((asset) => { const basePriority = PRELOAD_CONFIG.priority.neighborBase / distance; const typePriority = PRELOAD_CONFIG.priority.assetType[asset.assetType] || 0; neighborAssets.push({ ...asset, priority: basePriority + typePriority, }); }); }); // Combine and sort by priority (highest first) const allAssets = [...currentPageAssets, ...neighborAssets]; // Deduplicate by URL, keeping highest priority const urlToPriority = new Map(); allAssets.forEach((asset) => { const existing = urlToPriority.get(asset.url); if (!existing || asset.priority > existing.priority) { urlToPriority.set(asset.url, asset); } }); return Array.from(urlToPriority.values()).sort( (a, b) => b.priority - a.priority, ); }; }, [getAssetsForPages, getNeighbors, maxDepth]); return { getNeighbors, getAssetsForPages, getPrioritizedAssets, adjacencyList, }; }