feat(street-level): provider exports and a Mapillary Graph endpoint for hosts (#1324)

* feat(street-level): export the KartaView and Panoramax providers

A host that composes Street Level with its own provider list can already
import the Mapillary and Google Street View providers; KartaView and Panoramax
were reachable only through the default list, which needs a Mapillary source.
Exporting them lets a host offer the keyless providers without Mapillary.

* chore: changelog entry for the Street Level provider exports

* feat(street-level): send Mapillary Graph lookups to a source endpoint

A Mapillary source can take a graphEndpoint, a Graph API base that adds
the token itself. Its sequence lookups go there through the source's
transport, and the photo viewer uses MapillaryJS's stock GraphDataProvider
at that endpoint, so a host that proxies Mapillary needs no client token in
the page. Without it, behaviour is unchanged: direct Graph API calls with
the client token.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-23 07:47:57 -07:00
1 parent f19a6da5b8
commit 8cdd184a90
8 files changed
+161 -7

No files matched your search

@@ -0,0 +1,8 @@
- Export the KartaView and Panoramax Street Level providers, so a host composing
its own provider list can offer the keyless providers without Mapillary
(samehkhamis, #1324).
- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the
token itself (such as a same-origin proxy). Its sequence lookups go there
through the source's transport, and the photo viewer uses MapillaryJS's
Graph provider at that endpoint, so no client token is needed in the page
(samehkhamis, #1324).
+2
View File
@@ -272,6 +272,8 @@
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
"./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js",
"./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
+3 -1
View File
@@ -1430,7 +1430,9 @@
"./layers/street-level",
"./layers/street-level/mapillary",
"./layers/street-level/mapillary/source",
"./layers/street-level/google"
"./layers/street-level/google",
"./layers/street-level/kartaview",
"./layers/street-level/panoramax"
],
"modules": [
"src/layers/streetLevel/index.js",
+14
View File
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
test('every built-in provider is exported, so hosts can choose their own list', async () => {
const google = await import('gods-eye-view/layers/street-level/google');
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
const layer = createApplicationStreetLevel({
providers: [
google.createGoogleProvider({ getApiKey: () => null }),
kartaview.createKartaViewProvider(),
panoramax.createPanoramaxProvider(),
],
});
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
});
@@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) {
return payload;
}
/** `endpoint` with exactly one trailing slash, or '' when unset. */
function endpointBase(endpoint) {
const text = String(endpoint || '').trim();
return text ? text.replace(/\/*$/, '/') : '';
}
/**
* Mapillary network access: tiles via the local proxy, which adds the token
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
* With `graphEndpoint` (a Graph API base that adds the token itself, such as
* a same-origin proxy), the source's Graph lookups go there through
* `fetchImpl` instead, and the photo viewer sends its own lookups there too;
* no token is sent or needed.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object,
* graphEndpoint?: string}} options
*/
export function createMapillarySource({
token = '',
fetchImpl = (...args) => globalThis.fetch(...args),
endpoints = {},
graphEndpoint = '',
} = {}) {
const graphBase = endpointBase(graphEndpoint);
const urls = {
status: '/api/mapillary/status',
tiles: '/api/mapillary/tiles',
@@ -54,6 +67,13 @@ export function createMapillarySource({
};
async function graph(path, params = {}, { signal } = {}) {
if (graphBase) {
const search = new URLSearchParams(params);
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
signal,
});
return readJsonOrThrow(response, 'Mapillary graph');
}
if (!token)
throw new MapillarySourceError('Mapillary token not configured', {
status: 503,
@@ -70,6 +90,10 @@ export function createMapillarySource({
get token() {
return token;
},
/** The Graph API base that adds the token itself, or '' (direct). */
get graphEndpoint() {
return graphBase;
},
hasToken: () => Boolean(token),
async getStatus({ signal } = {}) {
@@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
'the Retry-After header wins over the body',
);
});
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph',
fetchImpl,
});
assert.equal(source.graphEndpoint, '/api/mapillary/graph/');
assert.equal(source.hasToken(), false);
const images = await source.getSequenceImages('seq-1');
assert.equal(images.length, 1);
const url = new URL(calls[0].url, 'http://localhost');
assert.equal(url.pathname, '/api/mapillary/graph/images');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.equal(url.searchParams.has('access_token'), false);
});
test('Graph endpoint errors keep their status', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph/',
fetchImpl,
});
await assert.rejects(
() => source.getSequenceImages('1'),
(error) => error.status === 503 && error.keyRequired === true,
);
});
@@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js';
* @property {string} externalUrl Deep link to the image on mapillary.com.
*/
/**
* The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS
* resolves its lookup paths against it; '' when the source has none.
*/
export function graphEndpointUrl(source) {
if (!source?.graphEndpoint) return '';
const url = new URL(
source.graphEndpoint,
globalThis.location?.href || 'http://localhost/',
);
url.pathname = url.pathname.replace(/\/*$/, '/');
return url.href;
}
/**
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
* a pose on every image, view or position change.
@@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) {
async function buildViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
const { Viewer, GraphDataProvider } = await ensureLibrary();
// Unmounted (layer off) while the library loaded: a viewer built now
// would hold a WebGL context nobody releases.
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
const endpoint = graphEndpointUrl(source);
viewer = new Viewer({
accessToken: source.token,
// A source with its own Graph endpoint (which adds the token itself)
// answers the viewer's lookups, sent without a token; otherwise
// MapillaryJS calls the Graph API with the client token.
...(endpoint
? { dataProvider: new GraphDataProvider({ endpoint }) }
: { accessToken: source.token }),
container: host,
component: {
cover: false,
+55 -3
View File
@@ -22,13 +22,16 @@ registerHooks({
const source = url.includes('/library?')
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
'export const Viewer = lib.Viewer;\n' +
'export const RenderMode = lib.RenderMode;\n'
'export const RenderMode = lib.RenderMode;\n' +
'export const GraphDataProvider = lib.GraphDataProvider;\n'
: 'export default {};\n';
return { format: 'module', shortCircuit: true, source };
},
});
const { createMapillaryViewer } =
const { createMapillaryViewer, graphEndpointUrl } =
await import('./providers/mapillary/viewer.js');
const { createMapillarySource } =
await import('./providers/mapillary/source.js');
/**
* A viewer host over `adapter`. `marker` records where the position marker
@@ -345,7 +348,7 @@ function fakeLibrary() {
}
globalThis.__gevMapillaryLibrary = async () => {
await gate.promise;
return { Viewer, RenderMode: {} };
return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider };
};
return { gate, viewers };
}
@@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets
host.close();
assert.equal(state.street.license, null);
});
/** MapillaryJS 4's Graph provider, recording what it was built with. */
class FakeGraphDataProvider {
constructor(options = {}) {
this.options = options;
}
}
test('without a Graph endpoint the viewer gets the client token, as before', async () => {
const library = fakeLibrary();
library.gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
await adapter.mount({});
const [viewer] = library.viewers.instances;
assert.equal(viewer.options.accessToken, 't');
assert.equal(viewer.options.dataProvider, undefined);
});
test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => {
const library = fakeLibrary();
library.gate.resolve();
const source = createMapillarySource({
graphEndpoint: 'https://app.example/api/mapillary/graph',
fetchImpl: async () => {
throw new Error('the viewer does not use the source transport');
},
});
const adapter = createMapillaryViewer({ source });
await adapter.mount({});
const [viewer] = library.viewers.instances;
assert.equal(viewer.options.accessToken, undefined);
const provider = viewer.options.dataProvider;
assert.ok(provider instanceof FakeGraphDataProvider);
assert.deepEqual(provider.options, {
endpoint: 'https://app.example/api/mapillary/graph/',
});
});
test('a relative Graph endpoint resolves against the page, with one trailing slash', () => {
assert.equal(
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }),
'http://localhost/api/mapillary/graph/',
);
assert.equal(
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }),
'http://localhost/api/mapillary/graph/',
);
assert.equal(graphEndpointUrl({ token: 't' }), '');
});