@osm-editor-kit/street-imagery-react 0.1.0-alpha.5 → 0.1.0-alpha.7

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.
Files changed (36) hide show
  1. package/README.md +45 -0
  2. package/dist/MapillaryPanel-N45NQY4C.js +2 -0
  3. package/dist/PanoramaxPanel-MFI4L67Z.js +2 -0
  4. package/dist/StreetLevelImagerySelectionOverlay.d.ts +20 -3
  5. package/dist/StreetLevelImagerySourcesAndLayers.d.ts +9 -1
  6. package/dist/StreetLevelImageryViewCone.d.ts +11 -1
  7. package/dist/StreetLevelImageryViewer.d.ts +7 -2
  8. package/dist/{chunk-72HJV6II.js → chunk-53RA4OSJ.js} +6 -2
  9. package/dist/chunk-EE7V3OIZ.js +116 -0
  10. package/dist/chunk-EQ2JKLGT.js +311 -0
  11. package/dist/hooks/useAllProviderMapFeatures.d.ts +0 -4
  12. package/dist/i18n/PhotoCount.d.ts +12 -0
  13. package/dist/i18n/PhotoDate.d.ts +16 -0
  14. package/dist/i18n/StreetImageryLocaleProvider.d.ts +21 -0
  15. package/dist/i18n/messages.d.ts +27 -0
  16. package/dist/index.d.ts +12 -4
  17. package/dist/index.js +593 -182
  18. package/dist/mapillary/MapillaryFeatureBar.d.ts +16 -0
  19. package/dist/mapillary/featureBarLabels.d.ts +16 -0
  20. package/dist/openers/LocationPickOnMap.d.ts +10 -0
  21. package/dist/openers/useLocationPickStore.d.ts +9 -0
  22. package/dist/panels/MapillaryPanel.d.ts +7 -1
  23. package/dist/panels/PanoramaxPanel.d.ts +8 -1
  24. package/dist/panels/panoramaxLimitPanning.d.ts +54 -0
  25. package/dist/panels/panoramaxPhotoFromMetadata.d.ts +8 -0
  26. package/dist/photo-details/PhotoDetailsDialog.d.ts +21 -0
  27. package/dist/photo-details/index.d.ts +6 -0
  28. package/dist/photo-details/labels.d.ts +33 -0
  29. package/dist/photo-details.js +235 -0
  30. package/dist/viewpoints/FloatingPhotoViewer.d.ts +33 -4
  31. package/dist/viewpoints/useViewpointSessionStore.d.ts +6 -10
  32. package/dist/viewpoints/viewDirectionLabels.d.ts +4 -4
  33. package/package.json +11 -2
  34. package/dist/MapillaryPanel-I5GKT6YT.js +0 -2
  35. package/dist/PanoramaxPanel-J6ANR6LB.js +0 -2
  36. package/dist/chunk-ZH3TPW4D.js +0 -165
