Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { MapComponent } from '../map.component';
import { FBChart } from 'src/app/types';
import { initPMTilesVectorLayer } from './pmtiles-utils';
import { extentFromBounds, resolveLayerMaxZoom } from './chart-utils';
import { createAbortableVectorTileLoader } from './tile-loader-abort';

// ** Freeboard Vector TileLayer Chart **
@Component({
Expand Down Expand Up @@ -85,7 +86,8 @@ export class VectorChartLayerComponent implements OnDestroy {
? chart[1].layers
: null
}),
maxZoom: maxZ
maxZoom: maxZ,
tileLoadFunction: createAbortableVectorTileLoader()
}),
preload: 0,
zIndex: this.zIndex(),
Expand Down
120 changes: 120 additions & 0 deletions src/app/modules/map/ol/lib/charts/tile-loader-abort.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { expect, describe, it } from 'vitest';
import {
bucketForZoom,
releaseController,
type PendingByZoom
} from './tile-loader-abort';

function makePending(): PendingByZoom {
return new Map();
}

describe('bucketForZoom', () => {
it('returns the same bucket on repeated calls at the same zoom', () => {
const pending = makePending();
const bucket1 = bucketForZoom(pending, 10);
const bucket2 = bucketForZoom(pending, 10);
expect(bucket1).toBe(bucket2);
});

it('does not abort any controller when the zoom is unchanged', () => {
const pending = makePending();
const bucket = bucketForZoom(pending, 10);
const controller = new AbortController();
bucket.add(controller);

bucketForZoom(pending, 10);

expect(controller.signal.aborted).toBe(false);
});

it('aborts all controllers from a superseded zoom', () => {
const pending = makePending();
const bucket = bucketForZoom(pending, 8);
const c1 = new AbortController();
const c2 = new AbortController();
bucket.add(c1);
bucket.add(c2);

bucketForZoom(pending, 12);

expect(c1.signal.aborted).toBe(true);
expect(c2.signal.aborted).toBe(true);
});

it('removes the old zoom bucket after superseding', () => {
const pending = makePending();
const oldBucket = bucketForZoom(pending, 8);
const c = new AbortController();
oldBucket.add(c);

bucketForZoom(pending, 12);

expect(pending.has(8)).toBe(false);
expect(pending.has(12)).toBe(true);
});

it('aborts controllers across multiple superseded zooms in one call', () => {
const pending = makePending();
const c5 = new AbortController();
const c7 = new AbortController();
pending.set(5, new Set([c5]));
pending.set(7, new Set([c7]));

bucketForZoom(pending, 10);

expect(c5.signal.aborted).toBe(true);
expect(c7.signal.aborted).toBe(true);
expect(pending.has(5)).toBe(false);
expect(pending.has(7)).toBe(false);
expect(pending.has(10)).toBe(true);
});
});

describe('releaseController', () => {
it('deletes the bucket when its last controller is removed', () => {
const pending = makePending();
const bucket = bucketForZoom(pending, 9);
const controller = new AbortController();
bucket.add(controller);

releaseController(pending, 9, controller);

expect(pending.has(9)).toBe(false);
});

it('leaves the bucket intact when other controllers remain', () => {
const pending = makePending();
const bucket = bucketForZoom(pending, 9);
const c1 = new AbortController();
const c2 = new AbortController();
bucket.add(c1);
bucket.add(c2);

releaseController(pending, 9, c1);

expect(pending.has(9)).toBe(true);
expect(pending.get(9)?.has(c2)).toBe(true);
expect(pending.get(9)?.has(c1)).toBe(false);
});

it('is a no-op when the zoom bucket has already been removed', () => {
const pending = makePending();
const controller = new AbortController();

expect(() => releaseController(pending, 9, controller)).not.toThrow();
expect(pending.has(9)).toBe(false);
});

it('is a no-op when the controller is not in the bucket', () => {
const pending = makePending();
const bucket = bucketForZoom(pending, 9);
const resident = new AbortController();
bucket.add(resident);
const stranger = new AbortController();

expect(() => releaseController(pending, 9, stranger)).not.toThrow();
expect(pending.has(9)).toBe(true);
expect(pending.get(9)?.has(resident)).toBe(true);
});
});
91 changes: 91 additions & 0 deletions src/app/modules/map/ol/lib/charts/tile-loader-abort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import TileState from 'ol/TileState';
import type VectorTile from 'ol/VectorTile';
import type { FeatureLike } from 'ol/Feature';
import type { Extent } from 'ol/extent';
import type Projection from 'ol/proj/Projection';

