Apple Map and MapKit Js
Glossary overview

Apple Map and MapKit Js

Делал на сайте Gstaadguy.

Слева города, справа показываем коордианты на карте.

Меняется когда листаем.

В админке для каждого города указываем ссылку с Apple Map:

Вот пример ссылки – https://maps.apple.com/place?place-id=I5A8FEE6D45BDBEA3&address=Boulevard+Jacqueline+Auriol+Inf%C3%A9rieur%2C+06200+Nice%2C+France&coordinate=43.6596058%2C7.2055078&name=%D0%B0%D1%8D%D1%80%D0%BE%D0%BF%D0%BE%D1%80%D1%82+%D0%9D%D0%B8%D1%86%D1%86%D0%B0+%D0%9B%D0%B0%D0%B7%D1%83%D1%80%D0%BD%D1%8B%D0%B9+%D0%91%D0%B5%D1%80%D0%B5%D0%B3&_provider=9902

Будем извлекать координаты в JS по слову “coordinate“.

HTML разметка.

Города:

$address = get_field('address', $post->ID);
$map_url = $address['google_maps_url'] ?? '';

....

<div class="section experience-item experience--js experience-map--js" id="<?php echo slugify($slug) ?>" data-travel-name="<?php echo $post_title?>" data-map-location="<?php echo $map_url?>">
	<div class="experience-item__box">
		<div class="container--narrow">

			<?php render_block_title($label, $title, $title_tag, $content); ?>

			<?php if ($list) : ?>
				<div class="experience-item__list-wrapper">
					<?php if ($list_header) : ?>
						<div class="experience-item__list-header">
							<?php echo $list_header; ?>
						</div>
					<?php endif; ?>
					<ul class="experience-item__list">
						<?php foreach ($list as $list_item) :

							$label = $list_item['label'];
							$content = $list_item['content'];

							?>
							<li class="experience-item__list-item body-s">
								<div class="experience-item__list-label body-s-bold">
									<?php echo $label; ?>
								</div>
								<div class="experience-item__list-content">
									<?php echo $content; ?>
								</div>
							</li>
						<?php endforeach; ?>
					</ul>
				</div>
			<?php endif; ?>

			<?php if ($pro_tip_content) : ?>
				<div class="experience-item__pro-tip">
					<?php if ($pro_tip_label) : ?>
						<div class="experience-item__pro-tip-label body-xs">
							<?php echo $pro_tip_label; ?>
						</div>
					<?php endif; ?>
					<h5 class="experience-item__pro-tip-content">
						<?php echo $pro_tip_content; ?>
					</h5>

				</div>
			<?php endif; ?>

			<?php if ($button && !empty($button['button']['link']['url']) && !empty($button['button']['link']['title'])): ?>
				<div class="experience-item__btn">
					<?php
					$link = $button['button']['link'];
					$url = esc_url($link['url']);
					$title = esc_html($link['title']);
					$target = !empty($link['target']) ? esc_attr($link['target']) : '_self';
					?>

					<?php if ($button_type === 'button--opentable'): ?>
						<a href="<?php echo $url; ?>" target="<?php echo $target; ?>" class="button button--secondary <?php echo esc_attr($button_type); ?>">
							<?php echo $title; ?>
							<?php echo get_inline_svg('OpentableLogo.svg'); ?>
						</a>
					<?php else: ?>
						<a href="<?php echo $url; ?>" target="<?php echo $target; ?>" class="button button--primary <?php echo esc_attr($button_type); ?>">
							<?php echo $title; ?>
							<?php echo get_inline_svg('whatsapp.svg'); ?>
						</a>
					<?php endif; ?>
				</div>
			<?php endif; ?>

		</div>
	</div>

	<?php if ($gallery): ?>
		<?php $is_disabled = count($gallery) < 4 ? 'slider--disabled' : ''; ?>
		<div class="experience-item__slider">
			<div class="container-slider">
				<div class="experience-item__slider-wrapper js-default-slider-wrapper lightgallery--js <?php echo $is_disabled; ?>">
					<ul class="js-default-slider">
						<?php foreach ($gallery as $image): ?>
							<li>
								<?php if (!empty($image['ID'])): ?>
									<img
										class="lightgallery-item"
										<?php awesome_acf_responsive_image(
											$image['ID'],
											'experience-gallery',
											'rectangle-rotated',
											300,
											555
										); ?>
										alt="<?php echo esc_attr($image['alt']); ?>"
									>
								<?php endif; ?>
							</li>
						<?php endforeach; ?>
					</ul>
					<div class="js-default-nav-prev">
						<?php echo get_inline_svg('ArrowSlider.svg') ?>
					</div>
					<div class="js-default-nav-next">
						<?php echo get_inline_svg('ArrowSlider.svg') ?>
					</div>
				</div>
			</div>
		</div>
	<?php endif; ?>

</div>

Ключевое для нас:

  • data-map-location=”<?php echo $map_url?>”
  • experience-map–js

Разметка контейнера:

<div class="single-guide js-map-wrapper" data-latitude="51.5074" data-longitude="-0.1278">
	<div class="single-guide__row">
		<div class="single-guide__col">
			<?php
			get_template_part('template-parts/acf-loop');
			?>
		</div>
		<div class="single-guide__col js-sticky-wrapper">
			<div id="map" class="sidebar map js-sticky-el js-map">

			</div>
		</div>
	</div>

</div>
  • data-latitude=”51.5074″ data-longitude=”-0.1278″ – начальные координаты карты. Я потом в JS переопределяю, ставлю координаты первого города.
  • Навешиваем классы .js-map-wrapper, js-sticky-wrapper, map js-sticky-el js-map
  • Навешиваем id=”map”

JavaScript.

Подключаем через npm gsap и в head подключаем MapKit JS.

<head>
	<meta charset="<?php bloginfo('charset'); ?>">
	<meta name="viewport" content="width=device-width initial-scale=1">

	<script
		src="https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js"
		crossorigin async
	></script>

Скрипт.

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

const map = () => {
	const JWT_TOKEN = codelibry.map_token;

	if (!JWT_TOKEN) return;

	const CLASSNAMES = {
		wrapper: '.js-map-wrapper',
		map: '.js-map',
		location: '[data-map-location]',
		stickyEl: '.js-sticky-el',
		stickyWrapper: '.js-sticky-wrapper',
		experienceItem: '.experience-map--js',
	};

	mapkit.init({
		authorizationCallback: function (done) {
			done(JWT_TOKEN);
		},
	});

	const $wrapper = document.querySelector(CLASSNAMES.wrapper);

	if (!$wrapper) return;

	const $map = document.querySelector(CLASSNAMES.map);
	const $locations = document.querySelectorAll(CLASSNAMES.location);
	const $stickyWrapper = document.querySelector(CLASSNAMES.stickyWrapper);
	const $stickyEl = document.querySelector(CLASSNAMES.stickyEl);
	const offsetTop = 100;
	const $experienceItem = document.querySelector(CLASSNAMES.experienceItem);

	let initialCoords = {
		latitude: Number($wrapper.dataset.latitude),
		longitude: Number($wrapper.dataset.longitude),
	};

	if ($experienceItem?.dataset.mapLocation) {
		const rawCoords = $experienceItem.dataset.mapLocation;
		const parsedCoords = getCoordinatesFromUrl(rawCoords);

		if (parsedCoords?.latitude && parsedCoords?.longitude) {
			initialCoords = parsedCoords;
		}

	}

	const mapInit = new mapkit.Map($map, {
		region: new mapkit.CoordinateRegion(
			new mapkit.Coordinate(initialCoords.latitude, initialCoords.longitude),
			new mapkit.CoordinateSpan(0.2, 0.2),
		),
	});


	const markersMap = new Map();

	function getCoordinatesFromUrl(url) {
		if (!url || typeof url !== 'string' || !url.includes('coordinate=')) {
			return null;
		}

		const coordinatePart = url.split('coordinate=')[1];
		if (!coordinatePart || !coordinatePart.includes('%2C')) {
			return null;
		}

		const coordinates = coordinatePart.split('%2C');
		const latitude = parseFloat(coordinates[0]);
		const longitude = parseFloat(coordinates[1]);

		if (isNaN(latitude) || isNaN(longitude)) {
			return null;
		}

		return { latitude, longitude };
	}


	// this func find all data from el and
	// add all marker on the map one time

	function initMarkers() {
		markersMap.forEach((data) => {
			mapInit.removeAnnotation(data.marker);
		});
		markersMap.clear();

		$locations.forEach(($location) => {
			const itemUrl = $location.getAttribute('data-map-location');
			const coords = getCoordinatesFromUrl(itemUrl);

			// ⛔ Пропускаємо, якщо координати некоректні або відсутні
			if (!coords) {
				console.warn('Пропущено елемент без координат:', $location);
				return;
			}

			const coordinate = new mapkit.Coordinate(coords.latitude, coords.longitude);
			const locationId = $location.getAttribute('id');

			const marker = new mapkit.MarkerAnnotation(coordinate, {
				title: 'Location',
				subtitle: $location.textContent,
				color: '#FF5733',
				glyphText: '!',
				selected: false,
				callout: {
					enabled: true,
					calloutContentForAnnotation: () => {
						const div = document.createElement('div');
						div.innerHTML = `
						<a class="map__marker_link" href="#${locationId}"
							style="color: blue; text-decoration: underline;">
							${$location.textContent}
						</a>
					`;
						return div;
					},
				},
			});

			mapInit.addAnnotation(marker);
			markersMap.set(locationId, {
				marker: marker,
				coordinate: coordinate,
			});
		});
	}


	// sticky animation
	function initStickyAnimation() {
		ScrollTrigger.create({
			id: 'sticky-animation',
			trigger: $stickyWrapper,
			start: `top-=${offsetTop}px top`,
			end: () => `bottom ${$stickyEl.offsetHeight + offsetTop}px`,
			pin: $stickyEl,
			pinSpacing: false,
			markers: true,
			invalidateOnRefresh: true,
		});
	}

	// trigger callback
	function initLocationTriggers() {
		$locations.forEach(($location) => {
			ScrollTrigger.create({
				trigger: $location,
				start: 'center center',
				onEnter: () => handleLocationTrigger($location),
				onEnterBack: () => handleLocationTrigger($location),
			});
		});
	}

	function handleLocationTrigger($location) {
		const locationId = $location.getAttribute('id');
		const markerData = markersMap.get(locationId);

		if (!markerData) return;

		// map zoom
		const zoom = 0.004;
		const region = new mapkit.CoordinateRegion(markerData.coordinate, new mapkit.CoordinateSpan(zoom, zoom));

		// add color to active marker
		markerData.marker.selected = true;
		markerData.marker.color = '#5F0100';

		// remove active color
		markersMap.forEach((data, id) => {
			if (id !== locationId) {
				data.marker.selected = false;
				data.marker.color = '#FF5733';
			}
		});

		// animation when map scroll to marker
		mapInit.setRegionAnimated(region, {
			animate: true,
			duration: 1.0,
		});
	}

	let mm = gsap.matchMedia();

	mm.add('(min-width: 800px)', () => {
		ScrollTrigger.refresh();
		initMarkers();
		initStickyAnimation();
		initLocationTriggers();
	});

	mm.add('(max-width: 799px)', () => {

	});

	window.addEventListener('resize', () => {
		ScrollTrigger.refresh();
	});
};

