276 lines
7.6 KiB
TypeScript
276 lines
7.6 KiB
TypeScript
/**
|
|
* 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<string, string[]>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, unknown>, 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<string, unknown>, 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<string, string[]>();
|
|
|
|
// 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<string>();
|
|
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<string>();
|
|
|
|
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<string, PreloadAssetInfo>();
|
|
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,
|
|
};
|
|
}
|