@yavqo/streets 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/package.json +52 -4
- package/src/config.js +1 -0
- package/src/coverage.js +68 -0
- package/src/geo.d.ts +19 -0
- package/src/geo.js +74 -0
- package/src/index.d.ts +115 -0
- package/src/index.js +372 -0
- package/src/style.css +41 -0
- package/src/token.js +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yavqo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Yavqo Streets
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Real street-level imagery of any place, in the browser.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i @yavqo/streets
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<div id="streets" style="width:100vw;height:100vh"></div>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { YavqoStreets } from '@yavqo/streets';
|
|
17
|
+
import '@yavqo/streets/style.css';
|
|
18
|
+
|
|
19
|
+
const streets = new YavqoStreets({
|
|
20
|
+
container: 'streets',
|
|
21
|
+
center: [-73.9857, 40.7484], // [lng, lat]
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
streets.on('ready', () => console.log('Streets loaded'));
|
|
25
|
+
streets.on('error', (e) => console.error(e));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Drag to look around, click or use the arrow keys to move along the street.
|
|
29
|
+
|
|
30
|
+
## Options
|
|
31
|
+
|
|
32
|
+
| Option | Type | Description |
|
|
33
|
+
| ------------- | ----------------------- | ------------------------------------------------ |
|
|
34
|
+
| `container` | `string \| HTMLElement` | Element or element id (required) |
|
|
35
|
+
| `center` | `[lng, lat]` | Start at the closest imagery to this point |
|
|
36
|
+
| `imageId` | `string` | Start at a specific image (instead of `center`) |
|
|
37
|
+
|
|
38
|
+
One of `center` or `imageId` is required.
|
|
39
|
+
|
|
40
|
+
## Navigation
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
await streets.setPosition([2.3522, 48.8566]); // nearest imagery; rejects with NoCoverageError if none
|
|
44
|
+
await streets.moveTo('IMAGE_ID');
|
|
45
|
+
|
|
46
|
+
await streets.next(); // next photo along the street → true, or false at the end of the line
|
|
47
|
+
await streets.prev();
|
|
48
|
+
await streets.moveForward(); // step to the photo ahead of / behind where you are looking
|
|
49
|
+
await streets.moveBackward();
|
|
50
|
+
|
|
51
|
+
await streets.setHeading(90); // face east (0 = north) → resolves to the bearing reached
|
|
52
|
+
await streets.lookAt([2.2945, 48.8584]); // face a point
|
|
53
|
+
|
|
54
|
+
await streets.setZoom(1.5); // 0 (wide) to 3 (close)
|
|
55
|
+
await streets.toggleFullscreen();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Flat (non-360°) photos only cover part of the circle, so `setHeading` and `lookAt` stop at the edge of the photo and resolve to the bearing actually reached.
|
|
59
|
+
|
|
60
|
+
## Coverage
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
await streets.hasCoverage([lng, lat]); // within 30 m by default
|
|
64
|
+
await streets.findNearby([lng, lat], { radius: 150, limit: 10 });
|
|
65
|
+
// → [{ id, lngLat, distance, heading, capturedAt, isPano }], closest first
|
|
66
|
+
|
|
67
|
+
streets.on('no-coverage', ({ lngLat, nearest }) => {
|
|
68
|
+
// nearest = closest imagery within 2 km, or null
|
|
69
|
+
if (nearest) streets.moveTo(nearest.id);
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Connecting to a map
|
|
74
|
+
|
|
75
|
+
The `view` event fires whenever the position, direction or field of view changes. It carries everything a map needs to draw a "you are here" marker and view cone.
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import { viewCone } from '@yavqo/streets/geo';
|
|
79
|
+
|
|
80
|
+
streets.on('view', (view) => {
|
|
81
|
+
// view = { lngLat, heading, tilt, fov, imageId }
|
|
82
|
+
marker.setLngLat(view.lngLat);
|
|
83
|
+
marker.setRotation(view.heading);
|
|
84
|
+
map.getSource('cone').setData(viewCone(view, 40)); // GeoJSON polygon, 40 m long
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
map.on('click', (e) => streets.setPosition([e.lngLat.lng, e.lngLat.lat]).catch(() => {}));
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Other API
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
await streets.ready();
|
|
94
|
+
await streets.getPosition(); // { id, lngLat, heading, tilt, capturedAt, isPano }
|
|
95
|
+
await streets.getView(); // same shape as the `view` event
|
|
96
|
+
streets.resize(); // after the container changes size
|
|
97
|
+
streets.on('image', ({ id, lngLat, heading, capturedAt, isPano }) => {});
|
|
98
|
+
streets.on('bearing', (degrees) => {});
|
|
99
|
+
streets.on('fullscreen', (active) => {});
|
|
100
|
+
streets.on('error', (e) => {});
|
|
101
|
+
streets.destroy();
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Try it
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm run demo # http://localhost:3000 — controls for every feature plus a self-test
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Notes
|
|
111
|
+
|
|
112
|
+
- Imagery is contributed by the community and coverage varies by location.
|
|
113
|
+
- The viewer shows a Yavqo Streets footer: Yavqo Streets, Privacy Policy, and Terms of Use.
|
|
114
|
+
- Requires a modern browser with WebGL.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yavqo/streets",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Yavqo Streets \u2014 real street-level imagery of any place, for the browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./geo": {
|
|
15
|
+
"types": "./src/geo.d.ts",
|
|
16
|
+
"default": "./src/geo.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json",
|
|
19
|
+
"./style.css": "./src/style.css"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": [
|
|
27
|
+
"./src/style.css"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test test/*.test.js",
|
|
31
|
+
"demo": "node demo/server.js",
|
|
32
|
+
"prepublishOnly": "node scripts/check-token.js && npm test"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"yavqo",
|
|
36
|
+
"streets",
|
|
37
|
+
"street-view",
|
|
38
|
+
"street level",
|
|
39
|
+
"maps",
|
|
40
|
+
"panorama",
|
|
41
|
+
"3d"
|
|
42
|
+
],
|
|
43
|
+
"author": "Yavqo",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"mapillary-js": "^4.1.2"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ENDPOINT = 'https://graph.mapillary.com';
|
package/src/coverage.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { destination, distance, clamp, isLngLat } from './geo.js';
|
|
2
|
+
|
|
3
|
+
const SEARCH_RADII_M = [30, 120, 500];
|
|
4
|
+
const PAGE = 2000; // the API returns at most this many per request, in no particular order
|
|
5
|
+
const FIELDS = 'id,computed_geometry,computed_compass_angle,captured_at,is_pano';
|
|
6
|
+
|
|
7
|
+
export class NoCoverageError extends Error {
|
|
8
|
+
constructor(lngLat, nearest) {
|
|
9
|
+
super('YavqoStreets: no street-level imagery found near that location.');
|
|
10
|
+
this.name = 'NoCoverageError';
|
|
11
|
+
this.lngLat = lngLat;
|
|
12
|
+
this.nearest = nearest;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertLngLat(v) {
|
|
17
|
+
if (!isLngLat(v)) throw new TypeError('YavqoStreets: expected a [lng, lat] pair.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Raw imagery inside a square of `radius` meters around a point, each with its distance. */
|
|
21
|
+
export async function search(endpoint, lngLat, radius, limit = PAGE, fetchImpl = globalThis.fetch) {
|
|
22
|
+
assertLngLat(lngLat);
|
|
23
|
+
const [w] = destination(lngLat, 270, radius);
|
|
24
|
+
const [e] = destination(lngLat, 90, radius);
|
|
25
|
+
const [, s] = destination(lngLat, 180, radius);
|
|
26
|
+
const [, n] = destination(lngLat, 0, radius);
|
|
27
|
+
const url = `${endpoint}/images?fields=${FIELDS}&limit=${limit}&bbox=${w},${s},${e},${n}`;
|
|
28
|
+
const res = await fetchImpl(url);
|
|
29
|
+
if (!res.ok) throw new Error(`YavqoStreets: lookup failed (${res.status}).`);
|
|
30
|
+
const { data = [] } = await res.json();
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const img of data) {
|
|
33
|
+
const c = img.computed_geometry?.coordinates;
|
|
34
|
+
if (!c) continue;
|
|
35
|
+
out.push({
|
|
36
|
+
id: img.id,
|
|
37
|
+
lngLat: [c[0], c[1]],
|
|
38
|
+
distance: distance(lngLat, c),
|
|
39
|
+
heading: img.computed_compass_angle ?? null,
|
|
40
|
+
capturedAt: img.captured_at ?? null,
|
|
41
|
+
isPano: Boolean(img.is_pano),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return out.sort((a, b) => a.distance - b.distance);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Imagery near a point, closest first.
|
|
49
|
+
* The API caps each response and does not sort it, so a wide search in a dense area could miss the
|
|
50
|
+
* nearest photos. Search in growing rings instead: a ring that comes back under the cap is complete.
|
|
51
|
+
*/
|
|
52
|
+
export async function findNearby(endpoint, lngLat, { radius = 100, limit = 25 } = {}, fetchImpl) {
|
|
53
|
+
radius = clamp(radius, 1, 5000);
|
|
54
|
+
limit = clamp(Math.floor(limit), 1, 200);
|
|
55
|
+
for (let r = Math.min(25, radius); ; r = Math.min(r * 4, radius)) {
|
|
56
|
+
const found = (await search(endpoint, lngLat, r, PAGE, fetchImpl)).filter((i) => i.distance <= r);
|
|
57
|
+
if (found.length >= limit || r >= radius) return found.slice(0, limit);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Closest image, widening the search a few times. Returns null if there is none. */
|
|
62
|
+
export async function nearestImage(endpoint, lngLat, fetchImpl) {
|
|
63
|
+
for (const radius of SEARCH_RADII_M) {
|
|
64
|
+
const [best] = await search(endpoint, lngLat, radius, PAGE, fetchImpl);
|
|
65
|
+
if (best) return best;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
package/src/geo.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type LngLat = [number, number];
|
|
2
|
+
export function clamp(v: number, min: number, max: number): number;
|
|
3
|
+
export function normalizeBearing(deg: number): number;
|
|
4
|
+
export function destination(from: LngLat, bearing: number, meters: number): LngLat;
|
|
5
|
+
export function distance(a: LngLat, b: LngLat): number;
|
|
6
|
+
export function bearingTo(a: LngLat, b: LngLat): number;
|
|
7
|
+
export function isLngLat(v: unknown): v is LngLat;
|
|
8
|
+
export function angleDiff(to: number, from: number): number;
|
|
9
|
+
export interface ViewCone {
|
|
10
|
+
type: 'Feature';
|
|
11
|
+
properties: Record<string, never>;
|
|
12
|
+
geometry: { type: 'Polygon'; coordinates: LngLat[][] };
|
|
13
|
+
}
|
|
14
|
+
/** GeoJSON polygon of what the viewer can see. Pass the payload of the `view` event. */
|
|
15
|
+
export function viewCone(
|
|
16
|
+
view: { lngLat: LngLat; heading: number; fov: number },
|
|
17
|
+
radius?: number,
|
|
18
|
+
steps?: number,
|
|
19
|
+
): ViewCone;
|
package/src/geo.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const EARTH_RADIUS_M = 6371008.8;
|
|
2
|
+
const toRad = (d) => (d * Math.PI) / 180;
|
|
3
|
+
const toDeg = (r) => (r * 180) / Math.PI;
|
|
4
|
+
|
|
5
|
+
export const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
6
|
+
|
|
7
|
+
/** Normalise a bearing into [0, 360). */
|
|
8
|
+
export function normalizeBearing(deg) {
|
|
9
|
+
return ((deg % 360) + 360) % 360;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Point reached by travelling `meters` from [lng, lat] along `bearing` degrees. */
|
|
13
|
+
export function destination([lng, lat], bearing, meters) {
|
|
14
|
+
const d = meters / EARTH_RADIUS_M;
|
|
15
|
+
const b = toRad(bearing);
|
|
16
|
+
const φ1 = toRad(lat);
|
|
17
|
+
const λ1 = toRad(lng);
|
|
18
|
+
const φ2 = Math.asin(Math.sin(φ1) * Math.cos(d) + Math.cos(φ1) * Math.sin(d) * Math.cos(b));
|
|
19
|
+
const λ2 =
|
|
20
|
+
λ1 +
|
|
21
|
+
Math.atan2(Math.sin(b) * Math.sin(d) * Math.cos(φ1), Math.cos(d) - Math.sin(φ1) * Math.sin(φ2));
|
|
22
|
+
return [((toDeg(λ2) + 540) % 360) - 180, toDeg(φ2)];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Great-circle distance in meters. */
|
|
26
|
+
export function distance([lng1, lat1], [lng2, lat2]) {
|
|
27
|
+
const φ1 = toRad(lat1);
|
|
28
|
+
const φ2 = toRad(lat2);
|
|
29
|
+
const a =
|
|
30
|
+
Math.sin((φ2 - φ1) / 2) ** 2 +
|
|
31
|
+
Math.cos(φ1) * Math.cos(φ2) * Math.sin(toRad(lng2 - lng1) / 2) ** 2;
|
|
32
|
+
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Initial bearing in degrees from one point to another. */
|
|
36
|
+
export function bearingTo([lng1, lat1], [lng2, lat2]) {
|
|
37
|
+
const φ1 = toRad(lat1);
|
|
38
|
+
const φ2 = toRad(lat2);
|
|
39
|
+
const Δλ = toRad(lng2 - lng1);
|
|
40
|
+
const y = Math.sin(Δλ) * Math.cos(φ2);
|
|
41
|
+
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
|
42
|
+
return normalizeBearing(toDeg(Math.atan2(y, x)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when `v` looks like a [lng, lat] pair. */
|
|
46
|
+
export function isLngLat(v) {
|
|
47
|
+
return (
|
|
48
|
+
Array.isArray(v) &&
|
|
49
|
+
v.length >= 2 &&
|
|
50
|
+
Number.isFinite(v[0]) &&
|
|
51
|
+
Number.isFinite(v[1]) &&
|
|
52
|
+
Math.abs(v[0]) <= 180 &&
|
|
53
|
+
Math.abs(v[1]) <= 90
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Signed shortest rotation (degrees) that takes bearing `from` to bearing `to`, in (-180, 180]. */
|
|
58
|
+
export function angleDiff(to, from) {
|
|
59
|
+
const d = ((to - from + 540) % 360) - 180;
|
|
60
|
+
return d === -180 ? 180 : d;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* GeoJSON polygon of the area a viewer can see, ready to hand to a map's data source.
|
|
65
|
+
* `view` is the payload of the `view` event: { lngLat, heading, fov }.
|
|
66
|
+
*/
|
|
67
|
+
export function viewCone({ lngLat, heading, fov }, radius = 40, steps = 12) {
|
|
68
|
+
const ring = [lngLat];
|
|
69
|
+
for (let i = 0; i <= steps; i++) {
|
|
70
|
+
ring.push(destination(lngLat, heading - fov / 2 + (fov * i) / steps, radius));
|
|
71
|
+
}
|
|
72
|
+
ring.push(lngLat);
|
|
73
|
+
return { type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [ring] } };
|
|
74
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { LngLat } from './geo.js';
|
|
2
|
+
|
|
3
|
+
export type { LngLat };
|
|
4
|
+
|
|
5
|
+
export interface StreetsOptions {
|
|
6
|
+
/** Element or element id to render into. */
|
|
7
|
+
container: string | HTMLElement;
|
|
8
|
+
/** Start at the closest imagery to this [lng, lat]. */
|
|
9
|
+
center?: LngLat;
|
|
10
|
+
/** Start at a specific image id (alternative to `center`). */
|
|
11
|
+
imageId?: string;
|
|
12
|
+
/** Advanced viewer component options. */
|
|
13
|
+
component?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StreetsImage {
|
|
17
|
+
id: string;
|
|
18
|
+
lngLat: LngLat;
|
|
19
|
+
/** Direction the photo was taken, degrees from north. */
|
|
20
|
+
heading: number;
|
|
21
|
+
/** Capture time, milliseconds since the epoch. */
|
|
22
|
+
capturedAt: number;
|
|
23
|
+
/** True for 360° photos. */
|
|
24
|
+
isPano: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StreetsPosition extends StreetsImage {
|
|
28
|
+
/** Current look direction in degrees (0 = north). */
|
|
29
|
+
heading: number;
|
|
30
|
+
tilt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a map needs to draw the viewer: position, direction and view cone. */
|
|
34
|
+
export interface StreetsView {
|
|
35
|
+
lngLat: LngLat;
|
|
36
|
+
/** Look direction in degrees (0 = north). */
|
|
37
|
+
heading: number;
|
|
38
|
+
tilt: number;
|
|
39
|
+
/** Horizontal field of view in degrees. */
|
|
40
|
+
fov: number;
|
|
41
|
+
imageId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NearbyImage {
|
|
45
|
+
id: string;
|
|
46
|
+
lngLat: LngLat;
|
|
47
|
+
/** Meters from the search point. */
|
|
48
|
+
distance: number;
|
|
49
|
+
heading: number | null;
|
|
50
|
+
capturedAt: number | null;
|
|
51
|
+
isPano: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NoCoverageEvent {
|
|
55
|
+
lngLat: LngLat;
|
|
56
|
+
/** Closest imagery within 2 km, if any. */
|
|
57
|
+
nearest: NearbyImage | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class NoCoverageError extends Error {
|
|
61
|
+
lngLat: LngLat;
|
|
62
|
+
nearest: NearbyImage | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class YavqoStreets {
|
|
66
|
+
constructor(options: StreetsOptions);
|
|
67
|
+
/** The underlying viewer instance (available after `ready()`), for advanced use. */
|
|
68
|
+
readonly viewer: any;
|
|
69
|
+
ready(): Promise<this>;
|
|
70
|
+
|
|
71
|
+
/* navigation */
|
|
72
|
+
/** Rejects with NoCoverageError when there is no imagery nearby. */
|
|
73
|
+
setPosition(lngLat: LngLat): Promise<void>;
|
|
74
|
+
moveTo(imageId: string): Promise<void>;
|
|
75
|
+
/** Next / previous photo along the street. Resolve to false when there is nowhere to go. */
|
|
76
|
+
next(): Promise<boolean>;
|
|
77
|
+
prev(): Promise<boolean>;
|
|
78
|
+
moveForward(): Promise<boolean>;
|
|
79
|
+
moveBackward(): Promise<boolean>;
|
|
80
|
+
/** Turn to a compass bearing. Resolves to the bearing actually reached. */
|
|
81
|
+
setHeading(bearing: number): Promise<number>;
|
|
82
|
+
/** Turn to face a point. Resolves to the bearing reached. */
|
|
83
|
+
lookAt(lngLat: LngLat): Promise<number>;
|
|
84
|
+
/** 0 (widest) to 3 (closest). */
|
|
85
|
+
getZoom(): Promise<number>;
|
|
86
|
+
setZoom(level: number): Promise<void>;
|
|
87
|
+
isFullscreen(): boolean;
|
|
88
|
+
toggleFullscreen(): Promise<boolean>;
|
|
89
|
+
resize(): void;
|
|
90
|
+
|
|
91
|
+
/* coverage */
|
|
92
|
+
hasCoverage(lngLat: LngLat, radius?: number): Promise<boolean>;
|
|
93
|
+
findNearby(lngLat: LngLat, options?: { radius?: number; limit?: number }): Promise<NearbyImage[]>;
|
|
94
|
+
|
|
95
|
+
/* state */
|
|
96
|
+
getPosition(): Promise<StreetsPosition>;
|
|
97
|
+
getView(): Promise<StreetsView>;
|
|
98
|
+
|
|
99
|
+
/* events */
|
|
100
|
+
on(type: 'ready', fn: () => void): this;
|
|
101
|
+
on(type: 'image', fn: (image: StreetsImage) => void): this;
|
|
102
|
+
/** Fires whenever position, direction or field of view changes. Use it to drive a map. */
|
|
103
|
+
on(type: 'view', fn: (view: StreetsView) => void): this;
|
|
104
|
+
on(type: 'bearing', fn: (bearing: number) => void): this;
|
|
105
|
+
on(type: 'no-coverage', fn: (event: NoCoverageEvent) => void): this;
|
|
106
|
+
on(type: 'fullscreen', fn: (active: boolean) => void): this;
|
|
107
|
+
on(type: 'error', fn: (error: unknown) => void): this;
|
|
108
|
+
off(
|
|
109
|
+
type: 'ready' | 'image' | 'view' | 'bearing' | 'no-coverage' | 'fullscreen' | 'error',
|
|
110
|
+
fn: (...args: any[]) => void,
|
|
111
|
+
): this;
|
|
112
|
+
destroy(): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default YavqoStreets;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { Viewer, GraphDataProvider, NavigationDirection } from 'mapillary-js';
|
|
2
|
+
import { ENDPOINT } from './config.js';
|
|
3
|
+
import { TOKEN } from './token.js';
|
|
4
|
+
import { angleDiff, bearingTo, clamp, normalizeBearing } from './geo.js';
|
|
5
|
+
import { NoCoverageError, assertLngLat, findNearby, nearestImage } from './coverage.js';
|
|
6
|
+
|
|
7
|
+
export { NoCoverageError };
|
|
8
|
+
|
|
9
|
+
const toRad = (d) => (d * Math.PI) / 180;
|
|
10
|
+
const toDeg = (r) => (r * 180) / Math.PI;
|
|
11
|
+
|
|
12
|
+
export class YavqoStreets {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {string|HTMLElement} options.container Element or element id.
|
|
16
|
+
* @param {[number, number]} [options.center] Start near this [lng, lat].
|
|
17
|
+
* @param {string} [options.imageId] Start at this exact image instead.
|
|
18
|
+
*/
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
if (!options.container) throw new Error('YavqoStreets: `container` is required.');
|
|
21
|
+
if (!options.center && !options.imageId) {
|
|
22
|
+
throw new Error('YavqoStreets: provide `center` ([lng, lat]) or `imageId`.');
|
|
23
|
+
}
|
|
24
|
+
if (options.center) assertLngLat(options.center);
|
|
25
|
+
|
|
26
|
+
this.options = options;
|
|
27
|
+
if (!TOKEN) throw new Error('YavqoStreets: no access token is built in (src/token.js).');
|
|
28
|
+
this._endpoint = ENDPOINT;
|
|
29
|
+
this._fetch = (url) => fetch(url, { headers: { Authorization: `OAuth ${TOKEN}` } });
|
|
30
|
+
this._listeners = new Map();
|
|
31
|
+
this._viewQueued = false;
|
|
32
|
+
this.viewer = null;
|
|
33
|
+
this._footer = null;
|
|
34
|
+
this._mountFooter();
|
|
35
|
+
|
|
36
|
+
this._ready = this._init();
|
|
37
|
+
this._ready.catch((err) => {
|
|
38
|
+
if (!err.handled) this._emit('error', err);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async _init() {
|
|
43
|
+
const imageId = this.options.imageId ?? (await this._resolve(this.options.center));
|
|
44
|
+
|
|
45
|
+
this.viewer = new Viewer({
|
|
46
|
+
dataProvider: new GraphDataProvider({ endpoint: this._endpoint, accessToken: TOKEN }),
|
|
47
|
+
container: this.options.container,
|
|
48
|
+
imageId,
|
|
49
|
+
component: { cover: false, ...this.options.component, attribution: true },
|
|
50
|
+
});
|
|
51
|
+
this._mountFooter();
|
|
52
|
+
|
|
53
|
+
const v = this.viewer;
|
|
54
|
+
v.on('image', ({ image }) => this._emit('image', this._describe(image)));
|
|
55
|
+
v.on('bearing', ({ bearing }) => this._emit('bearing', bearing));
|
|
56
|
+
v.on('load', () => this._emit('ready'));
|
|
57
|
+
for (const type of ['image', 'pov', 'fov', 'position']) v.on(type, () => this._scheduleView());
|
|
58
|
+
|
|
59
|
+
this._onFullscreen = () => {
|
|
60
|
+
this.resize();
|
|
61
|
+
this._emit('fullscreen', this.isFullscreen());
|
|
62
|
+
};
|
|
63
|
+
document.addEventListener('fullscreenchange', this._onFullscreen);
|
|
64
|
+
return v;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_describe(image) {
|
|
68
|
+
return {
|
|
69
|
+
id: image.id,
|
|
70
|
+
lngLat: [image.lngLat.lng, image.lngLat.lat],
|
|
71
|
+
heading: image.computedCompassAngle ?? image.compassAngle,
|
|
72
|
+
capturedAt: image.capturedAt,
|
|
73
|
+
isPano: image.cameraType === 'spherical',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_container() {
|
|
78
|
+
const container =
|
|
79
|
+
typeof this.options.container === 'string'
|
|
80
|
+
? document.getElementById(this.options.container)
|
|
81
|
+
: this.options.container;
|
|
82
|
+
if (!container) throw new Error('YavqoStreets: container not found.');
|
|
83
|
+
return container;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_mountFooter() {
|
|
87
|
+
const container = this._container();
|
|
88
|
+
let footer = container.querySelector(':scope > .yavqo-streets-footer');
|
|
89
|
+
if (!footer) {
|
|
90
|
+
footer = document.createElement('footer');
|
|
91
|
+
footer.className = 'yavqo-streets-footer';
|
|
92
|
+
footer.setAttribute('aria-label', 'Yavqo Streets');
|
|
93
|
+
|
|
94
|
+
const brand = document.createElement('span');
|
|
95
|
+
brand.textContent = 'Yavqo Streets';
|
|
96
|
+
|
|
97
|
+
const privacy = document.createElement('a');
|
|
98
|
+
privacy.href = 'https://yavqo.com/privacy-policy';
|
|
99
|
+
privacy.target = '_blank';
|
|
100
|
+
privacy.rel = 'noopener noreferrer';
|
|
101
|
+
privacy.textContent = 'Privacy Policy';
|
|
102
|
+
|
|
103
|
+
const terms = document.createElement('a');
|
|
104
|
+
terms.href = 'https://yavqo.com/terms-of-use';
|
|
105
|
+
terms.target = '_blank';
|
|
106
|
+
terms.rel = 'noopener noreferrer';
|
|
107
|
+
terms.textContent = 'Terms Of Use';
|
|
108
|
+
|
|
109
|
+
for (const node of [brand, this._separator(), privacy, this._separator(), terms]) {
|
|
110
|
+
footer.append(node);
|
|
111
|
+
}
|
|
112
|
+
container.append(footer);
|
|
113
|
+
} else {
|
|
114
|
+
container.append(footer);
|
|
115
|
+
}
|
|
116
|
+
this._footer = footer;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_separator() {
|
|
120
|
+
const sep = document.createElement('span');
|
|
121
|
+
sep.className = 'yavqo-streets-footer-sep';
|
|
122
|
+
sep.setAttribute('aria-hidden', 'true');
|
|
123
|
+
sep.textContent = '|';
|
|
124
|
+
return sep;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ------------------------------------------------------------- coverage */
|
|
128
|
+
|
|
129
|
+
/** Image id closest to [lng, lat]. Emits `no-coverage` and throws NoCoverageError if there is none. */
|
|
130
|
+
async _resolve(lngLat) {
|
|
131
|
+
const best = await nearestImage(this._endpoint, lngLat, this._fetch);
|
|
132
|
+
if (best) return best.id;
|
|
133
|
+
|
|
134
|
+
const nearest = await findNearby(this._endpoint, lngLat, { radius: 2000, limit: 1 }, this._fetch).then(
|
|
135
|
+
(r) => r[0] ?? null,
|
|
136
|
+
() => null,
|
|
137
|
+
);
|
|
138
|
+
const err = new NoCoverageError(lngLat, nearest);
|
|
139
|
+
err.handled = (this._listeners.get('no-coverage')?.size ?? 0) > 0;
|
|
140
|
+
this._emit('no-coverage', { lngLat, nearest });
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Is there imagery within `radius` meters (default 30) of [lng, lat]? */
|
|
145
|
+
async hasCoverage(lngLat, radius = 30) {
|
|
146
|
+
return (await findNearby(this._endpoint, lngLat, { radius, limit: 1 }, this._fetch)).length > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Imagery around a point, closest first.
|
|
151
|
+
* Each item: { id, lngLat, distance, heading, capturedAt, isPano }.
|
|
152
|
+
*/
|
|
153
|
+
findNearby(lngLat, { radius = 100, limit = 25 } = {}) {
|
|
154
|
+
return findNearby(this._endpoint, lngLat, { radius, limit }, this._fetch);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ----------------------------------------------------------- navigation */
|
|
158
|
+
|
|
159
|
+
/** Resolves once the viewer is created. */
|
|
160
|
+
async ready() {
|
|
161
|
+
await this._ready;
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Jump to the image closest to [lng, lat]. Rejects with NoCoverageError if there is no imagery nearby. */
|
|
166
|
+
async setPosition(lngLat) {
|
|
167
|
+
assertLngLat(lngLat);
|
|
168
|
+
await this._ready;
|
|
169
|
+
const image = await this.viewer.moveTo(await this._resolve(lngLat));
|
|
170
|
+
await this._untilCurrent(image.id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Jump to a specific image by id. */
|
|
174
|
+
async moveTo(imageId) {
|
|
175
|
+
await this._ready;
|
|
176
|
+
const image = await this.viewer.moveTo(imageId);
|
|
177
|
+
await this._untilCurrent(image.id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The viewer reports a move as done slightly before it reports the new image as current. */
|
|
181
|
+
async _untilCurrent(id) {
|
|
182
|
+
for (let i = 0; i < 25; i++) {
|
|
183
|
+
if (!this.viewer || (await this.viewer.getImage()).id === id) return;
|
|
184
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async _move(direction) {
|
|
189
|
+
await this._ready;
|
|
190
|
+
let image;
|
|
191
|
+
try {
|
|
192
|
+
image = await this.viewer.moveDir(direction);
|
|
193
|
+
} catch {
|
|
194
|
+
return false; // nothing to move to in that direction
|
|
195
|
+
}
|
|
196
|
+
await this._untilCurrent(image.id);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Next / previous photo along the street. Resolve to false at the end of the line. */
|
|
201
|
+
next() {
|
|
202
|
+
return this._move(NavigationDirection.Next);
|
|
203
|
+
}
|
|
204
|
+
prev() {
|
|
205
|
+
return this._move(NavigationDirection.Prev);
|
|
206
|
+
}
|
|
207
|
+
/** Step to the nearest photo ahead of / behind where you are looking. */
|
|
208
|
+
moveForward() {
|
|
209
|
+
return this._move(NavigationDirection.StepForward);
|
|
210
|
+
}
|
|
211
|
+
moveBackward() {
|
|
212
|
+
return this._move(NavigationDirection.StepBackward);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Turn to face a compass bearing (0 = north). Resolves to the bearing reached.
|
|
217
|
+
* Flat (non-panoramic) photos only cover part of the circle, so the view may stop short.
|
|
218
|
+
*/
|
|
219
|
+
async setHeading(bearing) {
|
|
220
|
+
await this._ready;
|
|
221
|
+
const target = normalizeBearing(bearing);
|
|
222
|
+
const spherical = (await this.viewer.getImage()).cameraType === 'spherical';
|
|
223
|
+
let slope = spherical ? 1 / 360 : 1 / 70; // basic-coordinate change per degree; refined as we go
|
|
224
|
+
let scale = 1; // shrinks when the viewer ignores a step that would leave the photo
|
|
225
|
+
|
|
226
|
+
for (let i = 0; i < 9; i++) {
|
|
227
|
+
const pov = await this.viewer.getPointOfView();
|
|
228
|
+
const delta = angleDiff(target, pov.bearing);
|
|
229
|
+
if (Math.abs(delta) <= 1) return pov.bearing;
|
|
230
|
+
|
|
231
|
+
const [cx, cy] = await this.viewer.getCenter();
|
|
232
|
+
const raw = cx + delta * slope * scale;
|
|
233
|
+
const nx = spherical ? ((raw % 1) + 1) % 1 : clamp(raw, 0, 1);
|
|
234
|
+
if (nx === cx) return pov.bearing; // already at the edge of a flat photo
|
|
235
|
+
|
|
236
|
+
this.viewer.setCenter([nx, cy]);
|
|
237
|
+
const after = await this._settle();
|
|
238
|
+
const moved = angleDiff(after.bearing, pov.bearing);
|
|
239
|
+
if (Math.abs(moved) > 0.5) {
|
|
240
|
+
slope = (nx - cx) / moved;
|
|
241
|
+
scale = 1;
|
|
242
|
+
} else if (spherical) {
|
|
243
|
+
continue;
|
|
244
|
+
} else {
|
|
245
|
+
scale /= 2;
|
|
246
|
+
if (scale < 0.05) return after.bearing; // furthest this flat photo can turn
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return (await this.viewer.getPointOfView()).bearing;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Turn to face a [lng, lat] point from where you are standing. */
|
|
253
|
+
async lookAt(lngLat) {
|
|
254
|
+
assertLngLat(lngLat);
|
|
255
|
+
await this._ready;
|
|
256
|
+
const p = await this.viewer.getPosition();
|
|
257
|
+
return this.setHeading(bearingTo([p.lng, p.lat], lngLat));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Wait for the viewer's pan animation to finish, then return the resting point of view. */
|
|
261
|
+
async _settle() {
|
|
262
|
+
let last = await this.viewer.getPointOfView();
|
|
263
|
+
for (let i = 0; i < 25; i++) {
|
|
264
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
265
|
+
const now = await this.viewer.getPointOfView();
|
|
266
|
+
const still = Math.abs(angleDiff(now.bearing, last.bearing)) < 0.05 && Math.abs(now.tilt - last.tilt) < 0.05;
|
|
267
|
+
last = now;
|
|
268
|
+
if (still && i > 0) break;
|
|
269
|
+
}
|
|
270
|
+
return last;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Zoom level, 0 (widest) to 3 (closest). */
|
|
274
|
+
async getZoom() {
|
|
275
|
+
await this._ready;
|
|
276
|
+
return this.viewer.getZoom();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async setZoom(level) {
|
|
280
|
+
await this._ready;
|
|
281
|
+
this.viewer.setZoom(clamp(level, 0, 3));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
isFullscreen() {
|
|
285
|
+
return Boolean(this.viewer) && document.fullscreenElement === this.viewer.getContainer();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Enter or leave fullscreen. Resolves to the new state. */
|
|
289
|
+
async toggleFullscreen() {
|
|
290
|
+
await this._ready;
|
|
291
|
+
const el = this.viewer.getContainer();
|
|
292
|
+
if (document.fullscreenElement === el) await document.exitFullscreen();
|
|
293
|
+
else if (el.requestFullscreen) await el.requestFullscreen();
|
|
294
|
+
return this.isFullscreen();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ----------------------------------------------------------------- views */
|
|
298
|
+
|
|
299
|
+
/** Current photo and view direction. */
|
|
300
|
+
async getPosition() {
|
|
301
|
+
await this._ready;
|
|
302
|
+
const [image, pov] = await Promise.all([this.viewer.getImage(), this.viewer.getPointOfView()]);
|
|
303
|
+
return { ...this._describe(image), heading: pov.bearing, tilt: pov.tilt };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Everything a map needs to show where the viewer is and what it can see:
|
|
308
|
+
* { lngLat, heading, tilt, fov, imageId }. `fov` is the horizontal field of view in degrees.
|
|
309
|
+
*/
|
|
310
|
+
async getView() {
|
|
311
|
+
await this._ready;
|
|
312
|
+
const v = this.viewer;
|
|
313
|
+
const [pos, pov, vfov, image] = await Promise.all([
|
|
314
|
+
v.getPosition(),
|
|
315
|
+
v.getPointOfView(),
|
|
316
|
+
v.getFieldOfView(),
|
|
317
|
+
v.getImage(),
|
|
318
|
+
]);
|
|
319
|
+
const el = v.getContainer();
|
|
320
|
+
const aspect = el.clientWidth / Math.max(1, el.clientHeight);
|
|
321
|
+
const fov = clamp(toDeg(2 * Math.atan(Math.tan(toRad(vfov) / 2) * aspect)), 1, 180);
|
|
322
|
+
return { lngLat: [pos.lng, pos.lat], heading: pov.bearing, tilt: pov.tilt, fov, imageId: image.id };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_scheduleView() {
|
|
326
|
+
if (this._viewQueued) return;
|
|
327
|
+
this._viewQueued = true;
|
|
328
|
+
// A timer, not rAF: rAF stops in background tabs and would leave this flag stuck.
|
|
329
|
+
setTimeout(async () => {
|
|
330
|
+
this._viewQueued = false;
|
|
331
|
+
if (!this.viewer) return;
|
|
332
|
+
try {
|
|
333
|
+
this._emit('view', await this.getView());
|
|
334
|
+
} catch {
|
|
335
|
+
/* viewer was removed mid-update */
|
|
336
|
+
}
|
|
337
|
+
}, 16);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Call after the container changes size. */
|
|
341
|
+
resize() {
|
|
342
|
+
this.viewer?.resize();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ---------------------------------------------------------------- events */
|
|
346
|
+
|
|
347
|
+
on(type, fn) {
|
|
348
|
+
if (!this._listeners.has(type)) this._listeners.set(type, new Set());
|
|
349
|
+
this._listeners.get(type).add(fn);
|
|
350
|
+
return this;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
off(type, fn) {
|
|
354
|
+
this._listeners.get(type)?.delete(fn);
|
|
355
|
+
return this;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
_emit(type, payload) {
|
|
359
|
+
this._listeners.get(type)?.forEach((fn) => fn(payload));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
destroy() {
|
|
363
|
+
if (this._onFullscreen) document.removeEventListener('fullscreenchange', this._onFullscreen);
|
|
364
|
+
this._listeners.clear();
|
|
365
|
+
this._footer?.remove();
|
|
366
|
+
this._footer = null;
|
|
367
|
+
this.viewer?.remove();
|
|
368
|
+
this.viewer = null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export default YavqoStreets;
|
package/src/style.css
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@import "mapillary-js/dist/mapillary.css";
|
|
2
|
+
|
|
3
|
+
.mapillary-viewer .mapillary-attribution-container {
|
|
4
|
+
visibility: hidden !important;
|
|
5
|
+
opacity: 0 !important;
|
|
6
|
+
pointer-events: none !important;
|
|
7
|
+
user-select: none !important;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.yavqo-streets-footer {
|
|
11
|
+
position: absolute;
|
|
12
|
+
left: 0;
|
|
13
|
+
right: 0;
|
|
14
|
+
bottom: 0;
|
|
15
|
+
z-index: 30;
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: 8px;
|
|
19
|
+
height: 22px;
|
|
20
|
+
margin: 0;
|
|
21
|
+
padding: 0 10px;
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
background: rgba(0, 0, 0, 0.55);
|
|
24
|
+
color: #fff;
|
|
25
|
+
font: 11px/22px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
26
|
+
pointer-events: auto;
|
|
27
|
+
user-select: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.yavqo-streets-footer a {
|
|
31
|
+
color: inherit;
|
|
32
|
+
text-decoration: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.yavqo-streets-footer a:hover {
|
|
36
|
+
text-decoration: underline;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.yavqo-streets-footer-sep {
|
|
40
|
+
opacity: 0.7;
|
|
41
|
+
}
|
package/src/token.js
ADDED