diff --git a/src/App.vue b/src/App.vue index 801fdd7..97d6854 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,9 +1,7 @@ diff --git a/src/components.d.ts b/src/components.d.ts index 545c2a0..0a16f63 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -11,9 +11,13 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + GridViewport: typeof import('./components/GridViewport.vue')['default'] + InfiniteScrollContainer: typeof import('./components/InfiniteScrollContainer.vue')['default'] + InfiniteScrollDemo: typeof import('./components/InfiniteScrollDemo.vue')['default'] Layout: typeof import('./components/Layout.vue')['default'] Lenis: typeof import('./components/Lenis.vue')['default'] LoadingSpinner: typeof import('./components/LoadingSpinner.vue')['default'] + MosaicViewport: typeof import('./components/MosaicViewport.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/src/components/InfiniteScrollContainer.vue b/src/components/InfiniteScrollContainer.vue new file mode 100644 index 0000000..eaea32e --- /dev/null +++ b/src/components/InfiniteScrollContainer.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/components/InfiniteScrollDemo.vue b/src/components/InfiniteScrollDemo.vue new file mode 100644 index 0000000..a78f7f5 --- /dev/null +++ b/src/components/InfiniteScrollDemo.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/components/Layout.vue b/src/components/Layout.vue index 58a8b30..0788413 100644 --- a/src/components/Layout.vue +++ b/src/components/Layout.vue @@ -1,20 +1,30 @@ + + diff --git a/src/components/MosaicViewport.vue b/src/components/MosaicViewport.vue new file mode 100644 index 0000000..fdc5a05 --- /dev/null +++ b/src/components/MosaicViewport.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/src/composables/useInfiniteScroll.js b/src/composables/useInfiniteScroll.js new file mode 100644 index 0000000..3af2dcf --- /dev/null +++ b/src/composables/useInfiniteScroll.js @@ -0,0 +1,297 @@ +import { ref, reactive, computed, nextTick } from 'vue' +import { useWindowSize } from '@vueuse/core' +import useLenis from '@/composables/useLenis' + +// Window size for viewport calculations +const { height: wHeight } = useWindowSize() + +/** + * Bidirectional infinite scroll composable + * Manages virtual viewports with on-demand content generation + * + * @param {Object} options Configuration options + * @param {Function} options.generateContent Function to generate content for a viewport + * @param {Number} options.viewportHeight Height of each viewport (defaults to window height) + * @param {Number} options.bufferSize Number of viewports to keep in memory outside visible area + * @param {Number} options.initialIndex Starting viewport index + * @returns {Object} Infinite scroll state and methods + */ +export const useInfiniteScroll = (options = {}) => { + const { + generateContent = (index) => ({ index, content: `Viewport ${index}` }), + viewportHeight = null, + bufferSize = 1, + initialIndex = 0, + } = options + + // Computed viewport height (use provided or window height) + const vpHeight = computed(() => viewportHeight || wHeight.value) + + // Container reference + const containerRef = ref(null) + + // Core state + const state = reactive({ + // Current scroll position + scrollY: 0, + // Index of the currently visible viewport + currentViewportIndex: initialIndex, + // Map of loaded viewports { index: { content, element, height, offsetY } } + viewports: new Map(), + // Loading states + isLoadingUp: false, + isLoadingDown: false, + // Total virtual height (for scrollbar) + totalHeight: 0, + // Error handling + errors: new Map(), + hasError: false, + retryCount: new Map(), + }) + + // Initialize with starting viewport + const initializeViewports = async () => { + // Create initial viewport + const initialViewport = { + index: initialIndex, + content: await generateContent(initialIndex), + height: vpHeight.value, + offsetY: initialIndex * vpHeight.value, + } + + state.viewports.set(initialIndex, initialViewport) + + // Load buffer viewports above and below + await loadViewportsInRange( + initialIndex - bufferSize, + initialIndex + bufferSize, + ) + + // Update positions and total height + updateViewportPositions() + updateTotalHeight() + } + + // Load viewports in a range + const loadViewportsInRange = async (startIndex, endIndex) => { + const loadPromises = [] + + for (let i = startIndex; i <= endIndex; i++) { + if (!state.viewports.has(i)) { + loadPromises.push(loadViewport(i)) + } + } + + await Promise.all(loadPromises.filter(Boolean)) + updateViewportPositions() + } + + // Load a single viewport with retry logic + const loadViewport = async (index, isRetry = false) => { + const maxRetries = 3 + const currentRetryCount = state.retryCount.get(index) || 0 + + try { + // Set loading state based on direction + if (index < state.currentViewportIndex) { + state.isLoadingUp = true + } else if (index > state.currentViewportIndex) { + state.isLoadingDown = true + } + + const content = await generateContent(index) + const viewport = { + index, + content, + height: vpHeight.value, + offsetY: index * vpHeight.value, + } + + state.viewports.set(index, viewport) + + // Clear any previous errors for this viewport + state.errors.delete(index) + state.retryCount.delete(index) + state.hasError = state.errors.size > 0 + + return viewport + } catch (error) { + console.error(`Failed to load viewport ${index}:`, error) + + // Store error + state.errors.set(index, { + message: error.message || 'Failed to load content', + timestamp: Date.now(), + retryCount: currentRetryCount, + }) + state.hasError = true + + // Retry logic + if (currentRetryCount < maxRetries && !isRetry) { + state.retryCount.set(index, currentRetryCount + 1) + console.log( + `Retrying viewport ${index} (attempt ${currentRetryCount + 1}/${maxRetries})`, + ) + + // Exponential backoff + const delay = Math.pow(2, currentRetryCount) * 1000 + setTimeout(() => { + loadViewport(index, true) + }, delay) + } + + return null + } finally { + // Clear loading states + state.isLoadingUp = false + state.isLoadingDown = false + } + } + + // Update viewport positions based on their order + const updateViewportPositions = () => { + // Each viewport is positioned at index * viewport height + for (const [index, viewport] of state.viewports) { + viewport.offsetY = index * vpHeight.value + } + } + + // Update total virtual height for scrollbar + const updateTotalHeight = () => { + if (state.viewports.size === 0) { + state.totalHeight = wHeight.value * bufferSize // Minimum height + return + } + + const indexes = Array.from(state.viewports.keys()) + const minIndex = Math.min(...indexes) + const maxIndex = Math.max(...indexes) + + // Calculate total height needed to accommodate all viewports + // Add extra buffer above and below for infinite scroll + const bufferViewports = 10 + const totalRange = maxIndex - minIndex + 1 + bufferViewports * 2 + state.totalHeight = totalRange * vpHeight.value + } + + // Remove viewports outside buffer range + const cleanupViewports = () => { + const currentIndex = state.currentViewportIndex + const minKeepIndex = currentIndex - bufferSize - 2 + const maxKeepIndex = currentIndex + bufferSize + 2 + + for (const [index] of state.viewports) { + if (index < minKeepIndex || index > maxKeepIndex) { + state.viewports.delete(index) + } + } + + updateTotalHeight() + } + + // Calculate which viewport should be visible based on scroll position + const calculateVisibleViewport = (scrollY) => { + return Math.floor(scrollY / vpHeight.value) + } + + // Handle scroll events + const handleScroll = async (scrollData) => { + state.scrollY = scrollData.scroll + + const newViewportIndex = calculateVisibleViewport(state.scrollY) + + if (newViewportIndex !== state.currentViewportIndex) { + state.currentViewportIndex = newViewportIndex + + // Load new viewports in range + const rangeStart = newViewportIndex - bufferSize + const rangeEnd = newViewportIndex + bufferSize + + await loadViewportsInRange(rangeStart, rangeEnd) + + // Cleanup old viewports + cleanupViewports() + } + } + + // Setup scroll listener with Lenis + const lenis = useLenis(handleScroll) + + // Get viewports to render (current + buffer) + const visibleViewports = computed(() => { + const current = state.currentViewportIndex + const range = bufferSize + 1 + const start = current - range + const end = current + range + + const viewports = [] + for (let i = start; i <= end; i++) { + if (state.viewports.has(i)) { + viewports.push(state.viewports.get(i)) + } + } + + return viewports.sort((a, b) => a.index - b.index) + }) + + // Scroll to a specific viewport + const scrollToViewport = (index) => { + if (lenis.value) { + const targetY = index * vpHeight.value + lenis.value.scrollTo(targetY, { immediate: false }) + } + } + + // Retry failed viewport + const retryViewport = async (index) => { + state.errors.delete(index) + state.retryCount.delete(index) + state.hasError = state.errors.size > 0 + return await loadViewport(index) + } + + // Reset to initial state + const reset = async () => { + state.viewports.clear() + state.errors.clear() + state.retryCount.clear() + state.hasError = false + state.currentViewportIndex = initialIndex + state.isLoadingUp = false + state.isLoadingDown = false + await initializeViewports() + } + + // Initialize on creation + nextTick(() => { + initializeViewports() + }) + + return { + // State + containerRef, + currentViewportIndex: computed(() => state.currentViewportIndex), + visibleViewports, + isLoadingUp: computed(() => state.isLoadingUp), + isLoadingDown: computed(() => state.isLoadingDown), + totalHeight: computed(() => state.totalHeight), + scrollY: computed(() => state.scrollY), + + // Methods + scrollToViewport, + reset, + retryViewport, + + // Error state + hasError: computed(() => state.hasError), + errors: computed(() => + Array.from(state.errors.entries()).map(([index, error]) => ({ + index, + ...error, + })), + ), + + // Internal state for debugging + _state: state, + } +} diff --git a/src/composables/useLenis.js b/src/composables/useLenis.js index f5ef24c..600c080 100644 --- a/src/composables/useLenis.js +++ b/src/composables/useLenis.js @@ -1,12 +1,18 @@ -import { inject, onBeforeUnmount } from 'vue' +import { inject, onBeforeUnmount, watch } from 'vue' export default (callback = () => {}, instanceId) => { const instanceKey = `lenis${instanceId ? `-${instanceId}` : ''}` const lenis = inject(instanceKey) - if (lenis.value) { - lenis.value.on('scroll', callback) - } + watch( + lenis, + () => { + if (lenis.value) { + lenis.value.on('scroll', callback) + } + }, + { immediate: true }, + ) onBeforeUnmount(() => lenis.value?.off('scroll', callback)) diff --git a/src/composables/useMosaicLayout.js b/src/composables/useMosaicLayout.js new file mode 100644 index 0000000..c542661 --- /dev/null +++ b/src/composables/useMosaicLayout.js @@ -0,0 +1,187 @@ +import { useWindowSize } from '@vueuse/core' + +/** + * Mosaic Layout Generator + * Creates a space-filling layout by recursively subdividing areas + */ + +const { width: wWidth, height: wHeight } = useWindowSize() + +export const useMosaicLayout = () => { + /** + * Generate a space-filling mosaic layout + * @param {Object} options Layout configuration + * @param {number} options.width Viewport width + * @param {number} options.height Viewport height + * @param {number} options.minCellWidth Minimum cell width + * @param {number} options.maxCellWidth Maximum cell width + * @param {number} options.minCellHeight Minimum cell height + * @param {number} options.maxCellHeight Maximum cell height + * @param {number} options.targetCells Target number of cells to create (if specified, overrides subdivisionProbability) + * @param {number} options.subdivisionProbability Probability of subdivision (0-1) - used when targetCells is not specified + * @returns {Array} Array of cell objects with x, y, width, height, id + */ + const generateLayout = (options = {}) => { + const { + width = wWidth.value, + height = wHeight.value, + minCellWidth = 150, + minCellHeight = 150, + targetCells = 5, + } = options + + return generateLayoutWithCellCount( + width, + height, + targetCells, + minCellWidth, + minCellHeight, + ) + } + + /** + * Generate layout with a specific number of cells + * @param {number} width Viewport width + * @param {number} height Viewport height + * @param {number} targetCells Target number of cells + * @param {number} minCellWidth Minimum cell width + * @param {number} minCellHeight Minimum cell height + * @returns {Array} Array of cell objects + */ + const generateLayoutWithCellCount = ( + width, + height, + targetCells, + minCellWidth, + minCellHeight, + ) => { + const areas = [{ x: 0, y: 0, width, height, id: 0 }] + let cellId = 0 + + // Keep subdividing until we have enough cells or can't subdivide further + while (areas.length < targetCells) { + // Find areas that can be subdivided + const subdividableAreas = areas.filter((area) => { + const canSubdivideWidth = area.width >= minCellWidth * 2 + const canSubdivideHeight = area.height >= minCellHeight * 2 + return canSubdivideWidth || canSubdivideHeight + }) + + if (subdividableAreas.length === 0) { + // No more areas can be subdivided, break + console.log( + `Stopped at ${areas.length} cells - no more areas can be subdivided`, + ) + break + } + + // Choose area to subdivide - prefer larger areas + subdividableAreas.sort( + (a, b) => b.width * b.height - a.width * a.height, + ) + const areaToSplit = subdividableAreas[0] + const areaIndex = areas.indexOf(areaToSplit) + + // Remove the area we're about to split + areas.splice(areaIndex, 1) + + // Determine split direction + const canSubdivideWidth = areaToSplit.width >= minCellWidth * 2 + const canSubdivideHeight = areaToSplit.height >= minCellHeight * 2 + + let splitVertical = true + if (canSubdivideWidth && canSubdivideHeight) { + const aspectRatio = areaToSplit.width / areaToSplit.height + if (aspectRatio > 1.5) { + splitVertical = true // Split wide areas vertically + } else if (aspectRatio < 0.67) { + splitVertical = false // Split tall areas horizontally + } else { + splitVertical = Math.random() > 0.5 // Random for square-ish areas + } + } else { + splitVertical = canSubdivideWidth + } + + // Split the area + if (splitVertical) { + const splitRatio = 0.3 + Math.random() * 0.4 // Between 30% and 70% + const leftWidth = Math.max( + minCellWidth, + Math.min( + areaToSplit.width - minCellWidth, + areaToSplit.width * splitRatio, + ), + ) + const rightWidth = areaToSplit.width - leftWidth + + areas.push({ + x: areaToSplit.x, + y: areaToSplit.y, + width: leftWidth, + height: areaToSplit.height, + id: cellId++, + }) + areas.push({ + x: areaToSplit.x + leftWidth, + y: areaToSplit.y, + width: rightWidth, + height: areaToSplit.height, + id: cellId++, + }) + } else { + const splitRatio = 0.3 + Math.random() * 0.4 // Between 30% and 70% + const topHeight = Math.max( + minCellHeight, + Math.min( + areaToSplit.height - minCellHeight, + areaToSplit.height * splitRatio, + ), + ) + const bottomHeight = areaToSplit.height - topHeight + + areas.push({ + x: areaToSplit.x, + y: areaToSplit.y, + width: areaToSplit.width, + height: topHeight, + id: cellId++, + }) + areas.push({ + x: areaToSplit.x, + y: areaToSplit.y + topHeight, + width: areaToSplit.width, + height: bottomHeight, + id: cellId++, + }) + } + } + + // Convert areas to cells + return areas.map((area) => ({ + id: area.id, + x: area.x, + y: area.y, + width: area.width, + height: area.height, + })) + } + + /** + * Generate content for each cell in the layout + * @param {Array} cells Array of cell objects + * @param {Function} contentGenerator Function to generate content for each cell + * @returns {Array} Cells with content attached + */ + const populateCells = (cells, contentGenerator) => { + return cells.map((cell) => ({ + ...cell, + content: contentGenerator(cell), + })) + } + + return { + generateLayout, + populateCells, + } +}