diff --git a/apps/client/src/components/exemplarView/ExemplarView.vue b/apps/client/src/components/exemplarView/ExemplarView.vue index 3f8154f8..a419c5b7 100644 --- a/apps/client/src/components/exemplarView/ExemplarView.vue +++ b/apps/client/src/components/exemplarView/ExemplarView.vue @@ -145,7 +145,10 @@ const { selectedVar1, horizonChartSettings, histogramYAxisLabel, + visibleExemplarTracks, + visibleConditionGroupsCount, } = storeToRefs(exemplarViewStore); +const { loadMoreConditionGroups } = exemplarViewStore; const imageViewerStore = useImageViewerStore(); const selectedAttributeName = computed(() => exemplarViewStore.getAttributeName()); @@ -297,6 +300,15 @@ function handleScroll(delta: number) { scrollExtent.value.max ); + // Check if we are near the bottom of the scroll extent + const buffer = 500; // pixels + if (viewStateMirror.value.target[1] >= scrollExtent.value.max - buffer) { + // Trigger load more + loadMoreConditionGroups(); + // Since visible tracks changed, we need to recalculate Y offsets + // This will happen in renderDeckGL + } + const newViewState = cloneDeep(viewStateMirror.value); deckgl.value.setProps({ initialViewState: newViewState, @@ -448,6 +460,12 @@ watch( { immediate: false } // We don't need to run this immediately on mount ); +// Watch for changes in visible tracks (pagination) to re-render +watch(visibleExemplarTracks, () => { + safeRenderDeckGL(); +}); + + // Main rendering function for DeckGL ------------------------------------------------------------------- let deckGLLayers: any[] = []; @@ -528,7 +546,7 @@ const exemplarRenderInfo = ref(new Map()); // Finds the exemplar tracks that are currently on screen. const exemplarTracksOnScreen = computed(() => { - return exemplarTracks.value.filter((exemplar: ExemplarTrack) => { + return visibleExemplarTracks.value.filter((exemplar: ExemplarTrack) => { const renderInfo = exemplarRenderInfo.value.get( uniqueExemplarKey(exemplar) ); @@ -574,9 +592,9 @@ const bottomYOffset = computed(() => { function recalculateExemplarYOffsets(): void { exemplarRenderInfo.value.clear(); let yOffset = 0; - let lastExemplar = exemplarTracks.value[0]; - for (let i = 0; i < exemplarTracks.value.length; i++) { - const exemplar = exemplarTracks.value[i]; + let lastExemplar = visibleExemplarTracks.value[0]; + for (let i = 0; i < visibleExemplarTracks.value.length; i++) { + const exemplar = visibleExemplarTracks.value[i]; yOffset += exemplarHeight.value; if (i !== 0) { if (isEqual(exemplar.tags, lastExemplar.tags)) { @@ -678,7 +696,7 @@ watch( // Populate cellSegmentationData with all segmentation data for all cells in exemplar tracks ONCE. async function getCellSegmentationData() { // For every cell from every exemplar track, get its segmentation and location and push that to the cellSegmentationData - for (const exemplar of exemplarTracks.value) { + for (const exemplar of visibleExemplarTracks.value) { if (!exemplar.data || exemplar.data.length === 0) continue; // For every exemplar track, iterate through its cells to get segmentation and location for (const cell of exemplar.data) { diff --git a/apps/client/src/stores/componentStores/ExemplarViewStore.ts b/apps/client/src/stores/componentStores/ExemplarViewStore.ts index 4cfe50e4..450f14e2 100644 --- a/apps/client/src/stores/componentStores/ExemplarViewStore.ts +++ b/apps/client/src/stores/componentStores/ExemplarViewStore.ts @@ -342,6 +342,8 @@ export const useExemplarViewStore = defineStore('ExemplarViewStore', () => { } finally { // After fetching, we've finished loading. exemplarDataLoaded.value = true; + // Reset pagination + visibleConditionGroupsCount.value = 15; } } @@ -934,6 +936,28 @@ export const useExemplarViewStore = defineStore('ExemplarViewStore', () => { } } + // Pagination logic + const visibleConditionGroupsCount = ref(15); + const LOAD_INCREMENT = 5; + + // Cache the sorted groups to avoid re-sorting on every render/scroll + const sortedExemplarGroups = computed(() => { + return sortExemplarsByCondition(exemplarTracks.value); + }); + + function loadMoreConditionGroups() { + if (visibleConditionGroupsCount.value < sortedExemplarGroups.value.length) { + visibleConditionGroupsCount.value += LOAD_INCREMENT; + } + } + + // This is the subset of tracks that should actually be rendered + const visibleExemplarTracks = computed(() => { + const visibleGroups = sortedExemplarGroups.value.slice(0, visibleConditionGroupsCount.value); + // Flatten array of arrays + return visibleGroups.flatMap(group => group); + }); + // const getHistogramDataComputed = computed(() => histogramData.value); const conditionHistogramsComputed = computed( () => conditionHistograms.value @@ -944,6 +968,8 @@ export const useExemplarViewStore = defineStore('ExemplarViewStore', () => { getExemplarTracks, getExemplarImageUrls, exemplarTracks, + visibleExemplarTracks, // Export the visible tracks + loadMoreConditionGroups, // Export the load more action viewConfiguration, snippetZoom, exemplarHeight, @@ -965,5 +991,6 @@ export const useExemplarViewStore = defineStore('ExemplarViewStore', () => { histogramDomains: histogramDomainsComputed, exemplarDataLoaded, // export the loading state horizonChartScheme, + visibleConditionGroupsCount, // for debugging if needed }; }); diff --git a/apps/client/src/stores/componentStores/conditionSelectorStore.ts b/apps/client/src/stores/componentStores/conditionSelectorStore.ts index 2a097d19..6e089745 100644 --- a/apps/client/src/stores/componentStores/conditionSelectorStore.ts +++ b/apps/client/src/stores/componentStores/conditionSelectorStore.ts @@ -8,6 +8,7 @@ import { import { keysToString, stringToKeys } from '@/util/conChartStringFunctions'; import { useNotificationStore } from '../misc/notificationStore'; import { isEmpty } from 'lodash-es'; +import { findOptimalAxes } from '@/util/axisOptimizer'; export type Axis = 'x-axis' | 'y-axis'; @@ -110,10 +111,10 @@ export const useConditionSelectorStore = defineStore( watch( currentExperimentTags, (newExperimentTags) => { - const tagKeys = Object.keys(newExperimentTags); - if (tagKeys.length > 1) { - selectedXTag.value = tagKeys[0]; - selectedYTag.value = tagKeys[1]; + const optimalAxes = findOptimalAxes(newExperimentTags); + if (optimalAxes) { + selectedXTag.value = optimalAxes[0]; + selectedYTag.value = optimalAxes[1]; } }, { immediate: true } diff --git a/apps/client/src/util/axisOptimizer.ts b/apps/client/src/util/axisOptimizer.ts new file mode 100644 index 00000000..d18e7d91 --- /dev/null +++ b/apps/client/src/util/axisOptimizer.ts @@ -0,0 +1,46 @@ +/** + * Finds the optimal X and Y axes keys from a set of tags to minimize the total number of charts. + * The total number of charts is the product of the number of unique values in the X and Y axes. + * We prioritize pairs where the product is at least 2. + * If no pair has a product >= 2, we default to the first two available keys. + * + * @param tags A record where keys are tag names and values are arrays of tag options. + * @returns A tuple [xKey, yKey] representing the selected axes. Returns null if fewer than 2 keys exist. + */ +export function findOptimalAxes(tags: Record): [string, string] | null { + const keys = Object.keys(tags); + if (keys.length < 2) { + return null; + } + + let bestPair: [string, string] | null = null; + let minProduct = Infinity; + + // Iterate through all unique pairs. + for (let i = 0; i < keys.length; i++) { + for (let j = i + 1; j < keys.length; j++) { + const key1 = keys[i]; + const key2 = keys[j]; + + const count1 = tags[key1].length; + const count2 = tags[key2].length; + + const product = count1 * count2; + + if (product >= 2) { + if (product < minProduct) { + minProduct = product; + bestPair = [key1, key2]; + } + } + } + } + + // If we found a pair with product >= 2, return it. + if (bestPair) { + return bestPair; + } + + // Fallback + return [keys[0], keys[1]]; +}