Skip to content

Commit

Permalink
Move cache.fetch into tile.load
Browse files Browse the repository at this point in the history
  • Loading branch information
steinbro committed Nov 28, 2024
1 parent 2cdae4e commit c08e2a0
Show file tree
Hide file tree
Showing 4 changed files with 86 additions and 83 deletions.
39 changes: 39 additions & 0 deletions src/composables/tile.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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;
});
});
});
38 changes: 34 additions & 4 deletions src/composables/tile.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// 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";
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<string>();
Expand All @@ -19,6 +22,8 @@ interface TileCoordinates {

interface Tile extends TileCoordinates {
key: string;
url: string,
shouldRefresh: () => Promise<boolean>;
load: () => Promise<void>;
getFeatures: () => Promise<SoundscapeFeature[]>;
}
Expand Down Expand Up @@ -87,34 +92,59 @@ 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<boolean> {
// 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<void> {
if (tilesInProgressOrDone.has(tile.key)) {
// no need to request again
return;
}
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);
// should be retried when next needed
tilesInProgressOrDone.delete(tile.key);
}
},

getFeatures: async function(): Promise<SoundscapeFeature[]> {
return cache.getFeatures(tile.key);
},
Expand Down
56 changes: 10 additions & 46 deletions src/state/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);
Expand All @@ -32,61 +34,23 @@ 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 () => {
const result = await cache.getFeatureByOsmId(9999999);
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;
});
});
});
36 changes: 3 additions & 33 deletions src/state/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -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<IDBDatabase> {
Expand Down Expand Up @@ -80,9 +77,9 @@ async function clearObjectStore(objectStoreName: string): Promise<void> {
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<void> {
await clearObjectStore('features');
await clearObjectStore('urls');
},

lastFetchTime: function(url: string): Promise<number | null> {
Expand Down Expand Up @@ -112,17 +109,6 @@ export const cache = {
});
},

shouldFetch: async function(url: string): Promise<boolean> {
// 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<boolean> {
return new Promise(async (resolve, reject) => {
if (!cache.db) {
Expand All @@ -139,7 +125,6 @@ export const cache = {
});

putRequest.onsuccess = () => {
console.log("Fetched: ", url);
resolve(true);
};

Expand All @@ -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<any> {
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<void> {
return new Promise(async (resolve, reject) => {
Expand Down

0 comments on commit c08e2a0

Please sign in to comment.