@osm-editor-kit/street-imagery-react 0.1.0-alpha.6 → 0.1.0-alpha.8

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/README.md CHANGED
@@ -117,10 +117,49 @@ const { data: detections } = useMapillaryImageDetections(photo.photoId, {
117
117
  <StreetLevelImageryViewer photo={photo} outlines={detections?.map((d) => ({ id: d.id, outline: d.outline }))} … />
118
118
  ```
119
119
 
120
- - Map layers: `filter.mapFeatureValue` limits which signs/objects are drawn (`matchesAnyGroup(SIGN_GROUPS)`).
120
+ - Map layers: `filter.mapFeatureValue` limits which signs/objects are drawn: `signGroupFilter(['parking', 'bike'])` for sign groups, or `matchesAnyGroup(…)`.
121
121
  - Suggested views: `useViewSuggestions(viewpoints, { sources: [mapillaryPhotoSource, streetViewPhotoSource] })`.
122
122
  - `turnMapillaryViewerTo` / `setMapillaryViewerOutlines` work on a mapillary-js `Viewer` you own.
123
123
 
124
+ ## A selected sign: viewer, bar and map highlight
125
+
126
+ Keep the selected sign (`featureId`) and its shown photo in your own state or URL:
127
+
128
+ ```tsx
129
+ const { data, shownImage, firstImage } = useSelectedMapillaryFeature({
130
+ featureId,
131
+ shownPhotoId, // the photo the viewer shows now
132
+ minCapturedAt: fromMs, // optional: open the best photo from this time on
133
+ })
134
+ // Open `firstImage` first: showPhoto(targetImageToPhoto(firstImage))
135
+ <MapillaryFeatureBar data={data} shownImage={shownImage} onShow={(image) => showPhoto(targetImageToPhoto(image))} />
136
+ <StreetLevelImageryViewer lookAt={{ lngLat: data.feature.lngLat, outline: shownImage?.outline, value: data.feature.value }} … />
137
+ // On the map, after StreetLevelImagerySourcesAndLayers: the sign's halo and a line from the camera
138
+ <SelectedMapFeatureLayer data={data} shownImage={shownImage} providers={providers} bbox={bbox} zoom={zoom} />
139
+ ```
140
+
141
+ A click on a sign dot: `queryStreetImageryFeatures(event).find((hit) => hit.kind === 'mapFeature')?.featureId`.
142
+
143
+ ## Date filter with a slider
144
+
145
+ `PhotoDateRangeFilter` is the date filter of iD's photo panel as a controlled component: one slider with two handles on a scale that gives recent years more room. It works on plain `{ from?, to? }` days (`YYYY-MM-DD`), the same `DateRange` the layers take as `filter.date`.
146
+
147
+ ```tsx
148
+ const { photos } = useAllProviderPhotos(providers, bbox, zoom) // no date: all photos in view
149
+
150
+ <PhotoDateRangeFilter
151
+ value={date}
152
+ onChange={setDate} // on release of a handle, not while dragging
153
+ capturedAt={photos.map((photo) => photo.capturedAt)} // marks: where photos are
154
+ yearLines={[1, 2, 3, 4]} // lines with a label; a click shows the last N years
155
+ recommendedMaxAgeYears={2} // this line is red
156
+ markers={[{ date: '2024-04-01', label: 'Survey start' }]} // dashed lines for fixed dates
157
+ dateInputs="collapsed" // exact days: 'collapsed' (default), 'open' or 'none'
158
+ />
159
+ ```
160
+
161
+ A handle at its end means no limit, so the left end also shows photos older than the slider spans (`maxYears`, default 10). The scale itself is in the core package (`dateSliderPosition`, `dateSliderBins`, …) for a host that draws its own chart. The texts are in `messages.dateFilter`.
162
+
124
163
  ## Pick a place on the map to open elsewhere
125
164
 
126
165
  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.
@@ -1,4 +1,4 @@
1
- import { DEFAULT_STREET_IMAGERY_LOCALE, mapillaryValueName } from '@osm-editor-kit/street-imagery';
1
+ import { DEFAULT_STREET_IMAGERY_LOCALE } from '@osm-editor-kit/street-imagery';
2
2
  import { createContext, use } from 'react';
3
3
  import { jsx } from 'react/jsx-runtime';
4
4
 
@@ -11,13 +11,28 @@ var en = {
11
11
  forwardInHistory: "Forward in history (])",
12
12
  minimize: "Minimize",
13
13
  close: "Close (Esc)",
14
- views: "Views",
15
14
  suggestedViews: "Suggested views",
16
15
  suggestedViewsHint: "Suggested views: where the photo is taken from, and which way it looks",
17
16
  noPhoto: (label) => `${label} (no photo)`,
18
- noMatchingPhoto: (label) => `${label}: no matching photo`
17
+ noMatchingPhoto: (label) => `${label}: no matching photo`,
18
+ nextPhotoOfView: (label) => `${label} \u2014 click for the next photo`
19
19
  },
20
20
  unknownDate: "Unknown date",
21
+ dateFilter: {
22
+ allDates: "All dates",
23
+ since: (date) => `Since ${date}`,
24
+ until: (date) => `Until ${date}`,
25
+ between: (from, to) => `${from} \u2013 ${to}`,
26
+ yearsShort: (years) => `${years} y`,
27
+ lastYears: (years) => years === 1 ? "Show the last year" : `Show the last ${years} years`,
28
+ recommendedMaxAge: (years) => `Recommended: photos of the last ${years === 1 ? "year" : `${years} years`}`,
29
+ fromHandle: "Oldest photos to show",
30
+ toHandle: "Newest photos to show",
31
+ exactDates: "Exact dates",
32
+ from: "From",
33
+ to: "To",
34
+ photoCount: (count) => count === 1 ? "1 photo" : `${count} photos`
35
+ },
21
36
  viewpointRole: {
22
37
  here: "Here",
23
38
  "line-start": "Start",
@@ -25,24 +40,22 @@ var en = {
25
40
  "into-node": "Junction",
26
41
  custom: "View"
27
42
  },
43
+ compass: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"],
28
44
  viewDirection: (role, kind) => {
29
45
  switch (kind) {
30
46
  case "forward":
31
47
  return `${role}, looking along the street`;
32
48
  case "back":
33
49
  return `${role}, looking back along the street`;
50
+ case "right":
51
+ return `${role}, looking right across the street`;
52
+ case "left":
53
+ return `${role}, looking left across the street`;
34
54
  case "into":
35
55
  return `${role}, looking into the junction`;
36
56
  default:
37
57
  return `${role}, looking ${kind}`;
38
58
  }
39
- },
40
- feature: {
41
- name: (value) => mapillaryValueName(value, "en"),
42
- seen: "Seen",
43
- noPhotos: "Mapillary lists no photos for this feature.",
44
- daysSummary: (photos, days) => `${photos} photos on ${days} days`,
45
- dayPhotos: (photos, canStep) => `${photos} photo${photos === 1 ? "" : "s"}${canStep ? " \u2014 click for the next one" : ""}`
46
59
  }
47
60
  };
48
61
  var COMPASS_DE = { N: "Norden", E: "Osten", S: "S\xFCden", W: "Westen" };
@@ -54,13 +67,28 @@ var de = {
54
67
  forwardInHistory: "Vor im Verlauf (])",
55
68
  minimize: "Minimieren",
56
69
  close: "Schlie\xDFen (Esc)",
57
- views: "Blicke",
58
70
  suggestedViews: "Vorgeschlagene Blicke",
59
71
  suggestedViewsHint: "Vorgeschlagene Blicke: von wo das Foto aufgenommen ist und wohin es schaut",
60
72
  noPhoto: (label) => `${label} (kein Foto)`,
61
- noMatchingPhoto: (label) => `${label}: kein passendes Foto`
73
+ noMatchingPhoto: (label) => `${label}: kein passendes Foto`,
74
+ nextPhotoOfView: (label) => `${label} \u2014 klicken f\xFCr das n\xE4chste Foto`
62
75
  },
63
76
  unknownDate: "Datum unbekannt",
77
+ dateFilter: {
78
+ allDates: "Alle Daten",
79
+ since: (date) => `Seit ${date}`,
80
+ until: (date) => `Bis ${date}`,
81
+ between: (from, to) => `${from} \u2013 ${to}`,
82
+ yearsShort: (years) => `${years} J.`,
83
+ lastYears: (years) => years === 1 ? "Das letzte Jahr zeigen" : `Die letzten ${years} Jahre zeigen`,
84
+ recommendedMaxAge: (years) => `Empfohlen: Fotos ${years === 1 ? "des letzten Jahres" : `der letzten ${years} Jahre`}`,
85
+ fromHandle: "\xC4lteste Fotos, die gezeigt werden",
86
+ toHandle: "Neueste Fotos, die gezeigt werden",
87
+ exactDates: "Genaue Daten",
88
+ from: "Von",
89
+ to: "Bis",
90
+ photoCount: (count) => count === 1 ? "1 Foto" : `${count} Fotos`
91
+ },
64
92
  viewpointRole: {
65
93
  here: "Hier",
66
94
  "line-start": "Anfang",
@@ -68,24 +96,22 @@ var de = {
68
96
  "into-node": "Knoten",
69
97
  custom: "Blick"
70
98
  },
99
+ compass: ["N", "NO", "O", "SO", "S", "SW", "W", "NW"],
71
100
  viewDirection: (role, kind) => {
72
101
  switch (kind) {
73
102
  case "forward":
74
103
  return `${role}, Blick entlang der Stra\xDFe`;
75
104
  case "back":
76
105
  return `${role}, Blick zur\xFCck entlang der Stra\xDFe`;
106
+ case "right":
107
+ return `${role}, Blick nach rechts quer zur Stra\xDFe`;
108
+ case "left":
109
+ return `${role}, Blick nach links quer zur Stra\xDFe`;
77
110
  case "into":
78
111
  return `${role}, Blick in den Knoten`;
79
112
  default:
80
113
  return `${role}, Blick nach ${COMPASS_DE[kind] ?? kind}`;
81
114
  }
82
- },
83
- feature: {
84
- name: (value) => mapillaryValueName(value, "de"),
85
- seen: "Gesehen",
86
- noPhotos: "Mapillary f\xFChrt keine Fotos zu diesem Objekt.",
87
- daysSummary: (photos, days) => `${photos} Fotos an ${days} Tagen`,
88
- dayPhotos: (photos, canStep) => `${photos} Foto${photos === 1 ? "" : "s"}${canStep ? " \u2014 klicken f\xFCr das n\xE4chste" : ""}`
89
115
  }
90
116
  };
91
117
  var STREET_IMAGERY_MESSAGES = {
@@ -99,10 +125,11 @@ var DEFAULT_I18N = {
99
125
  var I18nContext = createContext(DEFAULT_I18N);
100
126
  var mergeMessages = (base, overrides) => overrides ? {
101
127
  unknownDate: overrides.unknownDate ?? base.unknownDate,
128
+ dateFilter: { ...base.dateFilter, ...overrides.dateFilter },
102
129
  viewer: { ...base.viewer, ...overrides.viewer },
103
130
  viewpointRole: { ...base.viewpointRole, ...overrides.viewpointRole },
104
- viewDirection: overrides.viewDirection ?? base.viewDirection,
105
- feature: { ...base.feature, ...overrides.feature }
131
+ compass: overrides.compass?.length === 8 ? overrides.compass : base.compass,
132
+ viewDirection: overrides.viewDirection ?? base.viewDirection
106
133
  } : base;
107
134
  var StreetImageryLocaleProvider = ({
108
135
  locale,
@@ -0,0 +1,41 @@
1
+ import { type DateRange } from '@osm-editor-kit/street-imagery';
2
+ /** A fixed date to show in the chart, e.g. the start of a project or of a survey. */
3
+ export type PhotoDateMarker = {
4
+ /** A day as `YYYY-MM-DD`, or a time (ms). */
5
+ date: string | number;
6
+ label: string;
7
+ /** CSS color of line and label. Default red. */
8
+ color?: string;
9
+ };
10
+ export type PhotoDateRangeFilterProps = {
11
+ /** The chosen range, days as `YYYY-MM-DD`. No `from` and no `to`: all dates. */
12
+ value: DateRange;
13
+ /** Called when a handle is released, a year label is clicked or a date input changes. */
14
+ onChange: (range: DateRange) => void;
15
+ /**
16
+ * Capture times (ms) of the photos to show as marks above the slider, usually all photos in
17
+ * view before the date filter. Without it there are no marks.
18
+ */
19
+ capturedAt?: Iterable<number | null | undefined>;
20
+ /** Lines with a label at these ages in years; a click on a label shows that many years. */
21
+ yearLines?: readonly number[];
22
+ /** Its line is red; added to `yearLines` when it is not one of them. */
23
+ recommendedMaxAgeYears?: number;
24
+ markers?: readonly PhotoDateMarker[];
25
+ /** Years the slider spans. Default 10. */
26
+ maxYears?: number;
27
+ /** Reference time (ms). Default: the time of the first render. */
28
+ now?: number;
29
+ /**
30
+ * The two date inputs for exact days, in sync with the slider: folded away (default), open,
31
+ * or left out.
32
+ */
33
+ dateInputs?: 'collapsed' | 'open' | 'none';
34
+ className?: string;
35
+ };
36
+ /**
37
+ * Date filter for photos after iD's photo age slider: one slider with two handles on a scale that
38
+ * gives recent years more room, marks that show how many photos there are at each time, lines at
39
+ * whole years, and optional fixed markers. Controlled: `value` in, `onChange` out.
40
+ */
41
+ export declare const PhotoDateRangeFilter: ({ value, onChange, capturedAt, yearLines, recommendedMaxAgeYears, markers, maxYears, now: nowProp, dateInputs, className, }: PhotoDateRangeFilterProps) => import("react").JSX.Element;
@@ -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 {};
@@ -7,7 +7,7 @@ type StreetImageryI18n = {
7
7
  };
8
8
  /** Overrides per group, e.g. `{ feature: { name: germanSignName } }`. */
9
9
  export type StreetImageryMessageOverrides = {
10
- [Group in keyof StreetImageryMessages]?: StreetImageryMessages[Group] extends object ? StreetImageryMessages[Group] extends (...args: never[]) => unknown ? StreetImageryMessages[Group] : Partial<StreetImageryMessages[Group]> : StreetImageryMessages[Group];
10
+ [Group in keyof StreetImageryMessages]?: StreetImageryMessages[Group] extends object ? StreetImageryMessages[Group] extends ((...args: never[]) => unknown) | readonly unknown[] ? StreetImageryMessages[Group] : Partial<StreetImageryMessages[Group]> : StreetImageryMessages[Group];
11
11
  };
12
12
  type StreetImageryLocaleProviderProps = {
13
13
  /** Language of the package's texts and date formats. Without a provider: English. */
@@ -11,25 +11,35 @@ export type StreetImageryMessages = {
11
11
  forwardInHistory: string;
12
12
  minimize: string;
13
13
  close: string;
14
- views: string;
15
14
  suggestedViews: string;
16
15
  suggestedViewsHint: string;
17
16
  noPhoto: (label: string) => string;
18
17
  noMatchingPhoto: (label: string) => string;
18
+ nextPhotoOfView: (label: string) => string;
19
19
  };
20
20
  viewpointRole: Record<ViewpointRole, string>;
21
+ /** Eight compass points, clockwise from north: N, NE, E, SE, S, SW, W, NW. */
22
+ compass: readonly string[];
21
23
  unknownDate: string;
24
+ dateFilter: {
25
+ allDates: string;
26
+ /** e.g. "Since 5 Oct 2024". */
27
+ since: (date: string) => string;
28
+ until: (date: string) => string;
29
+ between: (from: string, to: string) => string;
30
+ /** Short label below a year line, e.g. "2 y". */
31
+ yearsShort: (years: number) => string;
32
+ /** Tooltip of a year label; a click shows the photos of the last `years` years. */
33
+ lastYears: (years: number) => string;
34
+ recommendedMaxAge: (years: number) => string;
35
+ fromHandle: string;
36
+ toHandle: string;
37
+ exactDates: string;
38
+ from: string;
39
+ to: string;
40
+ photoCount: (count: number) => string;
41
+ };
22
42
  /** e.g. "Start, looking along the street"; `role` is the viewpoint's label. */
23
43
  viewDirection: (role: string, kind: ViewDirection['kind']) => string;
24
- feature: {
25
- /** Name of a Mapillary value like `regulatory--turn-right-ahead--g1`. */
26
- name: (value: string) => string;
27
- /** Line below the name; `first` and `last` are the rendered dates. */
28
- seen: string;
29
- noPhotos: string;
30
- daysSummary: (photos: number, days: number) => string;
31
- /** Third tooltip line of a day button, below date and age. */
32
- dayPhotos: (photos: number, canStep: boolean) => string;
33
- };
34
44
  };
35
45
  export declare const STREET_IMAGERY_MESSAGES: Record<StreetImageryLocale, StreetImageryMessages>;
package/dist/index.d.ts CHANGED
@@ -21,7 +21,11 @@ export { mapillaryViewFor, setMapillaryViewerOutlines, turnMapillaryViewerTo, ty
21
21
  export * from './mapillary/useMapillaryFeatures';
22
22
  export { LocationPickOnMap, type LocationPickOnMapProps } from './openers/LocationPickOnMap';
23
23
  export * from './openers/useLocationPickStore';
24
+ export { SelectedMapFeatureLayer, type SelectedMapFeatureLayerProps, } from './mapillary/SelectedMapFeatureLayer';
24
25
  export { MapillaryFeatureBar, type MapillaryFeatureBarProps } from './mapillary/MapillaryFeatureBar';
26
+ export { MAPILLARY_FEATURE_BAR_LABELS, type MapillaryFeatureBarLabels, } from './mapillary/featureBarLabels';
25
27
  export { STREET_IMAGERY_MESSAGES, type StreetImageryMessages } from './i18n/messages';
26
28
  export { StreetImageryLocaleProvider, useStreetImageryI18n, type StreetImageryMessageOverrides, } from './i18n/StreetImageryLocaleProvider';
29
+ export { PhotoDateRangeFilter, type PhotoDateMarker, type PhotoDateRangeFilterProps, } from './filters/PhotoDateRangeFilter';
27
30
  export { PhotoDate } from './i18n/PhotoDate';
31
+ export { PhotoCount } from './i18n/PhotoCount';
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { useStreetImageryI18n } from './chunk-HA4GJWHI.js';
2
- export { STREET_IMAGERY_MESSAGES, StreetImageryLocaleProvider, useStreetImageryI18n } from './chunk-HA4GJWHI.js';
1
+ import { useStreetImageryI18n } from './chunk-ZAIXGYAH.js';
2
+ export { STREET_IMAGERY_MESSAGES, StreetImageryLocaleProvider, useStreetImageryI18n } from './chunk-ZAIXGYAH.js';
3
3
  export { MapillaryPanel, mapillaryViewFor, setMapillaryViewerOutlines, turnMapillaryViewerTo } from './chunk-53RA4OSJ.js';
4
4
  export { PanoramaxPanel } from './chunk-EQ2JKLGT.js';
5
5
  export { useViewerActions, useViewerBearing, useViewerHfov, useViewerLngLat } from './chunk-25UGGXIE.js';
6
- import { distanceMeters, sequencesToFeatureCollection, emptyLineCollection, adapterById, viewConeGeoJson, viewShapeRadiusMeters, setStreetImageryConfig, viewfieldLayerId, photoLayerId, featureLayerId, PROVIDER_IDS, mapFeatureMatchesDateRange, isBrowserAvailableProvider, photoMatchesFilters, resolvePhotoPanoramaUrl, photoFullUrlQueryKey, resolvePhotoThumbnailUrl, photoThumbnailQueryKey, formatLocalDateTime, formatMonth, formatRelativeDate, formatDateTooltip, mapillaryPhotoSource, buildViewSuggestions, yearsToMs, SIGN_TARGET, fetchMapFeatureImages, fetchMapillaryMapFeaturesInBbox, bboxAround, fetchImageDetections, findLocationOpener, mapillaryIconUrl, openLocationInNewTab, alignLineToPoints, coneRadiusMeters, mapFeaturesToFeatureCollection, emptyPointCollection, buildMapFeatureLayerFilter, featureSourceId, photosToFeatureCollection, photosToViewfieldsFeatureCollection, emptyPolygonCollection, buildPhotoLayerFilter, photoSourceId, viewfieldSourceId, sequenceSourceId, sequenceLayerId, viewfieldLineLayerId, VIEWFIELD_MIN_ZOOM } from '@osm-editor-kit/street-imagery';
6
+ import { distanceMeters, sequencesToFeatureCollection, emptyLineCollection, adapterById, providerById, providerCoversBbox, viewConeGeoJson, viewShapeRadiusMeters, setStreetImageryConfig, viewfieldLayerId, photoLayerId, featureLayerId, PROVIDER_IDS, mapFeatureMatchesDateRange, isBrowserAvailableProvider, photoMatchesFilters, resolvePhotoPanoramaUrl, photoFullUrlQueryKey, resolvePhotoThumbnailUrl, photoThumbnailQueryKey, formatLocalDateTime, formatMonth, formatRelativeDate, formatDateTooltip, mapillaryPhotoSource, buildViewSuggestions, yearsToMs, SIGN_TARGET, fetchMapFeatureImages, fetchMapillaryMapFeaturesInBbox, bboxAround, fetchImageDetections, bestTargetImage, findLocationOpener, MAP_FEATURE_COLOR, mapillaryValueName, mapillaryIconUrl, dateRangeToSliderPositions, sliderPositionsToDateRange, dateSliderBins, dateSliderPosition, yearsAgoMs, parseIsoDateStartMs, openLocationInNewTab, formatDate, isIsoDate, alignLineToPoints, coneRadiusMeters, mapFeaturesToFeatureCollection, emptyPointCollection, buildMapFeatureLayerFilter, featureSourceId, photosToFeatureCollection, photosToViewfieldsFeatureCollection, emptyPolygonCollection, buildPhotoLayerFilter, photoSourceId, viewfieldSourceId, sequenceSourceId, sequenceLayerId, viewfieldLineLayerId, VIEWFIELD_MIN_ZOOM } from '@osm-editor-kit/street-imagery';
7
7
  import { Source, Layer, useMap } from 'react-map-gl/maplibre';
8
8
  import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
9
9
  import { lazy, useEffect, Fragment as Fragment$1, Suspense, useState, useRef, useEffectEvent } from 'react';
@@ -139,7 +139,8 @@ var withinBbox = (bbox) => (item) => {
139
139
  };
140
140
  var useProviderPhotos = (providerId, bbox, zoom) => {
141
141
  const adapter = adapterById[providerId];
142
- const enabled = adapter.kind === "photo" && bbox != null && zoom >= adapter.minZoom && adapter.fetchPhotos != null;
142
+ const meta = providerById[providerId];
143
+ const enabled = meta.kind === "photo" && bbox != null && providerCoversBbox(providerId, bbox) && zoom >= meta.minZoom && adapter?.fetchPhotos != null;
143
144
  return useQuery({
144
145
  queryKey: ["provider-photos", providerId, bboxKey(bbox), zoom],
145
146
  queryFn: async ({ signal }) => {
@@ -152,18 +153,20 @@ var useProviderPhotos = (providerId, bbox, zoom) => {
152
153
  };
153
154
  var useProviderSequences = (providerId, bbox, zoom) => {
154
155
  const adapter = adapterById[providerId];
155
- const sequencesMinZoom = adapter.sequencesMinZoom ?? adapter.minZoom;
156
- const enabled = adapter.kind === "photo" && bbox != null && zoom >= sequencesMinZoom && adapter.fetchSequences != null;
156
+ const meta = providerById[providerId];
157
+ const sequencesMinZoom = meta.sequencesMinZoom;
158
+ const enabled = meta.kind === "photo" && bbox != null && providerCoversBbox(providerId, bbox) && zoom >= sequencesMinZoom && adapter?.fetchSequences != null;
157
159
  return useQuery({
158
160
  queryKey: ["provider-sequences", providerId, bboxKey(bbox), zoom],
159
- queryFn: ({ signal }) => adapter.fetchSequences?.(bbox, zoom, signal) ?? [],
161
+ queryFn: ({ signal }) => adapter?.fetchSequences?.(bbox, zoom, signal) ?? [],
160
162
  enabled,
161
163
  placeholderData: (previous) => previous
162
164
  });
163
165
  };
164
166
  var useProviderMapFeatures = (providerId, bbox, zoom) => {
165
167
  const adapter = adapterById[providerId];
166
- const enabled = adapter.kind === "mapFeature" && bbox != null && zoom >= adapter.minZoom && adapter.fetchMapFeatures != null;
168
+ const meta = providerById[providerId];
169
+ const enabled = meta.kind === "mapFeature" && bbox != null && providerCoversBbox(providerId, bbox) && zoom >= meta.minZoom && adapter?.fetchMapFeatures != null;
167
170
  return useQuery({
168
171
  queryKey: ["provider-map-features", providerId, bboxKey(bbox), zoom],
169
172
  queryFn: async ({ signal }) => {
@@ -283,11 +286,12 @@ var PhotoProviderLayer = ({
283
286
  shownPhotoId
284
287
  }) => {
285
288
  const adapter = adapterById[providerId];
289
+ const meta = providerById[providerId];
286
290
  const { data: photos = [] } = useProviderPhotos(providerId, bbox, zoom);
287
291
  const { data: sequences = [] } = useProviderSequences(providerId, bbox, zoom);
288
- const visiblePhotos = zoom >= adapter.minZoom ? photos.filter((photo) => photoMatchesFilters(photo, filter?.photoTypes, filter?.date)) : [];
292
+ const visiblePhotos = zoom >= meta.minZoom ? photos.filter((photo) => photoMatchesFilters(photo, filter?.photoTypes, filter?.date)) : [];
289
293
  const drawBbox = bbox ? padBbox(bbox, 0.25) : null;
290
- const photoCollection = zoom >= adapter.minZoom ? photosToFeatureCollection(
294
+ const photoCollection = zoom >= meta.minZoom ? photosToFeatureCollection(
291
295
  drawBbox ? visiblePhotos.filter((photo) => lngLatInBbox(photo.lngLat, drawBbox)) : visiblePhotos
292
296
  ) : emptyPointCollection();
293
297
  const viewfieldsActive = showViewfields && zoom >= VIEWFIELD_MIN_ZOOM;
@@ -338,7 +342,7 @@ var PhotoProviderLayer = ({
338
342
  sequenceCapturedAtById.set(photo.sequenceId, photo.capturedAt);
339
343
  }
340
344
  }
341
- const filteredSequences = showSequences && zoom >= (adapter.sequencesMinZoom ?? adapter.minZoom) ? sequences.map((sequence) => {
345
+ const filteredSequences = showSequences && zoom >= meta.sequencesMinZoom ? sequences.map((sequence) => {
342
346
  if (sequence.capturedAt != null) return sequence;
343
347
  const fromPhotos = sequenceCapturedAtById.get(sequence.sequenceId);
344
348
  return fromPhotos != null ? { ...sequence, capturedAt: fromPhotos } : sequence;
@@ -383,7 +387,29 @@ var PhotoProviderLayer = ({
383
387
  });
384
388
  const sequenceCollection = alignedSequences.length > 0 ? sequencesToFeatureCollection(alignedSequences) : emptyLineCollection();
385
389
  return /* @__PURE__ */ jsxs(Fragment, { children: [
386
- adapter.fetchSequences && showSequences ? /* @__PURE__ */ jsxs(Fragment, { children: [
390
+ meta.coverageTiles ? /* @__PURE__ */ jsxs(Fragment, { children: [
391
+ /* @__PURE__ */ jsx(
392
+ Source,
393
+ {
394
+ id: `coverage-tiles-source-${providerId}`,
395
+ minzoom: meta.coverageTiles.minZoom,
396
+ tileSize: meta.coverageTiles.tileSize,
397
+ tiles: [meta.coverageTiles.url],
398
+ type: "raster"
399
+ }
400
+ ),
401
+ /* @__PURE__ */ jsx(
402
+ Layer,
403
+ {
404
+ id: `coverage-tiles-${providerId}`,
405
+ minzoom: meta.coverageTiles.minZoom,
406
+ paint: { "raster-opacity": 0.7 },
407
+ source: `coverage-tiles-source-${providerId}`,
408
+ type: "raster"
409
+ }
410
+ )
411
+ ] }) : null,
412
+ adapter?.fetchSequences && showSequences ? /* @__PURE__ */ jsxs(Fragment, { children: [
387
413
  /* @__PURE__ */ jsx(
388
414
  Source,
389
415
  {
@@ -484,10 +510,10 @@ var MapFeatureProviderLayer = ({
484
510
  filter,
485
511
  mapFeatureCircleColor
486
512
  }) => {
487
- const adapter = adapterById[providerId];
513
+ const meta = providerById[providerId];
488
514
  const { data: features = [] } = useProviderMapFeatures(providerId, bbox, zoom);
489
515
  const valueFilter = filter?.mapFeatureValue;
490
- const featureCollection = zoom >= adapter.minZoom ? mapFeaturesToFeatureCollection(
516
+ const featureCollection = zoom >= meta.minZoom ? mapFeaturesToFeatureCollection(
491
517
  valueFilter ? features.filter((feature) => valueFilter(feature.value)) : features
492
518
  ) : emptyPointCollection();
493
519
  const featureFilter = buildMapFeatureLayerFilter(filter?.date);
@@ -520,8 +546,7 @@ var MapFeatureProviderLayer = ({
520
546
  ] });
521
547
  };
522
548
  var ProviderLayer = (props) => {
523
- const adapter = adapterById[props.providerId];
524
- return adapter.kind === "mapFeature" ? /* @__PURE__ */ jsx(MapFeatureProviderLayer, { ...props }) : /* @__PURE__ */ jsx(PhotoProviderLayer, { ...props });
549
+ return providerById[props.providerId].kind === "mapFeature" ? /* @__PURE__ */ jsx(MapFeatureProviderLayer, { ...props }) : /* @__PURE__ */ jsx(PhotoProviderLayer, { ...props });
525
550
  };
526
551
  var StreetLevelImagerySourcesAndLayers = ({
527
552
  providers,
@@ -562,7 +587,7 @@ var StreetLevelImagerySourcesAndLayers = ({
562
587
  const selectedSequence = resolveSelectedSequence(selectedPhoto, sequences, activeSequenceId);
563
588
  const providerDrawsSequences = selectedProviderId != null && showSequences && providers.includes(selectedProviderId);
564
589
  const firstPhotoProvider = providers.find(
565
- (id) => adapterById[id].kind === "photo" && adapterById[id].fetchPhotos
590
+ (id) => providerById[id].kind === "photo" && adapterById[id]?.fetchPhotos
566
591
  );
567
592
  const sequenceBeforeId = firstPhotoProvider ? showViewfields ? viewfieldLayerId(firstPhotoProvider) : photoLayerId(firstPhotoProvider) : void 0;
568
593
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -814,6 +839,24 @@ var usePhotoThumbnail = (photo) => useQuery({
814
839
  enabled: photo != null,
815
840
  staleTime: 24 * 60 * 60 * 1e3
816
841
  });
842
+ var PhotoCount = ({ index, total, className }) => /* @__PURE__ */ jsxs("span", { className: `inline-flex shrink-0 items-center gap-0.5 tabular-nums ${className ?? ""}`, children: [
843
+ /* @__PURE__ */ jsx(
844
+ "svg",
845
+ {
846
+ "aria-hidden": true,
847
+ className: "size-3 shrink-0",
848
+ fill: "none",
849
+ stroke: "currentColor",
850
+ strokeLinecap: "round",
851
+ strokeLinejoin: "round",
852
+ strokeWidth: 1.75,
853
+ viewBox: "0 0 24 24",
854
+ children: /* @__PURE__ */ jsx("path", { d: "M4 6h16v12H4ZM4 15l4.5-4.5 4 4L15 12l5 5M9 9.5h.01" })
855
+ }
856
+ ),
857
+ index != null ? `${index}/` : "",
858
+ total
859
+ ] });
817
860
  var PhotoDate = ({ timestamp, className, localDateTime }) => {
818
861
  const { locale, messages } = useStreetImageryI18n();
819
862
  if (timestamp == null) {
@@ -896,40 +939,74 @@ var ToolbarButton = ({
896
939
  var SuggestionChip = ({
897
940
  suggestion,
898
941
  active,
942
+ shownPhotoId,
943
+ showPlace,
899
944
  onSelect
900
945
  }) => {
901
946
  const { messages } = useStreetImageryI18n();
902
- const best = suggestion.candidates[0];
903
- const label = viewSuggestionLabel(suggestion, messages);
947
+ const { candidates } = suggestion;
948
+ const best = candidates[0];
949
+ const shownIndex = active ? candidates.findIndex((candidate) => candidate.photo.photoId === shownPhotoId) : -1;
950
+ const shown = shownIndex >= 0 ? candidates[shownIndex] : best;
951
+ const canStep = active && candidates.length > 1;
952
+ const hasCount = candidates.length > 0;
953
+ const name = viewSuggestionLabel(suggestion, messages);
954
+ const label = canStep ? messages.viewer.nextPhotoOfView(name) : name;
904
955
  return /* @__PURE__ */ jsxs(
905
956
  "button",
906
957
  {
907
958
  "aria-label": best ? label : messages.viewer.noPhoto(label),
908
959
  "aria-pressed": active,
909
960
  className: [
910
- "inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
961
+ "flex min-w-0 flex-col items-center rounded-md border px-1 py-0.5 text-xs leading-tight",
911
962
  active ? "border-fuchsia-600 bg-fuchsia-600 text-white" : best ? "border-slate-300 bg-white text-slate-700 hover:border-fuchsia-500" : "border-dashed border-slate-200 bg-white text-slate-400"
912
963
  ].join(" "),
913
964
  disabled: !best,
914
- onClick: onSelect,
965
+ onClick: () => {
966
+ const next = canStep ? candidates[(shownIndex + 1) % candidates.length] : best;
967
+ if (next) {
968
+ onSelect(next);
969
+ }
970
+ },
915
971
  title: best ? label : messages.viewer.noMatchingPhoto(label),
916
972
  type: "button",
917
973
  children: [
918
- /* @__PURE__ */ jsx("span", { className: "font-medium", children: suggestion.viewpoint.label ?? messages.viewpointRole[suggestion.viewpoint.role] }),
919
- /* @__PURE__ */ jsx(
974
+ /* @__PURE__ */ jsxs(
920
975
  "span",
921
976
  {
922
- "aria-hidden": true,
923
- className: "inline-flex",
924
- style: { transform: `rotate(${suggestion.direction.bearing}deg)` },
925
- children: /* @__PURE__ */ jsx(Icon, { className: "size-3", path: ICONS.arrow })
977
+ className: `flex items-center gap-1 font-medium ${hasCount ? "w-full justify-between px-1" : "max-w-full"}`,
978
+ children: [
979
+ /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 items-center gap-1", children: [
980
+ /* @__PURE__ */ jsx(
981
+ "span",
982
+ {
983
+ "aria-hidden": true,
984
+ className: "inline-flex shrink-0",
985
+ style: { transform: `rotate(${suggestion.direction.bearing}deg)` },
986
+ children: /* @__PURE__ */ jsx(Icon, { className: "size-3", path: ICONS.arrow })
987
+ }
988
+ ),
989
+ /* @__PURE__ */ jsxs("span", { className: "truncate", children: [
990
+ showPlace ? `${suggestion.viewpoint.label ?? messages.viewpointRole[suggestion.viewpoint.role]} ` : "",
991
+ messages.compass[Math.round(suggestion.direction.bearing / 45) % 8]
992
+ ] })
993
+ ] }),
994
+ hasCount ? /* @__PURE__ */ jsx(
995
+ PhotoCount,
996
+ {
997
+ className: `font-normal ${active ? "text-fuchsia-100" : "text-slate-500"}`,
998
+ index: shownIndex >= 0 ? shownIndex + 1 : void 0,
999
+ total: candidates.length
1000
+ }
1001
+ ) : null
1002
+ ]
926
1003
  }
927
1004
  ),
928
- best ? /* @__PURE__ */ jsx(
1005
+ shown ? /* @__PURE__ */ jsx(
929
1006
  PhotoDate,
930
1007
  {
931
- className: active ? "text-fuchsia-100" : "text-slate-400",
932
- timestamp: best.photo.capturedAt
1008
+ className: `max-w-full truncate ${active ? "text-fuchsia-100" : "text-slate-400"}`,
1009
+ timestamp: shown.photo.capturedAt
933
1010
  }
934
1011
  ) : /* @__PURE__ */ jsx("span", { className: "text-slate-400", children: "\u2014" })
935
1012
  ]
@@ -946,6 +1023,7 @@ var FloatingPhotoViewer = ({
946
1023
  suggestions,
947
1024
  activeDirectionKey,
948
1025
  onSelectSuggestion,
1026
+ shownPhotoId,
949
1027
  canGoBack,
950
1028
  canGoForward,
951
1029
  onBack,
@@ -960,6 +1038,7 @@ var FloatingPhotoViewer = ({
960
1038
  storageKey = "street-imagery:floating-viewer"
961
1039
  }) => {
962
1040
  const { messages } = useStreetImageryI18n();
1041
+ const severalViewpoints = new Set(suggestions.map((suggestion) => suggestion.viewpoint.id)).size > 1;
963
1042
  const [layout, setLayout] = useState(() => readLayout(storageKey));
964
1043
  const [drawerOpen, setDrawerOpen] = useState(false);
965
1044
  const dragRef = useRef(null);
@@ -1105,24 +1184,23 @@ var FloatingPhotoViewer = ({
1105
1184
  ]
1106
1185
  }
1107
1186
  ),
1108
- toolbar ? /* @__PURE__ */ jsx("div", { className: "border-b border-slate-100 px-2 py-1.5", children: toolbar }) : suggestions.length > 0 ? /* @__PURE__ */ jsxs(
1187
+ toolbar ? /* @__PURE__ */ jsx("div", { className: "border-b border-slate-100 px-2 py-1.5", children: toolbar }) : suggestions.length > 0 ? /* @__PURE__ */ jsx(
1109
1188
  "nav",
1110
1189
  {
1111
1190
  "aria-label": messages.viewer.suggestedViews,
1112
- className: "flex gap-1 overflow-x-auto border-b border-slate-100 px-2 py-1.5",
1191
+ className: "grid grid-cols-[repeat(auto-fit,minmax(5.25rem,1fr))] gap-1 border-b border-slate-100 px-2 py-1.5",
1113
1192
  title: messages.viewer.suggestedViewsHint,
1114
- children: [
1115
- /* @__PURE__ */ jsx("span", { className: "shrink-0 self-center pr-0.5 text-xs text-slate-500", children: messages.viewer.views }),
1116
- suggestions.map((suggestion) => /* @__PURE__ */ jsx(
1117
- SuggestionChip,
1118
- {
1119
- active: suggestion.direction.key === activeDirectionKey,
1120
- onSelect: () => onSelectSuggestion(suggestion),
1121
- suggestion
1122
- },
1123
- suggestion.direction.key
1124
- ))
1125
- ]
1193
+ children: suggestions.map((suggestion) => /* @__PURE__ */ jsx(
1194
+ SuggestionChip,
1195
+ {
1196
+ active: suggestion.direction.key === activeDirectionKey,
1197
+ onSelect: (candidate) => onSelectSuggestion(suggestion, candidate),
1198
+ shownPhotoId,
1199
+ showPlace: severalViewpoints,
1200
+ suggestion
1201
+ },
1202
+ suggestion.direction.key
1203
+ ))
1126
1204
  }
1127
1205
  ) : null,
1128
1206
  /* @__PURE__ */ jsxs("div", { className: "min-h-0 overflow-y-auto", children: [
@@ -1269,7 +1347,12 @@ var useViewpointSessionStore = create()((set, get) => ({
1269
1347
  showPhoto: (entry) => set((state) => {
1270
1348
  const current = state.history[state.historyIndex];
1271
1349
  if (samePhoto(current?.photo, entry.photo)) {
1272
- return entry.directionKey != null && entry.directionKey !== state.activeDirectionKey ? { activeDirectionKey: entry.directionKey } : state;
1350
+ if (entry.directionKey == null || entry.directionKey === current?.directionKey) {
1351
+ return entry.directionKey != null && entry.directionKey !== state.activeDirectionKey ? { activeDirectionKey: entry.directionKey } : state;
1352
+ }
1353
+ const history2 = [...state.history];
1354
+ history2[state.historyIndex] = { ...entry };
1355
+ return { history: history2, activeDirectionKey: entry.directionKey };
1273
1356
  }
1274
1357
  const history = [...state.history.slice(0, state.historyIndex + 1), entry].slice(
1275
1358
  -100
@@ -1401,6 +1484,20 @@ var useMapillaryImageDetections = (imageId, { filter, filterKey } = {}) => useQu
1401
1484
  enabled: !!imageId,
1402
1485
  staleTime: STALE_TIME
1403
1486
  });
1487
+ var useSelectedMapillaryFeature = ({
1488
+ featureId,
1489
+ shownPhotoId,
1490
+ minCapturedAt,
1491
+ shape
1492
+ }) => {
1493
+ const { data, isLoading, isError } = useMapillaryMapFeatureImages(featureId, { shape });
1494
+ const shownImage = data?.images.find((image) => image.id === shownPhotoId) ?? null;
1495
+ const firstImage = data ? bestTargetImage(data.images, data.feature.lngLat, {
1496
+ minCapturedAt: minCapturedAt ?? void 0,
1497
+ shape
1498
+ }) ?? null : null;
1499
+ return { data: data ?? null, isLoading, isError, shownImage, firstImage };
1500
+ };
1404
1501
  var useLocationPickStore = create()((set) => ({
1405
1502
  armedOpenerId: null,
1406
1503
  actions: {
@@ -1452,9 +1549,94 @@ var LocationPickOnMap = ({ onPick }) => {
1452
1549
  );
1453
1550
  return null;
1454
1551
  };
1455
- var MapillaryFeatureBar = ({ data, shownImage, onShow }) => {
1456
- const { locale, messages } = useStreetImageryI18n();
1457
- const labels = messages.feature;
1552
+ var SelectedMapFeatureLayer = ({
1553
+ data,
1554
+ shownImage,
1555
+ providers,
1556
+ bbox,
1557
+ zoom,
1558
+ color = MAP_FEATURE_COLOR
1559
+ }) => {
1560
+ const drawnFeatures = useAllProviderMapFeatures(providers, bbox, zoom);
1561
+ const target = data ? drawnFeatures.find((feature) => feature.featureId === data.feature.id)?.lngLat ?? data.feature.lngLat : null;
1562
+ const camera = shownImage?.lngLat;
1563
+ const points = {
1564
+ type: "FeatureCollection",
1565
+ features: target ? [{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: target } }] : []
1566
+ };
1567
+ const line = {
1568
+ type: "FeatureCollection",
1569
+ features: target && camera ? [
1570
+ {
1571
+ type: "Feature",
1572
+ properties: {},
1573
+ geometry: { type: "LineString", coordinates: [camera, target] }
1574
+ }
1575
+ ] : []
1576
+ };
1577
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1578
+ /* @__PURE__ */ jsx(Source, { data: line, id: "selected-map-feature-line", type: "geojson" }),
1579
+ /* @__PURE__ */ jsx(
1580
+ Layer,
1581
+ {
1582
+ id: "selected-map-feature-line",
1583
+ paint: { "line-color": SELECTION_COLOR, "line-width": 1.5, "line-dasharray": [2, 1.5] },
1584
+ source: "selected-map-feature-line",
1585
+ type: "line"
1586
+ }
1587
+ ),
1588
+ /* @__PURE__ */ jsx(Source, { data: points, id: "selected-map-feature-point", type: "geojson" }),
1589
+ /* @__PURE__ */ jsx(
1590
+ Layer,
1591
+ {
1592
+ id: "selected-map-feature-disk",
1593
+ paint: {
1594
+ "circle-radius": 17,
1595
+ "circle-color": color,
1596
+ "circle-opacity": VIEW_SHAPE_FILL_OPACITY
1597
+ },
1598
+ source: "selected-map-feature-point",
1599
+ type: "circle"
1600
+ }
1601
+ ),
1602
+ /* @__PURE__ */ jsx(
1603
+ Layer,
1604
+ {
1605
+ id: "selected-map-feature-dot",
1606
+ paint: {
1607
+ "circle-radius": ["interpolate", ["linear"], ["zoom"], 10, 1.8, 14, 3.6, 18, 4.8],
1608
+ "circle-color": color
1609
+ },
1610
+ source: "selected-map-feature-point",
1611
+ type: "circle"
1612
+ }
1613
+ )
1614
+ ] });
1615
+ };
1616
+ var MAPILLARY_FEATURE_BAR_LABELS = {
1617
+ en: {
1618
+ name: (value) => mapillaryValueName(value, "en"),
1619
+ seen: "Seen",
1620
+ noPhotos: "Mapillary lists no photos for this feature.",
1621
+ daysSummary: (photos, days) => `${photos} photos on ${days} days`,
1622
+ dayPhotos: (photos, canStep) => `${photos} photo${photos === 1 ? "" : "s"}${canStep ? " \u2014 click for the next one" : ""}`
1623
+ },
1624
+ de: {
1625
+ name: (value) => mapillaryValueName(value, "de"),
1626
+ seen: "Gesehen",
1627
+ noPhotos: "Mapillary f\xFChrt keine Fotos zu diesem Objekt.",
1628
+ daysSummary: (photos, days) => `${photos} Fotos an ${days} Tagen`,
1629
+ dayPhotos: (photos, canStep) => `${photos} Foto${photos === 1 ? "" : "s"}${canStep ? " \u2014 klicken f\xFCr das n\xE4chste" : ""}`
1630
+ }
1631
+ };
1632
+ var MapillaryFeatureBar = ({
1633
+ data,
1634
+ shownImage,
1635
+ onShow,
1636
+ labels: labelOverrides
1637
+ }) => {
1638
+ const { locale } = useStreetImageryI18n();
1639
+ const labels = { ...MAPILLARY_FEATURE_BAR_LABELS[locale], ...labelOverrides };
1458
1640
  const { feature, days, images } = data;
1459
1641
  return /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
1460
1642
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", title: feature.value, children: [
@@ -1509,10 +1691,14 @@ ${labels.dayPhotos(day.images.length, active && day.images.length > 1)}`,
1509
1691
  children: [
1510
1692
  /* @__PURE__ */ jsx("span", { className: "font-medium", children: formatMonth(noon, locale) }),
1511
1693
  " ",
1512
- /* @__PURE__ */ jsxs("span", { className: active ? "text-amber-800" : "text-slate-500", children: [
1513
- active ? `${shownIndex + 1}/` : "",
1514
- day.images.length
1515
- ] })
1694
+ /* @__PURE__ */ jsx(
1695
+ PhotoCount,
1696
+ {
1697
+ className: `align-[-1px] ${active ? "text-amber-800" : "text-slate-500"}`,
1698
+ index: active ? shownIndex + 1 : void 0,
1699
+ total: day.images.length
1700
+ }
1701
+ )
1516
1702
  ]
1517
1703
  },
1518
1704
  day.day
@@ -1522,5 +1708,233 @@ ${labels.dayPhotos(day.images.length, active && day.images.length > 1)}`,
1522
1708
  )
1523
1709
  ] });
1524
1710
  };
1711
+ var DEFAULT_YEAR_LINES = [1, 2, 3, 4];
1712
+ var BINS = 60;
1713
+ var STEP = 1e-3;
1714
+ var CHART_HEIGHT_PX = 36;
1715
+ var RED = "#dc2626";
1716
+ var HANDLE_CLASS = "pointer-events-none absolute inset-0 m-0 h-full w-full cursor-pointer appearance-none bg-transparent focus:outline-none [&::-webkit-slider-runnable-track]:appearance-none [&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-slate-800 [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:box-border [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-slate-800 [&::-moz-range-thumb]:shadow focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-sky-500 focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-sky-500";
1717
+ var percent = (position) => `${position * 100}%`;
1718
+ var PhotoDateRangeFilter = ({
1719
+ value,
1720
+ onChange,
1721
+ capturedAt,
1722
+ yearLines = DEFAULT_YEAR_LINES,
1723
+ recommendedMaxAgeYears,
1724
+ markers = [],
1725
+ maxYears,
1726
+ now: nowProp,
1727
+ dateInputs = "collapsed",
1728
+ className
1729
+ }) => {
1730
+ const { locale, messages } = useStreetImageryI18n();
1731
+ const text = messages.dateFilter;
1732
+ const [startNow] = useState(() => Date.now());
1733
+ const now = nowProp ?? startNow;
1734
+ const scale = { now, maxYears };
1735
+ const [dragged, setDragged] = useState(null);
1736
+ const fromInput = useRef(null);
1737
+ const toInput = useRef(null);
1738
+ const positions = dragged ?? dateRangeToSliderPositions(value, scale);
1739
+ const shownRange = dragged ? sliderPositionsToDateRange(dragged, scale) : value;
1740
+ const readPositions = (moved) => {
1741
+ const from = Number(fromInput.current?.value ?? positions.from);
1742
+ const to = Number(toInput.current?.value ?? positions.to);
1743
+ return moved === "from" ? { from: Math.min(from, to), to } : { from, to: Math.max(from, to) };
1744
+ };
1745
+ const commitOnRelease = (moved) => (input) => {
1746
+ if (!input) {
1747
+ return void 0;
1748
+ }
1749
+ const commit = () => {
1750
+ setDragged(null);
1751
+ const next = sliderPositionsToDateRange(readPositions(moved), scale);
1752
+ const other = moved === "from" ? "to" : "from";
1753
+ onChange({ ...next, [other]: value[other] });
1754
+ };
1755
+ input.addEventListener("change", commit);
1756
+ return () => {
1757
+ input.removeEventListener("change", commit);
1758
+ };
1759
+ };
1760
+ const trackDrag = (moved, event) => {
1761
+ if (event.type === "input") {
1762
+ setDragged(readPositions(moved));
1763
+ }
1764
+ };
1765
+ const bins = capturedAt ? dateSliderBins(capturedAt, scale, BINS) : [];
1766
+ const maxCount = Math.max(1, ...bins);
1767
+ const years = [
1768
+ ...new Set(recommendedMaxAgeYears ? [...yearLines, recommendedMaxAgeYears] : yearLines)
1769
+ ].map((count) => ({ count, position: dateSliderPosition(yearsAgoMs(count, now), scale) })).filter(({ position }) => position > 0 && position < 1);
1770
+ const shownMarkers = markers.map((marker) => {
1771
+ const time = typeof marker.date === "number" ? marker.date : parseIsoDateStartMs(marker.date);
1772
+ return { ...marker, position: dateSliderPosition(time, scale) };
1773
+ }).filter(({ position }) => position > 0 && position < 1);
1774
+ const day = (isoDate) => formatDate(parseIsoDateStartMs(isoDate), locale);
1775
+ const rangeLabel = shownRange.from && shownRange.to ? text.between(day(shownRange.from), day(shownRange.to)) : shownRange.from ? text.since(day(shownRange.from)) : shownRange.to ? text.until(day(shownRange.to)) : text.allDates;
1776
+ const setYears = (count) => {
1777
+ onChange({ from: new Date(yearsAgoMs(count, now)).toISOString().slice(0, 10) });
1778
+ };
1779
+ const setDay = (key, input) => {
1780
+ if (input === "") {
1781
+ onChange({ ...value, [key]: void 0 });
1782
+ } else if (isIsoDate(input)) {
1783
+ onChange({ ...value, [key]: input });
1784
+ }
1785
+ };
1786
+ const isAllDates = !shownRange.from && !shownRange.to;
1787
+ return /* @__PURE__ */ jsxs("div", { className, children: [
1788
+ /* @__PURE__ */ jsx("div", { className: "mx-2", children: /* @__PURE__ */ jsxs(
1789
+ "div",
1790
+ {
1791
+ className: "relative",
1792
+ style: { height: CHART_HEIGHT_PX, marginTop: shownMarkers.length > 0 ? 14 : 0 },
1793
+ children: [
1794
+ /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "absolute inset-0 flex items-end", children: bins.map((count, index) => {
1795
+ const start = index / BINS;
1796
+ const inRange = start + 1 / BINS > positions.from && start < positions.to;
1797
+ return /* @__PURE__ */ jsx(
1798
+ "div",
1799
+ {
1800
+ className: `flex-1 rounded-t-[1px] ${inRange ? "bg-slate-500" : "bg-slate-300"}`,
1801
+ style: {
1802
+ height: count === 0 ? 0 : Math.max(2, Math.sqrt(count / maxCount) * CHART_HEIGHT_PX)
1803
+ },
1804
+ title: count > 0 ? text.photoCount(count) : void 0
1805
+ },
1806
+ index
1807
+ );
1808
+ }) }),
1809
+ years.map(({ count, position }) => /* @__PURE__ */ jsx(
1810
+ "div",
1811
+ {
1812
+ "aria-hidden": true,
1813
+ className: "pointer-events-none absolute inset-y-0 w-px -translate-x-1/2",
1814
+ style: {
1815
+ left: percent(position),
1816
+ backgroundColor: count === recommendedMaxAgeYears ? RED : "rgb(148 163 184 / 0.7)"
1817
+ }
1818
+ },
1819
+ count
1820
+ )),
1821
+ shownMarkers.map((marker) => /* @__PURE__ */ jsx(
1822
+ "div",
1823
+ {
1824
+ className: "pointer-events-none absolute inset-y-0 w-0 border-l-2 border-dashed",
1825
+ style: { left: percent(marker.position), borderColor: marker.color ?? RED },
1826
+ children: /* @__PURE__ */ jsx(
1827
+ "span",
1828
+ {
1829
+ className: "absolute -top-3.5 -translate-x-1/2 text-[10px] leading-none whitespace-nowrap",
1830
+ style: { color: marker.color ?? RED },
1831
+ children: marker.label
1832
+ }
1833
+ )
1834
+ },
1835
+ `${marker.date}-${marker.label}`
1836
+ ))
1837
+ ]
1838
+ }
1839
+ ) }),
1840
+ /* @__PURE__ */ jsxs("div", { className: "relative h-4", children: [
1841
+ /* @__PURE__ */ jsx("div", { className: "absolute inset-x-2 top-1/2 h-1 -translate-y-1/2 rounded-full bg-slate-200", children: /* @__PURE__ */ jsx(
1842
+ "div",
1843
+ {
1844
+ className: "absolute inset-y-0 rounded-full bg-slate-700",
1845
+ style: {
1846
+ left: percent(positions.from),
1847
+ right: percent(1 - positions.to)
1848
+ }
1849
+ }
1850
+ ) }),
1851
+ /* @__PURE__ */ jsx(
1852
+ "input",
1853
+ {
1854
+ "aria-label": text.fromHandle,
1855
+ "aria-valuetext": shownRange.from ? day(shownRange.from) : text.allDates,
1856
+ className: HANDLE_CLASS,
1857
+ max: 1,
1858
+ min: 0,
1859
+ onChange: (event) => {
1860
+ trackDrag("from", event.nativeEvent);
1861
+ },
1862
+ ref: (input) => {
1863
+ fromInput.current = input;
1864
+ return commitOnRelease("from")(input);
1865
+ },
1866
+ step: STEP,
1867
+ type: "range",
1868
+ value: positions.from
1869
+ }
1870
+ ),
1871
+ /* @__PURE__ */ jsx(
1872
+ "input",
1873
+ {
1874
+ "aria-label": text.toHandle,
1875
+ "aria-valuetext": shownRange.to ? day(shownRange.to) : text.allDates,
1876
+ className: HANDLE_CLASS,
1877
+ max: 1,
1878
+ min: 0,
1879
+ onChange: (event) => {
1880
+ trackDrag("to", event.nativeEvent);
1881
+ },
1882
+ ref: (input) => {
1883
+ toInput.current = input;
1884
+ return commitOnRelease("to")(input);
1885
+ },
1886
+ step: STEP,
1887
+ type: "range",
1888
+ value: positions.to
1889
+ }
1890
+ )
1891
+ ] }),
1892
+ /* @__PURE__ */ jsx("div", { className: "relative mx-2 h-4", children: years.map(({ count, position }) => /* @__PURE__ */ jsx(
1893
+ "button",
1894
+ {
1895
+ className: `absolute top-0 -translate-x-1/2 rounded px-0.5 text-[10px] leading-4 whitespace-nowrap hover:underline ${count === recommendedMaxAgeYears ? "font-semibold text-red-600" : "text-slate-500 hover:text-slate-900"}`,
1896
+ onClick: () => {
1897
+ setYears(count);
1898
+ },
1899
+ style: { left: percent(position) },
1900
+ title: count === recommendedMaxAgeYears ? text.recommendedMaxAge(count) : text.lastYears(count),
1901
+ type: "button",
1902
+ children: text.yearsShort(count)
1903
+ },
1904
+ count
1905
+ )) }),
1906
+ /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-baseline justify-between gap-2 text-xs", children: [
1907
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-slate-700", children: rangeLabel }),
1908
+ isAllDates ? null : /* @__PURE__ */ jsx(
1909
+ "button",
1910
+ {
1911
+ className: "shrink-0 font-medium text-slate-600 underline decoration-slate-300 underline-offset-2 hover:text-slate-900",
1912
+ onClick: () => {
1913
+ onChange({});
1914
+ },
1915
+ type: "button",
1916
+ children: text.allDates
1917
+ }
1918
+ )
1919
+ ] }),
1920
+ dateInputs === "none" ? null : /* @__PURE__ */ jsxs("details", { className: "mt-1 text-xs text-slate-600", open: dateInputs === "open" || void 0, children: [
1921
+ /* @__PURE__ */ jsx("summary", { className: "cursor-pointer select-none hover:text-slate-900", children: text.exactDates }),
1922
+ /* @__PURE__ */ jsx("div", { className: "mt-1.5 grid grid-cols-2 gap-2", children: ["from", "to"].map((key) => /* @__PURE__ */ jsxs("label", { className: "flex flex-col gap-1", children: [
1923
+ text[key],
1924
+ /* @__PURE__ */ jsx(
1925
+ "input",
1926
+ {
1927
+ className: "rounded-md border border-slate-200 px-2 py-1.5 text-sm text-slate-800",
1928
+ onChange: (event) => {
1929
+ setDay(key, event.target.value);
1930
+ },
1931
+ type: "date",
1932
+ value: value[key] ?? ""
1933
+ }
1934
+ )
1935
+ ] }, key)) })
1936
+ ] })
1937
+ ] });
1938
+ };
1525
1939
 
1526
- export { BASE_COLOR, FloatingPhotoViewer, FloatingViewerInfoButton, LocationPickOnMap, MapillaryFeatureBar, PhotoDate, SELECTION_COLOR, StreetLevelImagerySelectionOverlay, StreetLevelImagerySourcesAndLayers, StreetLevelImageryViewCone, StreetLevelImageryViewer, VIEWPOINT_DEFAULT_COLOR, VIEWPOINT_DIRECTION_LAYER_ID, VIEW_SHAPE_FILL_OPACITY, ViewpointLayer, getViewpointSession, queryStreetImageryFeatures, resolveSelectedSequence, streetImageryInteractiveLayerIds, useActiveDirectionKey, useAllProviderMapFeatures, useAllProviderPhotos, useArmedLocationOpenerId, useCanGoBack, useCanGoForward, useCurrentHistoryEntry, useLocationPickActions, useMapViewportBbox, useMapillaryImageDetections, useMapillaryMapFeatureImages, useMapillaryMapFeaturesNear, usePhotoFullUrl, usePhotoThumbnail, useProviderMapFeatures, useProviderPhotos, useProviderSequences, useViewSuggestions, useViewpointLine, useViewpoints, viewDirectionKeyFromFeatures, viewSuggestionLabel };
1940
+ export { BASE_COLOR, FloatingPhotoViewer, FloatingViewerInfoButton, LocationPickOnMap, MAPILLARY_FEATURE_BAR_LABELS, MapillaryFeatureBar, PhotoCount, PhotoDate, PhotoDateRangeFilter, SELECTION_COLOR, SelectedMapFeatureLayer, StreetLevelImagerySelectionOverlay, StreetLevelImagerySourcesAndLayers, StreetLevelImageryViewCone, StreetLevelImageryViewer, VIEWPOINT_DEFAULT_COLOR, VIEWPOINT_DIRECTION_LAYER_ID, VIEW_SHAPE_FILL_OPACITY, ViewpointLayer, getViewpointSession, queryStreetImageryFeatures, resolveSelectedSequence, streetImageryInteractiveLayerIds, useActiveDirectionKey, useAllProviderMapFeatures, useAllProviderPhotos, useArmedLocationOpenerId, useCanGoBack, useCanGoForward, useCurrentHistoryEntry, useLocationPickActions, useMapViewportBbox, useMapillaryImageDetections, useMapillaryMapFeatureImages, useMapillaryMapFeaturesNear, usePhotoFullUrl, usePhotoThumbnail, useProviderMapFeatures, useProviderPhotos, useProviderSequences, useSelectedMapillaryFeature, useViewSuggestions, useViewpointLine, useViewpoints, viewDirectionKeyFromFeatures, viewSuggestionLabel };
@@ -1,8 +1,11 @@
1
1
  import { type MapFeatureImages, type TargetImage } from '@osm-editor-kit/street-imagery';
2
+ import { type MapillaryFeatureBarLabels } from './featureBarLabels';
2
3
  export type MapillaryFeatureBarProps = {
3
4
  data: MapFeatureImages;
4
5
  shownImage: TargetImage | null;
5
6
  onShow: (image: TargetImage) => void;
7
+ /** Replace single texts, e.g. `name` to map values to your own sign catalogue. */
8
+ labels?: Partial<MapillaryFeatureBarLabels>;
6
9
  };
7
10
  /**
8
11
  * A Mapillary sign or object above its photo: what it is, when Mapillary saw it, and one button
@@ -10,4 +13,4 @@ export type MapillaryFeatureBarProps = {
10
13
  * that day's photos. Dates show month and year; the tooltip has the full date and the age.
11
14
  * Texts and dates follow the `StreetImageryLocaleProvider`.
12
15
  */
13
- export declare const MapillaryFeatureBar: ({ data, shownImage, onShow }: MapillaryFeatureBarProps) => import("react").JSX.Element;
16
+ export declare const MapillaryFeatureBar: ({ data, shownImage, onShow, labels: labelOverrides, }: MapillaryFeatureBarProps) => import("react").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { type Bbox, type MapFeatureImages, type ProviderId, type TargetImage } from '@osm-editor-kit/street-imagery';
2
+ export type SelectedMapFeatureLayerProps = {
3
+ /** From `useSelectedMapillaryFeature`. Nothing is drawn without it. */
4
+ data: MapFeatureImages | null;
5
+ /** The shown photo of the feature: a dotted line runs from its camera to the feature. */
6
+ shownImage?: TargetImage | null;
7
+ /** The providers whose map features are drawn, to ring the dot that is on the map. */
8
+ providers: ProviderId[];
9
+ bbox: Bbox | null;
10
+ zoom: number;
11
+ /** Color of the dot and disk. Default: the map features' purple. */
12
+ color?: string;
13
+ };
14
+ /**
15
+ * The selected sign or object on the map: its dot on a light disk, and a dotted line to it from
16
+ * the camera position of the shown photo (Mapillary's computed position, which the feature was
17
+ * located from). Draw it after `StreetLevelImagerySourcesAndLayers`.
18
+ */
19
+ export declare const SelectedMapFeatureLayer: ({ data, shownImage, providers, bbox, zoom, color, }: SelectedMapFeatureLayerProps) => import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { type StreetImageryLocale } from '@osm-editor-kit/street-imagery';
2
+ /**
3
+ * Texts of `MapillaryFeatureBar`. Kept out of the package-wide messages, so the German sign names
4
+ * are only bundled by apps that show the feature bar.
5
+ */
6
+ export type MapillaryFeatureBarLabels = {
7
+ /** Name of a Mapillary value like `regulatory--turn-right-ahead--g1`. */
8
+ name: (value: string) => string;
9
+ /** Line below the name; `first` and `last` are the rendered dates. */
10
+ seen: string;
11
+ noPhotos: string;
12
+ daysSummary: (photos: number, days: number) => string;
13
+ /** Third tooltip line of a day button, below date and age. */
14
+ dayPhotos: (photos: number, canStep: boolean) => string;
15
+ };
16
+ export declare const MAPILLARY_FEATURE_BAR_LABELS: Record<StreetImageryLocale, MapillaryFeatureBarLabels>;
@@ -34,3 +34,21 @@ export declare const useMapillaryImageDetections: (imageId: string | null | unde
34
34
  filter?: (value: string) => boolean;
35
35
  filterKey?: string;
36
36
  }) => import("@tanstack/react-query").UseQueryResult<NoInfer<import("@osm-editor-kit/street-imagery").MapillaryDetection[]>, Error>;
37
+ /**
38
+ * A selected Mapillary map feature (sign, object) for a host's feature + photo selection: all its
39
+ * photos by day, the photo that is shown (`shownPhotoId`), and the one to open first — the best of
40
+ * the newest day, from within `minCapturedAt` (ms) when set. Feed `data` and `shownImage` to
41
+ * `MapillaryFeatureBar` and `SelectedMapFeatureLayer`, and `shownImage.outline` to `lookAt`.
42
+ */
43
+ export declare const useSelectedMapillaryFeature: ({ featureId, shownPhotoId, minCapturedAt, shape, }: {
44
+ featureId: string | null | undefined;
45
+ shownPhotoId?: string | null;
46
+ minCapturedAt?: number | null;
47
+ shape?: TargetShape;
48
+ }) => {
49
+ data: NonNullable<NoInfer<import("@osm-editor-kit/street-imagery").MapFeatureImages | null>> | null;
50
+ isLoading: boolean;
51
+ isError: boolean;
52
+ shownImage: import("@osm-editor-kit/street-imagery").TargetImage | null;
53
+ firstImage: import("@osm-editor-kit/street-imagery").TargetImage | null;
54
+ };
@@ -1,4 +1,4 @@
1
- import { useStreetImageryI18n } from './chunk-HA4GJWHI.js';
1
+ import { useStreetImageryI18n } from './chunk-ZAIXGYAH.js';
2
2
  import { providerById, formatLocalDateTime, formatDate, formatRelativeDate } from '@osm-editor-kit/street-imagery';
3
3
  import { useRef, useEffect } from 'react';
4
4
  import { jsx, jsxs } from 'react/jsx-runtime';
@@ -1,4 +1,4 @@
1
- import type { ViewSuggestion } from '@osm-editor-kit/street-imagery';
1
+ import type { PhotoCandidate, ViewSuggestion } from '@osm-editor-kit/street-imagery';
2
2
  import { type ReactNode } from 'react';
3
3
  /** Info button for `titleActions`; `label` is its tooltip and accessible name. */
4
4
  export declare const FloatingViewerInfoButton: ({ label, onClick, }: {
@@ -12,7 +12,13 @@ export type FloatingPhotoViewerProps = {
12
12
  /** Suggested views (viewpoint × direction); shown as chips. Empty hides the strip. */
13
13
  suggestions: ViewSuggestion[];
14
14
  activeDirectionKey: string | null;
15
- onSelectSuggestion: (suggestion: ViewSuggestion) => void;
15
+ /**
16
+ * A view button was clicked. `candidate` is the photo to show: the view's best one, or the next
17
+ * one when the active view is clicked again.
18
+ */
19
+ onSelectSuggestion: (suggestion: ViewSuggestion, candidate: PhotoCandidate) => void;
20
+ /** The photo in the viewer, so the active view can show which of its photos that is. */
21
+ shownPhotoId?: string | null;
16
22
  canGoBack: boolean;
17
23
  canGoForward: boolean;
18
24
  onBack: () => void;
@@ -51,4 +57,4 @@ export type FloatingPhotoViewerProps = {
51
57
  * Drag the header to move, drag the left edge to resize. Keys: Esc closes, [ and ] step history,
52
58
  * Alt + arrow left/right step along (`step`).
53
59
  */
54
- export declare const FloatingPhotoViewer: ({ title, titleActions, suggestions, activeDirectionKey, onSelectSuggestion, canGoBack, canGoForward, onBack, onForward, onClose, step, toolbar, status, children, footer, drawer, storageKey, }: FloatingPhotoViewerProps) => import("react").JSX.Element;
60
+ export declare const FloatingPhotoViewer: ({ title, titleActions, suggestions, activeDirectionKey, onSelectSuggestion, shownPhotoId, canGoBack, canGoForward, onBack, onForward, onClose, step, toolbar, status, children, footer, drawer, storageKey, }: FloatingPhotoViewerProps) => import("react").JSX.Element;
@@ -19,7 +19,10 @@ export declare const useCanGoForward: () => boolean;
19
19
  export declare const getViewpointSession: () => {
20
20
  actions: {
21
21
  open: (input: OpenInput) => void;
22
- /** Show a photo; truncates forward history. No-op when it is already the current photo. */
22
+ /**
23
+ * Show a photo; truncates forward history. For the photo that is shown already, only its
24
+ * direction changes.
25
+ */
23
26
  showPhoto: (entry: ViewpointHistoryEntry) => void;
24
27
  back: () => ViewpointHistoryEntry | null;
25
28
  forward: () => ViewpointHistoryEntry | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osm-editor-kit/street-imagery-react",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.8",
4
4
  "description": "React MapLibre layers and photo viewers for street-level imagery providers.",
5
5
  "homepage": "https://github.com/osmberlin/street-space-editor/tree/main/packages/street-imagery-react",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@
14
14
  "dist"
15
15
  ],
16
16
  "type": "module",
17
+ "sideEffects": false,
17
18
  "exports": {
18
19
  ".": {
19
20
  "types": "./dist/index.d.ts",
@@ -55,7 +56,7 @@
55
56
  "zustand": "^5.0.7"
56
57
  },
57
58
  "peerDependencies": {
58
- "@osm-editor-kit/street-imagery": "^0.1.0-alpha.5",
59
+ "@osm-editor-kit/street-imagery": "^0.1.0-alpha.7",
59
60
  "@panoramax/web-viewer": "^5",
60
61
  "@tanstack/react-query": "^5",
61
62
  "mapillary-js": "^4",