39948-vm/frontend/src/hooks/useNeighborGraph.ts
2026-03-26 21:19:18 +04:00

283 lines
7.9 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)) {
// Classify asset type - transition videos get highest priority
const lowerKey = key.toLowerCase();
let assetType: 'transition' | 'video' | 'audio' | 'image';
if (lowerKey.includes('transition')) {
assetType = 'transition';
} else if (lowerKey.includes('video')) {
assetType = 'video';
} else if (lowerKey.includes('audio')) {
assetType = 'audio';
} else {
assetType = '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,
};
}