Локации Академии
Академия находится в Салеме, что севернее Бостона. Её кампус раскинулся на острове Уинтер, узкой и вытянутой вдоль океана земле в восточной части города с солёным ветром и испещрённую дорожками, которые то уходят в тень деревьев, то резко выводят к камням и воде. Здесь постоянно слышно море: оно обволакивает берег, приносит ветер и иногда окутывает всё вязким туманом.[float=right]
42.529617251361515, -70.868893210953[/float]
Для простых людей это место известно как частное образовательное учреждение — «Салемский подготовительный колледж», в инфраструктуру которого входят яхтовый клуб и здания бывшего морского исследовательского центра Cat Cove. Для одарённых же это место в котором они могут находить друг друга и развивать свои магические таланты.
Кампус разделён на несколько функциональных зон, соединённых дорогами, пешеходными маршрутами и служебными проездами. Основными ориентирами служат береговая линия на востоке острова, здание Главного корпуса в его южной части, а в северной зоне расположившиеся там мужское и женское общежития студентов. Западный сектор, находящийся на материке, занимает административно-служебный комплекс Академии в зданиях бывшего центра морских исследований Cat Cove.
[html]
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Карта кампуса</title>
<link rel="preconnect" href="https://forumstatic.ru" crossorigin />
<link rel="preload" href="https://forumstatic.ru/files/001c/b8/c1/54570.svg" as="fetch" crossorigin />
<link rel="stylesheet" href="https://forumstatic.ru/files/001c/b8/c1/95532.css?v=8" />
<script src="https://feather-tail.github.io/MyBB-scripts-bundle/js/theme-switcher/theme-iframe.js"></script>
<script src="https://feather-tail.github.io/MyBB-scripts-bundle/js/font-resizer/font-resizer-iframe.js"></script>
</head>
<body class="ks-map-page">
<main class="ks-map-shell" aria-labelledby="mapTitle">
<div class="ks-map-shell__inner">
<header class="ks-map-head">
<p class="ks-map-kicker">Winter Island Campus</p>
<h1 class="ks-title" id="mapTitle">Карта кампуса</h1>
</header>
<div class="ks-map-layout">
<aside class="ks-map-panel" aria-label="Объекты кампуса">
<p class="ks-panel-title">Объекты кампуса</p>
<p class="ks-map-status" id="mapStatus">Выбери объект на карте или в списке.</p>
<div class="ks-location-list" id="mapLocations" aria-label="Список объектов кампуса"></div>
</aside>
<section class="ks-map-card" aria-label="Интерактивная карта">
<div class="ks-map-card__head">
<p class="ks-map-card__title">План острова</p>
</div>
<div class="ks-map" id="mapRoot">
<div class="ks-map__viewport" id="mapViewport">
<div class="ks-map__loading" id="mapLoading">Карта загрузится, когда дойдёшь до неё…</div>
</div>
</div>
</section>
<section class="ks-map-details" id="mapDetails" aria-live="polite" aria-label="Описание выбранного объекта">
<div class="ks-map-details__empty" id="detailsEmpty">
<p class="ks-map-details__eyebrow">Описание</p>
<h2 class="ks-map-details__title">Ничего не выбрано</h2>
<p class="ks-map-details__desc">Нажми на объект карты или выбери пункт в списке, чтобы открыть описание локации.</p>
</div>
<div class="ks-map-details__content" id="detailsContent" hidden>
<img class="ks-map-details__img" id="detailsImg" alt="" />
<div class="ks-map-details__text">
<p class="ks-map-details__eyebrow">Выбранный объект</p>
<h2 class="ks-map-details__title" id="detailsTitle">—</h2>
<p class="ks-map-details__desc" id="detailsDesc">—</p>
<a class="ks-map-details__more" id="detailsMore" href="#" target="_blank" rel="noopener noreferrer" hidden>Читать описание в теме</a>
</div>
</div>
</section>
</div>
</div>
</main>
<div class="ks-tooltip" id="mapTooltip" hidden role="tooltip">
<span class="ks-tooltip__title" id="tipTitle">—</span>
</div>
<div id="mapData" hidden>
<div
data-map-id="_Слой_2-2"
data-title="Главное здание"
data-desc="Главное здание Академии расположено в южной части острова, ближе к открытому побережью. Его архитектура сочетает массивные каменные конструкции и современные элементы: стекло, металл, строгие линии фасада."
data-img="https://forumstatic.ru/files/001c/8d/fd/24675.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1110"
></div>
<div
data-map-id="_Слой_3"
data-title="Медицинский корпус"
data-desc="Медицинский центр в салемском кампусе куда уместнее было бы называть небольшим медицинским центром. Он представляет из себя отдельное здание в западной части острова, неподалёку от КПП и общежитий."
data-img="https://forumstatic.ru/files/001c/8d/fd/24675.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1122"
></div>
<div
data-map-id="_Слой_4"
data-title="Мужское общежитие"
data-desc="Жилые корпуса студентов находятся в северной части острова, ближе к въезду и внешней инфраструктуре кампуса. Такое расположение позволяет отделить учебные и жилые зоны, сохраняя при этом удобную связь между ними. Общежития стоят чуть в стороне от оживлённых маршрутов и окружены зелёными участками, создающими более спокойную и камерную атмосферу."
data-img="https://forumstatic.ru/files/001c/8d/fd/24675.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1115"
></div>
<div
data-map-id="_Слой_12"
data-title="Женское общежитие"
data-desc="Жилые корпуса студентов находятся в северной части острова, ближе к въезду и внешней инфраструктуре кампуса. Такое расположение позволяет отделить учебные и жилые зоны, сохраняя при этом удобную связь между ними. Общежития стоят чуть в стороне от оживлённых маршрутов и окружены зелёными участками, создающими более спокойную и камерную атмосферу."
data-img="https://forumstatic.ru/files/001c/8d/fd/24675.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1115"
></div>
<div
data-map-id="_Слой_5"
data-title="КПП"
data-desc="Основной доступ на территорию кампуса осуществляется через северный въезд со стороны материка. Дорога выводит к контрольно-пропускному пункту..."
data-img="https://forumstatic.ru/files/001c/8d/fd/24675.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1111"
></div>
<div
data-map-id="_Слой_6"
data-title="Здание Службы безопасности"
data-desc="Здания бывшего университетского комплекса Cat Cove используются как административно-служебная зона Академии. Здесь размещаются подразделения службы безопасности, технические службы и центры связи."
data-img="https://forumstatic.ru/files/001c/8d/fd/24675.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1121"
></div>
<div
data-map-id="ks_science_complex"
data-title="Научный комплекс"
data-desc="Научный комплекс располагается в западной части острова между медицинским корпусом и Главным зданием, ограждая остров Уинтер от взглядом любопытных жителей. Он представляет из себя несколько блоков, соединённых друг с другом переходами. Само здание имеет пять этажей, один из которых является подземным. Предназначение комплекса - учёба студентов Академии и научные изыскания."
data-img="https://forumstatic.ru/files/001c/b8/c1/17838.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p104343"
></div>
<div
data-map-id="ks_fountain_square"
data-title="Площадь с фонтаном"
data-desc="Его украшают фигуры юноши и девушки, ведьмы и её фамильяра. Бронзовые силуэты стоят почти вплотную, но не касаются друг друга: между ладонью ведьмы и зверем — тем самым, в котором каждый неизменно узнаёт своего, — струя воды сворачивается в тонкую мерцающую ленту, будто живая связь, обвивающая их тела. Фонтан не знает зимы и даже в сильные морозы вода в нём продолжает течь, а над чашей поднимается лёгкий пар."
data-img="https://forumstatic.ru/files/001c/b8/c1/41119.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1111"
></div>
<div
data-map-id="Слой_4"
data-title="Оранжереи и теплицы"
data-desc="Оранжерейный комплекс Академии расположен на восточной окраине кампуса в Салеме. Комплекс возведён на слегка возвышенном участке, дренированном естественными подземными ключами, и обращён к юго-востоку для максимального захвата утреннего солнца, которое в этих краях часто скрыто за туманами."
data-img="https://forumstatic.ru/files/001c/b8/c1/45827.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1111"
></div>
<div
data-map-id="Слой_5"
data-title="Спортплощадка"
data-desc="По территории разбросан целый комплекс спортивных площадок. У общежитий расположена небольшая площадка с уличными тренажёрами, в том числе с баскетбольным кольцом. Беговые дорожки проложены в западной части, в том числе вокруг нескольких зданий. На искусственно созданном насыпном пляже установлены стойки для волейбола. Пользоваться всем этим можно и в свободное от учёбы время. Иногда занятия проводятся на пляже и, в том числе, в воде — тогда защиту обеспечивают ведьмы, контролируя прибрежное пространство."
data-img="https://forumstatic.ru/files/001c/b8/c1/84921.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1111"
></div>
<div
data-map-id="Слой_6"
data-title="Сады и пруды"
data-desc="Более двух гектаров островной земли отведено под сады, искусственные водные объекты и озеленение. Конечно, где-то сохранён естественный рельеф острова, но в основном пространство благоустроено и облагорожено так, чтобы по нему было легко и приятно гулять."
data-img="https://forumstatic.ru/files/001c/b8/c1/66511.png"
data-url="https://kindredspirits.ru/viewtopic.php?id=84#p1111"
></div>
</div>
<script>
const SETTINGS = {
svgUrl: 'https://forumstatic.ru/files/001c/b8/c1/54570.svg',
hitPadding: 10,
lazyRootMargin: '300px',
};
const PLACEHOLDER_IMG =
'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">' +
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">' +
'<stop offset="0" stop-color="#cfe1f3"/><stop offset="1" stop-color="#bfc8d0"/>' +
'</linearGradient></defs>' +
'<rect width="192" height="192" rx="28" fill="url(#g)"/>' +
'<path d="M52 88l44-34 44 34v50a8 8 0 0 1-8 8H60a8 8 0 0 1-8-8V88z" fill="rgba(0,0,0,0.10)"/>' +
'<path d="M82 146V110h28v36" fill="rgba(0,0,0,0.14)"/>' +
'<path d="M52 88l44-34 44 34" fill="none" stroke="rgba(0,0,0,0.18)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>',
);
const mapRoot = document.getElementById('mapRoot');
const viewport = document.getElementById('mapViewport');
const loading = document.getElementById('mapLoading');
const tooltip = document.getElementById('mapTooltip');
const tipTitle = document.getElementById('tipTitle');
const locationList = document.getElementById('mapLocations');
const mapStatus = document.getElementById('mapStatus');
const detailsCard = document.getElementById('mapDetails');
const detailsEmpty = document.getElementById('detailsEmpty');
const detailsContent = document.getElementById('detailsContent');
const detailsImg = document.getElementById('detailsImg');
const detailsTitle = document.getElementById('detailsTitle');
const detailsDesc = document.getElementById('detailsDesc');
const detailsMore = document.getElementById('detailsMore');
const escapeCss = (s) =>
window.CSS && typeof CSS.escape === 'function'
? CSS.escape(s)
: String(s).replace(
/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g,
'\\$1',
);
function parseHotspotsFromData() {
const nodes = Array.from(
document.querySelectorAll('#mapData [data-map-id]'),
);
return nodes
.map((n) => ({
id: n.dataset.mapId || '',
title: n.dataset.title || '',
desc: n.dataset.desc || '',
img: n.dataset.img || '',
url: n.dataset.url || '',
}))
.filter((x) => x.id && x.title);
}
function sanitizeSvgText(svgText) {
const parser = new DOMParser();
const doc = parser.parseFromString(svgText, 'image/svg+xml');
const svg = doc.documentElement;
Array.from(svg.querySelectorAll('script')).forEach((n) => n.remove());
Array.from(svg.querySelectorAll('foreignObject')).forEach((n) =>
n.remove(),
);
for (const el of svg.querySelectorAll('*')) {
for (const attr of Array.from(el.attributes)) {
if (/^on/i.test(attr.name)) el.removeAttribute(attr.name);
}
}
return svg.outerHTML;
}
function svgEl(tag) {
return document.createElementNS('http://www.w3.org/2000/svg', tag);
}
function closestHotspot(node) {
if (!node) return null;
if (node.closest) return node.closest('[data-map-id]');
let cur = node;
while (cur && cur.getAttribute) {
if (cur.getAttribute('data-map-id')) return cur;
cur = cur.parentNode;
}
return null;
}
function isHoverDisabled() {
return (
document.documentElement.classList.contains('force-mobile') ||
document.body.classList.contains('force-mobile') ||
window.matchMedia('(hover: none), (pointer: coarse)').matches
);
}
function isMobileLayout() {
return (
document.documentElement.classList.contains('force-mobile') ||
document.body.classList.contains('force-mobile') ||
window.matchMedia('(max-width: 760px)').matches
);
}
function attachHitRect(target, data) {
let bbox = null;
try {
bbox = target.getBBox();
} catch (e) {
bbox = null;
}
if (!bbox) return null;
const pad = SETTINGS.hitPadding;
const rect = svgEl('rect');
rect.setAttribute('x', String(bbox.x - pad));
rect.setAttribute('y', String(bbox.y - pad));
rect.setAttribute('width', String(bbox.width + pad * 2));
rect.setAttribute('height', String(bbox.height + pad * 2));
rect.setAttribute('fill', '#000');
rect.setAttribute('fill-opacity', '0');
rect.classList.add('ks-map__hitbox');
rect.dataset.mapId = data.id;
rect.dataset.title = data.title;
rect.dataset.desc = data.desc;
rect.dataset.img = data.img;
rect.dataset.url = data.url;
target.classList.add('ks-map__hotspot-target', 'is-click-through');
target.dataset.mapId = data.id;
const parent = target.parentNode;
if (parent) parent.insertBefore(rect, target.nextSibling);
return rect;
}
function getDataFromHotspotEl(hs) {
return {
id: hs.dataset.mapId || '',
title: hs.dataset.title || '',
desc: hs.dataset.desc || '',
img: hs.dataset.img || '',
url: hs.dataset.url || '',
};
}
let svgRoot = null;
let activeTarget = null;
let hoveredTarget = null;
const idToTarget = new Map();
const buttonById = new Map();
function setHovered(target, on) {
if (!target) return;
target.classList.toggle('is-hovered', !!on);
}
function setActive(target, on) {
if (!target) return;
target.classList.toggle('is-active', !!on);
const button = buttonById.get(target.dataset.mapId || '');
if (button) button.classList.toggle('is-active', !!on);
}
function showTitleTooltip(title, x, y) {
if (isHoverDisabled()) return;
tipTitle.textContent = title || '—';
tooltip.hidden = false;
moveTitleTooltip(x, y);
}
function moveTitleTooltip(x, y) {
if (tooltip.hidden) return;
const gap = 14;
const rect = tooltip.getBoundingClientRect();
const left = Math.min(
Math.max(10, x + gap),
window.innerWidth - rect.width - 10,
);
const top = Math.min(
Math.max(10, y + gap),
window.innerHeight - rect.height - 10,
);
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
function hideTitleTooltip() {
tooltip.hidden = true;
tooltip.style.left = '0px';
tooltip.style.top = '0px';
}
function updateDetails(data) {
detailsEmpty.hidden = true;
detailsContent.hidden = false;
detailsTitle.textContent = data.title || '—';
detailsDesc.textContent = data.desc || 'Описание пока не добавлено.';
detailsImg.src = data.img || PLACEHOLDER_IMG;
detailsImg.alt = data.title || '';
if (data.url) {
detailsMore.href = data.url;
detailsMore.hidden = false;
} else {
detailsMore.hidden = true;
detailsMore.removeAttribute('href');
}
}
function selectHotspot(data, target, options = {}) {
if (!target) return;
if (activeTarget && activeTarget !== target) setActive(activeTarget, false);
activeTarget = target;
setActive(activeTarget, true);
mapRoot.classList.add('is-focus');
if (mapStatus) mapStatus.textContent = 'Выбрано: ' + (data.title || 'объект кампуса');
updateDetails(data);
hideTitleTooltip();
if (options.scrollDetails && isMobileLayout()) {
detailsCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
function pickHotspot(item, options = {}) {
const target = idToTarget.get(item.id);
if (!target) return;
selectHotspot(item, target, options);
}
function renderLocationList(hotspots) {
if (!locationList) return;
locationList.replaceChildren();
buttonById.clear();
const frag = document.createDocumentFragment();
hotspots.forEach((item, index) => {
const button = document.createElement('button');
button.className = 'ks-location-card';
button.type = 'button';
button.dataset.mapId = item.id;
const number = document.createElement('span');
number.className = 'ks-location-card__num';
number.textContent = String(index + 1).padStart(2, '0');
button.appendChild(number);
const title = document.createElement('span');
title.className = 'ks-location-card__title';
title.textContent = item.title;
button.appendChild(title);
button.addEventListener('click', () =>
pickHotspot(item, { scrollDetails: true }),
);
buttonById.set(item.id, button);
frag.appendChild(button);
});
locationList.appendChild(frag);
}
function bindDelegatedEvents() {
svgRoot.addEventListener('pointerover', (e) => {
if (e.pointerType !== 'mouse' || isHoverDisabled()) return;
const hs = closestHotspot(e.target);
if (!hs) return;
const id = hs.dataset.mapId || '';
const target = idToTarget.get(id);
if (!target || target === hoveredTarget) return;
if (hoveredTarget) setHovered(hoveredTarget, false);
hoveredTarget = target;
setHovered(hoveredTarget, true);
showTitleTooltip(getDataFromHotspotEl(hs).title, e.clientX, e.clientY);
});
svgRoot.addEventListener('pointerout', (e) => {
if (e.pointerType !== 'mouse' || isHoverDisabled()) return;
const from = closestHotspot(e.target);
if (!from) return;
const to = closestHotspot(e.relatedTarget);
if (to && to.dataset.mapId === from.dataset.mapId) return;
if (hoveredTarget) {
setHovered(hoveredTarget, false);
hoveredTarget = null;
}
hideTitleTooltip();
});
svgRoot.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse' || isHoverDisabled()) return;
moveTitleTooltip(e.clientX, e.clientY);
});
svgRoot.addEventListener('click', (e) => {
const hs = closestHotspot(e.target);
if (!hs) return;
e.stopPropagation();
const target = idToTarget.get(hs.dataset.mapId || '');
selectHotspot(getDataFromHotspotEl(hs), target, { scrollDetails: true });
});
}
async function loadMap() {
try {
const hotspots = parseHotspotsFromData();
const res = await fetch(SETTINGS.svgUrl, { cache: 'force-cache' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const raw = await res.text();
const cleaned = sanitizeSvgText(raw);
viewport.innerHTML = cleaned;
svgRoot = viewport.querySelector('svg');
if (!svgRoot) throw new Error('SVG не найден');
svgRoot.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const misses = [];
for (const h of hotspots) {
const target = svgRoot.querySelector('#' + escapeCss(h.id));
if (!target) {
misses.push(h.id);
continue;
}
idToTarget.set(h.id, target);
const hit = attachHitRect(target, h);
if (!hit) {
target.dataset.mapId = h.id;
target.dataset.title = h.title;
target.dataset.desc = h.desc;
target.dataset.img = h.img;
target.dataset.url = h.url;
target.classList.add('ks-map__hotspot-target');
}
}
renderLocationList(hotspots);
bindDelegatedEvents();
detailsImg.addEventListener('error', () => {
detailsImg.src = PLACEHOLDER_IMG;
});
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideTitleTooltip();
});
window.addEventListener('resize', hideTitleTooltip, { passive: true });
if (loading) loading.remove();
if (misses.length) {
const warn = document.createElement('div');
warn.className = 'ks-map__error';
warn.textContent = 'Часть объектов не найдена по ID: ' + misses.join(', ');
viewport.appendChild(warn);
setTimeout(() => warn.remove(), 3800);
}
} catch (err) {
viewport.innerHTML = '';
const box = document.createElement('div');
box.className = 'ks-map__error';
box.textContent = 'Не удалось загрузить карту. Проверь доступность SVG по ссылке и CORS.';
viewport.appendChild(box);
}
}
function initLazy() {
if (!('IntersectionObserver' in window)) {
loadMap();
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
loadMap();
},
{ rootMargin: SETTINGS.lazyRootMargin },
);
observer.observe(mapRoot);
}
initLazy();
</script>
</body>
</html>
[/html]
[hideprofile]
























