diff --git a/src/composables/tile.test.ts b/src/composables/tile.test.ts index 8866343..3b9403d 100644 --- a/src/composables/tile.test.ts +++ b/src/composables/tile.test.ts @@ -1,9 +1,13 @@ +import fs from 'fs'; +import nock from 'nock'; import { expect } from "chai"; import { createBoundingBox, + createTile, latLonToTileCoords, enumerateTilesAround, } from "./tile"; +import config from '../config'; describe('Tile', () => { describe("createBoundingBox", () => { @@ -60,4 +64,39 @@ describe('Tile', () => { expect(result.length).to.equal(25); }); }); + + describe('load', () => { + before(() => { + nock.disableNetConnect(); + config.tileServer = 'https://tiles.soundscape.services'; + }); + afterEach(() => { + nock.cleanAll(); + }); + + const tileData = JSON.parse( + fs.readFileSync( + 'cypress/fixtures/tiles_16_18109_23965.json', + 'utf8' + ) + ); + + it('should fetch tile data when unavailable', async () => { + const scope = nock(config.tileServer) + .get('/16/18109/23965.json') + .reply(200, tileData); + + await createTile(18109, 23965, 16).load(); + expect(scope.isDone()).to.be.true; + }); + + it('should not re-request fresh tile data', async () => { + const scope = nock(config.tileServer) + .get('/16/18109/23965.json') + .reply(200, tileData); + + await createTile(18109, 23965, 16).load(); + expect(scope.isDone()).to.be.false; + }); + }); }); \ No newline at end of file diff --git a/src/composables/tile.ts b/src/composables/tile.ts index 3dc75bc..fb3672b 100644 --- a/src/composables/tile.ts +++ b/src/composables/tile.ts @@ -1,5 +1,7 @@ // Copyright (c) Daniel W. Steinbrook. -// with many thanks to ChatGPT + +// So that our unit tests can run outside of a browser +import fetch from 'isomorphic-fetch'; import { point, buffer, bbox } from '@turf/turf'; import { BBox } from "geojson"; @@ -7,6 +9,7 @@ import { cache, SoundscapeFeature } from '../state/cache' import config from '../config' export const zoomLevel = 16; +const maxAgeMilliseconds = 1000 * 60 * 60 * 24 * 7; // 1 week // Track tiles that don't need to be re-requested at the moment const tilesInProgressOrDone = new Set(); @@ -19,6 +22,8 @@ interface TileCoordinates { interface Tile extends TileCoordinates { key: string; + url: string, + shouldRefresh: () => Promise; load: () => Promise; getFeatures: () => Promise; } @@ -87,12 +92,25 @@ export function enumerateTilesInBoundingBox( return tiles; } -function createTile(x: number, y: number, z: number): Tile { +export function createTile(x: number, y: number, z: number): Tile { const tile: Tile = { x, y, z, key: `${z}/${x}/${y}`, + url: `${config.tileServer}/${z}/${x}/${y}.json`, + + shouldRefresh: async function(): Promise { + // Check if the cached entry is still valid based on maxAgeMilliseconds + const currentTime = new Date().getTime(); + const lastFetchTime = await cache.lastFetchTime(this.url); + + return ( + lastFetchTime === null || + currentTime - lastFetchTime > maxAgeMilliseconds + ); + }, + load: async function(): Promise { if (tilesInProgressOrDone.has(tile.key)) { // no need to request again @@ -100,14 +118,25 @@ function createTile(x: number, y: number, z: number): Tile { } tilesInProgressOrDone.add(tile.key); - const urlToFetch = `${config.tileServer}/${tile.key}.json`; + if (!await this.shouldRefresh()) { + // Data is still fresh; no need to refresh + return; + } + + // Delete any stale features + cache.deleteFeatures(this.key); + try { - const data = await cache.fetch(urlToFetch, tile.key); + // Fetch the URL since it's not in the cache or has expired + const response = await fetch(this.url); + console.log("Fetched: ", this.url); + const data = await response.json(); if (data?.features) { for (const feature of data.features) { await cache.addFeature(feature, tile.key); } console.log(`Loaded ${data.features.length} new features.`); + cache.updateLastFetch(this.url); } } catch (error) { console.error(error); @@ -115,6 +144,7 @@ function createTile(x: number, y: number, z: number): Tile { tilesInProgressOrDone.delete(tile.key); } }, + getFeatures: async function(): Promise { return cache.getFeatures(tile.key); }, diff --git a/src/state/cache.test.ts b/src/state/cache.test.ts index 6d8a4a2..5bdc6f1 100644 --- a/src/state/cache.test.ts +++ b/src/state/cache.test.ts @@ -1,7 +1,5 @@ // Mock out IndexedDB and Fetch API for testing import 'fake-indexeddb/auto'; -import fs from 'fs'; -import nock from 'nock'; import { expect } from "chai"; import { cache, SoundscapeFeature } from "./cache"; @@ -24,6 +22,10 @@ describe("Cache", () => { }; describe("feature", () => { + beforeEach(async () =>{ + await cache.clear(); + }); + it('should be empty for unloaded tiles', async () => { const result = await cache.getFeatures("16/18109/23965"); expect(result.length).to.equal(0); @@ -32,16 +34,18 @@ describe("Cache", () => { it('should store and retrieve features', async () => { cache.addFeature(testFeature, "16/18109/23965"); const result = await cache.getFeatures("16/18109/23965"); - expect(result.length).to.equal(1); + expect(result[0].id).to.be.a('number'); // Original feature will be annotated with an id - expect(result[0]).to.deep.equal({...testFeature, id: 1}); - }); + expect(result[0].properties).to.deep.equal(testFeature.properties); + expect(result![0].geometry).to.deep.equal(testFeature.geometry); }); it ('should fetch features by OSM ID', async () => { cache.addFeature(testFeature, "16/18109/23965"); const result = await cache.getFeatureByOsmId(testFeature.osm_ids[0]); // Original feature will be annotated with an id - expect(result).to.deep.equal({...testFeature, id: 1}); + expect(result!.id).to.be.a('number'); + expect(result!.properties).to.deep.equal(testFeature.properties); + expect(result!.geometry).to.deep.equal(testFeature.geometry); }); it ('should return no results for invalid OSM ID', async () => { @@ -49,44 +53,4 @@ describe("Cache", () => { expect(result).to.equal(null); }); }); - - describe('tile', () => { - before(() => { - nock.disableNetConnect(); - }); - afterEach(() => { - nock.cleanAll(); - }); - - const tileData = JSON.parse( - fs.readFileSync( - 'cypress/fixtures/tiles_16_18109_23965.json', - 'utf8' - ) - ); - - it('should fetch tile data when unavailable', async () => { - const scope = nock('https://tiles.soundscape.services') - .get('/tiles/16/18109/23965.json') - .reply(200, tileData); - - await cache.fetch( - 'https://tiles.soundscape.services/tiles/16/18109/23965.json', - '16/18109/23965' - ); - expect(scope.isDone()).to.be.true; - }); - - it('should not re-request fresh tile data', async () => { - const scope = nock('https://tiles.soundscape.services') - .get('/tiles/16/18109/23965.json') - .reply(200, tileData); - - await cache.fetch( - 'https://tiles.soundscape.services/tiles/16/18109/23965.json', - '16/18109/23965' - ); - expect(scope.isDone()).to.be.false; - }); - }); }); \ No newline at end of file diff --git a/src/state/cache.ts b/src/state/cache.ts index 7c66ef7..c10b0d3 100644 --- a/src/state/cache.ts +++ b/src/state/cache.ts @@ -2,8 +2,6 @@ // with many thanks to ChatGPT import { Feature } from 'geojson'; -// So that our unit tests can run outside of a browser -import fetch from 'isomorphic-fetch'; // Tile server's custom extensions to GeoJSON Features export type SoundscapeFeature = Feature & { @@ -27,7 +25,6 @@ interface IDBEventTargetWithResult extends EventTarget { // Bump this when changing schema (e.g. adding an index) const dbVersion = 1; const dbName = 'TileCache'; -const maxAgeMilliseconds = 1000 * 60 * 60 * 24 * 7; // 1 week // Function to open the IndexedDB database async function openDatabase(): Promise { @@ -80,9 +77,9 @@ async function clearObjectStore(objectStoreName: string): Promise { export const cache = { db: null as IDBDatabase | null, // to be populated on first request - clear: function(): void { - clearObjectStore('features'); - clearObjectStore('urls'); + clear: async function(): Promise { + await clearObjectStore('features'); + await clearObjectStore('urls'); }, lastFetchTime: function(url: string): Promise { @@ -112,17 +109,6 @@ export const cache = { }); }, - shouldFetch: async function(url: string): Promise { - // Check if the cached entry is still valid based on maxAgeMilliseconds - const currentTime = new Date().getTime(); - const lastFetchTime = await this.lastFetchTime(url); - - return ( - lastFetchTime === null || - currentTime - lastFetchTime > maxAgeMilliseconds - ); - }, - updateLastFetch: function(url: string): Promise { return new Promise(async (resolve, reject) => { if (!cache.db) { @@ -139,7 +125,6 @@ export const cache = { }); putRequest.onsuccess = () => { - console.log("Fetched: ", url); resolve(true); }; @@ -149,21 +134,6 @@ export const cache = { }); }, - // Function to fetch a URL only if it hasn't been fetched for a certain duration - fetch: async function(url: string, tileKey: string): Promise { - if (!await cache.shouldFetch(url)) { - return null; - } - - // Delete any stale features - cache.deleteFeatures(tileKey); - - // Fetch the URL since it's not in the cache or has expired - const response = await fetch(url); - cache.updateLastFetch(url); - return await response.json(); - }, - // Function to add GeoJSON feature to the cache addFeature: function(feature: SoundscapeFeature, tile: string): Promise { return new Promise(async (resolve, reject) => {