export type PendingByZoom = Map<number, Set<AbortController>>;

/**
* Abort and drop controllers held under zoom levels other than `z`, then
* return (or create) the controller bucket for `z`. Keeps the vector
* loader's per-zoom bookkeeping in lockstep.
*/
export function bucketForZoom(
pending: PendingByZoom,
z: number
): Set<AbortController> {
for (const [oldZ, controllers] of pending) {
if (oldZ !== z) {
for (const c of controllers) c.abort();
pending.delete(oldZ);
}
}
let set = pending.get(z);
if (!set) {
set = new Set();
pending.set(z, set);
}
return set;
}

export function releaseController(
pending: PendingByZoom,
z: number,
controller: AbortController
) {
const s = pending.get(z);
if (s) {
s.delete(controller);
if (s.size === 0) pending.delete(z);
}
}

/**
* Build an OL VectorTile `tileLoadFunction` that aborts in-flight loads from
* superseded zoom levels. When a new tile request arrives at zoom Z, any
* outstanding controllers for zoom !== Z are aborted so they free up the
* browser's connection pool for the now-visible level. The fetch is wrapped
* inside `tile.setLoader` because OL's VectorTile contract has the tile own
* its decoded features.
*/
export function createAbortableVectorTileLoader(): (
tile: VectorTile<FeatureLike>,
src: string
) => void {
const pending: PendingByZoom = new Map();

return function loader(tile: VectorTile<FeatureLike>, src: string) {
Comment on lines +52 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Expose a teardown hook for pending controllers.

pending is trapped inside this factory, so VectorChartLayerComponent.ngOnDestroy() has no way to abort still-running tile fetches when the layer is removed. Those requests keep consuming the shared tile budget until the network settles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/modules/map/ol/lib/charts/tile-loader-abort.ts` around lines 52 - 58,
Expose a teardown hook from createAbortableVectorTileLoader so callers can abort
all pending controllers held in the pending map. Update the loader factory and
the createAbortableVectorTileLoader return type to include a cleanup/teardown
function, and use it from VectorChartLayerComponent.ngOnDestroy() to cancel any
still-running tile fetches before the layer is removed.

const z = tile.getTileCoord()[0];
const bucket = bucketForZoom(pending, z);

tile.setLoader(
(extent: Extent, _resolution: number, projection: Projection) => {
const controller = new AbortController();
bucket.add(controller);

tile.setState(TileState.LOADING);
fetch(src, { signal: controller.signal })
.then((r) => r.arrayBuffer())
.then((data) => {
const format = tile.getFormat();
const features = format.readFeatures(data, {
extent,
featureProjection: projection
}) as FeatureLike[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(tile as any).setFeatures(features);
tile.setState(TileState.LOADED);
})
.catch((err) => {
if (err?.name !== 'AbortError') {
tile.setState(TileState.ERROR);
}
})
.finally(() => {
releaseController(pending, z, controller);
Comment on lines +80 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for OpenLayers TileState docs/usages in repo
rg -n "TileState|abort.*tile|AbortError|setState\\(TileState\\.(EMPTY|ERROR|LOADING|LOADED)\\)" . -S --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**'

# Inspect package versions if present
if [ -f package.json ]; then
  nl -ba package.json | sed -n '1,220p'
fi
if [ -f pnpm-lock.yaml ]; then
  rg -n "^  ol@|/ol/|name: ol|version:" pnpm-lock.yaml -S | sed -n '1,120p'
fi
if [ -f package-lock.json ]; then
  rg -n '"ol"|TileState' package-lock.json -S | sed -n '1,120p'
fi
if [ -f yarn.lock ]; then
  rg -n '(^ol@|/ol@|TileState)' yarn.lock -S | sed -n '1,120p'
fi

Repository: SignalK/freeboard-sk

Length of output: 714


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the target file region with line numbers using sed (no nl dependency)
sed -n '1,180p' src/app/modules/map/ol/lib/charts/tile-loader-abort.ts

# Inspect package metadata for the OpenLayers version if available
if [ -f package.json ]; then
  sed -n '1,240p' package.json
fi

Repository: SignalK/freeboard-sk

Length of output: 6452


🌐 Web query:

OpenLayers 10.7 TileState abort EMPTY ERROR loading tile image loader semantics

💡 Result:

In OpenLayers, TileState is an enumeration used to track the lifecycle of a tile. The states relevant to your query are defined in the ol/TileState module [1]: IDLE (0): Initial state [1]. LOADING (1): The tile is currently being fetched [1]. LOADED (2): The tile data has been successfully loaded [1]. ERROR (3): The tile loading process failed [1]. EMPTY (4): The tile is empty or has been disposed of [1][2]. Regarding tile loader semantics and state management: Tile Loading and Errors: When implementing a custom tileLoadFunction, it is critical to explicitly call tile.setState(TileState.ERROR) if the tile fails to load [3][4]. If this is not done, the tile will remain in a loading state, blocking the tile queue and preventing other requests from being processed [3][4]. Handling EMPTY State: A tile is assigned the TileState.EMPTY state when it is disposed of or is intentionally empty [2]. Recent improvements to OpenLayers ensure that an EMPTY tile remains in that state [2]. The internal setState method now returns early if a tile is already marked as EMPTY, preventing late-arriving load or error callbacks from incorrectly updating the state of a disposed tile [2]. Future Directions: OpenLayers is shifting away from the legacy UrlTile API (which uses tileLoadFunction) toward the DataTile and ImageTile sources using a loader function [5]. When using these newer loaders, if a tile request fails, you have the option to resolve the loader with empty data (e.g., an empty image) to treat the failure as an empty tile rather than an error, providing more granular control over how missing data is handled [5].

Citations:


🌐 Web query:

site:openlayers.org ol TileState EMPTY disposed intentionally empty abort loading setState ERROR

💡 Result:

In OpenLayers, TileState is an enumeration used to track the lifecycle status of a tile [1][2]. The states are: IDLE (0): The tile is initialized but not yet loading [1][2]. LOADING (1): The tile is currently being fetched [1][2]. LOADED (2): The tile has been successfully loaded [1][2]. ERROR (3): The tile failed to load [1][2]. EMPTY (4): The tile is intentionally empty (e.g., no data exists for this tile coordinate) [1][2]. When implementing a custom tile load function, you are responsible for updating the state of the tile [3][4]. It is critical to set the state to ERROR if the loading process fails, as failing to do so will leave the tile in a pending state, potentially causing the tile queue to block subsequent requests [3][4]. The EMPTY state is typically used when a tile is intentionally left blank because no data is available for that specific location or zoom level, distinguishing it from an error state where a retrieval attempt failed. Disposed tiles or those whose loading has been aborted are handled by the library's internal management, often triggering a change in the tile's state or removal from the active tile queue [5].

Citations:


Set aborted tiles to ERROR, not leave them LOADING. AbortError currently skips every terminal tile.setState(...) path, so superseded tiles can remain active in OL’s queue. EMPTY is for intentionally blank/disposed tiles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/modules/map/ol/lib/charts/tile-loader-abort.ts` around lines 80 - 86,
The abort handling in tile-loader-abort should put superseded tiles into a
terminal state instead of leaving them stuck in LOADING. Update the abort path
around the tile load promise and the tile state logic in tile-loader-abort so
AbortError is treated as a terminal outcome and the tile is set to ERROR via
tile.setState, while keeping EMPTY reserved for intentionally blank/disposed
tiles. Use the existing tile.setState, TileState.ERROR, and abort catch/finally
flow to locate the fix.

});
}
);
};
}
6 changes: 5 additions & 1 deletion src/app/modules/map/ol/lib/map.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ export class MapComponent implements OnInit, OnDestroy {

ngOnInit() {
const target = this.element.nativeElement.firstElementChild;
this.map = new Map();
this.map = new Map({
// 2x OL default (16); keeps the browser HTTP/1.1 per-host connection
// pool fully fed during zoom-out tile bursts.
maxTilesLoading: 32
});
this.map.setTarget(target);
this.map.setProperties(this.properties, true);
// register the map in the injectable mapService
Expand Down
Loading