mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
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).
|
||||||
@@ -272,6 +272,8 @@
|
|||||||
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
|
"./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/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.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": "./src/tools/index.js",
|
||||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||||
"./tools/panel": "./src/tools/panel.js",
|
"./tools/panel": "./src/tools/panel.js",
|
||||||
|
|||||||
@@ -1430,7 +1430,9 @@
|
|||||||
"./layers/street-level",
|
"./layers/street-level",
|
||||||
"./layers/street-level/mapillary",
|
"./layers/street-level/mapillary",
|
||||||
"./layers/street-level/mapillary/source",
|
"./layers/street-level/mapillary/source",
|
||||||
"./layers/street-level/google"
|
"./layers/street-level/google",
|
||||||
|
"./layers/street-level/kartaview",
|
||||||
|
"./layers/street-level/panoramax"
|
||||||
],
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
|
|||||||
@@ -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.
|
// Without the application's Street Level source there are none.
|
||||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
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;
|
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
|
* Mapillary network access: tiles via the local proxy, which adds the token
|
||||||
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
|
* 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({
|
export function createMapillarySource({
|
||||||
token = '',
|
token = '',
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
endpoints = {},
|
endpoints = {},
|
||||||
|
graphEndpoint = '',
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const graphBase = endpointBase(graphEndpoint);
|
||||||
const urls = {
|
const urls = {
|
||||||
status: '/api/mapillary/status',
|
status: '/api/mapillary/status',
|
||||||
tiles: '/api/mapillary/tiles',
|
tiles: '/api/mapillary/tiles',
|
||||||
@@ -54,6 +67,13 @@ export function createMapillarySource({
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function graph(path, params = {}, { signal } = {}) {
|
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)
|
if (!token)
|
||||||
throw new MapillarySourceError('Mapillary token not configured', {
|
throw new MapillarySourceError('Mapillary token not configured', {
|
||||||
status: 503,
|
status: 503,
|
||||||
@@ -70,6 +90,10 @@ export function createMapillarySource({
|
|||||||
get token() {
|
get token() {
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
|
/** The Graph API base that adds the token itself, or '' (direct). */
|
||||||
|
get graphEndpoint() {
|
||||||
|
return graphBase;
|
||||||
|
},
|
||||||
hasToken: () => Boolean(token),
|
hasToken: () => Boolean(token),
|
||||||
|
|
||||||
async getStatus({ signal } = {}) {
|
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',
|
'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.
|
* @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
|
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
|
||||||
* a pose on every image, view or position change.
|
* a pose on every image, view or position change.
|
||||||
@@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) {
|
|||||||
async function buildViewer(host) {
|
async function buildViewer(host) {
|
||||||
const built = generation;
|
const built = generation;
|
||||||
destroyViewer();
|
destroyViewer();
|
||||||
const { Viewer } = await ensureLibrary();
|
const { Viewer, GraphDataProvider } = await ensureLibrary();
|
||||||
// Unmounted (layer off) while the library loaded: a viewer built now
|
// Unmounted (layer off) while the library loaded: a viewer built now
|
||||||
// would hold a WebGL context nobody releases.
|
// would hold a WebGL context nobody releases.
|
||||||
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
|
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
|
||||||
|
const endpoint = graphEndpointUrl(source);
|
||||||
viewer = new Viewer({
|
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,
|
container: host,
|
||||||
component: {
|
component: {
|
||||||
cover: false,
|
cover: false,
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ registerHooks({
|
|||||||
const source = url.includes('/library?')
|
const source = url.includes('/library?')
|
||||||
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
|
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
|
||||||
'export const Viewer = lib.Viewer;\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';
|
: 'export default {};\n';
|
||||||
return { format: 'module', shortCircuit: true, source };
|
return { format: 'module', shortCircuit: true, source };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { createMapillaryViewer } =
|
const { createMapillaryViewer, graphEndpointUrl } =
|
||||||
await import('./providers/mapillary/viewer.js');
|
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
|
* A viewer host over `adapter`. `marker` records where the position marker
|
||||||
@@ -345,7 +348,7 @@ function fakeLibrary() {
|
|||||||
}
|
}
|
||||||
globalThis.__gevMapillaryLibrary = async () => {
|
globalThis.__gevMapillaryLibrary = async () => {
|
||||||
await gate.promise;
|
await gate.promise;
|
||||||
return { Viewer, RenderMode: {} };
|
return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider };
|
||||||
};
|
};
|
||||||
return { gate, viewers };
|
return { gate, viewers };
|
||||||
}
|
}
|
||||||
@@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets
|
|||||||
host.close();
|
host.close();
|
||||||
assert.equal(state.street.license, null);
|
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' }), '');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user