-
Notifications
You must be signed in to change notification settings - Fork 0
Implement client-side map data caching #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
e5a0e95
80a9a60
d3881a8
e6add1f
4ecd0b0
2017d2d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,10 +12,12 @@ import { TranslationService } from "../services/translation.service"; | |
| import { Router, ActivatedRoute } from "@angular/router"; | ||
| import { MapInstanceDialogComponent } from "../map-instance-dialog/map-instance-dialog.component"; | ||
| import { switchMap } from "rxjs/operators"; | ||
| import { Observable } from "rxjs"; | ||
| import { Observable, of } from "rxjs"; | ||
| import { MapDataDTO } from "../models/map-data.model"; | ||
| import { v4 as uuidv4 } from "uuid"; | ||
| import { SignalRService } from "../services/signal-r.service"; | ||
| import { MapDataCacheService } from "../services/map-data-cache.service"; | ||
| import { DataUpdateService } from "../services/data-update.service"; | ||
| import { | ||
| NgTemplateOutlet, | ||
| NgClass, | ||
|
|
@@ -60,10 +62,14 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { | |
| private activatedRoute = inject(ActivatedRoute); | ||
| private signalRService = inject(SignalRService); | ||
| private cd = inject(ChangeDetectorRef); | ||
| private mapDataCacheService = inject(MapDataCacheService); | ||
| private dataUpdateService = inject(DataUpdateService); | ||
|
|
||
| readonly guid = input<string>(undefined); | ||
| readonly mapContainer = viewChild<HTMLElement>("mapContainer"); | ||
| loading: boolean | number = false; | ||
| isFromCache = signal<boolean>(false); | ||
| cacheAge = signal<number | null>(null); | ||
| from: moment.Moment; | ||
| to: moment.Moment; | ||
| selectedRegion: number[] = []; | ||
|
|
@@ -228,6 +234,11 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { | |
| } | ||
| }, | ||
| }); | ||
|
|
||
| // Subscribe to data updates to clear cache | ||
| this.dataUpdateService.updateRequested$.subscribe(() => { | ||
| this.mapDataCacheService.clear(); | ||
| }); | ||
| } | ||
|
|
||
| ngOnDestroy() { | ||
|
|
@@ -271,8 +282,32 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { | |
| this.setApplicableFilter(); | ||
| } | ||
|
|
||
| private getCurrentCacheKey(filter: string): string { | ||
| return this.mapDataCacheService.getCacheKey( | ||
| this.guid() || '', | ||
| filter, | ||
| this.translationService.language, | ||
| this.includeLineColours, | ||
| this.limitToSelectedArea | ||
| ); | ||
| } | ||
|
|
||
| private getRoutes(filter: string): Observable<MapDataDTO> { | ||
| //Generate a GUID | ||
| // Check cache first | ||
| const cacheKey = this.getCurrentCacheKey(filter); | ||
|
|
||
| const cachedData = this.mapDataCacheService.get(cacheKey); | ||
| if (cachedData) { | ||
| this.isFromCache.set(true); | ||
| this.cacheAge.set(this.mapDataCacheService.getCacheAge(cacheKey)); | ||
| return of(cachedData); | ||
| } | ||
|
|
||
| // Not in cache, fetch from API | ||
| this.isFromCache.set(false); | ||
| this.cacheAge.set(null); | ||
|
|
||
| // Generate a GUID | ||
| this.requestIdentifier = uuidv4(); | ||
|
|
||
| return this.apiService.getRoutes( | ||
|
|
@@ -285,6 +320,12 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { | |
| ); | ||
| } | ||
| private showRoutes(data: MapDataDTO) { | ||
| // Cache the data if it's not from cache | ||
| if (!this.isFromCache()) { | ||
| const cacheKey = this.getCurrentCacheKey(this.getFilter()); | ||
| this.mapDataCacheService.set(cacheKey, data); | ||
|
Comment on lines
+325
to
+326
|
||
| } | ||
|
|
||
| const parent = this; | ||
| const track = geoJSON(data.routes, { | ||
| style: (feature) => { | ||
|
|
@@ -566,6 +607,7 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy { | |
| } | ||
|
|
||
| refresh() { | ||
| this.mapDataCacheService.clear(); | ||
| this.getRoutes$.next(this.getFilter()); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,102 @@ | ||||||||||||||||||||||||||||||||
| import { TestBed } from '@angular/core/testing'; | ||||||||||||||||||||||||||||||||
| import { MapDataCacheService } from './map-data-cache.service'; | ||||||||||||||||||||||||||||||||
| import { MapDataDTO } from '../models/map-data.model'; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| describe('MapDataCacheService', () => { | ||||||||||||||||||||||||||||||||
| let service: MapDataCacheService; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| beforeEach(() => { | ||||||||||||||||||||||||||||||||
| TestBed.configureTestingModule({}); | ||||||||||||||||||||||||||||||||
| service = TestBed.inject(MapDataCacheService); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it('should be created', () => { | ||||||||||||||||||||||||||||||||
| expect(service).toBeTruthy(); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it('should generate unique cache keys', () => { | ||||||||||||||||||||||||||||||||
| const key1 = service.getCacheKey('guid1', 'filter1', 'en', true, false); | ||||||||||||||||||||||||||||||||
| const key2 = service.getCacheKey('guid1', 'filter1', 'en', false, false); | ||||||||||||||||||||||||||||||||
| const key3 = service.getCacheKey('guid1', 'filter1', 'nl', true, false); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| expect(key1).not.toEqual(key2); | ||||||||||||||||||||||||||||||||
| expect(key1).not.toEqual(key3); | ||||||||||||||||||||||||||||||||
| expect(key2).not.toEqual(key3); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it('should store and retrieve cached data', () => { | ||||||||||||||||||||||||||||||||
| const mockData: MapDataDTO = { | ||||||||||||||||||||||||||||||||
| routes: { type: 'FeatureCollection', features: [] }, | ||||||||||||||||||||||||||||||||
| area: null | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
| const key = service.getCacheKey('guid1', 'filter1', 'en', true, false); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| service.set(key, mockData); | ||||||||||||||||||||||||||||||||
| const retrieved = service.get(key); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| expect(retrieved).toEqual(mockData); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it('should return null for non-existent cache key', () => { | ||||||||||||||||||||||||||||||||
| const retrieved = service.get('non-existent-key'); | ||||||||||||||||||||||||||||||||
| expect(retrieved).toBeNull(); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it('should clear all cache', () => { | ||||||||||||||||||||||||||||||||
| const mockData: MapDataDTO = { | ||||||||||||||||||||||||||||||||
| routes: { type: 'FeatureCollection', features: [] }, | ||||||||||||||||||||||||||||||||
| area: null | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
| const key1 = service.getCacheKey('guid1', 'filter1', 'en', true, false); | ||||||||||||||||||||||||||||||||
| const key2 = service.getCacheKey('guid2', 'filter2', 'nl', false, true); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| service.set(key1, mockData); | ||||||||||||||||||||||||||||||||
| service.set(key2, mockData); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| service.clear(); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| expect(service.get(key1)).toBeNull(); | ||||||||||||||||||||||||||||||||
| expect(service.get(key2)).toBeNull(); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it('should return cache age in seconds', () => { | ||||||||||||||||||||||||||||||||
| const mockData: MapDataDTO = { | ||||||||||||||||||||||||||||||||
| routes: { type: 'FeatureCollection', features: [] }, | ||||||||||||||||||||||||||||||||
| area: null | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
| const key = service.getCacheKey('guid1', 'filter1', 'en', true, false); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| service.set(key, mockData); | ||||||||||||||||||||||||||||||||
| const age = service.getCacheAge(key); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| expect(age).toBeDefined(); | ||||||||||||||||||||||||||||||||
| expect(age).toBeGreaterThanOrEqual(0); | ||||||||||||||||||||||||||||||||
| expect(age).toBeLessThan(2); // Should be less than 2 seconds old | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it('should return null for cache age of non-existent key', () => { | ||||||||||||||||||||||||||||||||
| const age = service.getCacheAge('non-existent-key'); | ||||||||||||||||||||||||||||||||
| expect(age).toBeNull(); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it('should expire cache after 5 minutes', () => { | ||||||||||||||||||||||||||||||||
| const mockData: MapDataDTO = { | ||||||||||||||||||||||||||||||||
| routes: { type: 'FeatureCollection', features: [] }, | ||||||||||||||||||||||||||||||||
| area: null | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
| const key = service.getCacheKey('guid1', 'filter1', 'en', true, false); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Store data in cache | ||||||||||||||||||||||||||||||||
| service.set(key, mockData); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Verify data is in cache | ||||||||||||||||||||||||||||||||
| expect(service.get(key)).toEqual(mockData); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Mock Date.now to return time 5 minutes and 1 second in the future | ||||||||||||||||||||||||||||||||
| spyOn(Date, 'now').and.returnValue(Date.now() + (5 * 60 * 1000 + 1000)); | ||||||||||||||||||||||||||||||||
|
Comment on lines
+90
to
+96
|
||||||||||||||||||||||||||||||||
| service.set(key, mockData); | |
| // Verify data is in cache | |
| expect(service.get(key)).toEqual(mockData); | |
| // Mock Date.now to return time 5 minutes and 1 second in the future | |
| spyOn(Date, 'now').and.returnValue(Date.now() + (5 * 60 * 1000 + 1000)); | |
| const cacheTime = Date.now(); | |
| service.set(key, mockData); | |
| // Verify data is in cache | |
| expect(service.get(key)).toEqual(mockData); | |
| // Mock Date.now to return time 5 minutes and 1 second after cache was set | |
| spyOn(Date, 'now').and.returnValue(cacheTime + (5 * 60 * 1000 + 1000)); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { Injectable } from '@angular/core'; | ||
| import { MapDataDTO } from '../models/map-data.model'; | ||
|
|
||
| interface CachedMapData { | ||
| data: MapDataDTO; | ||
| timestamp: number; | ||
| } | ||
|
|
||
| @Injectable({ | ||
| providedIn: 'root' | ||
| }) | ||
| export class MapDataCacheService { | ||
| private cache = new Map<string, CachedMapData>(); | ||
| private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes | ||
|
|
||
| getCacheKey( | ||
| guid: string, | ||
| filter: string, | ||
| language: string, | ||
| includeLineColours: boolean, | ||
| limitToSelectedArea: boolean | ||
| ): string { | ||
| return `${guid}|${filter}|${language}|${includeLineColours}|${limitToSelectedArea}`; | ||
| } | ||
|
|
||
| get(key: string): MapDataDTO | null { | ||
| const cached = this.cache.get(key); | ||
|
|
||
| if (!cached) { | ||
| return null; | ||
| } | ||
|
|
||
| // Check if expired | ||
| if (Date.now() - cached.timestamp > this.CACHE_DURATION) { | ||
| this.cache.delete(key); | ||
| return null; | ||
| } | ||
|
|
||
| return cached.data; | ||
| } | ||
|
|
||
| set(key: string, data: MapDataDTO): void { | ||
| this.cache.set(key, { | ||
| data, | ||
| timestamp: Date.now() | ||
| }); | ||
| } | ||
|
|
||
| clear(): void { | ||
| this.cache.clear(); | ||
| } | ||
|
|
||
| // Get cache age in seconds for a key (for UI display) | ||
| getCacheAge(key: string): number | null { | ||
| const cached = this.cache.get(key); | ||
| if (!cached) return null; | ||
| return Math.floor((Date.now() - cached.timestamp) / 1000); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Memory leak: The subscription to
dataUpdateService.updateRequested$is not unsubscribed when the component is destroyed. This subscription should be stored and unsubscribed in thengOnDestroymethod to prevent memory leaks.Consider storing the subscription and cleaning it up: