@projektemacher/storymapjs 0.10.8 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG +81 -0
- package/README.md +129 -12
- package/dist/assets/{demo-DBpeF31K.js → demo-bWnOyfUw.js} +1 -1
- package/dist/assets/harness-C3JUv5Ii.js +1 -0
- package/dist/assets/{harness-multi-CYHgI0a9.js → harness-multi-XZNex3tb.js} +1 -1
- package/dist/assets/{harness-support-Do7sIp2j.js → harness-support-CjfZIVeb.js} +1 -1
- package/dist/assets/main-DQ32hWBM.js +17 -0
- package/dist/context.json +13 -2
- package/dist/css/storymap.css +1 -1
- package/dist/demo.html +3 -2
- package/dist/docs/api/assets/navigation.js +1 -1
- package/dist/docs/api/assets/search.js +1 -1
- package/dist/docs/api/classes/StoryMap.html +777 -269
- package/dist/docs/api/functions/MediaType.html +1 -1
- package/dist/docs/api/functions/isPresentation3Collection.html +1 -1
- package/dist/docs/api/functions/isPresentation3Manifest.html +1 -1
- package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
- package/dist/docs/api/functions/loadCSS.html +1 -1
- package/dist/docs/api/functions/manifestToStorymapData.html +1 -1
- package/dist/docs/api/functions/setLanguage.html +1 -1
- package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
- package/dist/docs/api/functions/slideshowVersion.html +48 -0
- package/dist/docs/api/functions/storymapToManifest.html +1 -1
- package/dist/docs/api/functions/validateStorymap.html +1 -1
- package/dist/docs/api/functions/validateStorymapAndReport.html +1 -1
- package/dist/docs/api/index.html +115 -12
- package/dist/docs/api/interfaces/AnimationHandle.html +2 -2
- package/dist/docs/api/interfaces/ImagereadyPayload.html +4 -4
- package/dist/docs/api/interfaces/LanguageEntry.html +6 -6
- package/dist/docs/api/interfaces/LatLngLiteral.html +4 -4
- package/dist/docs/api/interfaces/LoadOptions.html +2 -2
- package/dist/docs/api/interfaces/MapEvents.html +116 -0
- package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
- package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
- package/dist/docs/api/interfaces/MediaEvents.html +78 -0
- package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
- package/dist/docs/api/interfaces/MessageEvents.html +72 -0
- package/dist/docs/api/interfaces/OlLayer.html +1 -1
- package/dist/docs/api/interfaces/OlMap.html +1 -1
- package/dist/docs/api/interfaces/OlSource.html +1 -1
- package/dist/docs/api/interfaces/OlTileLayer.html +1 -1
- package/dist/docs/api/interfaces/OlVectorLayer.html +1 -1
- package/dist/docs/api/interfaces/OlView.html +1 -1
- package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
- package/dist/docs/api/interfaces/SlideEvents.html +78 -0
- package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
- package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
- package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
- package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
- package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
- package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
- package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
- package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
- package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
- package/dist/docs/api/interfaces/StorymapData.html +12 -6
- package/dist/docs/api/interfaces/StorymapDataWrapper.html +2 -2
- package/dist/docs/api/interfaces/StorymapDocument.html +2 -2
- package/dist/docs/api/interfaces/StorymapError.html +3 -3
- package/dist/docs/api/interfaces/StorymapGeoreference.html +5 -5
- package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +4 -4
- package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +4 -4
- package/dist/docs/api/interfaces/StorymapLanguageMap.html +2 -2
- package/dist/docs/api/interfaces/StorymapManifest.html +12 -12
- package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +43 -11
- package/dist/docs/api/interfaces/StorymapManifestBackground.html +6 -6
- package/dist/docs/api/interfaces/StorymapManifestCanvas.html +65 -17
- package/dist/docs/api/interfaces/StorymapManifestContentResource.html +8 -8
- package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +7 -7
- package/dist/docs/api/interfaces/StorymapManifestImageService.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestRange.html +5 -5
- package/dist/docs/api/interfaces/StorymapManifestService.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestStatement.html +3 -3
- package/dist/docs/api/interfaces/StorymapMapOptions.html +5 -5
- package/dist/docs/api/interfaces/StorymapOptions.html +277 -75
- package/dist/docs/api/interfaces/StorymapOverlayLayer.html +42 -11
- package/dist/docs/api/interfaces/StorymapSlide.html +70 -16
- package/dist/docs/api/interfaces/StorymapSlideBackground.html +4 -4
- package/dist/docs/api/interfaces/StorymapSlideLocation.html +58 -14
- package/dist/docs/api/interfaces/StorymapSlideMarker.html +8 -9
- package/dist/docs/api/interfaces/StorymapSlideMedia.html +49 -10
- package/dist/docs/api/interfaces/StorymapSlideNarration.html +61 -9
- package/dist/docs/api/interfaces/StorymapSlideText.html +4 -4
- package/dist/docs/api/interfaces/StorymapTilejson.html +8 -8
- package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
- package/dist/docs/api/interfaces/TextEvents.html +66 -0
- package/dist/docs/api/media/events.md +66 -0
- package/dist/docs/api/media/migration-from-knightlab.md +89 -49
- package/dist/docs/api/media/slideshow.md +109 -0
- package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
- package/dist/docs/api/modules.html +6 -6
- package/dist/docs/api/types/ContentState.html +3 -3
- package/dist/docs/api/types/FiredEvent.html +43 -0
- package/dist/docs/api/types/ImagereadyKind.html +1 -1
- package/dist/docs/api/types/MediaTypeMatch.html +5 -5
- package/dist/docs/api/types/SwipeDirection.html +36 -0
- package/dist/docs/api/types/TileSourceFactory.html +1 -1
- package/dist/docs/authoring.html +20 -7
- package/dist/docs/events.html +263 -0
- package/dist/docs/iiif.html +136 -22
- package/dist/docs/migration.html +66 -19
- package/dist/docs/readme.html +113 -12
- package/dist/embed/index.html +7 -0
- package/dist/examples/font-css-false.json +33 -0
- package/dist/examples/issue-image-region.json +1 -1
- package/dist/examples/layers-control.json +60 -0
- package/dist/examples/map-template-provider.json +36 -0
- package/dist/examples/multi-media.json +71 -0
- package/dist/examples-iiif/annotated-image.json +16 -0
- package/dist/examples-iiif/font-css-false.json +114 -0
- package/dist/examples-iiif/issue-image-region.json +1 -1
- package/dist/examples-iiif/layers-control.json +226 -0
- package/dist/examples-iiif/map-template-provider.json +133 -0
- package/dist/examples-iiif/multi-media.json +237 -0
- package/dist/examples-slideshow/single.json +79 -0
- package/dist/examples-slideshow/static/pixel.png +0 -0
- package/dist/examples-slideshow/static.json +44 -0
- package/dist/harness-multi.html +3 -3
- package/dist/harness.html +4 -3
- package/dist/index.html +3 -2
- package/dist/js/storymap.d.ts +1326 -289
- package/dist/js/storymap.js +6690 -4784
- package/dist/js/storymap.js.map +1 -1
- package/dist/navplace-properties.json +14 -0
- package/dist/site.css +54 -36
- package/docs/migration-from-knightlab.md +89 -49
- package/docs/storymap-as-iiif-manifest.md +199 -126
- package/package.json +4 -2
- package/schema/storymap.schema.json +334 -34
- package/src/core/Util.ts +3 -1
- package/src/core/mixins.ts +42 -28
- package/src/language/Language.ts +7 -0
- package/src/language/locale/.expected-gaps.json +287 -27
- package/src/language/locale/de.json +11 -1
- package/src/language/locale/en.json +11 -1
- package/src/main.ts +51 -0
- package/src/map/Map.ts +44 -6
- package/src/map/MapMarker.ts +16 -3
- package/src/map/openlayers/Map.OpenLayers.ts +787 -62
- package/src/map/openlayers/MapMarker.OpenLayers.ts +113 -13
- package/src/map/slideView.ts +149 -0
- package/src/map/types.ts +2 -0
- package/src/media/EmbedUtil.ts +27 -2
- package/src/media/Media.ts +27 -4
- package/src/media/embedId.ts +6 -0
- package/src/media/types/Flickr.ts +14 -3
- package/src/media/types/HtmlMedia.ts +66 -0
- package/src/media/types/Image.ts +15 -3
- package/src/media/types/SoundCloud.ts +1 -1
- package/src/media/types/Text.ts +27 -8
- package/src/media/types/Twitter.ts +9 -7
- package/src/media/types/Vimeo.ts +1 -1
- package/src/media/types/YouTube.ts +2 -2
- package/src/scss/Typography.scss +15 -15
- package/src/scss/VCO.StoryMap.scss +173 -8
- package/src/scss/core/Reset.scss +5 -18
- package/src/scss/map/VCO.Map.scss +1 -1
- package/src/scss/map/VCO.MapMarker.scss +20 -9
- package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
- package/src/scss/media/VCO.Media.scss +7 -7
- package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
- package/src/scss/media/types/VCO.Media.Text.scss +5 -5
- package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
- package/src/scss/media/types/VCO.Media.Wikipedia.scss +2 -19
- package/src/scss/site/site.scss +55 -36
- package/src/scss/slider/VCO.Slide.scss +49 -11
- package/src/scss/slider/VCO.SlideNav.scss +23 -7
- package/src/scss/slider/VCO.StorySlider.scss +50 -0
- package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
- package/src/scss/ui/VCO.Consent.scss +20 -14
- package/src/scss/ui/VCO.Layers.scss +119 -0
- package/src/scss/ui/VCO.MenuBar.Button.scss +140 -19
- package/src/scss/ui/VCO.MenuBar.scss +9 -2
- package/src/scss/ui/VCO.Message.scss +14 -0
- package/src/slider/Slide.ts +232 -74
- package/src/slider/SlideNav.ts +15 -4
- package/src/slider/StorySlider.ts +203 -22
- package/src/storymap/Consent.ts +22 -0
- package/src/storymap/StoryMap.ts +944 -131
- package/src/storymap/content-state.ts +3 -8
- package/src/storymap/font-css.ts +72 -0
- package/src/storymap/from-slideshow.ts +785 -0
- package/src/storymap/iiif-shared.ts +228 -0
- package/src/storymap/iiif.ts +289 -100
- package/src/storymap/to-iiif.ts +131 -44
- package/src/types.ts +382 -17
- package/src/ui/LayersControl.ts +302 -0
- package/src/ui/MenuBar.ts +232 -21
- package/src/ui/Message.ts +36 -3
- package/src/ui/Swipable.ts +25 -7
- package/dist/assets/harness-Cr0rYpvt.js +0 -1
- package/dist/assets/main-DXalctuR.js +0 -17
- package/src/scss/VCO.StoryMap.Dark.scss +0 -150
- package/src/scss/Variables.Dark.scss +0 -57
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import OlMap from "ol/Map";
|
|
2
2
|
import View from "ol/View";
|
|
3
|
+
import { unByKey } from "ol/Observable";
|
|
3
4
|
import type { Control } from "ol/control";
|
|
4
5
|
import type { Interaction } from "ol/interaction";
|
|
5
|
-
import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
|
|
6
|
+
import { Image as ImageLayer, Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
|
|
6
7
|
import Layer from "ol/layer/Layer";
|
|
7
8
|
import VectorTileLayer from "ol/layer/VectorTile";
|
|
8
9
|
import { XYZ, OSM, IIIF } from "ol/source";
|
|
10
|
+
import Static from "ol/source/ImageStatic";
|
|
9
11
|
import type Source from "ol/source/Source";
|
|
10
12
|
import VectorSource from "ol/source/Vector";
|
|
11
13
|
import LineString from "ol/geom/LineString";
|
|
@@ -27,15 +29,26 @@ import "ol/ol.css";
|
|
|
27
29
|
import Map from "../Map";
|
|
28
30
|
import OpenLayersMapMarker from "./MapMarker.OpenLayers";
|
|
29
31
|
import { padCroppedZoomifyTile } from "./zoomifyTiles";
|
|
32
|
+
import { prefersReducedMotion } from "../../core/Util";
|
|
30
33
|
import type { LinePoint, ViewToOptions } from "../types";
|
|
31
34
|
import type {
|
|
32
35
|
LatLngLiteral,
|
|
36
|
+
StorymapImageOverlay,
|
|
33
37
|
StorymapOverlayLayer,
|
|
34
38
|
StorymapTilejson,
|
|
35
39
|
StorymapSlide,
|
|
36
40
|
StorymapSlideLocation,
|
|
37
41
|
} from "../../types";
|
|
38
42
|
import { fitGeoreference, resolveInfoJsonUrl } from "../georeference";
|
|
43
|
+
import {
|
|
44
|
+
buildSlideFilter,
|
|
45
|
+
isStaticImageUrl,
|
|
46
|
+
normalizeMask,
|
|
47
|
+
serviceToInfoJson,
|
|
48
|
+
slideBasemapKey,
|
|
49
|
+
slideRotationRad,
|
|
50
|
+
type NormalizedMask,
|
|
51
|
+
} from "../slideView";
|
|
39
52
|
import {
|
|
40
53
|
consentManagerOf,
|
|
41
54
|
tileService,
|
|
@@ -50,6 +63,16 @@ import { sanitizeSlideText } from "../../media/EmbedUtil";
|
|
|
50
63
|
|
|
51
64
|
const MAX_ZOOM = 19;
|
|
52
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Natural-size probe timeout for the static fallback: how long the viewer
|
|
68
|
+
* waits for the fallback image's dimensions before giving up and logging
|
|
69
|
+
* the IIIF error. A named constant (rather than an inline literal) so hosts
|
|
70
|
+
* and tests can find the single knob. There is no shared network-timeout
|
|
71
|
+
* option to reuse — `options.duration` is animation timing and the 1200ms
|
|
72
|
+
* media delay is a load deferral, not a watchdog.
|
|
73
|
+
*/
|
|
74
|
+
const STATIC_PROBE_TIMEOUT_MS = 15000;
|
|
75
|
+
|
|
53
76
|
/** What kind of imagery an `imageready` source carries. */
|
|
54
77
|
export type ImagereadyKind = "iiif" | "zoomify" | "tiles";
|
|
55
78
|
|
|
@@ -92,12 +115,50 @@ export default class OpenLayers extends Map {
|
|
|
92
115
|
declare "_line_animation": number | null;
|
|
93
116
|
/** Sources `imageready` has already been fired for. */
|
|
94
117
|
declare "_imageready_fired": WeakSet<object>;
|
|
118
|
+
/** The `loadend` subscription key, stored so dispose() can detach it. */
|
|
119
|
+
declare "_loadend_key": Parameters<typeof unByKey>[0] | null;
|
|
120
|
+
/**
|
|
121
|
+
* Per-slide basemap layers by key (multi-manifest tours): built on first
|
|
122
|
+
* use through the tile layer factory, then reused. Empty until a slide
|
|
123
|
+
* names a `location.basemap`.
|
|
124
|
+
*/
|
|
125
|
+
declare "_basemap_cache": globalThis.Map<string, Layer>;
|
|
126
|
+
/**
|
|
127
|
+
* The active per-slide basemap key, or null when the story basemap is
|
|
128
|
+
* showing. Restored to the story basemap on slides without one.
|
|
129
|
+
*/
|
|
130
|
+
declare "_slide_basemap": string | null;
|
|
131
|
+
/** Spotlight mask element (slideshow passepartout), or null when off. */
|
|
132
|
+
declare "_mask_el": HTMLDivElement | null;
|
|
133
|
+
/** Per-slide image overlay layer (slideshow imgoverlay), or null. */
|
|
134
|
+
declare "_imgoverlay_layer": Layer | null;
|
|
135
|
+
/** Guards stale static-fallback probes (see `_probeStaticFallback`). */
|
|
136
|
+
declare "_static_probe_token": number;
|
|
137
|
+
/**
|
|
138
|
+
* Set by `dispose()` so in-flight fetches and probes resolve into
|
|
139
|
+
* no-ops instead of touching a torn-down map.
|
|
140
|
+
*/
|
|
141
|
+
declare "_disposed": boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Per-URL probe tokens for static slide basemaps: concurrent probes for
|
|
144
|
+
* different slides must not cancel each other (see
|
|
145
|
+
* `_createStaticImageBasemap`).
|
|
146
|
+
*/
|
|
147
|
+
declare "_slide_probe_tokens": globalThis.Map<string, number>;
|
|
95
148
|
|
|
96
149
|
/* Create the Map
|
|
97
150
|
================================================== */
|
|
98
151
|
_createMap(): void {
|
|
99
152
|
const is_image_map = this.isImageSpace();
|
|
100
153
|
this._imageready_fired = new WeakSet();
|
|
154
|
+
this._disposed = false;
|
|
155
|
+
this._basemap_cache = new globalThis.Map();
|
|
156
|
+
this._slide_basemap = null;
|
|
157
|
+
this._story_basemap_layer = null;
|
|
158
|
+
this._mask_el = null;
|
|
159
|
+
this._imgoverlay_layer = null;
|
|
160
|
+
this._static_probe_token = 0;
|
|
161
|
+
this._slide_probe_tokens = new globalThis.Map();
|
|
101
162
|
|
|
102
163
|
// Caller-supplied OpenLayers options: `controls` replaces the defaults
|
|
103
164
|
// (the viewer installs none), `interactions` are added to the viewer's
|
|
@@ -161,7 +222,7 @@ export default class OpenLayers extends Map {
|
|
|
161
222
|
}),
|
|
162
223
|
});
|
|
163
224
|
|
|
164
|
-
this._map.on("loadend", () => {
|
|
225
|
+
this._loadend_key = this._map.on("loadend", () => {
|
|
165
226
|
this._onMapLoaded(undefined);
|
|
166
227
|
});
|
|
167
228
|
|
|
@@ -315,6 +376,120 @@ export default class OpenLayers extends Map {
|
|
|
315
376
|
}
|
|
316
377
|
}
|
|
317
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Provider credit for a `map_type`, in both renderings: the linked HTML
|
|
381
|
+
* for our own `.vco-map-attribution` line and the plain text for the
|
|
382
|
+
* OpenLayers source `attributions`. One table — the two previous
|
|
383
|
+
* functions repeated the same `osm/stadia/mapbox/esri` switch and could
|
|
384
|
+
* drift apart, leaving a provider credited in one place but not the other
|
|
385
|
+
* (which their terms of service require).
|
|
386
|
+
*/
|
|
387
|
+
_providerCredit(map_type: string): { html: string; text: string } {
|
|
388
|
+
// A full XYZ template naming a known provider
|
|
389
|
+
// (https://tile.openstreetmap.org/{z}/{x}/{y}.png) is not the `osm`
|
|
390
|
+
// map type, so match its host as well as the type prefix — otherwise
|
|
391
|
+
// both renderings fall through to the generic "Map data" line.
|
|
392
|
+
const byHost = this._hostCredit(map_type);
|
|
393
|
+
if (byHost) return byHost;
|
|
394
|
+
// Emitting provider credit only for OSM left Stadia, Mapbox and the
|
|
395
|
+
// OL XYZ template providers uncredited, which their terms of service
|
|
396
|
+
// require. The credit is keyed off the resolved map_type, so a custom
|
|
397
|
+
// `{z}` template gets the generic "map data" line and the author can
|
|
398
|
+
// still override it with the `attribution` option.
|
|
399
|
+
const table: { test: (t: string) => boolean; html: string; text: string }[] = [
|
|
400
|
+
{
|
|
401
|
+
test: (t) => t === "" || t.startsWith("osm"),
|
|
402
|
+
html: "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
|
|
403
|
+
text: "© OpenStreetMap contributors",
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
test: (t) => t.startsWith("stadia") || t === "stamen",
|
|
407
|
+
html:
|
|
408
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
|
|
409
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
|
|
410
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
411
|
+
text: "© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors",
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
test: (t) => t.startsWith("mapbox://"),
|
|
415
|
+
html: '© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>',
|
|
416
|
+
text: "© Mapbox",
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
test: (t) => t.startsWith("ch-") || t.startsWith("esri"),
|
|
420
|
+
html: 'Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>',
|
|
421
|
+
text: "Map data © Esri",
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
test: () => true,
|
|
425
|
+
html: "Map data",
|
|
426
|
+
text: "Map data",
|
|
427
|
+
},
|
|
428
|
+
];
|
|
429
|
+
const entry = table.find((row) => row.test(map_type)) ?? table[table.length - 1];
|
|
430
|
+
return { html: entry.html, text: entry.text };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The table row for an absolute tile template's host, or null when the
|
|
435
|
+
* template is relative (a host serving its own tiles credits itself via
|
|
436
|
+
* `attribution`) or unknown. Subdomains match their parent, so
|
|
437
|
+
* `a.tile.openstreetmap.org` credits OSM.
|
|
438
|
+
*/
|
|
439
|
+
_hostCredit(map_type: string): { html: string; text: string } | null {
|
|
440
|
+
let host: string | null = null;
|
|
441
|
+
try {
|
|
442
|
+
host = new URL(map_type).hostname;
|
|
443
|
+
} catch {
|
|
444
|
+
// Not absolute: only a protocol-relative template (`//host/...`)
|
|
445
|
+
// still names a host. A relative template resolves against the
|
|
446
|
+
// dummy base below and is rejected by the host check.
|
|
447
|
+
try {
|
|
448
|
+
const resolved = new URL(map_type, "https://storymap.invalid").hostname;
|
|
449
|
+
host = resolved === "storymap.invalid" ? null : resolved;
|
|
450
|
+
} catch {
|
|
451
|
+
host = null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (!host) return null;
|
|
455
|
+
const rows: { hosts: string[]; html: string; text: string }[] = [
|
|
456
|
+
{
|
|
457
|
+
hosts: ["openstreetmap.org", "osm.org"],
|
|
458
|
+
html: "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
|
|
459
|
+
text: "© OpenStreetMap contributors",
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
hosts: ["stadiamaps.com", "openmaptiles.org"],
|
|
463
|
+
html:
|
|
464
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
|
|
465
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
|
|
466
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
467
|
+
text: "© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors",
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
hosts: ["mapbox.com"],
|
|
471
|
+
html: '© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>',
|
|
472
|
+
text: "© Mapbox",
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
hosts: ["arcgisonline.com", "esri.com"],
|
|
476
|
+
html: 'Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>',
|
|
477
|
+
text: "Map data © Esri",
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
hosts: ["carto.com", "basemaps.cartocdn.com"],
|
|
481
|
+
html:
|
|
482
|
+
"© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors © " +
|
|
483
|
+
'<a target="_blank" href="https://carto.com/">CARTO</a>',
|
|
484
|
+
text: "© OpenStreetMap contributors © CARTO",
|
|
485
|
+
},
|
|
486
|
+
];
|
|
487
|
+
const row = rows.find(({ hosts }) =>
|
|
488
|
+
hosts.some((h) => host === h || host.endsWith(`.${h}`)),
|
|
489
|
+
);
|
|
490
|
+
return row ? { html: row.html, text: row.text } : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
318
493
|
_getAttribution(map_type: string): string[] {
|
|
319
494
|
const parts = [
|
|
320
495
|
"<a href='https://storymap.knightlab.com/' target='_blank' class='vco-knightlab-brand'><span>■</span> StoryMapJS</a>",
|
|
@@ -324,29 +499,7 @@ export default class OpenLayers extends Map {
|
|
|
324
499
|
// what the visitor sees. Sources additionally carry the plain-text
|
|
325
500
|
// credit from `_sourceAttributions`, so `source.getAttributions()`
|
|
326
501
|
// and a consumer's own Attribution control are not left empty.
|
|
327
|
-
|
|
328
|
-
// only for OSM left Stadia, Mapbox and the OL XYZ template providers
|
|
329
|
-
// uncredited, which their terms of service require. The credit is
|
|
330
|
-
// keyed off the resolved map_type, so a custom `{z}` template gets
|
|
331
|
-
// the generic "map data" line and the author can still override it
|
|
332
|
-
// with the `attribution` option.
|
|
333
|
-
if (map_type === "" || map_type.startsWith("osm")) {
|
|
334
|
-
parts.push(
|
|
335
|
-
"© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
|
|
336
|
-
);
|
|
337
|
-
} else if (map_type.startsWith("stadia") || map_type === "stamen") {
|
|
338
|
-
parts.push(
|
|
339
|
-
'© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
|
|
340
|
-
'© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
|
|
341
|
-
'© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
342
|
-
);
|
|
343
|
-
} else if (map_type.startsWith("mapbox://")) {
|
|
344
|
-
parts.push('© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>');
|
|
345
|
-
} else if (map_type.startsWith("ch-") || map_type.startsWith("esri")) {
|
|
346
|
-
parts.push('Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>');
|
|
347
|
-
} else {
|
|
348
|
-
parts.push("Map data");
|
|
349
|
-
}
|
|
502
|
+
parts.push(this._providerCredit(map_type).html);
|
|
350
503
|
if (this.options.attribution) {
|
|
351
504
|
parts.push(this.options.attribution);
|
|
352
505
|
}
|
|
@@ -361,18 +514,7 @@ export default class OpenLayers extends Map {
|
|
|
361
514
|
* `ol/control/Attribution`) work instead of returning nothing.
|
|
362
515
|
*/
|
|
363
516
|
_sourceAttributions(map_type: string): string[] {
|
|
364
|
-
const parts: string[] = [];
|
|
365
|
-
if (map_type === "" || map_type.startsWith("osm")) {
|
|
366
|
-
parts.push("© OpenStreetMap contributors");
|
|
367
|
-
} else if (map_type.startsWith("stadia") || map_type === "stamen") {
|
|
368
|
-
parts.push("© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors");
|
|
369
|
-
} else if (map_type.startsWith("mapbox://")) {
|
|
370
|
-
parts.push("© Mapbox");
|
|
371
|
-
} else if (map_type.startsWith("ch-") || map_type.startsWith("esri")) {
|
|
372
|
-
parts.push("Map data © Esri");
|
|
373
|
-
} else {
|
|
374
|
-
parts.push("Map data");
|
|
375
|
-
}
|
|
517
|
+
const parts: string[] = [this._providerCredit(map_type).text];
|
|
376
518
|
if (this.options.attribution) {
|
|
377
519
|
parts.push(this.options.attribution);
|
|
378
520
|
}
|
|
@@ -392,6 +534,10 @@ export default class OpenLayers extends Map {
|
|
|
392
534
|
// to bury the route lines under fresh tiles
|
|
393
535
|
this._tile_layer.setZIndex(0);
|
|
394
536
|
this._map.addLayer(this._tile_layer);
|
|
537
|
+
// the story's own base layer: per-slide basemaps hand back to it
|
|
538
|
+
if (!this._story_basemap_layer) {
|
|
539
|
+
this._story_basemap_layer = this._tile_layer;
|
|
540
|
+
}
|
|
395
541
|
}
|
|
396
542
|
|
|
397
543
|
/**
|
|
@@ -601,6 +747,15 @@ export default class OpenLayers extends Map {
|
|
|
601
747
|
return this._overlay_layers.length;
|
|
602
748
|
}
|
|
603
749
|
|
|
750
|
+
/**
|
|
751
|
+
* The built overlay entries, parallel to `getOverlayLayers()`: malformed
|
|
752
|
+
* entries are skipped, so this is what a control must iterate to stay on
|
|
753
|
+
* the same index space as `setOverlayVisible()`.
|
|
754
|
+
*/
|
|
755
|
+
getOverlayEntries(): StorymapOverlayLayer[] {
|
|
756
|
+
return [...this._overlay_entries];
|
|
757
|
+
}
|
|
758
|
+
|
|
604
759
|
/**
|
|
605
760
|
* Show or hide a stacked overlay by index (re-syncs attribution). The
|
|
606
761
|
* index counts built layers, i.e. it skips malformed entries.
|
|
@@ -637,6 +792,7 @@ export default class OpenLayers extends Map {
|
|
|
637
792
|
_onTilesAllowed(): void {
|
|
638
793
|
this._addTileLayer();
|
|
639
794
|
this._buildOverlays();
|
|
795
|
+
this.fire("tilesallowed");
|
|
640
796
|
if (!this._tile_layer_mini) {
|
|
641
797
|
this._tile_layer_mini = this._createTileLayer(this.options.map_type);
|
|
642
798
|
}
|
|
@@ -655,6 +811,9 @@ export default class OpenLayers extends Map {
|
|
|
655
811
|
if (latlon) {
|
|
656
812
|
this._fitView(this._map, [[latlon.lon, latlon.lat]], 0);
|
|
657
813
|
}
|
|
814
|
+
// consent arrived after markers: the current slide may name
|
|
815
|
+
// a per-slide basemap
|
|
816
|
+
this._switchSlideBasemap(marker.data.location ?? null);
|
|
658
817
|
}
|
|
659
818
|
}
|
|
660
819
|
}
|
|
@@ -738,6 +897,154 @@ export default class OpenLayers extends Map {
|
|
|
738
897
|
return { zoom, center };
|
|
739
898
|
}
|
|
740
899
|
|
|
900
|
+
/**
|
|
901
|
+
* Paint the story's static image when its IIIF `info.json` cannot be
|
|
902
|
+
* used (unreachable, or missing dimensions): a single-resolution
|
|
903
|
+
* `ImageStatic` layer replaces the empty tile layer, so the tour still
|
|
904
|
+
* shows its picture instead of a blank basemap. Needs
|
|
905
|
+
* `iiif.fallbackUrl` (the full-size image) plus `iiif.width`/`height`
|
|
906
|
+
* (canvas size); without dimensions the natural size is probed, but
|
|
907
|
+
* only for slideshow tours — internal documents keep the existing
|
|
908
|
+
* error path. Returns false when nothing was (or will be) painted.
|
|
909
|
+
*/
|
|
910
|
+
_useStaticFallback(empty_layer: TileLayer): boolean {
|
|
911
|
+
const width = this.options.iiif.width;
|
|
912
|
+
const height = this.options.iiif.height;
|
|
913
|
+
const url = this.options.iiif.fallbackUrl;
|
|
914
|
+
if (typeof url !== "string" || url === "") {
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
if (typeof width === "number" && width > 0 && typeof height === "number" && height > 0) {
|
|
918
|
+
this._buildStaticLayer(empty_layer, url, width, height);
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
if (this.options.slideshow_source !== true) {
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
void this._probeStaticFallback(empty_layer, url);
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Resolve a fallback image's natural size, then paint it. Stale probes
|
|
930
|
+
* (a newer probe started, e.g. after a basemap switch) and unusable
|
|
931
|
+
* dimensions fall back to the logged IIIF error.
|
|
932
|
+
*/
|
|
933
|
+
async _probeStaticFallback(empty_layer: TileLayer, url: string): Promise<void> {
|
|
934
|
+
const token = ++this._static_probe_token;
|
|
935
|
+
try {
|
|
936
|
+
const dims = await this._probeStaticImage(url);
|
|
937
|
+
if (this._disposed || token !== this._static_probe_token) return;
|
|
938
|
+
if (!dims || !(dims.width > 0) || !(dims.height > 0)) {
|
|
939
|
+
console.error("Static fallback image has no usable dimensions:", url);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
this._buildStaticLayer(empty_layer, url, dims.width, dims.height);
|
|
943
|
+
} catch (err) {
|
|
944
|
+
if (this._disposed || token !== this._static_probe_token) return;
|
|
945
|
+
console.error(
|
|
946
|
+
"Static fallback image could not be probed:",
|
|
947
|
+
url,
|
|
948
|
+
(err as Error)?.stack || err,
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* The natural size of an image URL. Separated for testability: unit
|
|
955
|
+
* tests stub the `Image` constructor rather than the network.
|
|
956
|
+
*/
|
|
957
|
+
_probeStaticImage(url: string): Promise<{ width: number; height: number }> {
|
|
958
|
+
return new Promise((resolve, reject) => {
|
|
959
|
+
const img = new Image();
|
|
960
|
+
const timer = setTimeout(() => {
|
|
961
|
+
reject(new Error(`timed out after ${STATIC_PROBE_TIMEOUT_MS}ms`));
|
|
962
|
+
}, STATIC_PROBE_TIMEOUT_MS);
|
|
963
|
+
img.onload = () => {
|
|
964
|
+
clearTimeout(timer);
|
|
965
|
+
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
966
|
+
};
|
|
967
|
+
img.onerror = () => {
|
|
968
|
+
clearTimeout(timer);
|
|
969
|
+
reject(new Error("load error"));
|
|
970
|
+
};
|
|
971
|
+
img.crossOrigin = "anonymous";
|
|
972
|
+
img.src = url;
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* One static image source: shared by the story fallback (known extent),
|
|
978
|
+
* per-slide static basemaps (probed extent) and per-slide overlays
|
|
979
|
+
* (view extent). Layer assembly stays per call site — base (z 0),
|
|
980
|
+
* pending-fill and overlay (z 5, opacity) have different lifecycles.
|
|
981
|
+
*/
|
|
982
|
+
_newStaticSource(
|
|
983
|
+
url: string,
|
|
984
|
+
extent: Extent,
|
|
985
|
+
projection: string | Projection,
|
|
986
|
+
attributions?: string | string[],
|
|
987
|
+
): Static {
|
|
988
|
+
return new Static({
|
|
989
|
+
url,
|
|
990
|
+
imageExtent: extent,
|
|
991
|
+
projection,
|
|
992
|
+
crossOrigin: "anonymous",
|
|
993
|
+
...(attributions !== undefined ? { attributions } : {}),
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* One static image layer: the constructor shared by the story fallback,
|
|
999
|
+
* per-slide static basemaps and per-slide overlays. Extent and
|
|
1000
|
+
* projection are the caller's policy (canvas size, probed size, or view
|
|
1001
|
+
* extent); presentation rides the options.
|
|
1002
|
+
*/
|
|
1003
|
+
_newStaticLayer(
|
|
1004
|
+
url: string,
|
|
1005
|
+
extent: Extent,
|
|
1006
|
+
projection: string | Projection,
|
|
1007
|
+
presentation: { opacity?: number; attributions?: string | string[]; zIndex?: number } = {},
|
|
1008
|
+
): ImageLayer<Static> {
|
|
1009
|
+
const layer = new ImageLayer({
|
|
1010
|
+
source: this._newStaticSource(url, extent, projection, presentation.attributions),
|
|
1011
|
+
...(presentation.opacity !== undefined ? { opacity: presentation.opacity } : {}),
|
|
1012
|
+
});
|
|
1013
|
+
layer.setZIndex(presentation.zIndex ?? 0);
|
|
1014
|
+
return layer;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Swap the empty tile layer for a static image of the given extent. */
|
|
1018
|
+
_buildStaticLayer(empty_layer: TileLayer, url: string, width: number, height: number): void {
|
|
1019
|
+
if (this._disposed) return;
|
|
1020
|
+
const layer = this._newStaticLayer(url, [0, 0, width, height], "EPSG:4326", {
|
|
1021
|
+
attributions: this.options.iiif.attribution || [],
|
|
1022
|
+
});
|
|
1023
|
+
if (this._tile_layer === (empty_layer as unknown as Layer)) {
|
|
1024
|
+
this._map.removeLayer(empty_layer);
|
|
1025
|
+
this._tile_layer = layer;
|
|
1026
|
+
this._map.addLayer(layer);
|
|
1027
|
+
} else {
|
|
1028
|
+
// superseded while resolving (basemap switch, dispose): announce
|
|
1029
|
+
// nothing and fit nothing for a layer that never shows
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const source = this._sourceOf(layer);
|
|
1033
|
+
if (!source) return;
|
|
1034
|
+
this._fireImageready(source, "iiif", layer);
|
|
1035
|
+
if (typeof source.getState === "function" && source.getState() === "ready") {
|
|
1036
|
+
this._markerOverview();
|
|
1037
|
+
} else if (typeof (source as { once?: unknown }).once === "function") {
|
|
1038
|
+
(source as unknown as { once(e: string, fn: () => void): void }).once("change", () => {
|
|
1039
|
+
if (source.getState() === "ready") {
|
|
1040
|
+
this._markerOverview();
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
} else {
|
|
1044
|
+
this._markerOverview();
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
741
1048
|
_createTileLayer(map_type: string): Layer {
|
|
742
1049
|
// issue #473: custom OpenLayers tile layer/source factory first —
|
|
743
1050
|
// the base layer, overlays, minimap and runtime map_type switches
|
|
@@ -828,16 +1135,35 @@ export default class OpenLayers extends Map {
|
|
|
828
1135
|
* used afterwards.
|
|
829
1136
|
*/
|
|
830
1137
|
dispose(): void {
|
|
1138
|
+
this._disposed = true;
|
|
1139
|
+
// invalidate in-flight static probes and info.json fetches: their
|
|
1140
|
+
// continuations check the flag or their token and resolve quietly
|
|
1141
|
+
this._static_probe_token++;
|
|
831
1142
|
if (this._line_animation !== null) {
|
|
832
1143
|
cancelAnimationFrame(this._line_animation);
|
|
833
1144
|
this._line_animation = null;
|
|
834
1145
|
}
|
|
1146
|
+
if (this._loadend_key) {
|
|
1147
|
+
unByKey(this._loadend_key);
|
|
1148
|
+
this._loadend_key = null;
|
|
1149
|
+
}
|
|
1150
|
+
if (this._onWheelBound) {
|
|
1151
|
+
this._el.map?.removeEventListener("wheel", this._onWheelBound);
|
|
1152
|
+
this._onWheelBound = null;
|
|
1153
|
+
}
|
|
835
1154
|
// markers own their click listener and an overlay; disposing the
|
|
836
1155
|
// collection releases both
|
|
837
1156
|
for (const marker of this._markers) {
|
|
838
1157
|
marker.dispose?.();
|
|
839
1158
|
}
|
|
840
1159
|
this._markers = [];
|
|
1160
|
+
this._mask_el?.remove();
|
|
1161
|
+
this._mask_el = null;
|
|
1162
|
+
this._basemap_cache.clear();
|
|
1163
|
+
this._slide_probe_tokens.clear();
|
|
1164
|
+
this._slide_basemap = null;
|
|
1165
|
+
this._story_basemap_layer = null;
|
|
1166
|
+
this._imgoverlay_layer = null;
|
|
841
1167
|
// detach the viewport first: dispose() alone leaves the canvas in
|
|
842
1168
|
// the caller's DOM
|
|
843
1169
|
this._map.setTarget(undefined);
|
|
@@ -999,6 +1325,17 @@ export default class OpenLayers extends Map {
|
|
|
999
1325
|
|
|
1000
1326
|
case "iiif": {
|
|
1001
1327
|
const iiif_layer: TileLayer = new TileLayer();
|
|
1328
|
+
// a static-image tour states no info.json at all: skip the
|
|
1329
|
+
// fetch and go straight to the fallback (which probes when
|
|
1330
|
+
// no dimensions are stated either)
|
|
1331
|
+
if (typeof this.options.iiif.url !== "string" || this.options.iiif.url === "") {
|
|
1332
|
+
if (!this._useStaticFallback(iiif_layer)) {
|
|
1333
|
+
console.error(
|
|
1334
|
+
"IIIF info.json URL is missing and no static fallback is stated.",
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
return iiif_layer;
|
|
1338
|
+
}
|
|
1002
1339
|
fetch(this.options.iiif.url)
|
|
1003
1340
|
.then((r) => r.json())
|
|
1004
1341
|
.then((info: unknown) => {
|
|
@@ -1013,10 +1350,15 @@ export default class OpenLayers extends Map {
|
|
|
1013
1350
|
typeof fallback.width !== "number" ||
|
|
1014
1351
|
typeof fallback.height !== "number"
|
|
1015
1352
|
) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
)
|
|
1353
|
+
// without dimensions there is no grid to build;
|
|
1354
|
+
// fall back to the static image when the story
|
|
1355
|
+
// states dimensions of its own
|
|
1356
|
+
if (!this._useStaticFallback(iiif_layer)) {
|
|
1357
|
+
console.error(
|
|
1358
|
+
"IIIF info.json is missing width/height:",
|
|
1359
|
+
this.options.iiif.url,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1020
1362
|
return;
|
|
1021
1363
|
}
|
|
1022
1364
|
const source = new IIIF({
|
|
@@ -1039,11 +1381,18 @@ export default class OpenLayers extends Map {
|
|
|
1039
1381
|
}
|
|
1040
1382
|
})
|
|
1041
1383
|
.catch((err) =>
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1384
|
+
// the tile pyramid is unreachable: paint the static
|
|
1385
|
+
// image instead when the story states one, so the
|
|
1386
|
+
// tour still shows its picture
|
|
1387
|
+
{
|
|
1388
|
+
if (!this._useStaticFallback(iiif_layer)) {
|
|
1389
|
+
console.error(
|
|
1390
|
+
"IIIF info.json could not be loaded:",
|
|
1391
|
+
this.options.iiif.url,
|
|
1392
|
+
err?.stack || err,
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
},
|
|
1047
1396
|
);
|
|
1048
1397
|
return iiif_layer;
|
|
1049
1398
|
}
|
|
@@ -1381,6 +1730,10 @@ export default class OpenLayers extends Map {
|
|
|
1381
1730
|
_createMarker(d: StorymapSlide): void {
|
|
1382
1731
|
const marker = new OpenLayersMapMarker(d, this.options);
|
|
1383
1732
|
marker.on("markerclick", this._onMarkerClick, this);
|
|
1733
|
+
// Forward popup cards to the map surface (fresh payloads, so the
|
|
1734
|
+
// marker stays the event target of the original, not the re-fire).
|
|
1735
|
+
marker.on("popupopen", this._onMarkerPopupOpen, this);
|
|
1736
|
+
marker.on("popupclose", this._onMarkerPopupClose, this);
|
|
1384
1737
|
this._addMarker(marker);
|
|
1385
1738
|
this._markers.push(marker);
|
|
1386
1739
|
marker.marker_number = this._markers.length - 1;
|
|
@@ -1515,7 +1868,7 @@ export default class OpenLayers extends Map {
|
|
|
1515
1868
|
panel_rect.bottom > map_rect.top;
|
|
1516
1869
|
if (!overlaps) return padding;
|
|
1517
1870
|
const layout = this.options.layout;
|
|
1518
|
-
if (layout === "portrait") {
|
|
1871
|
+
if (layout === "portrait" || this.options.textmode === "bottom") {
|
|
1519
1872
|
padding[2] += Math.max(0, map_rect.bottom - panel_rect.top);
|
|
1520
1873
|
} else {
|
|
1521
1874
|
padding[1] += Math.max(0, panel_rect.right - map_rect.left);
|
|
@@ -1549,7 +1902,7 @@ export default class OpenLayers extends Map {
|
|
|
1549
1902
|
size: ol_map.getSize(),
|
|
1550
1903
|
padding: this._opaquePanelPadding(),
|
|
1551
1904
|
maxZoom: 12,
|
|
1552
|
-
duration: duration,
|
|
1905
|
+
duration: prefersReducedMotion() ? 0 : duration,
|
|
1553
1906
|
easing: this._easing,
|
|
1554
1907
|
});
|
|
1555
1908
|
}
|
|
@@ -1712,7 +2065,8 @@ export default class OpenLayers extends Map {
|
|
|
1712
2065
|
|
|
1713
2066
|
this._cancelLineAnimation();
|
|
1714
2067
|
|
|
1715
|
-
|
|
2068
|
+
// Reduced motion draws the full path at once instead of tracing it.
|
|
2069
|
+
const duration = prefersReducedMotion() ? 0 : (animate?.duration ?? 0);
|
|
1716
2070
|
if (duration <= 0 || view_coords.length < 2) {
|
|
1717
2071
|
setGeometry(view_coords);
|
|
1718
2072
|
return;
|
|
@@ -1858,7 +2212,7 @@ export default class OpenLayers extends Map {
|
|
|
1858
2212
|
_panTo(loc: LatLngLiteral, animate?: boolean): void {
|
|
1859
2213
|
this._map.getView().animate({
|
|
1860
2214
|
center: this._toViewCoords(loc),
|
|
1861
|
-
duration: this.options.duration,
|
|
2215
|
+
duration: prefersReducedMotion() ? 0 : this.options.duration,
|
|
1862
2216
|
easing: this._easing,
|
|
1863
2217
|
});
|
|
1864
2218
|
}
|
|
@@ -1866,13 +2220,20 @@ export default class OpenLayers extends Map {
|
|
|
1866
2220
|
_zoomTo(z: number, animate?: boolean): void {
|
|
1867
2221
|
this._map.getView().animate({
|
|
1868
2222
|
zoom: z,
|
|
1869
|
-
duration: this.options.duration,
|
|
2223
|
+
duration: prefersReducedMotion() ? 0 : this.options.duration,
|
|
1870
2224
|
easing: this._easing,
|
|
1871
2225
|
});
|
|
1872
2226
|
}
|
|
1873
2227
|
|
|
1874
2228
|
_viewTo(loc: StorymapSlideLocation, opts?: ViewToOptions): void {
|
|
1875
|
-
//
|
|
2229
|
+
// Per-slide presentation first: basemap swap, filter grading and
|
|
2230
|
+
// spotlight mask are synchronous state, independent of movement.
|
|
2231
|
+
// Rotation joins the movement animation below (or _fitRegion's).
|
|
2232
|
+
// Slideshow tours only: internal documents move exactly as before.
|
|
2233
|
+
const feat = this.options.slideshow_source === true ? loc : undefined;
|
|
2234
|
+
this._switchSlideBasemap(feat);
|
|
2235
|
+
this._applySlideFilterAndMask(feat);
|
|
2236
|
+
// Image region stops (slideshow-style): in image mode the view is
|
|
1876
2237
|
// EPSG:4326 where coordinates are raw image pixels — fit the xywh
|
|
1877
2238
|
// region instead of flying to a point. Everything else (geo maps,
|
|
1878
2239
|
// absent/invalid regions) keeps the point behavior unchanged.
|
|
@@ -1882,12 +2243,15 @@ export default class OpenLayers extends Map {
|
|
|
1882
2243
|
? loc.region
|
|
1883
2244
|
: null;
|
|
1884
2245
|
if (region) {
|
|
1885
|
-
this._fitRegion(region, opts);
|
|
2246
|
+
this._fitRegion(region, opts, feat);
|
|
1886
2247
|
return;
|
|
1887
2248
|
}
|
|
1888
2249
|
|
|
1889
2250
|
const start = this._latLngOf(loc);
|
|
1890
2251
|
if (!start) {
|
|
2252
|
+
// location-less slide (region-only on a geo map): nothing to
|
|
2253
|
+
// fly to, but an explicit rotation still applies
|
|
2254
|
+
this._applySlideRotation(feat, opts?.duration, opts?.duration === 0);
|
|
1891
2255
|
return;
|
|
1892
2256
|
}
|
|
1893
2257
|
let _animate = true,
|
|
@@ -1924,10 +2288,16 @@ export default class OpenLayers extends Map {
|
|
|
1924
2288
|
_location = this._getMapCenterOffset(_location, _zoom);
|
|
1925
2289
|
}
|
|
1926
2290
|
|
|
2291
|
+
// A slide rotation joins the glide; the animation arg is only added
|
|
2292
|
+
// when the slide or the view is rotated, so unrotated stories
|
|
2293
|
+
// animate exactly as before.
|
|
2294
|
+
const _rotation = slideRotationRad(feat);
|
|
2295
|
+
const _current_rotation = this._map.getView().getRotation();
|
|
1927
2296
|
this._map.getView().animate({
|
|
1928
2297
|
center: this._toViewCoords(_location),
|
|
1929
2298
|
zoom: _zoom,
|
|
1930
|
-
|
|
2299
|
+
...(_rotation !== null || _current_rotation !== 0 ? { rotation: _rotation ?? 0 } : {}),
|
|
2300
|
+
duration: _animate && !prefersReducedMotion() ? _duration : 0,
|
|
1931
2301
|
easing: this._easing,
|
|
1932
2302
|
});
|
|
1933
2303
|
|
|
@@ -1947,9 +2317,15 @@ export default class OpenLayers extends Map {
|
|
|
1947
2317
|
/**
|
|
1948
2318
|
* Image region stop: fit the xywh bbox ([x, y, w, h] image pixels —
|
|
1949
2319
|
* the EPSG:4326 image space) with the shared animation options, then
|
|
1950
|
-
* keep the minimap collapse state in sync like `_viewTo`.
|
|
2320
|
+
* keep the minimap collapse state in sync like `_viewTo`. A slide
|
|
2321
|
+
* rotation follows the fit (fit() takes no rotation), plus the
|
|
2322
|
+
* filter/mask tail when the location is passed.
|
|
1951
2323
|
*/
|
|
1952
|
-
_fitRegion(
|
|
2324
|
+
_fitRegion(
|
|
2325
|
+
region: [number, number, number, number],
|
|
2326
|
+
opts?: ViewToOptions,
|
|
2327
|
+
loc?: StorymapSlideLocation,
|
|
2328
|
+
): void {
|
|
1953
2329
|
const [x, y, w, h] = region;
|
|
1954
2330
|
if (!(w > 0) || !(h > 0)) {
|
|
1955
2331
|
return;
|
|
@@ -1966,9 +2342,13 @@ export default class OpenLayers extends Map {
|
|
|
1966
2342
|
this._map.getView().fit([x, y, x + w, y + h], {
|
|
1967
2343
|
size: this._map.getSize(),
|
|
1968
2344
|
padding: this._opaquePanelPadding(),
|
|
1969
|
-
duration: _animate ? _duration : 0,
|
|
2345
|
+
duration: _animate && !prefersReducedMotion() ? _duration : 0,
|
|
1970
2346
|
easing: this._easing,
|
|
1971
2347
|
});
|
|
2348
|
+
if (loc) {
|
|
2349
|
+
this._applySlideRotation(loc, _duration, !_animate);
|
|
2350
|
+
this._applySlideFilterAndMask(loc);
|
|
2351
|
+
}
|
|
1972
2352
|
if (this._mini_map && this.options.width > this.options.skinny_size) {
|
|
1973
2353
|
this._mini_map.setCollapsed(true);
|
|
1974
2354
|
}
|
|
@@ -1980,6 +2360,295 @@ export default class OpenLayers extends Map {
|
|
|
1980
2360
|
return fromLonLat([loc.lon, loc.lat]);
|
|
1981
2361
|
}
|
|
1982
2362
|
|
|
2363
|
+
/* Per-slide view presentation (slideshow rotation/filter/mask/basemap)
|
|
2364
|
+
================================================== */
|
|
2365
|
+
/**
|
|
2366
|
+
* Swap the base layer for a slide `location.basemap` (multi-manifest
|
|
2367
|
+
* tours): built on first use through the tile layer factory (custom
|
|
2368
|
+
* factories apply), then cached. A slide without one restores the story
|
|
2369
|
+
* basemap. No-op when the requested key is already showing, so resize
|
|
2370
|
+
* and re-fit paths pass through untouched. Consent-denied tiles skip
|
|
2371
|
+
* the switch entirely.
|
|
2372
|
+
*/
|
|
2373
|
+
_switchSlideBasemap(loc: StorymapSlideLocation | null | undefined): void {
|
|
2374
|
+
// slideshow tours only: internal documents keep the story basemap
|
|
2375
|
+
if (this.options.slideshow_source !== true) return;
|
|
2376
|
+
if (!this._tilesAllowed()) return;
|
|
2377
|
+
const key = slideBasemapKey(loc);
|
|
2378
|
+
if (key === this._slide_basemap) return;
|
|
2379
|
+
if (this._tile_layer) {
|
|
2380
|
+
this._map.removeLayer(this._tile_layer);
|
|
2381
|
+
}
|
|
2382
|
+
if (key === null) {
|
|
2383
|
+
this._tile_layer =
|
|
2384
|
+
this._story_basemap_layer ?? this._createTileLayer(this.options.map_type);
|
|
2385
|
+
this._story_basemap_layer = this._tile_layer;
|
|
2386
|
+
} else {
|
|
2387
|
+
let layer = this._basemap_cache.get(key);
|
|
2388
|
+
if (!layer) {
|
|
2389
|
+
layer = this._createSlideBasemap(key);
|
|
2390
|
+
layer.setZIndex(0);
|
|
2391
|
+
this._basemap_cache.set(key, layer);
|
|
2392
|
+
}
|
|
2393
|
+
this._tile_layer = layer;
|
|
2394
|
+
}
|
|
2395
|
+
this._tile_layer.setZIndex(0);
|
|
2396
|
+
this._map.addLayer(this._tile_layer);
|
|
2397
|
+
this._slide_basemap = key;
|
|
2398
|
+
this._updateAttribution();
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* Build a per-slide basemap layer: IIIF `info.json` URLs (or bare
|
|
2403
|
+
* service bases) become tile layers; plain image URLs become static
|
|
2404
|
+
* image layers (dimensions probed); anything else goes through the
|
|
2405
|
+
* regular tile layer factory, so keywords and templates keep working.
|
|
2406
|
+
*/
|
|
2407
|
+
_createSlideBasemap(key: string): Layer {
|
|
2408
|
+
// plain image files (absolute or same-origin relative) skip the
|
|
2409
|
+
// doomed info.json fetch; services and keywords flow on
|
|
2410
|
+
if (isStaticImageUrl(key)) {
|
|
2411
|
+
return this._createStaticImageBasemap(key);
|
|
2412
|
+
}
|
|
2413
|
+
if (/^https?:\/\//i.test(key) && !key.includes("{z}")) {
|
|
2414
|
+
const info_url = serviceToInfoJson(key);
|
|
2415
|
+
const pending: TileLayer = new TileLayer();
|
|
2416
|
+
fetch(info_url)
|
|
2417
|
+
.then((r) => r.json())
|
|
2418
|
+
.then((info: unknown) => {
|
|
2419
|
+
if (this._disposed) return;
|
|
2420
|
+
const parsed = new IIIFInfo(
|
|
2421
|
+
info as ImageInformationResponse,
|
|
2422
|
+
).getTileSourceOptions();
|
|
2423
|
+
const dims = info as { width?: number; height?: number };
|
|
2424
|
+
if (typeof dims.width !== "number" || typeof dims.height !== "number") {
|
|
2425
|
+
this._replaceWithStaticBasemap(key, pending);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
const source = new IIIF({
|
|
2429
|
+
...(parsed ?? {}),
|
|
2430
|
+
projection: "EPSG:4326",
|
|
2431
|
+
size: [dims.width, dims.height],
|
|
2432
|
+
crossOrigin: "anonymous",
|
|
2433
|
+
});
|
|
2434
|
+
pending.setSource(source);
|
|
2435
|
+
this._fireImageready(source, "iiif", pending);
|
|
2436
|
+
})
|
|
2437
|
+
.catch(() => {
|
|
2438
|
+
// not an image service after all (a plain file, e.g. a
|
|
2439
|
+
// `.jpx` that serves no info.json): probe it as one
|
|
2440
|
+
this._replaceWithStaticBasemap(key, pending);
|
|
2441
|
+
});
|
|
2442
|
+
return pending;
|
|
2443
|
+
}
|
|
2444
|
+
return this._createTileLayer(key);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/**
|
|
2448
|
+
* A basemap from a plain image URL (no IIIF service): an `ImageLayer`
|
|
2449
|
+
* filled in once the natural size probes. Used for per-slide basemaps
|
|
2450
|
+
* pointing at static files, and as the fallback when an `info.json`
|
|
2451
|
+
* turns out unreachable (some `.jpx`/`.jp2` bases serve one, some are
|
|
2452
|
+
* plain files — only the fetch tells). Unusable dimensions keep the
|
|
2453
|
+
* error logged and the layer empty, like the failed-service path.
|
|
2454
|
+
*/
|
|
2455
|
+
_createStaticImageBasemap(url: string): Layer {
|
|
2456
|
+
const pending = new ImageLayer();
|
|
2457
|
+
pending.setZIndex(0);
|
|
2458
|
+
const token = (this._slide_probe_tokens.get(url) ?? 0) + 1;
|
|
2459
|
+
this._slide_probe_tokens.set(url, token);
|
|
2460
|
+
void this._probeStaticImage(url).then(
|
|
2461
|
+
(dims) => {
|
|
2462
|
+
if (this._disposed || this._slide_probe_tokens.get(url) !== token) return;
|
|
2463
|
+
if (!dims || !(dims.width > 0) || !(dims.height > 0)) {
|
|
2464
|
+
console.error("Slide basemap image has no usable dimensions:", url);
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
pending.setSource(
|
|
2468
|
+
this._newStaticSource(url, [0, 0, dims.width, dims.height], "EPSG:4326"),
|
|
2469
|
+
);
|
|
2470
|
+
this._fireImageready(
|
|
2471
|
+
pending.getSource() as { getState?(): string },
|
|
2472
|
+
"iiif",
|
|
2473
|
+
pending,
|
|
2474
|
+
);
|
|
2475
|
+
},
|
|
2476
|
+
(err) =>
|
|
2477
|
+
console.error(
|
|
2478
|
+
"Slide basemap image could not be probed:",
|
|
2479
|
+
url,
|
|
2480
|
+
(err as Error)?.stack || err,
|
|
2481
|
+
),
|
|
2482
|
+
);
|
|
2483
|
+
return pending;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* Replace a failed IIIF slide basemap with a probed static image under
|
|
2488
|
+
* the same cache key, swapping it into the map when still current (the
|
|
2489
|
+
* visitor may have moved on while the fetch was in flight, in which
|
|
2490
|
+
* case the cache holds it for a return visit).
|
|
2491
|
+
*/
|
|
2492
|
+
_replaceWithStaticBasemap(key: string, pending: TileLayer): void {
|
|
2493
|
+
if (this._disposed) return;
|
|
2494
|
+
const staticLayer = this._createStaticImageBasemap(key);
|
|
2495
|
+
this._basemap_cache.set(key, staticLayer);
|
|
2496
|
+
if (this._tile_layer === (pending as unknown as Layer)) {
|
|
2497
|
+
this._map.removeLayer(pending);
|
|
2498
|
+
this._tile_layer = staticLayer;
|
|
2499
|
+
this._map.addLayer(staticLayer);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/**
|
|
2504
|
+
* The story's own base layer (as opposed to a per-slide one): stashed
|
|
2505
|
+
* on creation so a slide basemap can hand back to it without refetching.
|
|
2506
|
+
*/
|
|
2507
|
+
declare "_story_basemap_layer": Layer | null;
|
|
2508
|
+
|
|
2509
|
+
/**
|
|
2510
|
+
* Apply a slide's CSS filter grading to the map viewport
|
|
2511
|
+
* (slideshow `filters`). Always assigned — "" clears — so grading never
|
|
2512
|
+
* leaks onto the next slide.
|
|
2513
|
+
*/
|
|
2514
|
+
_applySlideFilter(loc: StorymapSlideLocation | null | undefined): void {
|
|
2515
|
+
this._map.getViewport().style.filter = buildSlideFilter(loc?.filter);
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/**
|
|
2519
|
+
* Apply a slide's spotlight mask (slideshow `passepartout`): normalized
|
|
2520
|
+
* viewport fractions stay visible while the rest is dimmed. Rendered as
|
|
2521
|
+
* up to four percentage-positioned divs (or one for `invert`), so it
|
|
2522
|
+
* needs no updates on pan/zoom/resize. `pointer-events: none` keeps
|
|
2523
|
+
* markers clickable through the shade; the marker overlay container
|
|
2524
|
+
* (z-index 1) paints above it. Absent/invalid clears the mask.
|
|
2525
|
+
*/
|
|
2526
|
+
_applySlideMask(loc: StorymapSlideLocation | null | undefined): void {
|
|
2527
|
+
const mask: NormalizedMask | null = normalizeMask(loc?.mask);
|
|
2528
|
+
if (!mask) {
|
|
2529
|
+
this._mask_el?.remove();
|
|
2530
|
+
this._mask_el = null;
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
const viewport = this._map.getViewport();
|
|
2534
|
+
if (!this._mask_el) {
|
|
2535
|
+
const el = document.createElement("div");
|
|
2536
|
+
el.className = "vco-map-mask";
|
|
2537
|
+
el.setAttribute("aria-hidden", "true");
|
|
2538
|
+
el.style.position = "absolute";
|
|
2539
|
+
el.style.inset = "0";
|
|
2540
|
+
el.style.zIndex = "0";
|
|
2541
|
+
el.style.pointerEvents = "none";
|
|
2542
|
+
el.style.overflow = "hidden";
|
|
2543
|
+
viewport.appendChild(el);
|
|
2544
|
+
this._mask_el = el;
|
|
2545
|
+
}
|
|
2546
|
+
const pct = (n: number) => `${n * 100}%`;
|
|
2547
|
+
const shade = (left: string, top: string, width: string, height: string) =>
|
|
2548
|
+
`<div style="position:absolute;left:${left};top:${top};width:${width};height:${height};background:${mask.color};opacity:${mask.opacity}"></div>`;
|
|
2549
|
+
this._mask_el.innerHTML = mask.invert
|
|
2550
|
+
? shade(pct(mask.x), pct(mask.y), pct(mask.w), pct(mask.h))
|
|
2551
|
+
: shade("0", "0", "100%", pct(mask.y)) +
|
|
2552
|
+
shade("0", pct(mask.y + mask.h), "100%", pct(1 - mask.y - mask.h)) +
|
|
2553
|
+
shade("0", pct(mask.y), pct(mask.x), pct(mask.h)) +
|
|
2554
|
+
shade(pct(mask.x + mask.w), pct(mask.y), pct(1 - mask.x - mask.w), pct(mask.h));
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/** Filter + mask together: the synchronous tail of every navigation. */
|
|
2558
|
+
_applySlideFilterAndMask(loc: StorymapSlideLocation | null | undefined): void {
|
|
2559
|
+
this._applySlideFilter(loc);
|
|
2560
|
+
this._applySlideMask(loc);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
/**
|
|
2564
|
+
* Rotate the view to a slide `rotation` (degrees clockwise). Merges into
|
|
2565
|
+
* the caller's animation when one is running; on its own (region fits,
|
|
2566
|
+
* location-less slides, overview reset) it animates alone, or sets
|
|
2567
|
+
* synchronously when the navigation is instant. A no-op when neither
|
|
2568
|
+
* the slide nor the view is rotated, so unrotated stories animate
|
|
2569
|
+
* exactly as before.
|
|
2570
|
+
*/
|
|
2571
|
+
_applySlideRotation(
|
|
2572
|
+
loc: StorymapSlideLocation | null | undefined,
|
|
2573
|
+
duration?: number,
|
|
2574
|
+
instant = false,
|
|
2575
|
+
): void {
|
|
2576
|
+
const rotation = slideRotationRad(loc);
|
|
2577
|
+
const view = this._map.getView();
|
|
2578
|
+
if (rotation === null && view.getRotation() === 0) return;
|
|
2579
|
+
const target = rotation ?? 0;
|
|
2580
|
+
if (instant || prefersReducedMotion()) {
|
|
2581
|
+
view.setRotation(target);
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
view.animate({
|
|
2585
|
+
rotation: target,
|
|
2586
|
+
duration: duration ?? this.options.duration,
|
|
2587
|
+
easing: this._easing,
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Show a per-slide image overlay (slideshow `imgoverlay`) while its
|
|
2593
|
+
* slide is active. `extent` pins it (`[west, south, east, north]` lon/lat,
|
|
2594
|
+
* raw image pixels on image-space maps); otherwise it covers the current
|
|
2595
|
+
* view, optionally scaled to `size` (fraction of the viewport). Painted
|
|
2596
|
+
* above the base (z 5) below the route lines. Null/empty clears.
|
|
2597
|
+
* Consent-denied tiles skip it like every other layer.
|
|
2598
|
+
*/
|
|
2599
|
+
setSlideImageOverlay(overlay: StorymapImageOverlay | null | undefined): void {
|
|
2600
|
+
if (this._imgoverlay_layer) {
|
|
2601
|
+
this._map.removeLayer(this._imgoverlay_layer);
|
|
2602
|
+
this._imgoverlay_layer = null;
|
|
2603
|
+
}
|
|
2604
|
+
if (!overlay || typeof overlay.url !== "string" || overlay.url === "") return;
|
|
2605
|
+
if (!this._tilesAllowed()) return;
|
|
2606
|
+
// slideshow tours only: internal documents never paint overlays
|
|
2607
|
+
if (this.options.slideshow_source !== true) return;
|
|
2608
|
+
const given = overlay.extent;
|
|
2609
|
+
let extent: Extent;
|
|
2610
|
+
if (
|
|
2611
|
+
Array.isArray(given) &&
|
|
2612
|
+
given.length === 4 &&
|
|
2613
|
+
given.every((n) => typeof n === "number" && Number.isFinite(n))
|
|
2614
|
+
) {
|
|
2615
|
+
const raw = (
|
|
2616
|
+
this.isImageSpace()
|
|
2617
|
+
? [given[0], given[1], given[2], given[3]]
|
|
2618
|
+
: [...fromLonLat([given[0], given[1]]), ...fromLonLat([given[2], given[3]])]
|
|
2619
|
+
) as Extent;
|
|
2620
|
+
// lon/lat corners can arrive swapped; the view fit needs min/max
|
|
2621
|
+
extent = [
|
|
2622
|
+
Math.min(raw[0], raw[2]),
|
|
2623
|
+
Math.min(raw[1], raw[3]),
|
|
2624
|
+
Math.max(raw[0], raw[2]),
|
|
2625
|
+
Math.max(raw[1], raw[3]),
|
|
2626
|
+
];
|
|
2627
|
+
} else {
|
|
2628
|
+
extent = this._map.getView().calculateExtent(this._viewportSize());
|
|
2629
|
+
if (typeof overlay.size === "number" && Number.isFinite(overlay.size)) {
|
|
2630
|
+
const scale = Math.min(1, Math.max(0.05, overlay.size));
|
|
2631
|
+
const cx = (extent[0] + extent[2]) / 2;
|
|
2632
|
+
const cy = (extent[1] + extent[3]) / 2;
|
|
2633
|
+
const hw = ((extent[2] - extent[0]) * scale) / 2;
|
|
2634
|
+
const hh = ((extent[3] - extent[1]) * scale) / 2;
|
|
2635
|
+
extent = [cx - hw, cy - hh, cx + hw, cy + hh];
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
const opacity =
|
|
2639
|
+
typeof overlay.opacity === "number" && Number.isFinite(overlay.opacity)
|
|
2640
|
+
? Math.min(1, Math.max(0, overlay.opacity))
|
|
2641
|
+
: 1;
|
|
2642
|
+
const layer = this._newStaticLayer(
|
|
2643
|
+
overlay.url,
|
|
2644
|
+
extent,
|
|
2645
|
+
this._map.getView().getProjection(),
|
|
2646
|
+
{ opacity, zIndex: 5 },
|
|
2647
|
+
);
|
|
2648
|
+
this._map.addLayer(layer);
|
|
2649
|
+
this._imgoverlay_layer = layer;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
1983
2652
|
_getMapZoom(): number {
|
|
1984
2653
|
// fractional zoom on purpose: overview fits produce non-integer zooms and
|
|
1985
2654
|
// rounding here would snap the view on the next navigation
|
|
@@ -2115,6 +2784,25 @@ export default class OpenLayers extends Map {
|
|
|
2115
2784
|
this._cancelLineAnimation();
|
|
2116
2785
|
this._line_active.setVisible(false);
|
|
2117
2786
|
|
|
2787
|
+
// The overview shows the whole story: per-slide grading, mask and
|
|
2788
|
+
// rotation do not apply. Each reset is guarded so the common path
|
|
2789
|
+
// (nothing set) touches nothing.
|
|
2790
|
+
if (this._map.getView().getRotation() !== 0) {
|
|
2791
|
+
this._map.getView().animate({
|
|
2792
|
+
rotation: 0,
|
|
2793
|
+
duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
|
|
2794
|
+
easing: this._easing,
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
if (this._map.getViewport().style.filter !== "") {
|
|
2798
|
+
this._map.getViewport().style.filter = "";
|
|
2799
|
+
}
|
|
2800
|
+
if (this._mask_el) {
|
|
2801
|
+
this._mask_el.remove();
|
|
2802
|
+
this._mask_el = null;
|
|
2803
|
+
}
|
|
2804
|
+
this._switchSlideBasemap(null);
|
|
2805
|
+
|
|
2118
2806
|
// repeated presses while an overview animation is running snap
|
|
2119
2807
|
// instantly instead of restarting the ~1s animation
|
|
2120
2808
|
if (duration && this._map.getView().getAnimating()) {
|
|
@@ -2145,7 +2833,7 @@ export default class OpenLayers extends Map {
|
|
|
2145
2833
|
this._map.getView().animate({
|
|
2146
2834
|
center: center_view,
|
|
2147
2835
|
zoom: view_zoom,
|
|
2148
|
-
duration: duration ?? this._transition_duration,
|
|
2836
|
+
duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
|
|
2149
2837
|
easing: this._easing,
|
|
2150
2838
|
});
|
|
2151
2839
|
}
|
|
@@ -2156,6 +2844,22 @@ export default class OpenLayers extends Map {
|
|
|
2156
2844
|
}
|
|
2157
2845
|
const fit = () => {
|
|
2158
2846
|
try {
|
|
2847
|
+
// static fallback (no tile grid): fit the single image
|
|
2848
|
+
// extent directly so the overview still frames the picture
|
|
2849
|
+
const static_extent = (
|
|
2850
|
+
source as { getImageExtent?: () => Extent | null }
|
|
2851
|
+
).getImageExtent?.();
|
|
2852
|
+
if (static_extent) {
|
|
2853
|
+
this._map.getView().fit(static_extent, {
|
|
2854
|
+
size: this._viewportSize(),
|
|
2855
|
+
padding: this._opaquePanelPadding(),
|
|
2856
|
+
duration: prefersReducedMotion()
|
|
2857
|
+
? 0
|
|
2858
|
+
: (duration ?? this._transition_duration),
|
|
2859
|
+
easing: this._easing,
|
|
2860
|
+
});
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2159
2863
|
// a custom layer with a source is not necessarily a tile
|
|
2160
2864
|
// source; only tile grids can supply a fit extent
|
|
2161
2865
|
const grid = (
|
|
@@ -2187,7 +2891,9 @@ export default class OpenLayers extends Map {
|
|
|
2187
2891
|
view.animate({
|
|
2188
2892
|
center: this._toViewCoords(offset_location),
|
|
2189
2893
|
zoom: zoom,
|
|
2190
|
-
duration:
|
|
2894
|
+
duration: prefersReducedMotion()
|
|
2895
|
+
? 0
|
|
2896
|
+
: (duration ?? this._transition_duration),
|
|
2191
2897
|
easing: this._easing,
|
|
2192
2898
|
});
|
|
2193
2899
|
}
|
|
@@ -2228,7 +2934,7 @@ export default class OpenLayers extends Map {
|
|
|
2228
2934
|
this._map.getView().animate({
|
|
2229
2935
|
center: this._toViewCoords(offset_location),
|
|
2230
2936
|
zoom: zoom,
|
|
2231
|
-
duration: duration ?? this._transition_duration,
|
|
2937
|
+
duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
|
|
2232
2938
|
easing: this._easing,
|
|
2233
2939
|
});
|
|
2234
2940
|
} else if (
|
|
@@ -2254,7 +2960,9 @@ export default class OpenLayers extends Map {
|
|
|
2254
2960
|
this._map.getView().animate({
|
|
2255
2961
|
center: this._toViewCoords(offset_location),
|
|
2256
2962
|
zoom: zoom,
|
|
2257
|
-
duration:
|
|
2963
|
+
duration: prefersReducedMotion()
|
|
2964
|
+
? 0
|
|
2965
|
+
: (duration ?? this._transition_duration),
|
|
2258
2966
|
easing: this._easing,
|
|
2259
2967
|
});
|
|
2260
2968
|
}
|
|
@@ -2275,7 +2983,9 @@ export default class OpenLayers extends Map {
|
|
|
2275
2983
|
view.animate({
|
|
2276
2984
|
center: this._toViewCoords(offset_location),
|
|
2277
2985
|
zoom: zoom,
|
|
2278
|
-
duration:
|
|
2986
|
+
duration: prefersReducedMotion()
|
|
2987
|
+
? 0
|
|
2988
|
+
: (duration ?? this._transition_duration),
|
|
2279
2989
|
easing: this._easing,
|
|
2280
2990
|
});
|
|
2281
2991
|
}
|
|
@@ -2336,6 +3046,11 @@ export default class OpenLayers extends Map {
|
|
|
2336
3046
|
this._tile_layer.setZIndex(0);
|
|
2337
3047
|
this._map.addLayer(this._tile_layer);
|
|
2338
3048
|
}
|
|
3049
|
+
// the story basemap changed: per-slide layers stay cached
|
|
3050
|
+
// (their keys are unchanged) but the active one is rebuilt
|
|
3051
|
+
// by the re-fit below through _switchSlideBasemap
|
|
3052
|
+
this._story_basemap_layer = this._tile_layer ?? null;
|
|
3053
|
+
this._slide_basemap = null;
|
|
2339
3054
|
this._refreshMiniMapLayer();
|
|
2340
3055
|
this._updateAttribution();
|
|
2341
3056
|
this._el.map.style.backgroundColor = this.options.map_background_color;
|
|
@@ -2485,6 +3200,16 @@ export default class OpenLayers extends Map {
|
|
|
2485
3200
|
const view = this._map.getView();
|
|
2486
3201
|
view.setZoom(zoom);
|
|
2487
3202
|
view.setCenter(this._toViewCoords(_location));
|
|
3203
|
+
// instant path (resize): rotation sets synchronously, filter and
|
|
3204
|
+
// mask re-apply idempotently — slideshow tours only, so the resize
|
|
3205
|
+
// path for internal documents touches nothing new
|
|
3206
|
+
if (this.options.slideshow_source === true) {
|
|
3207
|
+
const rotation = slideRotationRad(loc);
|
|
3208
|
+
if (rotation !== null || view.getRotation() !== 0) {
|
|
3209
|
+
view.setRotation(rotation ?? 0);
|
|
3210
|
+
}
|
|
3211
|
+
this._applySlideFilterAndMask(loc);
|
|
3212
|
+
}
|
|
2488
3213
|
this._map.renderSync();
|
|
2489
3214
|
}
|
|
2490
3215
|
}
|