From 789c6887caedc0e5ca9c42ca4c63f4890cd67687 Mon Sep 17 00:00:00 2001 From: maximo Date: Wed, 27 Sep 2023 15:02:28 +0700 Subject: [PATCH] Using expiring storage --- database/getters.ts | 2 +- src/components/DesktopView.vue | 10 ++- src/components/MobileView.vue | 4 +- src/components/elements/FilterModal.vue | 4 +- src/components/elements/InteractionBar.vue | 4 +- src/components/markers/MapMarkers.vue | 4 +- src/composables/useCaptcha.ts | 7 +- src/composables/useExpiringStorage.ts | 84 ++++++++++++++++++---- src/composables/useInitialMapPosition.ts | 2 +- src/stores/app.ts | 16 ++--- src/stores/captcha.ts | 49 ------------- src/stores/cryptocity.ts | 4 +- src/stores/map.ts | 6 +- src/stores/{cluster.ts => markers.ts} | 55 +++++++++++--- 14 files changed, 140 insertions(+), 111 deletions(-) delete mode 100644 src/stores/captcha.ts rename src/stores/{cluster.ts => markers.ts} (71%) diff --git a/database/getters.ts b/database/getters.ts index ce52edc4..a698dae7 100644 --- a/database/getters.ts +++ b/database/getters.ts @@ -61,5 +61,5 @@ export async function getClusterMaxZoom(dbArgs: DatabaseAuthArgs | DatabaseAnonA } export async function getCryptocityPolygon(dbArgs: DatabaseAuthArgs | DatabaseAnonArgs, city: Cryptocity): Promise { - return await fetchDb(AnonDbFunction.GetCryptocityPolygon, dbArgs, { body: { query: new URLSearchParams({ city }) } }) + return await fetchDb(AnonDbFunction.GetCryptocityPolygon, dbArgs, { query: new URLSearchParams({ city }) }) } diff --git a/src/components/DesktopView.vue b/src/components/DesktopView.vue index c5f57a24..f0cd202a 100644 --- a/src/components/DesktopView.vue +++ b/src/components/DesktopView.vue @@ -9,10 +9,10 @@ import InteractionBar from '@/components/elements/InteractionBar.vue' import TheMapInstance from '@/components/elements/TheMapInstance.vue' import IconChevronDown from '@/components/icons/icon-chevron-down.vue' import { useApp } from '@/stores/app' -import { useCluster } from '@/stores/cluster' +import { useMarkers } from '@/stores/markers' -const { isListShown, firstLocationsLoaded } = storeToRefs(useApp()) -const { singlesInView, clustersInView } = storeToRefs(useCluster()) +const { isListShown } = storeToRefs(useApp()) +const { singlesInView, clustersInView } = storeToRefs(useMarkers()) const openSuggestions = ref(false) @@ -32,9 +32,7 @@ const openSuggestions = ref(false) diff --git a/src/components/MobileView.vue b/src/components/MobileView.vue index b5890be6..60ef5fc6 100644 --- a/src/components/MobileView.vue +++ b/src/components/MobileView.vue @@ -8,11 +8,11 @@ import InteractionBar from '@/components/elements/InteractionBar.vue' import MobileList from '@/components/elements/MobileList.vue' import TheMapInstance from '@/components/elements/TheMapInstance.vue' import { useApp } from '@/stores/app' -import { useCluster } from '@/stores/cluster' +import { useMarkers } from '@/stores/markers' import { useLocations } from '@/stores/locations' const { isListShown } = storeToRefs(useApp()) -const { singlesInView, clustersInView } = storeToRefs(useCluster()) +const { singlesInView, clustersInView } = storeToRefs(useMarkers()) // TODO: Only show list when user searched for something // watch(firstLocationsLoaded, () => { diff --git a/src/components/elements/FilterModal.vue b/src/components/elements/FilterModal.vue index c4fb4781..ef90916e 100644 --- a/src/components/elements/FilterModal.vue +++ b/src/components/elements/FilterModal.vue @@ -14,7 +14,7 @@ import CrossIcon from '@/components/icons/icon-cross.vue' import FilterIcon from '@/components/icons/icon-filter.vue' import { useFilters } from '@/stores/filters' import { translateCategory, translateCurrency } from '@/translations' -import { useCluster } from '@/stores/cluster' +import { useMarkers } from '@/stores/markers' const isOpen = ref(false) const isMobile = useBreakpoints(screens).smaller('md') @@ -42,7 +42,7 @@ const nFilters = computed(() => { function updateFilters() { filtersStore.setSelectedCategories(unappliedFiltersCategories.value) filtersStore.setSelectedCurrencies(unappliedFiltersCurrencies.value) - useCluster().cluster() + useMarkers().cluster() } function clearFilters() { diff --git a/src/components/elements/InteractionBar.vue b/src/components/elements/InteractionBar.vue index 990c9688..13d2e805 100644 --- a/src/components/elements/InteractionBar.vue +++ b/src/components/elements/InteractionBar.vue @@ -10,7 +10,7 @@ import { useAutocomplete } from '@/composables/useAutocomplete' import { useMap } from '@/stores/map' import { useLocations } from '@/stores/locations' import { useApp } from '@/stores/app' -import { useCluster } from '@/stores/cluster' +import { useMarkers } from '@/stores/markers' defineEmits({ open: (value: boolean) => value, @@ -18,7 +18,7 @@ defineEmits({ const { querySearch, status, suggestions } = useAutocomplete() -const { singles } = storeToRefs(useCluster()) +const { singles } = storeToRefs(useMarkers()) const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) async function onSelect(suggestion?: Suggestion) { diff --git a/src/components/markers/MapMarkers.vue b/src/components/markers/MapMarkers.vue index fde5406a..85da9d2e 100644 --- a/src/components/markers/MapMarkers.vue +++ b/src/components/markers/MapMarkers.vue @@ -2,10 +2,10 @@ import { storeToRefs } from 'pinia' import ClusterMarkers from './ClusterMarkers.vue' import SingleMarkers from './SingleMarkers.vue' -import { useCluster } from '@/stores/cluster' +import { useMarkers } from '@/stores/markers' import { useCryptocity } from '@/stores/cryptocity' -const { clusters, singles } = storeToRefs(useCluster()) +const { clusters, singles } = storeToRefs(useMarkers()) const { cryptocities } = storeToRefs(useCryptocity()) // const { zoom } = storeToRefs(useMap()) diff --git a/src/composables/useCaptcha.ts b/src/composables/useCaptcha.ts index 227c3ad5..56b289af 100644 --- a/src/composables/useCaptcha.ts +++ b/src/composables/useCaptcha.ts @@ -13,11 +13,8 @@ async function _useCaptcha() { return await grecaptcha.execute(RECAPTCHA_SITE_KEY, { action: 'idle' }) } - async function getCaptchaUuid() { - return await authenticateAnonUser(DATABASE_ARGS, await getCaptchaToken()) - } - - const { payload: captchaToken } = await useExpiringStorage('captcha_token_uuid', getCaptchaUuid, { expiresIn: CAPTCHA_TOKEN_VALIDITY }) + const { payload: captchaToken, init } = useExpiringStorage('captcha_token_uuid', { expiresIn: CAPTCHA_TOKEN_VALIDITY, getAsyncValue: async () => authenticateAnonUser(DATABASE_ARGS, await getCaptchaToken()) }) + await init() // const loadRecaptcha = () => { // if (loaded) diff --git a/src/composables/useExpiringStorage.ts b/src/composables/useExpiringStorage.ts index d7dda6d8..57a84900 100644 --- a/src/composables/useExpiringStorage.ts +++ b/src/composables/useExpiringStorage.ts @@ -1,12 +1,13 @@ +import type { Serializer } from '@vueuse/core' import type { Ref } from 'vue' import { computed, ref, watch } from 'vue' -interface ExpiringValue { +export interface ExpiringValue { value: T expires: string } -interface UseExpiringStorageOptions { +interface UseExpiringStorageBaseOptions { /** * The amount of time in ms when the storage expires */ @@ -17,6 +18,40 @@ interface UseExpiringStorageOptions { * @default true */ autoRefresh?: boolean + + /** + * The serializer to use + * @default { read: JSON.parse, write: JSON.stringify } + */ + serializer?: Serializer> +} + +interface UseExpiringStorageSyncOptions extends UseExpiringStorageBaseOptions { + /** + * If provided, it will be used to get the value when the storage is empty and once the storage expires + * @default undefined + */ + getValue?: () => T +} + +interface UseExpiringStorageAsyncOptions extends UseExpiringStorageBaseOptions { + /** + * If provided, it will be used to get the value when the storage is empty and once the storage expires. + * If you want to initialize the storage with a value, use init() as follows + * @example + * const { payload, init } = useExpiringStorage('YOUR_KEY', {getAsyncValue: 'your async function'}) + * await init() // Initialize the storage with the value if it doesn't exist + * + * @default undefined + */ + getAsyncValue?: () => Promise +} + +const storage = globalThis.localStorage +const hasExpired = (expiryDate: string) => new Date(expiryDate).getTime() <= Date.now() +const defaultSerializer: Serializer> = { + read: JSON.parse, + write: JSON.stringify, } /** @@ -27,25 +62,34 @@ interface UseExpiringStorageOptions { * @param getValue the function to get the value. It will run when the storage is empty and once the storage expires * @returns */ -export async function useExpiringStorage(key: string, getValue: () => Promise, options: UseExpiringStorageOptions) { - const { expiresIn, autoRefresh = true } = options +export function useExpiringStorage(key: string, options: UseExpiringStorageSyncOptions | UseExpiringStorageAsyncOptions) { + const { expiresIn, autoRefresh = true, serializer = defaultSerializer as Serializer> } = options + if (!(options as UseExpiringStorageSyncOptions).getValue && !(options as UseExpiringStorageAsyncOptions).getAsyncValue) + throw new Error('Either getValue or getAsyncValue must be provided') - const hasExpired = (expiryDate: string) => new Date(expiryDate).getTime() <= Date.now() + const isAsync = 'getAsyncValue' in options + const getValue = isAsync ? (options as UseExpiringStorageAsyncOptions).getAsyncValue! : (options as UseExpiringStorageSyncOptions).getValue! - const storage = globalThis.localStorage - - const storedValue = storage.getItem(key) ? JSON.parse(storage.getItem(key)!) as ExpiringValue : undefined - const alreadyExists = storedValue && !hasExpired(storedValue.expires) + const storedValue = storage.getItem(key) ? serializer.read(storage.getItem(key)!) as ExpiringValue : undefined + const alreadyExists = !!storedValue && !hasExpired(storedValue.expires) // eslint-disable-next-line no-console - console.log(`LocalStorage ${key}: ${alreadyExists ? '♻️ Reusing value' : '🛎️ Creating new one'}`) + console.log(`useExpiringStorage ${key}: ${alreadyExists ? '♻️ Reusing value' : `🛎️ Needs to create a new one. ${'getAsyncValue' in options ? 'Use `await init()` before reading payload.' : ''}`}`) + + let initialValue: T | undefined + if (alreadyExists) + initialValue = storedValue!.value + else if (!isAsync) + initialValue = getValue() as T - const stored = ref(alreadyExists ? storedValue.value : await getValue()) as Ref + const stored = ref(initialValue) as Ref watch(stored, () => { + if (!stored.value) + return const expires = new Date(Date.now() + expiresIn).toISOString() - storage.setItem(key, JSON.stringify({ value: stored.value, expires })) - }, { immediate: true }) + storage.setItem(key, serializer.write({ value: stored.value, expires })) + }, { immediate: true, deep: true }) async function refreshData(expiresIn: number) { if (autoRefresh) { @@ -56,11 +100,21 @@ export async function useExpiringStorage(key: string, getValue: () => Promise refreshData(expiresIn) }, expiresIn) } - }; + } + + /** + * If the value in the storage has expired or it does not exists, it will be updated with the new value + */ + async function init() { + if (!alreadyExists) + stored.value = await getValue() + } - const remainingTime = alreadyExists && storedValue ? expiresIn - (Date.now() - new Date(storedValue.expires).getTime()) : expiresIn + const remainingTime = alreadyExists && storedValue.value ? new Date(storedValue.expires).getTime() - Date.now() : expiresIn refreshData(remainingTime) return { payload: computed(() => stored.value), + init, + alreadyExists, } } diff --git a/src/composables/useInitialMapPosition.ts b/src/composables/useInitialMapPosition.ts index fbf4127b..f3ded72f 100644 --- a/src/composables/useInitialMapPosition.ts +++ b/src/composables/useInitialMapPosition.ts @@ -17,7 +17,7 @@ async function selectAndOpenCard(uuid: string) { if (location) { // We set the locations in the store, and then we show the card // https://github.com/nimiq/crypto-map/commit/45b8cdf2e7aabba6039d69043190b8357950736d#r126800610 - (await import ('@/stores/cluster')).useCluster().singles = [location] + (await import ('@/stores/markers')).useMarkers().singles = [location] const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) await sleep(500) // We need to wait for the marker to be rendered in the right position to avoid CLS ;(document.querySelector(`[data-trigger-uuid="${uuid}"]`) as HTMLElement)?.click() diff --git a/src/stores/app.ts b/src/stores/app.ts index 35feb622..acdde82a 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -1,26 +1,21 @@ -import { watchOnce } from '@vueuse/core' import { defineStore, storeToRefs } from 'pinia' import { ref, watch } from 'vue' -import { useCluster } from './cluster' +import { useMarkers } from '@/stores/markers' export const useApp = defineStore('app', () => { // We just track the first load, so we can show a loading indicator - const firstLocationsLoaded = ref(false) const isListShown = ref(false) const mapLoaded = ref(false) + const { loaded: markersLoaded } = storeToRefs(useMarkers()) + const until = Date.now() + 200 // Show the splash screen at least for 300ms const showSplashScreen = ref(true) - watch([mapLoaded, firstLocationsLoaded], () => { - if (mapLoaded.value && firstLocationsLoaded.value) + watch([mapLoaded, markersLoaded], () => { + if (mapLoaded.value && markersLoaded.value) setTimeout(() => showSplashScreen.value = false, Math.max(0, until - Date.now())) }) - const { singles, clusters } = storeToRefs(useCluster()) - - // The moment any of these two stores are loaded, we set the firstLocationsLoaded to true - watchOnce([singles, clusters], () => firstLocationsLoaded.value = true) - // We track if the user has hidden the search box hint using localStorage const shouldShowSearchBoxHint = ref(!localStorage.getItem('hideSearchBoxHint')) document.documentElement.style.setProperty('--search-box-hint', shouldShowSearchBoxHint.value ? '1' : '0') @@ -31,7 +26,6 @@ export const useApp = defineStore('app', () => { } return { - firstLocationsLoaded, isListShown, shouldShowSearchBoxHint, hideSearchBoxHint, diff --git a/src/stores/captcha.ts b/src/stores/captcha.ts deleted file mode 100644 index 94a30788..00000000 --- a/src/stores/captcha.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { defineStore } from 'pinia' -import { authenticateAnonUser } from 'database' -import { useExpiringStorage } from '@/composables/useExpiringStorage' -import { DATABASE_ARGS } from '@/shared' - -const CAPTCHA_TOKEN_VALIDITY = 10 * 60 * 1000 // 10 minutes for the captcha token -const RECAPTCHA_SITE_KEY = import.meta.env.VITE_RECAPTCHA_SITE_KEY - -export const useCaptcha = defineStore('captcha', async () => { - async function getCaptchaToken() { - while (!globalThis.grecaptcha) - await new Promise(resolve => setTimeout(resolve, 100)) - return await grecaptcha.execute(RECAPTCHA_SITE_KEY, { action: 'idle' }) - } - - async function getCaptchaUuid() { - return await authenticateAnonUser(DATABASE_ARGS, await getCaptchaToken()) - } - - const { payload: captchaToken } = await useExpiringStorage('captcha_token_uuid', getCaptchaUuid, { expiresIn: CAPTCHA_TOKEN_VALIDITY }) - - // const loadRecaptcha = () => { - // if (loaded) - // return - // const script = document.createElement('script') - - // script.src = `https://www.google.com/recaptcha/api.js?render=${recapthaKey}` - // script.id = 'recaptcha-script' - // script.async = true - - // document.body.append(script) - // script.onload = () => loaded = true - // } - - // const removeRecaptcha = () => { - // const script = document.getElementById('recaptcha-script') - // if (script) - // script.remove() - - // const recaptchaElems = document.getElementsByClassName('grecaptcha-badge') - // if (recaptchaElems.length) - // recaptchaElems[0].remove() - // } - - return { - getCaptchaToken, - captchaToken, - } -}) diff --git a/src/stores/cryptocity.ts b/src/stores/cryptocity.ts index da007865..4baf7167 100644 --- a/src/stores/cryptocity.ts +++ b/src/stores/cryptocity.ts @@ -4,7 +4,7 @@ import { defineStore, storeToRefs } from 'pinia' import { addBBoxToArea, bBoxIsWithinArea, bBoxesIntersect, distanceInPx } from 'shared' import type { BoundingBox, Cryptocity, CryptocityMarker, CryptocityMemoized } from 'types' import { computed, ref, watch } from 'vue' -import { useCluster } from './cluster' +import { useMarkers } from './markers' import { useMap } from './map' import { cryptocitiesData } from '@/assets-dev/cryptocities-assets.ts' import { getAnonDatabaseArgs } from '@/shared' @@ -12,7 +12,7 @@ import { getAnonDatabaseArgs } from '@/shared' export const useCryptocity = defineStore('cryptocities', () => { const { map, boundingBox, zoom, latInPx, lngInPx } = storeToRefs(useMap()) - const { clustersInView } = storeToRefs(useCluster()) + const { clustersInView } = storeToRefs(useMarkers()) const cryptocities = useLocalStorage('cryptocities', cryptocitiesData) diff --git a/src/stores/map.ts b/src/stores/map.ts index a3d343f0..3e030e02 100644 --- a/src/stores/map.ts +++ b/src/stores/map.ts @@ -4,8 +4,8 @@ import type { BoundingBox, EstimatedMapPosition, MapPosition, Point } from 'type import { computed, ref, shallowRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import type { GoogleMap } from 'vue3-google-map' -import { useCluster } from './cluster' import { useLocations } from './locations' +import { useMarkers } from './markers' export const useMap = defineStore('map', () => { const mapInstance = shallowRef() @@ -30,7 +30,7 @@ export const useMap = defineStore('map', () => { replace: true, }) }, 300, { maxWait: 2000 }) - const clusterDebouncer = useDebounceFn(() => useCluster().cluster(), 300, { maxWait: 2000 }) + const clusterDebouncer = useDebounceFn(() => useMarkers().cluster(), 300, { maxWait: 2000 }) function boundsToBox(bounds: google.maps.LatLngBounds) { const { lat: swLat, lng: swLng } = bounds.getSouthWest().toJSON() @@ -55,7 +55,7 @@ export const useMap = defineStore('map', () => { // If we don't have the item in the memoized map, we need to update the clusters // If we have it, getMemoized will update the active value - if (useCluster().needsToUpdate()) + if (useMarkers().needsToUpdate()) clusterDebouncer() } diff --git a/src/stores/cluster.ts b/src/stores/markers.ts similarity index 71% rename from src/stores/cluster.ts rename to src/stores/markers.ts index 1f98ea5c..70aac944 100644 --- a/src/stores/cluster.ts +++ b/src/stores/markers.ts @@ -2,18 +2,21 @@ import { getClusterMaxZoom, getClusters } from 'database' import { defineStore, storeToRefs } from 'pinia' import type { Cluster, ComputedClusterSet, Location, LocationClusterParams, LocationClusterSet } from 'types' import { CLUSTERS_MAX_ZOOM, addBBoxToArea, algorithm, bBoxIsWithinArea, getItemsWithinBBox, toMultiPolygon } from 'shared' -import { computed, shallowRef } from 'vue' +import { computed, ref, shallowRef } from 'vue' import { useLocations } from './locations' import { useFilters } from './filters' import { useMap } from './map' import { getAnonDatabaseArgs, parseLocation } from '@/shared' import { computeCluster } from '@/../shared/compute-cluster' +import type { ExpiringValue } from '@/composables/useExpiringStorage' +import { useExpiringStorage } from '@/composables/useExpiringStorage' -export const useCluster = defineStore('cluster', () => { +export const useMarkers = defineStore('markers', () => { const { setLocations, getLocations } = useLocations() const { visitedAreas } = storeToRefs(useLocations()) const { filterLocations, filtersToString } = useFilters() const { zoom, boundingBox } = storeToRefs(useMap()) + const loaded = ref(false) /* With memoziation, we reduce redundant calculations/requests and optimizes user map interactions to optimize map performance: @@ -21,7 +24,32 @@ export const useCluster = defineStore('cluster', () => { - Before re-clustering, we check for existing data matching the current zoom, bounding box, and filters. - If a match is found, we reuse stored clusters; otherwise, new clusters are computed and stored. */ - const memoized = new Map() + const { payload: memoized, alreadyExists } = useExpiringStorage('memoized_clusters_locations', + { + expiresIn: 7 * 24 * 60 * 60 * 1000, + getValue: () => new Map(), + serializer: { + read: (str) => { + const obj = JSON.parse(str) + const { value, expires } = obj + const map = new Map() + for (const key in obj) { + const { zoom, categories, currencies } = JSON.parse(key) + map.set({ zoom, categories, currencies }, obj[key]) + } + return { value, expires } as ExpiringValue> + }, + write: (value: ExpiringValue>) => { + const obj: Record = {} + for (const [key, val] of value.value.entries()) + obj[JSON.stringify(key)] = val + return JSON.stringify(obj) + }, + }, + }) + + if (alreadyExists) + loaded.value = alreadyExists /** * The clusters and singles are computed from the memoized clusters and singles. For each zoom level and each filter combination, @@ -33,7 +61,7 @@ export const useCluster = defineStore('cluster', () => { const singlesInView = computed(() => boundingBox.value ? getItemsWithinBBox(filterLocations(singles.value), boundingBox.value) : []) function getKey({ zoom, categories, currencies }: LocationClusterParams): LocationClusterParams | undefined { - for (const key of memoized.keys()) { + for (const key of memoized.value.keys()) { if (key.zoom === zoom && key.categories === categories && key.currencies === currencies) return key } @@ -44,7 +72,7 @@ export const useCluster = defineStore('cluster', () => { // If the key already exists, we need to reference the existing key by memory. Creating a new object, even with the same values, will not work. const key = getKey(obj) || obj - const item: LocationClusterSet | undefined = memoized.get(key) + const item: LocationClusterSet | undefined = memoized.value.get(key) // If the item exists and the bounding box is within the memoized area, we can reuse the memoized item and there is no need to re-cluster const needsToUpdate = !item || !boundingBox.value || !bBoxIsWithinArea(boundingBox.value, item.memoizedArea) @@ -65,15 +93,16 @@ export const useCluster = defineStore('cluster', () => { : getMemoized().needsToUpdate } - let maxZoomFromServer: number | undefined + const { init: initMaxZoom, payload: maxZoomFromServer } = useExpiringStorage('max_zoom_from_server', { expiresIn: 7 * 24 * 60 * 60 * 1000, getAsyncValue: async () => getClusterMaxZoom(await getAnonDatabaseArgs()) }) + async function shouldRunInClient({ zoom, categories, currencies }: LocationClusterParams): Promise { // We cannot compute all clusters combinations in the server, if user has selected currencies or categories // we need to compute the clusters in the client if (currencies || categories) return true - maxZoomFromServer ||= await getClusterMaxZoom(await getAnonDatabaseArgs()) - return zoom > maxZoomFromServer + await initMaxZoom() // Get the value from the server if it doesn't exist + return zoom > maxZoomFromServer.value } async function getClusterFromClient(): Promise { @@ -98,8 +127,10 @@ export const useCluster = defineStore('cluster', () => { const { item, key, needsToUpdate } = getMemoized() - if (!needsToUpdate) + if (!needsToUpdate) { + loaded.value = true return + } const { clusters: newClusters, singles: newSingles } = await shouldRunInClient(key) ? await getClusterFromClient() @@ -111,7 +142,7 @@ export const useCluster = defineStore('cluster', () => { item.memoizedSingles.push(...newSingles.filter(s => item.memoizedSingles.every(i => i.uuid !== s.uuid))) } else { - memoized.set(key, { + memoized.value.set(key, { memoizedArea: toMultiPolygon(boundingBox.value!).geometry, memoizedClusters: newClusters, memoizedSingles: newSingles, @@ -120,14 +151,18 @@ export const useCluster = defineStore('cluster', () => { clusters.value = newClusters singles.value = newSingles + + loaded.value = true } return { + memoized, cluster, clusters, singles, clustersInView, singlesInView, needsToUpdate, + loaded: computed(() => alreadyExists || singles.value.length > 0 || clusters.value.length > 0), } })