package/README.md CHANGED
@@ -120,3 +120,48 @@ const { data: detections } = useMapillaryImageDetections(photo.photoId, {
120
120
  - Map layers: `filter.mapFeatureValue` limits which signs/objects are drawn (`matchesAnyGroup(SIGN_GROUPS)`).
121
121
  - Suggested views: `useViewSuggestions(viewpoints, { sources: [mapillaryPhotoSource, streetViewPhotoSource] })`.
122
122
  - `turnMapillaryViewerTo` / `setMapillaryViewerOutlines` work on a mapillary-js `Viewer` you own.
123
+
124
+ ## Pick a place on the map to open elsewhere
125
+
126
+ A button arms a location opener (see `@osm-editor-kit/street-imagery`), the next map click opens the clicked place in that service and disarms; Escape cancels.
127
+
128
+ ```tsx
129
+ import {
130
+ LocationPickOnMap,
131
+ useArmedLocationOpenerId,
132
+ useLocationPickActions,
133
+ } from '@osm-editor-kit/street-imagery-react'
134
+
135
+ const armed = useArmedLocationOpenerId() // show it: pressed button, crosshair cursor, a hint on the map
136
+ const { toggle, disarm } = useLocationPickActions()
137
+
138
+ {
139
+ getLocationOpeners().map((opener) => (
140
+ <button key={opener.id} aria-pressed={armed === opener.id} onClick={() => toggle(opener.id)}>
141
+ Open in {opener.label}
142
+ </button>
143
+ ))
144
+ }
145
+ ;<Map cursor={armed ? 'crosshair' : undefined}>
146
+ <LocationPickOnMap />
147
+ </Map>
148
+ ```
149
+
150
+ The map's own click handlers still run; skip them while `armed` is set if the click should only open the place. When the place is known already (a selected feature), call `openLocationInNewTab` directly instead.
151
+
152
+ ## Photo details dialog (optional)
153
+
154
+ A modal with everything known about the shown photo: capture, camera, position, source and raw EXIF. It lives in its own entry, so apps that do not import it do not bundle it.
155
+
156
+ ```tsx
157
+ import { FloatingViewerInfoButton } from '@osm-editor-kit/street-imagery-react'
158
+ import { PHOTO_DETAILS_LABELS, PhotoDetailsDialog } from '@osm-editor-kit/street-imagery-react/photo-details'
159
+
160
+ <FloatingPhotoViewer
161
+ titleActions={<FloatingViewerInfoButton label={PHOTO_DETAILS_LABELS.en.open} onClick={() => setOpen(true)} />}
162
+ …
163
+ />
164
+ <PhotoDetailsDialog open={open} photo={viewerPhoto} onClose={() => setOpen(false)} />
165
+ ```
166
+
167
+ `viewerPhoto` is what `onViewerPhoto` of the viewer gives (Mapillary, Panoramax).
@@ -0,0 +1,2 @@
1
+ export { MapillaryPanel } from './chunk-53RA4OSJ.js';
2
+ import './chunk-25UGGXIE.js';
@@ -0,0 +1,2 @@
1
+ export { PanoramaxPanel } from './chunk-EQ2JKLGT.js';
2
+ import './chunk-25UGGXIE.js';
@@ -1,9 +1,26 @@
1
- import type { Bbox, NormalizedPhoto, NormalizedSequence } from '@osm-editor-kit/street-imagery';
1
+ import type { NormalizedPhoto, NormalizedSequence } from '@osm-editor-kit/street-imagery';
2
+ /** Lines and photo dots: near black. The shown photo's line is thicker, its marker larger. */
3
+ export declare const BASE_COLOR = "#171717";
4
+ /** The shown photo: fill of its marker and camera pin (black outline), and the line between. */
5
+ export declare const SELECTION_COLOR = "#f97316";
6
+ /** Width of the shown photo's sequence line; other lines are 1.25. */
7
+ export declare const ACTIVE_LINE_WIDTH = 2.5;
2
8
  export type StreetLevelImagerySelectionOverlayProps = {
3
9
  selectedPhoto?: NormalizedPhoto | null;
4
10
  selectedSequence?: NormalizedSequence | null;
11
+ /**
12
+ * Where the viewer says the camera was (Mapillary: the computed position, which the view cone
13
+ * starts at). When it differs from the photo dot, a pin marks it and a line connects both.
14
+ */
15
+ cameraLngLat?: [number, number] | null;
16
+ /** Layer to draw the sequence line below, so photo dots stay on top of it. */
17
+ sequenceBeforeId?: string;
5
18
  };
6
- export declare const StreetLevelImagerySelectionOverlay: ({ selectedPhoto, selectedSequence, }: StreetLevelImagerySelectionOverlayProps) => import("react").JSX.Element | null;
19
+ /**
20
+ * The shown photo on the map, bottom to top: its sequence line (only passed when the provider's
21
+ * own layers are off; else that layer marks it), a filled marker on its photo dot, and — when the camera position differs from the
22
+ * dot — a dashed connector and a pin at the camera position.
23
+ */
24
+ export declare const StreetLevelImagerySelectionOverlay: ({ selectedPhoto, selectedSequence, cameraLngLat, sequenceBeforeId, }: StreetLevelImagerySelectionOverlayProps) => import("react").JSX.Element | null;
7
25
  /** Resolve sequence geometry for highlight when only photo + fetched sequences are available. */
8
26
  export declare const resolveSelectedSequence: (selectedPhoto: NormalizedPhoto | null | undefined, sequences: NormalizedSequence[], sequenceId: string | null | undefined) => NormalizedSequence | null;
9
- export type SequenceHighlightBbox = Bbox | null;
@@ -20,7 +20,7 @@ export type StreetLevelImagerySourcesAndLayersProps = {
20
20
  options: {
21
21
  config?: StreetImageryConfig;
22
22
  showSequences?: boolean;
23
- /** Heading wedges / 360° disks per photo, from zoom 16, capped for dense areas. Default true. */
23
+ /** Heading wedges / 360° disks per photo, from zoom 17, capped for dense areas. Default true. */
24
24
  showViewfields?: boolean;
25
25
  showViewCone?: boolean;
26
26
  showSelectionHighlight?: boolean;
@@ -31,7 +31,15 @@ export type StreetLevelImagerySourcesAndLayersProps = {
31
31
  hfov?: number | null;
32
32
  lngLat?: [number, number] | null;
33
33
  } | null;
34
+ /**
35
+ * Style colour of a photo (by type, age …). It colours the view-direction shapes; photo dots
36
+ * and sequence lines are black.
37
+ */
34
38
  photoCircleColor: DataDrivenPropertyValueSpecification<string>;
39
+ /** Colour of the shown photo's view cone. Default: the selection colour (orange). */
40
+ viewConeColor?: DataDrivenPropertyValueSpecification<string>;
41
+ /** Length of the shown photo's view cone, as a multiple of the per-photo shapes. Default 2.5. */
42
+ viewConeScale?: number;
35
43
  mapFeatureCircleColor: DataDrivenPropertyValueSpecification<string>;
36
44
  };
37
45
  };
@@ -1,4 +1,7 @@
1
1
  import type { NormalizedPhoto } from '@osm-editor-kit/street-imagery';
2
+ import type { DataDrivenPropertyValueSpecification } from 'maplibre-gl';
3
+ /** Fill of view-direction shapes: the 360° disks, the flat wedges and the shown photo's cone. */
4
+ export declare const VIEW_SHAPE_FILL_OPACITY = 0.25;
2
5
  export type StreetLevelImageryViewConeProps = {
3
6
  selectedPhoto: NormalizedPhoto;
4
7
  zoom: number;
@@ -7,5 +10,12 @@ export type StreetLevelImageryViewConeProps = {
7
10
  hfov?: number | null;
8
11
  lngLat?: [number, number] | null;
9
12
  } | null;
13
+ /**
14
+ * Fill colour; an expression may read the photo's `isPano` and `capturedAt`, so the cone gets
15
+ * the same colour as the photo's own view-direction shape.
16
+ */
17
+ color?: DataDrivenPropertyValueSpecification<string>;
18
+ /** Cone length as a multiple of the per-photo view-direction shapes. Default 2.5. */
19
+ scale?: number;
10
20
  };
11
- export declare const StreetLevelImageryViewCone: ({ selectedPhoto, zoom, viewerPov, }: StreetLevelImageryViewConeProps) => import("react").JSX.Element | null;
21
+ export declare const StreetLevelImageryViewCone: ({ selectedPhoto, zoom, viewerPov, color, scale, }: StreetLevelImageryViewConeProps) => import("react").JSX.Element | null;
@@ -6,7 +6,7 @@ type StreetLevelImageryViewerProps = {
6
6
  groupPhotos: NormalizedPhoto[];
7
7
  onPhotoSelected: (selection: StreetImageryPhotoSelection) => void;
8
8
  onEaseMapToPoint: (lng: number, lat: number) => void;
9
- /** Mapillary only: full photo data for each image the viewer shows. */
9
+ /** Mapillary, Panoramax: full photo data (creator, licence …) for each image the viewer shows. */
10
10
  onViewerPhoto?: (photo: NormalizedPhoto) => void;
11
11
  /** Mapillary 360° photos: initial view bearing (e.g. a suggested view direction). */
12
12
  lookAtBearing?: number | null;
@@ -14,6 +14,11 @@ type StreetLevelImageryViewerProps = {
14
14
  lookAt?: MapillaryLookAt | null;
15
15
  /** Mapillary: outlines to draw in the shown image. */
16
16
  outlines?: MapillaryViewerOutline[];
17
+ /**
18
+ * Hide the viewer's own attribution (Mapillary) or legend (Panoramax); the host must then show
19
+ * creator and licence itself, from `onViewerPhoto`.
20
+ */
21
+ hideAttribution?: boolean;
17
22
  };
18
- export declare const StreetLevelImageryViewer: ({ photo, groupPhotos, onPhotoSelected, onEaseMapToPoint, onViewerPhoto, lookAtBearing, lookAt, outlines, }: StreetLevelImageryViewerProps) => import("react").JSX.Element | null;
23
+ export declare const StreetLevelImageryViewer: ({ photo, groupPhotos, onPhotoSelected, onEaseMapToPoint, onViewerPhoto, lookAtBearing, lookAt, outlines, hideAttribution, }: StreetLevelImageryViewerProps) => import("react").JSX.Element | null;
19
24
  export {};
@@ -105,7 +105,8 @@ var MapillaryPanel = ({
105
105
  onViewerPhoto,
106
106
  lookAtBearing,
107
107
  lookAt,
108
- outlines
108
+ outlines,
109
+ hideAttribution = false
109
110
  }) => {
110
111
  const containerRef = useRef(null);
111
112
  const viewerRef = useRef(null);
@@ -116,6 +117,7 @@ var MapillaryPanel = ({
116
117
  const pendingBearingRef = useRef(null);
117
118
  const actions = useViewerActions();
118
119
  const initialPhotoIdRef = useRef(photo.photoId);
120
+ const initialHideAttributionRef = useRef(hideAttribution);
119
121
  const emitPhotoSelected = useEffectEvent(onPhotoSelected);
120
122
  const emitEaseMapToPoint = useEffectEvent(onEaseMapToPoint);
121
123
  const turnToWantedBearing = useEffectEvent((viewer, image) => {
@@ -162,6 +164,7 @@ var MapillaryPanel = ({
162
164
  container,
163
165
  imageId: initialPhotoIdRef.current,
164
166
  component: {
167
+ attribution: !initialHideAttributionRef.current,
165
168
  cover: false,
166
169
  sequence: { visible: true },
167
170
  tag: true
@@ -205,7 +208,8 @@ var MapillaryPanel = ({
205
208
  capturedAt: image.capturedAt,
206
209
  isPano: image.cameraType === "spherical" || image.cameraType === "equirectangular",
207
210
  heading: image.computedCompassAngle ?? image.compassAngle,
208
- lngLat: [position.lng, position.lat]
211
+ lngLat: [position.lng, position.lat],
212
+ ...image.creatorUsername ? { creatorName: image.creatorUsername } : {}
209
213
  });
210
214
  actions.setPov({ lngLat: [position.lng, position.lat] });
211
215
  emitEaseMapToPoint(position.lng, position.lat);
@@ -0,0 +1,116 @@
1
+ import { DEFAULT_STREET_IMAGERY_LOCALE } from '@osm-editor-kit/street-imagery';
2
+ import { createContext, use } from 'react';
3
+ import { jsx } from 'react/jsx-runtime';
4
+
5
+ // src/i18n/messages.ts
6
+ var en = {
7
+ viewer: {
8
+ regionLabel: "Photo viewer",
9
+ loading: "Loading viewer\u2026",
10
+ backInHistory: "Back in history ([)",
11
+ forwardInHistory: "Forward in history (])",
12
+ minimize: "Minimize",
13
+ close: "Close (Esc)",
14
+ suggestedViews: "Suggested views",
15
+ suggestedViewsHint: "Suggested views: where the photo is taken from, and which way it looks",
16
+ noPhoto: (label) => `${label} (no photo)`,
17
+ noMatchingPhoto: (label) => `${label}: no matching photo`,
18
+ nextPhotoOfView: (label) => `${label} \u2014 click for the next photo`
19
+ },
20
+ unknownDate: "Unknown date",
21
+ viewpointRole: {
22
+ here: "Here",
23
+ "line-start": "Start",
24
+ "line-end": "End",
25
+ "into-node": "Junction",
26
+ custom: "View"
27
+ },
28
+ compass: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"],
29
+ viewDirection: (role, kind) => {
30
+ switch (kind) {
31
+ case "forward":
32
+ return `${role}, looking along the street`;
33
+ case "back":
34
+ return `${role}, looking back along the street`;
35
+ case "right":
36
+ return `${role}, looking right across the street`;
37
+ case "left":
38
+ return `${role}, looking left across the street`;
39
+ case "into":
40
+ return `${role}, looking into the junction`;
41
+ default:
42
+ return `${role}, looking ${kind}`;
43
+ }
44
+ }
45
+ };
46
+ var COMPASS_DE = { N: "Norden", E: "Osten", S: "S\xFCden", W: "Westen" };
47
+ var de = {
48
+ viewer: {
49
+ regionLabel: "Fotoansicht",
50
+ loading: "Ansicht wird geladen\u2026",
51
+ backInHistory: "Zur\xFCck im Verlauf ([)",
52
+ forwardInHistory: "Vor im Verlauf (])",
53
+ minimize: "Minimieren",
54
+ close: "Schlie\xDFen (Esc)",
55
+ suggestedViews: "Vorgeschlagene Blicke",
56
+ suggestedViewsHint: "Vorgeschlagene Blicke: von wo das Foto aufgenommen ist und wohin es schaut",
57
+ noPhoto: (label) => `${label} (kein Foto)`,
58
+ noMatchingPhoto: (label) => `${label}: kein passendes Foto`,
59
+ nextPhotoOfView: (label) => `${label} \u2014 klicken f\xFCr das n\xE4chste Foto`
60
+ },
61
+ unknownDate: "Datum unbekannt",
62
+ viewpointRole: {
63
+ here: "Hier",
64
+ "line-start": "Anfang",
65
+ "line-end": "Ende",
66
+ "into-node": "Knoten",
67
+ custom: "Blick"
68
+ },
69
+ compass: ["N", "NO", "O", "SO", "S", "SW", "W", "NW"],
70
+ viewDirection: (role, kind) => {
71
+ switch (kind) {
72
+ case "forward":
73
+ return `${role}, Blick entlang der Stra\xDFe`;
74
+ case "back":
75
+ return `${role}, Blick zur\xFCck entlang der Stra\xDFe`;
76
+ case "right":
77
+ return `${role}, Blick nach rechts quer zur Stra\xDFe`;
78
+ case "left":
79
+ return `${role}, Blick nach links quer zur Stra\xDFe`;
80
+ case "into":
81
+ return `${role}, Blick in den Knoten`;
82
+ default:
83
+ return `${role}, Blick nach ${COMPASS_DE[kind] ?? kind}`;
84
+ }
85
+ }
86
+ };
87
+ var STREET_IMAGERY_MESSAGES = {
88
+ en,
89
+ de
90
+ };
91
+ var DEFAULT_I18N = {
92
+ locale: DEFAULT_STREET_IMAGERY_LOCALE,
93
+ messages: STREET_IMAGERY_MESSAGES[DEFAULT_STREET_IMAGERY_LOCALE]
94
+ };
95
+ var I18nContext = createContext(DEFAULT_I18N);
96
+ var mergeMessages = (base, overrides) => overrides ? {
97
+ unknownDate: overrides.unknownDate ?? base.unknownDate,
98
+ viewer: { ...base.viewer, ...overrides.viewer },
99
+ viewpointRole: { ...base.viewpointRole, ...overrides.viewpointRole },
100
+ compass: overrides.compass?.length === 8 ? overrides.compass : base.compass,
101
+ viewDirection: overrides.viewDirection ?? base.viewDirection
102
+ } : base;
103
+ var StreetImageryLocaleProvider = ({
104
+ locale,
105
+ messages,
106
+ children
107
+ }) => /* @__PURE__ */ jsx(
108
+ I18nContext,
109
+ {
110
+ value: { locale, messages: mergeMessages(STREET_IMAGERY_MESSAGES[locale], messages) },
111
+ children
112
+ }
113
+ );
114
+ var useStreetImageryI18n = () => use(I18nContext);
115
+
116
+ export { STREET_IMAGERY_MESSAGES, StreetImageryLocaleProvider, useStreetImageryI18n };
@@ -0,0 +1,311 @@
1
+ import { useViewerActions } from './chunk-25UGGXIE.js';
2
+ import '@panoramax/web-viewer';
3
+ import { getStreetImageryConfig } from '@osm-editor-kit/street-imagery';
4
+ import { useRef, useEffectEvent, useEffect } from 'react';
5
+ import { jsxs, jsx } from 'react/jsx-runtime';
6
+
7
+ // src/panels/panoramaxLimitPanning.ts
8
+ var toRad = (degrees) => degrees * Math.PI / 180;
9
+ var MIN_VISIBLE_SHARE = 0.3;
10
+ var MIN_IMAGE_SHARE_OF_VIEW = 0.8;
11
+ var keepInView = (centre, imageMin, imageMax, halfView) => {
12
+ const keep = MIN_VISIBLE_SHARE * Math.min(imageMax - imageMin, 2 * halfView);
13
+ return Math.max(imageMin - halfView + keep, Math.min(imageMax + halfView - keep, centre));
14
+ };
15
+ var limitToImage = (psv, position) => {
16
+ const crop = psv.state.textureData?.panoData?.baseData;
17
+ if (!crop || !crop.fullWidth || !crop.fullHeight) {
18
+ return position;
19
+ }
20
+ const halfH = toRad(psv.state.hFov) / 2;
21
+ const halfV = toRad(psv.state.vFov) / 2;
22
+ let { yaw, pitch } = position;
23
+ if (crop.croppedWidth < crop.fullWidth) {
24
+ const left = crop.croppedX / crop.fullWidth * 2 * Math.PI - Math.PI;
25
+ const right = (crop.croppedX + crop.croppedWidth) / crop.fullWidth * 2 * Math.PI - Math.PI;
26
+ const centred = yaw > Math.PI ? yaw - 2 * Math.PI : yaw;
27
+ yaw = keepInView(centred, left, right, halfH);
28
+ }
29
+ if (crop.croppedHeight < crop.fullHeight) {
30
+ const top = Math.PI / 2 - crop.croppedY / crop.fullHeight * Math.PI;
31
+ const bottom = Math.PI / 2 - (crop.croppedY + crop.croppedHeight) / crop.fullHeight * Math.PI;
32
+ pitch = keepInView(pitch, bottom, top, halfV);
33
+ }
34
+ return { yaw, pitch };
35
+ };
36
+ var toDeg = (radians) => radians * 180 / Math.PI;
37
+ var widestZoomLevel = (psv) => {
38
+ const crop = psv.state.textureData?.panoData?.baseData;
39
+ if (!crop || !crop.fullWidth || !crop.fullHeight) {
40
+ return null;
41
+ }
42
+ const flatWide = crop.croppedWidth < crop.fullWidth;
43
+ const flatHigh = crop.croppedHeight < crop.fullHeight;
44
+ if (!flatWide && !flatHigh) {
45
+ return null;
46
+ }
47
+ const imageV = crop.croppedHeight / crop.fullHeight * 180;
48
+ const imageH = crop.croppedWidth / crop.fullWidth * 360;
49
+ const aspect = Math.tan(toRad(psv.state.hFov) / 2) / Math.tan(toRad(psv.state.vFov) / 2);
50
+ const vForImageWidth = toDeg(2 * Math.atan(Math.tan(toRad(Math.min(imageH, 179)) / 2) / aspect));
51
+ const widestV = Math.min(flatHigh ? imageV : 180, flatWide ? vForImageWidth : 180);
52
+ return psv.dataHelper.fovToZoomLevel(Math.min(widestV / MIN_IMAGE_SHARE_OF_VIEW, 179));
53
+ };
54
+ var limitPanningToImage = (psv) => {
55
+ const onMove = (event) => {
56
+ const move = event;
57
+ if (move.position) {
58
+ move.position = limitToImage(psv, move.position);
59
+ }
60
+ };
61
+ const onZoomOrLoad = () => {
62
+ const widest = widestZoomLevel(psv);
63
+ if (widest != null && psv.getZoomLevel() < widest - 0.01) {
64
+ psv.zoom(widest);
65
+ return;
66
+ }
67
+ const current = psv.getPosition();
68
+ const limited = limitToImage(psv, current);
69
+ if (Math.abs(limited.yaw - current.yaw) > 1e-4 || Math.abs(limited.pitch - current.pitch) > 1e-4) {
70
+ psv.rotate(limited);
71
+ }
72
+ };
73
+ psv.addEventListener("before-rotate", onMove);
74
+ psv.addEventListener("before-animate", onMove);
75
+ psv.addEventListener("zoom-updated", onZoomOrLoad);
76
+ psv.addEventListener("panorama-loaded", onZoomOrLoad);
77
+ return () => {
78
+ psv.removeEventListener("before-rotate", onMove);
79
+ psv.removeEventListener("before-animate", onMove);
80
+ psv.removeEventListener("zoom-updated", onZoomOrLoad);
81
+ psv.removeEventListener("panorama-loaded", onZoomOrLoad);
82
+ };
83
+ };
84
+
85
+ // src/panels/panoramaxPhotoFromMetadata.ts
86
+ var toMs = (iso) => {
87
+ const ms = iso ? Date.parse(iso) : Number.NaN;
88
+ return Number.isNaN(ms) ? null : ms;
89
+ };
90
+ var panoramaxPhotoFromMetadata = (metadata) => {
91
+ const properties = metadata.properties ?? {};
92
+ const producers = metadata.caption?.producer ?? [];
93
+ const creatorName = producers.find((entry) => !entry.includes("@")) ?? producers.at(-1);
94
+ const creatorContact = producers.find((entry) => entry.includes("@"));
95
+ const camera = properties["pers:interior_orientation"];
96
+ const cameraName = [camera?.camera_manufacturer, camera?.camera_model].filter(Boolean).join(" ");
97
+ const crop = metadata.panorama?.cropData;
98
+ const isFlat = crop?.croppedWidth != null && crop.fullWidth != null && crop.croppedWidth < crop.fullWidth;
99
+ const uploadedAt = toMs(properties.created);
100
+ const exifEntries = Object.entries(properties.exif ?? {}).filter(([, value]) => typeof value === "string" || typeof value === "number").map(([key, value]) => [key, String(value)]);
101
+ const exif = exifEntries.length > 0 ? Object.fromEntries(exifEntries) : null;
102
+ const details = {
103
+ ...creatorContact && creatorContact !== creatorName ? { creatorContact } : {},
104
+ ...properties.license ? { license: properties.license } : {},
105
+ ...metadata.origLinks?.find((link) => link.rel === "license")?.href ? { licenseUrl: metadata.origLinks.find((link) => link.rel === "license")?.href } : {},
106
+ ...properties.datetimetz ? { capturedAtLocal: properties.datetimetz } : {},
107
+ ...uploadedAt != null ? { uploadedAt } : {},
108
+ ...cameraName ? { camera: cameraName } : {},
109
+ ...isFlat && metadata.horizontalFov ? { fieldOfViewDeg: metadata.horizontalFov } : {},
110
+ ...properties["quality:horizontal_accuracy"] != null ? { positionAccuracyMeters: properties["quality:horizontal_accuracy"] } : {},
111
+ ...metadata.origInstance?.instance_name ? { instance: metadata.origInstance.instance_name } : {},
112
+ ...camera?.focal_length != null ? { focalLengthMm: camera.focal_length } : {},
113
+ ...properties["original_file:name"] ? { originalFileName: properties["original_file:name"] } : {},
114
+ ...properties["original_file:size"] != null ? { originalFileSizeBytes: properties["original_file:size"] } : {},
115
+ ...properties["geovisio:rank_in_collection"] != null ? { rankInSequence: properties["geovisio:rank_in_collection"] } : {},
116
+ ...exif ? { exif } : {}
117
+ };
118
+ return {
119
+ providerId: "panoramax",
120
+ photoId: metadata.id,
121
+ sequenceId: metadata.sequence?.id ?? null,
122
+ capturedAt: toMs(properties.datetime),
123
+ isPano: !isFlat,
124
+ heading: properties["view:azimuth"] ?? null,
125
+ lngLat: metadata.gps,
126
+ ...creatorName ? { creatorName } : {},
127
+ details
128
+ };
129
+ };
130
+ var panoramaxApiEndpoint = () => `${getStreetImageryConfig().panoramaxApiBase}/api`;
131
+ var HIDE_LEGEND_CSS = "pnx-photo-viewer pnx-bottom-drawer, pnx-photo-viewer pnx-picture-legend { display: none !important; }";
132
+ var normalizeBearing = (degrees) => (degrees % 360 + 360) % 360;
133
+ var PanoramaxPanel = ({
134
+ photo,
135
+ onPhotoSelected,
136
+ onEaseMapToPoint,
137
+ onViewerPhoto,
138
+ hideLegend = false
139
+ }) => {
140
+ const containerRef = useRef(null);
141
+ const viewerRef = useRef(null);
142
+ const lastViewerPhotoIdRef = useRef(null);
143
+ const readyRef = useRef(false);
144
+ const pendingSelectRef = useRef(null);
145
+ const photoRef = useRef(photo);
146
+ const bearingRafRef = useRef(null);
147
+ const pendingBearingRef = useRef(null);
148
+ const pendingHfovRef = useRef(null);
149
+ const actions = useViewerActions();
150
+ const emitViewerPhoto = useEffectEvent(
151
+ (viewerPhoto) => onViewerPhoto?.(viewerPhoto)
152
+ );
153
+ useEffect(
154
+ function syncPhotoRef() {
155
+ photoRef.current = photo;
156
+ },
157
+ [photo]
158
+ );
159
+ useEffect(
160
+ function resetViewerStoreOnProviderChange() {
161
+ return () => {
162
+ actions.reset();
163
+ };
164
+ },
165
+ [actions, photo.providerId]
166
+ );
167
+ useEffect(
168
+ function mountPanoramaxViewer() {
169
+ const viewer = viewerRef.current;
170
+ const container = containerRef.current;
171
+ if (!viewer || !container) {
172
+ return;
173
+ }
174
+ lastViewerPhotoIdRef.current = photoRef.current.photoId;
175
+ const flushBearing = () => {
176
+ bearingRafRef.current = null;
177
+ if (pendingBearingRef.current != null) {
178
+ actions.setPov({ bearing: pendingBearingRef.current });
179
+ pendingBearingRef.current = null;
180
+ }
181
+ if (pendingHfovRef.current != null) {
182
+ actions.setPov({ hfov: pendingHfovRef.current });
183
+ pendingHfovRef.current = null;
184
+ }
185
+ };
186
+ const onSelect = (event) => {
187
+ const { seqId, picId } = event.detail;
188
+ if (!picId) {
189
+ return;
190
+ }
191
+ lastViewerPhotoIdRef.current = picId;
192
+ const currentPhoto = photoRef.current;
193
+ const sequenceId = seqId ?? (picId === currentPhoto.photoId && currentPhoto.sequenceId ? currentPhoto.sequenceId : `photo:${picId}`);
194
+ onPhotoSelected({
195
+ provider: "panoramax",
196
+ sequenceId,
197
+ photoId: picId
198
+ });
199
+ };
200
+ const onPictureLoaded = (event) => {
201
+ const detail = event.detail;
202
+ const metadata = viewer.psv?.getPictureMetadata();
203
+ if (metadata) {
204
+ emitViewerPhoto(panoramaxPhotoFromMetadata(metadata));
205
+ }
206
+ if (detail.lon == null || detail.lat == null) {
207
+ return;
208
+ }
209
+ actions.setPov({ lngLat: [detail.lon, detail.lat] });
210
+ if (detail.x != null) {
211
+ actions.setPov({ bearing: normalizeBearing(detail.x) });
212
+ }
213
+ const psv = viewer.psv;
214
+ if (psv && detail.z != null) {
215
+ actions.setPov({ hfov: psv.dataHelper.zoomLevelToFov(detail.z) });
216
+ }
217
+ onEaseMapToPoint(detail.lon, detail.lat);
218
+ };
219
+ const onViewRotated = (event) => {
220
+ const detail = event.detail;
221
+ pendingBearingRef.current = normalizeBearing(detail.x);
222
+ const psv = viewer.psv;
223
+ if (psv && detail.z != null) {
224
+ pendingHfovRef.current = psv.dataHelper.zoomLevelToFov(detail.z);
225
+ }
226
+ if (bearingRafRef.current == null) {
227
+ bearingRafRef.current = requestAnimationFrame(flushBearing);
228
+ }
229
+ };
230
+ viewer.addEventListener("select", onSelect);
231
+ viewer.addEventListener("psv:picture-loaded", onPictureLoaded);
232
+ viewer.addEventListener("psv:view-rotated", onViewRotated);
233
+ const resizeObserver = new ResizeObserver(() => {
234
+ viewer.psv?.resize();
235
+ });
236
+ resizeObserver.observe(container);
237
+ let stopLimit = null;
238
+ const startLimit = () => {
239
+ if (!stopLimit && viewer.psv) {
240
+ stopLimit = limitPanningToImage(viewer.psv);
241
+ }
242
+ };
243
+ startLimit();
244
+ viewer.addEventListener("psv:picture-loaded", startLimit);
245
+ readyRef.current = true;
246
+ const pending = pendingSelectRef.current;
247
+ if (pending) {
248
+ pendingSelectRef.current = null;
249
+ if (typeof viewer.select === "function") {
250
+ viewer.select(pending.sequenceId ?? null, pending.photoId);
251
+ }
252
+ }
253
+ return () => {
254
+ readyRef.current = false;
255
+ if (bearingRafRef.current != null) {
256
+ cancelAnimationFrame(bearingRafRef.current);
257
+ bearingRafRef.current = null;
258
+ }
259
+ resizeObserver.disconnect();
260
+ viewer.removeEventListener("select", onSelect);
261
+ viewer.removeEventListener("psv:picture-loaded", onPictureLoaded);
262
+ viewer.removeEventListener("psv:view-rotated", onViewRotated);
263
+ viewer.removeEventListener("psv:picture-loaded", startLimit);
264
+ stopLimit?.();
265
+ };
266
+ },
267
+ [actions, onEaseMapToPoint, onPhotoSelected]
268
+ );
269
+ useEffect(
270
+ function syncExternalPhotoSelection() {
271
+ const viewer = viewerRef.current;
272
+ if (!viewer || photo.photoId === lastViewerPhotoIdRef.current) {
273
+ return;
274
+ }
275
+ actions.reset();
276
+ lastViewerPhotoIdRef.current = photo.photoId;
277
+ if (!readyRef.current) {
278
+ pendingSelectRef.current = { sequenceId: photo.sequenceId, photoId: photo.photoId };
279
+ return;
280
+ }
281
+ if (typeof viewer.select === "function") {
282
+ viewer.select(photo.sequenceId ?? null, photo.photoId);
283
+ }
284
+ },
285
+ [actions, photo.photoId, photo.sequenceId]
286
+ );
287
+ return /* @__PURE__ */ jsxs(
288
+ "div",
289
+ {
290
+ ref: containerRef,
291
+ className: "min-h-48 overflow-hidden rounded-lg border border-slate-200 bg-slate-900",
292
+ style: { aspectRatio: "4 / 3" },
293
+ children: [
294
+ hideLegend ? /* @__PURE__ */ jsx("style", { children: HIDE_LEGEND_CSS }) : null,
295
+ /* @__PURE__ */ jsx(
296
+ "pnx-photo-viewer",
297
+ {
298
+ ref: viewerRef,
299
+ className: "block h-full w-full",
300
+ endpoint: panoramaxApiEndpoint(),
301
+ "url-parameters": "false",
302
+ picture: photo.photoId,
303
+ sequence: photo.sequenceId ?? void 0
304
+ }
305
+ )
306
+ ]
307
+ }
308
+ );
309
+ };
310
+
311
+ export { PanoramaxPanel };
@@ -2,7 +2,3 @@ import type { DateRange } from '@osm-editor-kit/street-imagery';
2
2
  import type { Bbox, NormalizedMapFeature } from '@osm-editor-kit/street-imagery';
3
3
  import { type ProviderId } from '@osm-editor-kit/street-imagery';
4
4
  export declare const useAllProviderMapFeatures: (providerIds: ProviderId[], bbox: Bbox | null, zoom: number, date?: DateRange) => NormalizedMapFeature[];
5
- export declare const useAllProviderMapFeaturesLoading: (providerIds: ProviderId[], bbox: Bbox | null, zoom: number) => {
6
- isLoading: boolean;
7
- isFetching: boolean;
8
- };
@@ -0,0 +1,12 @@
1
+ type PhotoCountProps = {
2
+ /** Position of the shown photo, starting at 1; leave out to show only the total. */
3
+ index?: number;
4
+ total: number;
5
+ className?: string;
6
+ };
7
+ /**
8
+ * "1/5" behind a small picture icon: this button has several photos and a click on it shows the
9
+ * next one.
10
+ */
11
+ export declare const PhotoCount: ({ index, total, className }: PhotoCountProps) => import("react").JSX.Element;
12
+ export {};
@@ -0,0 +1,16 @@
1
+ type PhotoDateProps = {
2
+ /** Capture time (ms); `null` shows "Unknown date". */
3
+ timestamp: number | null | undefined;
4
+ className?: string;
5
+ /**
6
+ * Capture time with the camera's UTC offset (`2025-03-08T15:40:35+01:00`). The tooltip then
7
+ * shows date and time as the camera's clock had it, instead of the date alone.
8
+ */
9
+ localDateTime?: string;
10
+ };
11
+ /**
12
+ * A date as month and year ("Aug 2026"). The tooltip has the full date and, on a second line,
13
+ * how long ago that is.
14
+ */
15
+ export declare const PhotoDate: ({ timestamp, className, localDateTime }: PhotoDateProps) => import("react").JSX.Element;
16
+ export {};