Локации Академии
Академия находится в Салеме, что севернее Бостона. Её кампус раскинулся на острове Уинтер, узкой и вытянутой вдоль океана земле в восточной части города с солёным ветром и испещрённую дорожками, которые то уходят в тень деревьев, то резко выводят к камням и воде. Здесь постоянно слышно море: оно обволакивает берег, приносит ветер и иногда окутывает всё вязким туманом.[float=right]
42.529617251361515, -70.868893210953[/float]
Для простых людей это место известно как частное образовательное учреждение — «Салемский подготовительный колледж», в инфраструктуру которого входят яхтовый клуб и здания бывшего морского исследовательского центра Cat Cove. Для одарённых же это место в котором они могут находить друг друга и развивать свои магические таланты.
Кампус разделён на несколько функциональных зон, соединённых дорогами, пешеходными маршрутами и служебными проездами. Основными ориентирами служат береговая линия на востоке острова, здание Главного корпуса в его южной части, а в северной зоне расположившиеся там мужское и женское общежития студентов. Западный сектор, находящийся на материке, занимает административно-служебный комплекс Академии в зданиях бывшего центра морских исследований Cat Cove.
[html]
<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>
<!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/82/f2/62594.svg" as="fetch" crossorigin />
<style>
:root{
/* SETTINGS: размеры и поведение карты */
--ks-map-height: clamp(520px, 70vh, 820px);
--ks-map-ratio: 0.75595;
--ks-map-frame-pad: 10px;
/* SETTINGS: цвета рамки (градиент) */
--ks-frame-grad-a: color-mix(in srgb, var(--accent, #566a7c) 55%, #fff);
--ks-frame-grad-b: color-mix(in srgb, var(--accent2, #35597c) 45%, #fff);
--ks-radius: var(--radius, 10px);
--ks-gap: var(--gap, 16px);
--ks-gap-sm: var(--small-gap, 8px);
--ks-panel: var(--podform, #d4d4d5);
--ks-panel-2: var(--panel-bg-light, var(--quote, #d1cfcf));
--ks-border: var(--bord, rgba(0,0,0,.18));
--ks-border-2: var(--panel-border, var(--ks-border));
--ks-text: var(--text, #28323d);
--ks-muted: var(--sec-text, #414952);
--ks-accent: var(--accent, #566a7c);
--ks-accent2: var(--accent2, #35597c);
--ks-shadow: 0 10px 22px rgba(0,0,0,.12);
--ks-shadow-strong: 0 16px 38px rgba(0,0,0,.16);
--ks-map-bg: var(--htm-bg, none);
--ks-map-frame: var(--pf-bg, none);
--ks-hl-duration: 140ms;
--ks-hl-brightness: 1.08;
--ks-hl-saturate: 1.06;
--ks-active-brightness: 1.14;
--ks-active-saturate: 1.10;
--ks-dim-opacity: .55;
--ks-wash-opacity: .30;
--ks-wash-focus-opacity: .40;
--ks-dim-overlay: rgba(0,0,0,.16);
--ks-dim-overlay-focus: rgba(0,0,0,.28);
--ks-shell-pad: calc(var(--ks-gap) * 1.1);
--ks-map-width: min(100%, calc(var(--ks-map-height) * var(--ks-map-ratio)));
}
*{ box-sizing:border-box; }
body{
margin:0;
color: var(--ks-text);
font: 14px/1.4 var(--main-font, system-ui), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
background: transparent;
}
.ks-icon{
width: 16px;
height: 16px;
display:block;
fill: currentColor;
}
.ks-map-shell{
max-width: none;
width: min(100%, calc(var(--ks-map-width) + var(--ks-shell-pad) * 2));
margin: 0 auto;
padding: var(--ks-shell-pad);
border-radius: calc(var(--ks-radius) + 6px);
background:
linear-gradient(180deg, rgba(255,255,255,.55), rgba(255,255,255,.25)),
var(--ks-map-bg);
border: 1px solid color-mix(in srgb, var(--ks-border) 80%, transparent);
box-shadow: var(--ks-shadow);
position: relative;
overflow: hidden;
}
.ks-map-shell::before{
content:"";
position:absolute;
inset:0;
background:
radial-gradient(820px 300px at 18% 8%, color-mix(in srgb, var(--ks-accent) 18%, transparent), transparent 60%),
radial-gradient(760px 300px at 82% 0%, color-mix(in srgb, var(--ks-accent2) 14%, transparent), transparent 58%),
linear-gradient(180deg, rgba(255,255,255,.24), rgba(255,255,255,0));
pointer-events:none;
opacity:.9;
}
.ks-map-shell__inner{
position:relative;
z-index:1;
}
.ks-head{
width: var(--ks-map-width);
margin: 0 auto var(--ks-gap-sm);
display:flex;
justify-content:center;
text-align:center;
}
.ks-title{
margin:0;
font-family: var(--sec-font, var(--main-font, inherit));
font-size: 18px;
letter-spacing:.2px;
color: var(--ks-text);
}
.ks-toolbar{
width: var(--ks-map-width);
margin: 10px auto 14px;
display:flex;
gap: var(--ks-gap-sm);
align-items:center;
flex-wrap:wrap;
}
.ks-search{
flex: 1 1 auto;
min-width: 0;
position:relative;
}
.ks-search__icon{
position:absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: color-mix(in srgb, var(--ks-muted) 70%, transparent);
pointer-events:none;
}
.ks-search__clear{
position:absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--ks-border-2) 85%, transparent);
background:
linear-gradient(180deg, rgba(255,255,255,.65), rgba(255,255,255,.35)),
var(--ks-panel-2);
color: color-mix(in srgb, var(--ks-muted) 80%, transparent);
cursor:pointer;
box-shadow: 0 8px 16px rgba(0,0,0,.10);
display:none;
align-items:center;
justify-content:center;
transition: transform 120ms ease, background 120ms ease, color 120ms ease;
}
.ks-search__clear:hover{
transform: translateY(-50%) translateY(-1px);
color: color-mix(in srgb, var(--ks-text) 85%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--ks-accent) 20%, #fff), rgba(255,255,255,.45));
}
.ks-search input{
width:100%;
padding: 10px 44px 10px 36px;
border-radius: var(--ks-radius);
border: 1px solid color-mix(in srgb, var(--ks-border-2) 85%, transparent);
background:
linear-gradient(180deg, rgba(255,255,255,.6), rgba(255,255,255,.35)),
var(--ks-panel-2);
color: var(--ks-text);
outline:none;
box-shadow: 0 8px 18px rgba(0,0,0,.10);
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.ks-search input::placeholder{ color: color-mix(in srgb, var(--ks-muted) 70%, transparent); }
.ks-search input:focus{
border-color: color-mix(in srgb, var(--ks-accent) 70%, var(--ks-border-2));
box-shadow: 0 10px 22px color-mix(in srgb, var(--ks-accent) 22%, rgba(0,0,0,.10));
}
.ks-searchResults{
position:absolute;
top: calc(100% + 8px);
left:0;
right:0;
z-index: 40;
border-radius: var(--ks-radius);
border: 1px solid color-mix(in srgb, var(--ks-border-2) 85%, transparent);
background:
linear-gradient(180deg, rgba(255,255,255,.78), rgba(255,255,255,.55)),
var(--ks-panel-2);
box-shadow: var(--ks-shadow-strong);
overflow:hidden;
backdrop-filter: blur(6px);
}
.ks-searchResults[hidden]{ display:none; }
.ks-searchItem{
width:100%;
text-align:left;
padding: 10px 12px;
background: transparent;
border:0;
color: var(--ks-text);
cursor:pointer;
display:flex;
align-items:center;
gap: 10px;
font: inherit;
transition: background 120ms ease;
}
.ks-searchItem:hover{
background: color-mix(in srgb, var(--ks-accent) 12%, transparent);
}
.ks-searchItem small{
display:block;
margin-top:2px;
color: var(--ks-muted);
}
.ks-map{
width: var(--ks-map-width);
margin: 0 auto;
position:relative;
padding: var(--ks-map-frame-pad);
border-radius: calc(var(--ks-radius) + 10px);
border: 2px solid transparent;
background:
linear-gradient(180deg, rgba(255,255,255,.42), rgba(255,255,255,.18)) padding-box,
var(--ks-map-frame) padding-box,
linear-gradient(135deg, var(--ks-frame-grad-a), var(--ks-frame-grad-b)) border-box;
box-shadow: var(--ks-shadow-strong);
overflow:hidden;
}
.ks-map::after{
content:"";
position:absolute;
inset: 8px;
border-radius: calc(var(--ks-radius) + 4px);
pointer-events:none;
box-shadow:
inset 0 0 0 1px rgba(255,255,255,.35),
inset 0 0 0 2px rgba(0,0,0,.06);
z-index: 5;
}
.ks-map__viewport{
position:relative;
width:100%;
height: var(--ks-map-height);
background: rgba(255,255,255,.12);
overflow:hidden;
border-radius: calc(var(--ks-radius) + 6px);
}
.ks-map__viewport::before{
content:"";
position:absolute;
inset:0;
pointer-events:none;
opacity: var(--ks-wash-opacity);
z-index: 2;
background:
linear-gradient(180deg,
color-mix(in srgb, var(--ks-accent) 26%, transparent),
color-mix(in srgb, var(--ks-accent2) 20%, transparent)),
radial-gradient(1200px 700px at 30% 25%,
color-mix(in srgb, var(--ks-accent) 22%, transparent),
transparent 62%),
radial-gradient(1100px 650px at 70% 70%,
color-mix(in srgb, var(--ks-accent2) 18%, transparent),
transparent 60%);
mix-blend-mode: multiply;
}
.ks-map__viewport::after{
content:"";
position:absolute;
inset:0;
pointer-events:none;
background: var(--ks-dim-overlay);
opacity: 0;
transition: opacity 140ms ease, background 140ms ease;
z-index: 3;
}
.ks-map.is-focus .ks-map__viewport::before{ opacity: var(--ks-wash-focus-opacity); }
.ks-map.is-focus .ks-map__viewport::after{
opacity: 1;
background: var(--ks-dim-overlay-focus);
}
.ks-map__loading,
.ks-map__error{
position:absolute;
inset:0;
display:flex;
align-items:center;
justify-content:center;
text-align:center;
padding: 20px;
color: var(--ks-muted);
background: rgba(255,255,255,.35);
backdrop-filter: blur(8px);
z-index: 4;
}
.ks-map__viewport svg{
width:100%;
height:100%;
display:block;
user-select:none;
-webkit-user-drag:none;
position:relative;
z-index: 1;
transition: filter 140ms ease, opacity 140ms ease;
filter: grayscale(0.5);
}
.ks-map.is-focus .ks-map__viewport svg{
opacity: var(--ks-dim-opacity);
filter: grayscale(0.5) saturate(.92) brightness(.93) contrast(1.02);
}
.ks-map__hotspot-target{
transition: filter var(--ks-hl-duration) ease, opacity var(--ks-hl-duration) ease;
}
.ks-map__hotspot-target.is-hovered{
filter:
brightness(var(--ks-hl-brightness))
saturate(var(--ks-hl-saturate))
drop-shadow(0 6px 14px rgba(0,0,0,.18))
drop-shadow(0 0 14px color-mix(in srgb, var(--ks-accent) 55%, transparent));
opacity: 1;
}
.ks-map__hotspot-target.is-active{
filter:
brightness(var(--ks-active-brightness))
saturate(var(--ks-active-saturate))
drop-shadow(0 12px 20px rgba(0,0,0,.22))
drop-shadow(0 0 18px color-mix(in srgb, var(--ks-accent2) 55%, transparent));
opacity: 1;
}
@media (prefers-reduced-motion: reduce){
.ks-map__hotspot-target{ transition:none; }
.ks-map__viewport svg{ transition:none; }
.ks-map__viewport::after{ transition:none; }
}
.ks-tooltip{
position: fixed;
z-index: 60;
max-width: min(460px, calc(100vw - 18px));
left: 0;
top: 0;
transform: translate3d(0,0,0);
border-radius: calc(var(--ks-radius) + 6px);
border: 1px solid color-mix(in srgb, var(--ks-border-2) 85%, transparent);
background:
linear-gradient(180deg, rgba(255,255,255,.86), rgba(255,255,255,.64)),
var(--ks-panel-2);
box-shadow: var(--ks-shadow-strong);
color: var(--ks-text);
backdrop-filter: blur(10px);
pointer-events: auto;
}
.ks-tooltip[hidden]{ display:none; }
.ks-tooltip__inner{ padding: 12px; }
.ks-tooltip__top{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
}
.ks-tooltip__title{
margin:0;
font-size: 13px;
font-weight: 750;
letter-spacing: .2px;
font-family: var(--sec-font, var(--main-font, inherit));
color: var(--ks-text);
line-height: 1.25;
}
.ks-tooltip__close{
appearance:none;
border: 1px solid color-mix(in srgb, var(--ks-border-2) 85%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--ks-accent) 22%, #fff), rgba(255,255,255,.55));
color: color-mix(in srgb, var(--ks-text) 88%, transparent);
line-height: 1;
padding: 7px 9px;
cursor:pointer;
border-radius: 10px;
box-shadow: 0 8px 16px rgba(0,0,0,.10);
transition: transform 120ms ease, background 120ms ease, color 120ms ease;
flex: 0 0 auto;
display:flex;
align-items:center;
justify-content:center;
}
.ks-tooltip__close:hover{
transform: translateY(-1px);
color: var(--ks-text);
background: linear-gradient(180deg, color-mix(in srgb, var(--ks-accent) 32%, #fff), rgba(255,255,255,.60));
}
.ks-tooltip__body{
margin-top: 10px;
display:grid;
grid-template-columns: 96px 1fr;
gap: 10px;
align-items:start;
}
.ks-tooltip__img{
width: 96px;
height: 96px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--ks-border-2) 85%, transparent);
object-fit: cover;
background:
linear-gradient(180deg, rgba(255,255,255,.50), rgba(255,255,255,.20)),
var(--ks-panel);
display:block;
box-shadow: 0 10px 18px rgba(0,0,0,.10);
}
.ks-tooltip__desc{
margin:0;
font-size: 12px;
color: color-mix(in srgb, var(--ks-text) 86%, transparent);
}
.ks-tooltip__more{
display:inline-block;
margin-top: 8px;
font-size: 12px;
color: color-mix(in srgb, var(--ks-accent2) 85%, var(--ks-text));
text-decoration: none;
border-bottom: 1px dashed color-mix(in srgb, var(--ks-accent2) 45%, transparent);
}
.ks-tooltip__more:hover{
border-bottom-style: solid;
}
.ks-tooltip[data-mode="preview"] .ks-tooltip__close{ display:none; }
.ks-tooltip[data-mode="preview"] .ks-tooltip__body{ display:none; }
.ks-tooltip[data-mode="preview"] .ks-tooltip__inner{ padding: 10px 12px; }
@media (max-width: 720px){
.ks-map-shell{ width: 100%; }
.ks-head, .ks-toolbar, .ks-map{ width: 100%; }
.ks-map__viewport{
height: auto;
aspect-ratio: 3431.52 / 4539.36;
}
}
</style>
</head>
<body>
<section class="ks-map-shell">
<div class="ks-map-shell__inner">
<header class="ks-head">
<h1 class="ks-title">Карта кампуса</h1>
</header>
<div class="ks-toolbar">
<div class="ks-search">
<span class="ks-search__icon" aria-hidden="true">
<svg class="ks-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 4a6 6 0 1 1 0 12a6 6 0 0 1 0-12m0-2a8 8 0 1 0 4.9 14.3l4.4 4.4a1 1 0 0 0 1.4-1.4l-4.4-4.4A8 8 0 0 0 10 2Z"/>
</svg>
</span>
<input id="mapSearch" type="search" placeholder="Поиск по объектам…" autocomplete="off" />
<button class="ks-search__clear" id="mapSearchClear" type="button" aria-label="Очистить поиск">
<svg class="ks-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M18.3 5.7a1 1 0 0 0-1.4 0L12 10.6L7.1 5.7A1 1 0 0 0 5.7 7.1L10.6 12l-4.9 4.9a1 1 0 1 0 1.4 1.4l4.9-4.9l4.9 4.9a1 1 0 0 0 1.4-1.4L13.4 12l4.9-4.9a1 1 0 0 0 0-1.4Z"/>
</svg>
</button>
<div class="ks-searchResults" id="mapSearchResults" hidden></div>
</div>
</div>
<div class="ks-map" id="mapRoot">
<div class="ks-map__viewport" id="mapViewport">
<div class="ks-map__loading" id="mapLoading">Карта загрузится, когда дойдёшь до неё…</div>
</div>
</div>
</div>
</section>
<div class="ks-tooltip" id="mapTooltip" hidden data-mode="preview" role="dialog" aria-modal="false">
<div class="ks-tooltip__inner">
<div class="ks-tooltip__top">
<div style="min-width:0;">
<p class="ks-tooltip__title" id="tipTitle">—</p>
</div>
<button class="ks-tooltip__close" id="tipClose" aria-label="Закрыть">
<svg class="ks-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M18.3 5.7a1 1 0 0 0-1.4 0L12 10.6L7.1 5.7A1 1 0 0 0 5.7 7.1L10.6 12l-4.9 4.9a1 1 0 1 0 1.4 1.4l4.9-4.9l4.9 4.9a1 1 0 0 0 1.4-1.4L13.4 12l4.9-4.9a1 1 0 0 0 0-1.4Z"/>
</svg>
</button>
</div>
<div class="ks-tooltip__body">
<img class="ks-tooltip__img" id="tipImg" alt="" />
<div>
<p class="ks-tooltip__desc" id="tipDesc">—</p>
<a class="ks-tooltip__more" id="tipMore" href="#" target="_blank" rel="noopener noreferrer" hidden>Читать далее…</a>
</div>
</div>
</div>
</div>
<div id="mapData" hidden>
<div
data-map-id="_Слой_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>
<script>
const SETTINGS = {
// SETTINGS: ссылка на SVG карты
svgUrl: 'https://forumstatic.ru/files/001c/82/f2/62594.svg',
// SETTINGS: расширение зоны клика вокруг объекта (px)
hitPadding: 10,
// SETTINGS: отступ тултипа от якоря (px)
tooltipOffset: 12,
// SETTINGS: отступ от краёв экрана при размещении тултипа (px)
edgePad: 10,
// SETTINGS: long-press для тача (ms)
longPressMs: 480,
// SETTINGS: допуск движения при long-press (px)
longPressMovePx: 10,
// SETTINGS: максимум результатов поиска
searchMaxItems: 7,
// SETTINGS: насколько заранее грузить карту до попадания в viewport
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 tipClose = document.getElementById('tipClose');
const tipImg = document.getElementById('tipImg');
const tipDesc = document.getElementById('tipDesc');
const tipMore = document.getElementById('tipMore');
const searchInput = document.getElementById('mapSearch');
const searchResults = document.getElementById('mapSearchResults');
const searchClear = document.getElementById('mapSearchClear');
const escapeCss = (s) => (window.CSS && typeof CSS.escape === 'function')
? CSS.escape(s)
: String(s).replace(/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, '\\$1');
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
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());
const all = svg.querySelectorAll('*');
for (const el of all){
const attrs = Array.from(el.attributes);
for (const a of attrs){
if (/^on/i.test(a.name)) el.removeAttribute(a.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 setTooltipContent(data, mode){
tooltip.dataset.mode = mode;
tipTitle.textContent = data.title || '—';
tipDesc.textContent = data.desc || '';
tipImg.src = data.img || PLACEHOLDER_IMG;
tipImg.alt = data.title || '';
if (data.url){
tipMore.href = data.url;
tipMore.hidden = false;
} else {
tipMore.hidden = true;
tipMore.removeAttribute('href');
}
}
let tipMetrics = { mode: '', w: 0, h: 0, dirty: true };
function markTipDirty(){
tipMetrics.dirty = true;
}
function updateTipMetrics(mode){
if (!tipMetrics.dirty && tipMetrics.mode === mode && tipMetrics.w && tipMetrics.h) return;
tipMetrics.mode = mode;
tipMetrics.dirty = false;
const prevVis = tooltip.style.visibility;
const prevLeft = tooltip.style.left;
const prevTop = tooltip.style.top;
tooltip.style.visibility = 'hidden';
tooltip.style.left = '-9999px';
tooltip.style.top = '-9999px';
const r = tooltip.getBoundingClientRect();
tipMetrics.w = r.width;
tipMetrics.h = r.height;
tooltip.style.visibility = prevVis || '';
tooltip.style.left = prevLeft || '0px';
tooltip.style.top = prevTop || '0px';
}
function scorePlacement(left, top, w, h){
const vpW = window.innerWidth;
const vpH = window.innerHeight;
const overflowL = Math.max(0, SETTINGS.edgePad - left);
const overflowT = Math.max(0, SETTINGS.edgePad - top);
const overflowR = Math.max(0, (left + w) - (vpW - SETTINGS.edgePad));
const overflowB = Math.max(0, (top + h) - (vpH - SETTINGS.edgePad));
return overflowL + overflowT + overflowR + overflowB;
}
function placeTooltipAt(anchorX, anchorY){
const w = tipMetrics.w || tooltip.getBoundingClientRect().width;
const h = tipMetrics.h || tooltip.getBoundingClientRect().height;
const o = SETTINGS.tooltipOffset;
const candidates = [
{ p: 'top', left: anchorX - w / 2, top: anchorY - h - o },
{ p: 'bottom', left: anchorX - w / 2, top: anchorY + o },
{ p: 'right', left: anchorX + o, top: anchorY - h / 2 },
{ p: 'left', left: anchorX - w - o, top: anchorY - h / 2 }
];
let best = candidates[0];
let bestScore = Infinity;
for (const c of candidates){
const s = scorePlacement(c.left, c.top, w, h);
if (s < bestScore){
bestScore = s;
best = c;
}
}
const vpW = window.innerWidth;
const vpH = window.innerHeight;
const left = clamp(best.left, SETTINGS.edgePad, vpW - SETTINGS.edgePad - w);
const top = clamp(best.top, SETTINGS.edgePad, vpH - SETTINGS.edgePad - h);
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
tooltip.hidden = false;
tooltip.dataset.placement = best.p;
}
function hideTooltip(){
tooltip.hidden = true;
tooltip.style.left = '0px';
tooltip.style.top = '0px';
}
let svgRoot = null;
let activeTarget = null;
let pinnedOpen = false;
let previewOpen = false;
const idToTarget = 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);
}
function clearActive(){
if (activeTarget){
setActive(activeTarget, false);
activeTarget = null;
}
mapRoot.classList.remove('is-focus');
}
function openPreview(data, x, y){
if (pinnedOpen) return;
previewOpen = true;
setTooltipContent(data, 'preview');
tooltip.hidden = false;
markTipDirty();
updateTipMetrics('preview');
placeTooltipAt(x, y);
}
function closePreview(){
if (!previewOpen) return;
previewOpen = false;
hideTooltip();
}
function openPinned(data, target, x, y){
pinnedOpen = true;
previewOpen = false;
mapRoot.classList.add('is-focus');
setTooltipContent(data, 'pinned');
tooltip.hidden = false;
markTipDirty();
updateTipMetrics('pinned');
if (activeTarget && activeTarget !== target) setActive(activeTarget, false);
activeTarget = target;
setActive(activeTarget, true);
placeTooltipAt(x, y);
}
function closePinned(){
if (!pinnedOpen) return;
pinnedOpen = false;
hideTooltip();
clearActive();
}
function togglePinned(data, target, x, y){
if (pinnedOpen && activeTarget === target){
closePinned();
return;
}
openPinned(data, target, x, y);
}
function anchorFromTarget(target){
const r = target.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
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.style.cursor = 'pointer';
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');
target.style.pointerEvents = 'none';
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 || ''
};
}
function buildSearchUI(hotspots){
const normalize = (s) => String(s || '').trim().toLowerCase();
const closeList = () => { searchResults.hidden = true; searchResults.innerHTML = ''; };
const openList = () => { searchResults.hidden = false; };
const setClearVisible = () => {
searchClear.style.display = searchInput.value ? 'flex' : 'none';
};
const computeMatches = () => {
const q = normalize(searchInput.value);
if (!q) return [];
return hotspots
.map((h) => ({ h, t: normalize(h.title), d: normalize(h.desc) }))
.filter((x) => x.t.includes(q) || x.d.includes(q))
.slice(0, SETTINGS.searchMaxItems)
.map((x) => x.h);
};
const render = (items) => {
if (!items.length) { closeList(); return; }
openList();
searchResults.innerHTML = items.map((it, i) => {
const title = it.title.replace(/</g,'<').replace(/>/g,'>');
const desc = (it.desc || '').replace(/</g,'<').replace(/>/g,'>');
return (
'<button class="ks-searchItem" type="button" data-idx="' + i + '">' +
'<div style="min-width:0;">' +
'<div style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">' + title + '</div>' +
(desc ? '<small style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">' + desc + '</small>' : '') +
'</div>' +
'</button>'
);
}).join('');
};
const pick = (item) => {
const target = idToTarget.get(item.id);
if (!target) return;
viewport.scrollIntoView({ behavior: 'smooth', block: 'center' });
const a = anchorFromTarget(target);
togglePinned(item, target, a.x, a.y);
closeList();
searchInput.blur();
};
searchInput.addEventListener('input', () => {
setClearVisible();
const q = normalize(searchInput.value);
if (!q) { closeList(); return; }
render(computeMatches());
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeList(); return; }
if (e.key === 'Enter') {
const firstBtn = searchResults.querySelector('.ks-searchItem');
if (firstBtn){
firstBtn.click();
e.preventDefault();
}
}
});
searchResults.addEventListener('click', (e) => {
const btn = e.target.closest('.ks-searchItem');
if (!btn) return;
const idx = Number(btn.dataset.idx);
const items = computeMatches();
const item = items[idx];
if (item) pick(item);
});
document.addEventListener('click', (e) => {
if (!searchResults.hidden && !searchResults.contains(e.target) && e.target !== searchInput){
closeList();
}
});
searchClear.addEventListener('click', () => {
searchInput.value = '';
searchInput.focus();
setClearVisible();
closeList();
});
setClearVisible();
}
let rafMove = 0;
let lastMoveX = 0;
let lastMoveY = 0;
function schedulePreviewMove(x, y){
lastMoveX = x;
lastMoveY = y;
if (rafMove) return;
rafMove = requestAnimationFrame(() => {
rafMove = 0;
if (previewOpen && !pinnedOpen && !tooltip.hidden){
placeTooltipAt(lastMoveX, lastMoveY);
}
});
}
function bindDelegatedEvents(){
let hoveredId = null;
const clearHover = (id) => {
if (!id) return;
const t = idToTarget.get(id);
setHovered(t, false);
};
svgRoot.addEventListener('pointerover', (e) => {
if (e.pointerType !== 'mouse') return;
if (pinnedOpen) return;
const hs = closestHotspot(e.target);
if (!hs) return;
const id = hs.dataset.mapId || '';
if (!id || id === hoveredId) return;
if (hoveredId) clearHover(hoveredId);
hoveredId = id;
const target = idToTarget.get(id);
setHovered(target, true);
openPreview(getDataFromHotspotEl(hs), e.clientX, e.clientY);
});
svgRoot.addEventListener('pointerout', (e) => {
if (e.pointerType !== 'mouse') return;
if (pinnedOpen) return;
const from = closestHotspot(e.target);
if (!from) return;
const to = closestHotspot(e.relatedTarget);
if (to && to.dataset.mapId === from.dataset.mapId) return;
const id = from.dataset.mapId || '';
if (id && id === hoveredId){
clearHover(hoveredId);
hoveredId = null;
}
closePreview();
});
svgRoot.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return;
if (!previewOpen || pinnedOpen) return;
schedulePreviewMove(e.clientX, e.clientY);
});
svgRoot.addEventListener('click', (e) => {
const hs = closestHotspot(e.target);
if (!hs) return;
e.stopPropagation();
const id = hs.dataset.mapId || '';
const target = idToTarget.get(id);
if (!target) return;
togglePinned(getDataFromHotspotEl(hs), target, e.clientX, e.clientY);
});
let press = null;
const clearPress = () => {
if (press && press.timer) clearTimeout(press.timer);
press = null;
};
svgRoot.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse') return;
const hs = closestHotspot(e.target);
if (!hs) return;
const id = hs.dataset.mapId || '';
if (!id) return;
press = {
id,
startX: e.clientX,
startY: e.clientY,
fired: false,
timer: setTimeout(() => {
if (!press) return;
press.fired = true;
openPreview(getDataFromHotspotEl(hs), e.clientX, e.clientY);
}, SETTINGS.longPressMs)
};
}, { passive: true });
svgRoot.addEventListener('pointermove', (e) => {
if (!press) return;
const dx = e.clientX - press.startX;
const dy = e.clientY - press.startY;
if (Math.hypot(dx, dy) > SETTINGS.longPressMovePx){
clearPress();
}
}, { passive: true });
svgRoot.addEventListener('pointerup', (e) => {
if (!press) return;
const hs = closestHotspot(e.target);
const id = press.id;
const fired = press.fired;
clearPress();
if (fired){
closePreview();
return;
}
if (!hs) return;
if ((hs.dataset.mapId || '') !== id) return;
const target = idToTarget.get(id);
if (!target) return;
togglePinned(getDataFromHotspotEl(hs), target, e.clientX, e.clientY);
});
svgRoot.addEventListener('pointercancel', () => {
clearPress();
closePreview();
});
}
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');
target.style.cursor = 'pointer';
}
}
buildSearchUI(hotspots);
bindDelegatedEvents();
tipImg.addEventListener('error', () => { tipImg.src = PLACEHOLDER_IMG; });
tipClose.addEventListener('click', (e) => {
e.stopPropagation();
closePinned();
});
tooltip.addEventListener('click', (e) => e.stopPropagation());
viewport.addEventListener('click', () => {
closePinned();
closePreview();
});
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape'){
closePinned();
closePreview();
searchResults.hidden = true;
}
});
window.addEventListener('resize', () => {
markTipDirty();
if (!tooltip.hidden && pinnedOpen && activeTarget){
updateTipMetrics('pinned');
const a = anchorFromTarget(activeTarget);
placeTooltipAt(a.x, a.y);
}
}, { 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(){
let loaded = false;
const start = () => {
if (loaded) return;
loaded = true;
if (loading) loading.textContent = 'Загрузка карты…';
loadMap();
};
if (!('IntersectionObserver' in window)){
start();
return;
}
const obs = new IntersectionObserver((entries) => {
for (const e of entries){
if (e.isIntersecting){
obs.disconnect();
start();
break;
}
}
}, { root: null, rootMargin: SETTINGS.lazyRootMargin, threshold: 0.01 });
obs.observe(mapRoot);
}
initLazy();
</script>
</body>
</html>
[/html]
[hideprofile]





















