Real-world example: Explore Julian
Explore Julian is the destination site for Julian, Pennsylvania, and the Bald Eagle Valley. Every page on it shares one Wander site embed, mounted once in the site’s layout. Each page then tells that map what it is about. The map never reloads as a visitor moves around the site.
This page walks through how it is built, with excerpts from its source. Use it as a reference for your own integration. The excerpts are lightly trimmed, and they use these IDs:
| What | ID |
|---|---|
Map (mapId) | c2025939-af64-4371-a281-c6818b2cd96e |
| Namespace | explorejulian |
| Bald Eagle Creek (path POI) | 16aa9696-cae3-4e87-9dcd-25eee5570527 |
| Keystone Select section of the creek | 1a19623c-085c-4c68-8ea5-5deb4bf57382 |
What each page tells the map
Section titled “What each page tells the map”| Page | What it sends |
|---|---|
| Home | setContext in emphasize mode. As the visitor scrolls the story chapters, it draws Bald Eagle Creek with setLines and animateLine, highlights a path section, raises the terrain and dims the map for the evening chapter. |
Hub pages (/fish, /stay, /parks, /history) | The hub’s places in only mode, with fit: true. |
Article pages (/{hub}/{slug}) | The places the article mentions, as featured items with numbered badges. Callout buttons call highlightSection. |
Place pages (/place/{poiId}) | The place and its getting-there spots in only mode, then select and a camera move once the context has applied. |
| Itineraries | Each itinerary’s route as a line, with setLines. |
| An itinerary, such as Trout water on Bald Eagle Creek | A story route with storyRoute, its progress with storyRouteProgress, and the camera with flyTo({ …, duration: 0 }) on every animation frame. |
| Trail pages, such as Bald Eagle Creek | select on the path POI, which opens Trail View. The site sends no camera move. |
| Events | The event venues; an event page selects its first venue. The page also offers the events calendar embed as a month view. |
| Getting here | An empty context. The page shows the getting-here widget as a server-rendered iframe, followed by its attribution link. |
| Put the valley on your website | Every trail as featured in emphasize mode. The page itself is a kit of copyable embed snippets with live previews; see Every embed, ready to copy. |
Loading the SDK safely
Section titled “Loading the SDK safely”Explore Julian pins a versioned SDK file with its integrity hash, so a new SDK release can never change the live site by surprise. It sets integrity and crossOrigin before src, so the very first request is the checked one. It checks the version it got, and it reports a failed load to its own analytics:
const WANDER_SDK_URL = 'https://developer.wandermaps.com/sdk/wander-embed-0.11.0.js';const WANDER_SDK_INTEGRITY = 'sha384-iRDbk8kR8xUGKppKBZNAOIlGe0dFxdvVm4CW24EqQMQ35qsxhj8ZOyC8FDr8KKZF';
const script = document.createElement('script');script.addEventListener('load', () => { if (window.Wander?.siteMap) setSdkLoaded(true); else track('map_sdk_error', { kind: 'version', version: window.Wander?.VERSION ?? null });});// A network failure and an integrity mismatch look the same from here.script.addEventListener('error', () => track('map_sdk_error', { kind: 'script' }));script.integrity = WANDER_SDK_INTEGRITY; // before srcscript.crossOrigin = 'anonymous'; // integrity needs CORS modescript.src = WANDER_SDK_URL;script.async = true;document.head.appendChild(script);The page also preloads the same URL, with the same integrity, from its <head>. Moving to a new release means changing both strings together. Explore Julian keeps 0.10.0 and its hash as a rollback pin, which is why it subscribes to the 0.11.0 Trail View events inside a try: older SDKs throw on event names they don’t know.
Mounting the map once
Section titled “Mounting the map once”The map is mounted in the layout that wraps every page, never inside a page, so client-side navigation keeps it:
const map = window.Wander.siteMap(mapEl.current, { mapId: 'c2025939-af64-4371-a281-c6818b2cd96e', namespace: 'explorejulian', mode: 'emphasize', showControls: true, associationsInCard: false, // the sidebar lists related places itself trailView: true, // path POIs open in Trail View (SDK 0.11.0) controlsPosition: 'bottom-right', padding: padding(), // the parts of the map the sidebar covers title: 'Explore Julian map — Julian, Pennsylvania', fill: true, lazy: false, // the map is above the fold background: '#ECE5D6' // the site's paper colour, until tiles paint});
// The SDK has no failure event: report a map that never becomes ready.const readyTimer = setTimeout(() => track('map_ready_timeout', { sdkVersion: window.Wander.VERSION }), MAP_READY_TIMEOUT_MS);map.on('ready', () => clearTimeout(readyTimer));The SDK builds https://web.wander-app.com/siteembed/c2025939-af64-4371-a281-c6818b2cd96e?ns=explorejulian&mode=emphasize&…&trailView=1&…&sdk=0.11.0. The site never writes iframe HTML itself.
Telling the visitor’s clicks from its own
Section titled “Telling the visitor’s clicks from its own”The site opens a place’s sidebar when a visitor taps a pin. Its own select and focus calls come back as poiSelected too, with source: 'host', so it ignores those:
map.on('poiSelected', ({ poiId, source }) => { if (source === 'host') return; // our own select/focus/flyTo({ select: true }) openFromMap(poiId);});Moving the camera on a place page
Section titled “Moving the camera on a place page”A place page replaces the context, then selects the place and frames it with its parking and trailheads. It waits for contextApplied, because fitBounds and flyTo find places by looking in the current context. It then waits for the place’s associations, up to 1.5 s, so the final camera move is the last one:
const start = () => { map.off('contextApplied', start); let moved = false; const move = () => { if (moved) return; moved = true; map.off('associationsLoaded', onLoaded); if (fitIds) map.fitBounds(fitIds, padding()); // the place and its getting-there spots else map.flyTo({ poiId: placeId, zoom: 15 }); }; const onLoaded = ({ poiIds }) => { if (poiIds?.includes(placeId)) move(); }; map.on('associationsLoaded', onLoaded); setTimeout(move, 1500); map.select(placeId);};map.on('contextApplied', start);map.setContext(items, { mode: 'only', fit: false, padding: padding() });Story chapters on the home page
Section titled “Story chapters on the home page”The home page’s chapters use the story-map features. Each one is feature-detected, and each promise is caught, so the page still works against an embed that lacks one:
const can = (name) => map.hasCapability?.(name) === true;
// Fish chapter: draw the creek as the visitor arrives.if (can('lines')) { map.setLines([{ lineId: 'creek', pathId: '16aa9696-cae3-4e87-9dcd-25eee5570527', color: '#8FD0FF', glow: true, progress: 0 }]) .then(() => map.animateLine('creek', { to: 1, duration: 2600 })) .catch(() => {});}
// Leaving the story: put everything back.if (can('lines')) map.clearLines().catch(() => {});if (can('terrain')) map.resetTerrain({ duration: 600 }).catch(() => {});if (can('appearance')) map.clearAppearance({ duration: 600 }).catch(() => {});It also passes reveal: { stagger: 0 } and zero durations when the visitor’s system asks for reduced motion. The embed does the same by itself inside the iframe.
Highlighting a path section
Section titled “Highlighting a path section”Article callouts about the creek’s regulation stretches call highlightSection. If the embed hasn’t seen the creek yet, it answers error { scope: 'section', reason: 'unknown_section' }. The site then shows the whole valley once and asks again:
function highlightWithRetry(map, sectionId) { let retried = false; const onError = (e) => { if (e?.scope !== 'section' || e.sectionId !== sectionId || e.reason !== 'unknown_section') return; map.off('error', onError); if (retried) return; retried = true; map.flyTo({ center: { lng: -77.9, lat: 40.87 }, zoom: 10.5, duration: 0 }); setTimeout(() => map.highlightSection(sectionId, { fit: true }), 1500); }; map.on('error', onError); setTimeout(() => map.off('error', onError), 5000); map.highlightSection(sectionId, { fit: true });}
highlightWithRetry(map, '1a19623c-085c-4c68-8ea5-5deb4bf57382'); // Keystone SelectThe current embed fetches a section’s trail when it hasn’t loaded it (see Path sections), so unknown_section now means the ID isn’t on the map at all. The retry is harmless and still covers older embeds.
Trail View on the site map
Section titled “Trail View on the site map”Explore Julian’s four path POIs open in Trail View, on the same shared map:
- Julian Bridge Trail
- Black Moshannon Bog Trail
- Bald Eagle Creek, 31.8 mi, with its regulation path sections
- PA 504 fall drive
Three rules make it work:
- Only select. A trail page sends
select(trailId)and nothing else. Trail View flies the camera itself, and aflyToorfitBoundssent while it opens cancels that flight. - Let Trail View drive the layout. On
trailViewOpened, a desktop page widens the map so the embed can dock its panel. The panel needs at least 640 px of visible map between the padding. On a phone, the page hides its own sheet and tab bar, so the embed’s sheet has the screen.trailViewClosedputs the layout back, except forreason: 'select_other', when the next trail is already opening. - Hold the page’s context while its trail is open.
setContextcloses Trail View (reason: 'context'). A page about the open trail keeps its context untiltrailViewClosed, then sends it without aselectand without a fit.
map.on('trailViewOpened', ({ poiId, lengthM }) => { openTrail = poiId; layout.enterTrailMode(); // wider map on desktop, no host sheet on phones track('trail_view_opened', { poiId, miles: Math.round(lengthM / 160.9344) / 10 });});
map.on('trailViewClosed', ({ poiId, reason }) => { if (openTrail === poiId) openTrail = null; if (reason === 'select_other') return; // another trail is opening: keep the layout layout.leaveTrailMode(); if (held && held.select?.poiId === poiId) { // the page context held back while its trail was open map.setContext(held.items, { mode: held.mode, fit: false, padding: padding() }); held = null; }});
// A trail page:if (map.hasCapability('trailView')) map.select(trailId);Explore Julian’s version of this is in components/shell/site-shell.tsx and lib/wander/trail-view.ts.
Every embed, ready to copy
Section titled “Every embed, ready to copy”explorejulian.com/embed is a partner page for local businesses, organisers and writers. It offers each Wander embed with a live preview and code to copy, built from the site’s own data:
| Embed | What the page offers |
|---|---|
| A trail in Trail View | Wander.map(el, { mapId, trailView: true, height: 560, autoResize: false }), then map.on('ready', () => map.select(trailId)) |
| A day’s route | The list embed: ?pois=…&route=full&routeType=driving&showNumbers=true&showAllPOIs=false |
| One business | The simple embed with ?poiId= |
| The events calendar | The events calendar embed with ?view=month |
| Getting here | Wander.gettingHere, with its attribution link |
| The whole map | The full map embed with ?hideSignIn=true |
Each iframe snippet ends with the attribution line from Wander’s share links. The previews share the page with the site’s own map, and each one has its own namespace (see Namespaces).
Scroll-driven itinerary routes
Section titled “Scroll-driven itinerary routes”An itinerary page sends its stops once, then streams progress and the camera on every animation frame:
map.storyRoute(stops.map((s) => [s.lng, s.lat]), { profile: 'driving' });map.on('storyRouteLoaded', ({ legs, fallback }) => renderLegs(legs, fallback));
// Each frame, while the visitor scrolls:map.storyRouteProgress(routeAt); // legs completed + fraction of the current legmap.flyTo({ center: { lng, lat }, zoom, pitch, bearing, duration: 0 });
// Leaving the page: the map outlives it, so take the route away and flatten the camera.map.clearStoryRoute();map.flyTo({ center: { lng, lat }, zoom, pitch: 0, bearing: 0, duration: 0 });Outbound links and attribution
Section titled “Outbound links and attribution”Explore Julian passes nothing about the visitor into the iframe: only its namespace. Its own “Powered by Wander” links carry campaign parameters that name the site and the slot:
url.searchParams.set('utm_source', 'explorejulian');url.searchParams.set('utm_medium', 'referral');url.searchParams.set('utm_campaign', 'powered_by_wander');url.searchParams.set('utm_content', slot); // 'footer_badge', 'footer_dmo_cta'The footer also links to the full map at https://web.wander-app.com/c2025939-af64-4371-a281-c6818b2cd96e. See Attribution & analytics for how Wander credits embed traffic.
What to copy
Section titled “What to copy”- Mount one site map in your layout and keep it across navigation. Give any other embed on the same page its own namespace.
- Pin the SDK with its integrity hash, and check
Wander.VERSIONafter it loads. - Set
backgroundto your page colour andlazy: falsefor a map above the fold. - Time out on
readyyourself. - Act on
poiSelectedonly whensource === 'user'. - Move the camera after
contextApplied, not straight aftersetContext. - Feature-detect story-map features with
hasCapability, and catch their promises.