diff --git a/dist/js/docsView.js b/dist/js/docsView.js index 59a37fc9..77475204 100644 --- a/dist/js/docsView.js +++ b/dist/js/docsView.js @@ -29,6 +29,8 @@ const scrollSpyState = { activeId: "", rafId: null, onScroll: null, + observer: null, + passedHeadings: null, }; function getHashParams() { @@ -260,15 +262,11 @@ function resetSectionHighlight() { } function clearScrollSpy() { - if (scrollSpyState.onScroll && typeof window !== "undefined") { - window.removeEventListener("scroll", scrollSpyState.onScroll); - window.removeEventListener("resize", scrollSpyState.onScroll); + if (scrollSpyState.observer) { + scrollSpyState.observer.disconnect(); + scrollSpyState.observer = null; } - if (scrollSpyState.rafId && typeof cancelAnimationFrame === "function") { - cancelAnimationFrame(scrollSpyState.rafId); - } - scrollSpyState.rafId = null; - scrollSpyState.onScroll = null; + scrollSpyState.passedHeadings = null; scrollSpyState.headings = []; scrollSpyState.linkLookup = new Map(); resetSectionHighlight(); @@ -328,6 +326,7 @@ function setupScrollSpy(container) { const headings = Array.from( container.querySelectorAll("h2[id], h3[id], h4[id], h5[id]") ); + console.log(`[Spy] setup found ${headings.length} headings`); if (headings.length === 0) { return; } @@ -345,34 +344,56 @@ function setupScrollSpy(container) { scrollSpyState.headings = trackedHeadings; scrollSpyState.linkLookup = linkLookup; - const updateActiveFromScroll = () => { - scrollSpyState.rafId = null; - const offset = 96; - let nextId = scrollSpyState.headings[0]?.id || ""; - for (const heading of scrollSpyState.headings) { - const top = heading.getBoundingClientRect().top - offset; - if (top <= 0) { - nextId = heading.id; + const observerCallback = (entries) => { + const passed = scrollSpyState.passedHeadings || new Set(); + + entries.forEach((entry) => { + // If the heading intersects (enters the viewport from top or bottom), + // we check if it is above the offset. + if (entry.isIntersecting) { + // If it is intersecting, it is definitely "visible" in the viewport (minus top margin). + // It means it's not "passed" (above the top). + passed.delete(entry.target.id); } else { + // If not intersecting, check if it's above. + // rootMargin top is -96px. The root starts at 96px from top. + // If entry.boundingClientRect.top < 96, it is above the root. + if (entry.boundingClientRect.top < 96) { + passed.add(entry.target.id); + } else { + passed.delete(entry.target.id); + } + } + }); + + scrollSpyState.passedHeadings = passed; + + let nextId = ""; + // Find the last heading in the passed set (by DOM order) + for (let i = scrollSpyState.headings.length - 1; i >= 0; i--) { + const id = scrollSpyState.headings[i].id; + if (passed.has(id)) { + nextId = id; break; } } + // Fallback to the first heading if none are passed yet (scrolled to top) + if (!nextId && scrollSpyState.headings.length > 0) { + nextId = scrollSpyState.headings[0].id; + } + if (nextId) { setActiveSection(nextId); } }; - const onScroll = () => { - if (scrollSpyState.rafId) { - return; - } - scrollSpyState.rafId = requestAnimationFrame(updateActiveFromScroll); - }; + const observer = new IntersectionObserver(observerCallback, { + rootMargin: "-96px 0px 0px 0px", + threshold: 0, + }); - scrollSpyState.onScroll = onScroll; - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onScroll); - onScroll(); + scrollSpyState.headings.forEach((heading) => observer.observe(heading)); + scrollSpyState.observer = observer; } function resolveDocItem(slug) { diff --git a/dist/js/feedEngine/sorters.js b/dist/js/feedEngine/sorters.js index c4620509..f4b1e9e8 100644 --- a/dist/js/feedEngine/sorters.js +++ b/dist/js/feedEngine/sorters.js @@ -301,6 +301,13 @@ export function createExploreDiversitySorter({ const selected = []; const remaining = [...candidates]; + // Cache max similarity for each remaining candidate against the selected set + // Map + const similarityCache = new Map(); + for (const candidate of remaining) { + similarityCache.set(candidate, { penalty: 0, similarItemId: "" }); + } + while (remaining.length > 0) { let bestCandidate = null; let bestMmr = Number.NEGATIVE_INFINITY; @@ -326,22 +333,7 @@ export function createExploreDiversitySorter({ for (const candidate of remaining) { const rawScore = normalizeScore(candidate?.metadata?.exploreScore); - let penalty = 0; - let similarItemId = ""; - - if (selected.length > 0) { - const candidateVector = vectors.get(candidate); - for (const selectedItem of selected) { - const similarity = cosineSimilarity( - candidateVector, - vectors.get(selectedItem), - ); - if (similarity > penalty) { - penalty = similarity; - similarItemId = stableVideoId(selectedItem); - } - } - } + const { penalty, similarItemId } = similarityCache.get(candidate); const mmrScore = normalizedLambda * rawScore - (1 - normalizedLambda) * penalty; @@ -404,6 +396,22 @@ export function createExploreDiversitySorter({ const index = remaining.indexOf(bestCandidate); if (index >= 0) { remaining.splice(index, 1); + similarityCache.delete(bestCandidate); + } + + // Update similarity cache for remaining candidates against the newly selected item + const bestCandidateVector = vectors.get(bestCandidate); + for (const candidate of remaining) { + const candidateVector = vectors.get(candidate); + const similarity = cosineSimilarity( + candidateVector, + bestCandidateVector, + ); + const cache = similarityCache.get(candidate); + if (similarity > cache.penalty) { + cache.penalty = similarity; + cache.similarItemId = stableVideoId(bestCandidate); + } } } diff --git a/dist/js/state/profileCache.js b/dist/js/state/profileCache.js index eacf26f4..1fd3e253 100644 --- a/dist/js/state/profileCache.js +++ b/dist/js/state/profileCache.js @@ -26,7 +26,7 @@ function sanitizeProfileString(value) { class ProfileCache { constructor() { this.activePubkey = null; - this.memoryCache = new Map(); // For runtime decrypted data: pubkey:section -> data + this.memoryCache = new Map(); // For runtime decrypted data: pubkey -> Map(section -> data) this.listeners = new Set(); } @@ -240,9 +240,11 @@ class ProfileCache { getProfileData(pubkey, section) { // 1. Check memory cache (runtime data) - const memKey = `${pubkey}:${section}`; - if (this.memoryCache.has(memKey)) { - return this.memoryCache.get(memKey); + if (this.memoryCache.has(pubkey)) { + const userCache = this.memoryCache.get(pubkey); + if (userCache.has(section)) { + return userCache.get(section); + } } // 2. Load from persistence @@ -273,7 +275,10 @@ class ProfileCache { } // Populate memory cache to avoid repeat parsing - this.memoryCache.set(memKey, parsed); + if (!this.memoryCache.has(pubkey)) { + this.memoryCache.set(pubkey, new Map()); + } + this.memoryCache.get(pubkey).set(section, parsed); return parsed; } } catch (error) { @@ -302,39 +307,42 @@ class ProfileCache { } // Update memory cache - const memKey = `${pubkey}:${section}`; - this.memoryCache.set(memKey, data); + if (!this.memoryCache.has(pubkey)) { + this.memoryCache.set(pubkey, new Map()); + } + this.memoryCache.get(pubkey).set(section, data); this.emit("update", { pubkey, section, data }); this.emit("partition-updated", { pubkey, key: section }); } setMemoryDataForPubkey(pubkey, section, data) { - const memKey = `${pubkey}:${section}`; - this.memoryCache.set(memKey, data); + if (!this.memoryCache.has(pubkey)) { + this.memoryCache.set(pubkey, new Map()); + } + this.memoryCache.get(pubkey).set(section, data); + this.emit("update", { pubkey, section, data }); } setMemoryData(section, data) { if (!this.activePubkey) return; - const key = `${this.activePubkey}:${section}`; - this.memoryCache.set(key, data); + if (!this.memoryCache.has(this.activePubkey)) { + this.memoryCache.set(this.activePubkey, new Map()); + } + this.memoryCache.get(this.activePubkey).set(section, data); } getMemoryData(section) { if (!this.activePubkey) return null; - const key = `${this.activePubkey}:${section}`; - return this.memoryCache.get(key); + if (this.memoryCache.has(this.activePubkey)) { + return this.memoryCache.get(this.activePubkey).get(section); + } + return undefined; } clearMemoryCache(pubkey) { - // Clear all entries starting with pubkey: - const prefix = `${pubkey}:`; - for (const key of this.memoryCache.keys()) { - if (key.startsWith(prefix)) { - this.memoryCache.delete(key); - } - } + this.memoryCache.delete(pubkey); } clearSignerRuntime(pubkey) { diff --git a/js/docsView.js b/js/docsView.js index 59a37fc9..1df499b7 100644 --- a/js/docsView.js +++ b/js/docsView.js @@ -29,6 +29,8 @@ const scrollSpyState = { activeId: "", rafId: null, onScroll: null, + observer: null, + passedHeadings: null, }; function getHashParams() { @@ -260,15 +262,11 @@ function resetSectionHighlight() { } function clearScrollSpy() { - if (scrollSpyState.onScroll && typeof window !== "undefined") { - window.removeEventListener("scroll", scrollSpyState.onScroll); - window.removeEventListener("resize", scrollSpyState.onScroll); + if (scrollSpyState.observer) { + scrollSpyState.observer.disconnect(); + scrollSpyState.observer = null; } - if (scrollSpyState.rafId && typeof cancelAnimationFrame === "function") { - cancelAnimationFrame(scrollSpyState.rafId); - } - scrollSpyState.rafId = null; - scrollSpyState.onScroll = null; + scrollSpyState.passedHeadings = null; scrollSpyState.headings = []; scrollSpyState.linkLookup = new Map(); resetSectionHighlight(); @@ -345,34 +343,56 @@ function setupScrollSpy(container) { scrollSpyState.headings = trackedHeadings; scrollSpyState.linkLookup = linkLookup; - const updateActiveFromScroll = () => { - scrollSpyState.rafId = null; - const offset = 96; - let nextId = scrollSpyState.headings[0]?.id || ""; - for (const heading of scrollSpyState.headings) { - const top = heading.getBoundingClientRect().top - offset; - if (top <= 0) { - nextId = heading.id; + const observerCallback = (entries) => { + const passed = scrollSpyState.passedHeadings || new Set(); + + entries.forEach((entry) => { + // If the heading intersects (enters the viewport from top or bottom), + // we check if it is above the offset. + if (entry.isIntersecting) { + // If it is intersecting, it is definitely "visible" in the viewport (minus top margin). + // It means it's not "passed" (above the top). + passed.delete(entry.target.id); } else { + // If not intersecting, check if it's above. + // rootMargin top is -96px. The root starts at 96px from top. + // If entry.boundingClientRect.top < 96, it is above the root. + if (entry.boundingClientRect.top < 96) { + passed.add(entry.target.id); + } else { + passed.delete(entry.target.id); + } + } + }); + + scrollSpyState.passedHeadings = passed; + + let nextId = ""; + // Find the last heading in the passed set (by DOM order) + for (let i = scrollSpyState.headings.length - 1; i >= 0; i--) { + const id = scrollSpyState.headings[i].id; + if (passed.has(id)) { + nextId = id; break; } } + // Fallback to the first heading if none are passed yet (scrolled to top) + if (!nextId && scrollSpyState.headings.length > 0) { + nextId = scrollSpyState.headings[0].id; + } + if (nextId) { setActiveSection(nextId); } }; - const onScroll = () => { - if (scrollSpyState.rafId) { - return; - } - scrollSpyState.rafId = requestAnimationFrame(updateActiveFromScroll); - }; + const observer = new IntersectionObserver(observerCallback, { + rootMargin: "-96px 0px 0px 0px", + threshold: 0, + }); - scrollSpyState.onScroll = onScroll; - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onScroll); - onScroll(); + scrollSpyState.headings.forEach((heading) => observer.observe(heading)); + scrollSpyState.observer = observer; } function resolveDocItem(slug) {