export default map;

Ініціалізація карти

Мапа створюється з центром у координатах, які взяті з .js-map-wrapper або з першого .experience-map--js, якщо в нього є data-map-location.

Функція getCoordinatesFromUrl()

Розбирає координати з URL і повертає { latitude, longitude }. Якщо координати неправильні — повертає null.

Функція initMarkers()

  • Видаляє попередні маркери (очищення мапи).
  • Проходиться по всіх елементах з data-map-location.
  • Для кожного створює маркер на мапі.
  • Пропускає елементи без координат (із console.warn для відладки).

Sticky мапа (анімована при скролі)

ScrollTrigger.create({
	id: 'sticky-animation',
	trigger: $stickyWrapper,
	start: `top-=${offsetTop}px top`,
	end: () => `bottom ${$stickyEl.offsetHeight + offsetTop}px`,
	pin: $stickyEl,
});

Елемент .js-sticky-el залишається прикріпленим до екрану, поки користувач скролить список .experience-item.

Фокусування на маркер при скролі

function handleLocationTrigger($location) { ... }

Коли користувач доскролює до певного .experience-item, мапа:

  • автоматично масштабується до відповідного маркера,
  • виділяє його кольором,
  • скидає кольори інших маркерів.

GSAP MatchMedia

mm.add('(min-width: 800px)', () => { ... });

Карта, маркери та ScrollTrigger активуються лише для десктопів (≥800px ширина). На мобільних ця логіка вимикається.