На Тостере часто спрашивают о том, как сделать интерактивную схему дома, план его внутреннего устройства, возможность выбора этажей или квартир с выводом информации о них, вывод информации о деталях того или иного товара при наведении мыши на них на фотографии и.т.д. Речь там идет не о трехмерной модели, а именно о картинке с возможностью подсветить те или иные детали. Все эти задачи похожие и решаются достаточно просто, но тем не менее вопросы продолжают появляться, так что сегодня мы рассмотрим, как подобные вещи делаются при помощи SVG, графического редактора и щепотки javascript.
Выбор именно SVG обусловлен тем, что это максимально простой для разработки и отладки вариант. Я встречал людей, которые советовали все это делать на canvas, но там гораздо сложнее понять, что происходит, да и координаты всех точек на кривых нужно как-то заранее рассчитать, а здесь – открыл инструменты разработчика и сразу видишь всю структуру, все объекты, с которыми идет взаимодействие, а все остальное можно накликать мышкой в человеко-понятном интерфейсе. Производительность между обычным 2d-канвасом и SVG почти не будет отличаться. WebGL может дать некоторый бонус в этом плане, но и сроки разработки вырастут в разы, не говоря уже о дальнейшей поддержке, что не всегда вписывается в бюджет. Я бы даже сказал “никогда не вписывается”.
В этом туториале мы сделаем что-то вроде виджета для вымышленного сайта по аренде частных домов в каком-то районе, но понятно, что рассматриваемые принципы создания таких вещей применимы к любой тематике.
Начинаем начинать
Я буду все показывать на примере Inkscape, но все те же действия можно провернуть и в любом другом редакторе используя аналогичные функции.
Для работы нам понадобятся два основных диалоговых окна:
- XML Editor (Ctrl+Shift+X или иконка с угловыми скобками) — для просмотра структуры документа в виде разметки и редактирования отдельных элементов.
- Fill and Stroke (Ctrl+Shift+F или иконка с кистью в рамке) — в основном для заливки контуров.
Сразу запускаем их и переходим к созданию документа.
Если вы случайно перетянули их в отдельное окно, то можно кликнуть под верхней рамкой этого окна (где ничего нет), и перетянуть их обратно в основное. Это не совсем интуитивно понятно, но довольно удобно.
Открываем фотографию с видом на район. Мы можем выбрать – вставить саму картинку в виде base64-строки или внешней ссылкой. Поскольку она большая, выбираем ссылку. Путь к картинке потом поменяем руками при внедрении всего в страницы сайта. Создастся SVG-документ, в который фотография будет встроена через тег image.
Для растровых картинок, встроенных в SVG, встроенных в HTML, можно будет использовать lazy loading, так же, как и для обычных картинок на страницах. В этом примере мы не будем останавливаться на таких оптимизациях, но не стоит про них забывать в практической работе.
На текущем этапе видим перед собой примерно такую картину:
Теперь создаем новый слой (Ctrl+Shift+N или Menu > Layer > Add layer). В XML-редакторе видим, что появился обычный элемент g. Пока далеко не ушли, можем задать ему class, который потом будем использовать в скриптах.
Не стоит полагаться на id. Чем сложнее интерфейс, тем проще схлопотать их повторение и получить странные баги. А в нашей задаче от них все равно пользы нет никакой. Так что классы или data-атрибуты – наш выбор.
Если пристально посмотреть на структуру документа в XML-редакторе, то можно заметить, что там много лишнего. Любой более-менее сложный векторный графический редактор будет добавлять что-то свое в документы. Убирать все это руками – дело долгое и неблагодарное, редактор постоянно будет что-то добавлять заново. Так что очистка SVG от мусора производится только в конце работы. И желательно в автоматизированном виде, благо есть готовые варианты, тот же svgo к примеру.
Находим инструмент под названием Draw Bezier curves and Straight Lines (Shift+F6). С его помощью будем рисовать замкнутые контуры вокруг объектов. В нашей задаче нужно обрисовать все здания. Для примера ограничимся шестью, но в реальных условиях стоило бы заранее выделить время для того, чтобы аккуратно обрисовать все необходимые объекты. Хотя часто бывает, что есть много похожих сущностей – те же этажи на здании могут быть абсолютно идентичными. В таких случаях можно немного ускориться и копипастить кривые.
После того, как обвели нужные здания, возвращаемся в XML-редактор, добавляем классы или, что скорее всего будет даже удобнее, data-атрибуты с индексами для них (можно и с адресами, но т.к. у нас район вымышленный — есть только индексы), и перемещаем все в ранее созданный слой, чтобы все было “разложено по полочкам”. И картинку, кстати, тоже будет полезно туда переместить, чтобы все было в одном месте, но это уже мелочи.
Теперь, выбрав один path – кривую вокруг здания, можно выбрать их все с помощью Ctrl+A или Menu > Edit > Select All и редактировать одновременно. Нужно их все закрасить в окне Fill and Stroke, а заодно там же убрать лишнюю обводку. Ну или добавить ее, если она нужна из соображений дизайна.
Имеет смысл закрасить все контуры каким-нибудь цветом с минимальным значением opacity для них, даже если это не нужно в плане дизайна. Дело в том, что “умные” браузеры считают, что нельзя кликнуть по пустому контуру, а по залитому – можно, даже если никто этой заливки не видит.
В нашем примере оставим небольшую подсветку белым цветом, чтобы лучше видеть, с какими именно зданиями работаем, сохраним все и плавно переместимся в браузер и более привычный редактор кода.
Базовый пример
Сделаем пустую html-страницу, вставим полученную SVG прямо в нее и добавим немного CSS, чтобы ничего не вылезало за границы экрана. Тут даже комментировать нечего.
.map {
width: 90%;
max-width: 1300px;
margin: 2rem auto;
border: 1rem solid #fff;
border-radius: 1rem;
box-shadow: 0 0 .5rem rgba(0, 0, 0, .3);
}
.map > svg {
width: 100%;
height: auto;
border-radius: .5rem;
}
Вспоминаем, что добавили зданиям классы, и используем их, чтобы CSS был более-менее структурированным.
.building {
transition: opacity .3s ease-in-out;
}
.building:hover {
cursor: pointer;
opacity: .8 !important;
}
.building.-available {
fill: #0f0 !important;
}
.building.-reserved {
fill: #f00 !important;
}
.building.-service {
fill: #fff !important;
}
Поскольку мы задавали инлайновые стили в Inkscape, в CSS нужно их перебить. Было бы удобнее все делать в CSS? И да, и нет. Зависит от ситуации. Иногда выбора нет. Например если дизайнер нарисовал много всего разноцветного и переносить все в CSS и раздувать его до невозможности как-то не комильфо. В данном примере я использую “неудобный” вариант, чтобы показать, что и он не особенно то и страшный в контексте решаемой задачи.
Предположим, что к нам прилетели свежие данные по домам, и добавим им разные классы в зависимости от их текущего статуса:
const data = {
id_0: { status: 'service' },
id_1: { status: 'available' },
id_2: { status: 'reserved' },
id_3: { status: 'available' },
id_4: { status: 'available' },
id_5: { status: 'reserved' },
messages: {
'available': 'Доступно для аренды',
'reserved': 'Зарезервировано',
'service': 'Доступно через 1-2 дня'
}
};
const map = document.getElementById('my-map');
const buildings = map.querySelectorAll('.building');
for (building of buildings) {
const id = building.getAttribute('data-building-id');
const status = data[`id_${id}`].status;
building.classList.add(`-${status}`);
}
Получим что-то такое:
Уже виднеется что-то похожее на то, что нам нужно. На данном этапе мы имеем подсвеченные объекты на плане местности, которые реагируют на наведение мыши. И не составляет труда добавить для них реагирование на нажатие кнопки мыши через стандартный addEventListener.
Leader Line
Часто встречается задача сделать линии, которые будут соединять объекты на карте и какие-то элементы на странице с дополнительной информацией, а также сделать минимальные всплывающие подсказки при наведении на эти самые объекты. Для решения этих задач очень неплохо подходит мини-библиотека leader-line, которая создает векторные стрелочки на любой вкус и цвет.
Давайте добавим к данным цены для всплывающих подсказок и сделаем эти линии.
const data = {
id_0: { price: '3000', status: 'service' },
id_1: { price: '3000', status: 'available' },
id_2: { price: '2000', status: 'reserved' },
id_3: { price: '5000', status: 'available' },
id_4: { price: '2500', status: 'available' },
id_5: { price: '2500', status: 'reserved' },
messages: {
'available': 'Доступно для аренды',
'reserved': 'Зарезервировано',
'service': 'Производится техническое обслуживание (1-2 дня)'
}
};
const map = document.getElementById('my-map');
const buildings = map.querySelectorAll('.building');
const info = map.querySelector('.info');
const lines = [];
for (building of buildings) {
const id = building.getAttribute('data-building-id');
const status = data[`id_${id}`].status;
const price = data[`id_${id}`].price;
building.classList.add(`-${status}`);
const line = new LeaderLine(
LeaderLine.pointAnchor(building, { x: '50%', y: '50%' }),
LeaderLine.pointAnchor(info, { x: '50%', y: 0 }),
{
color: '#fff',
startPlug: 'arrow1',
endPlug: 'behind',
endSocket: 'top'
}
);
lines.push(line);
}
Как можно заметить, ничего сложного не происходит. У линии есть “места крепления” к элементам. Координаты этих точек относительно элементов обычно удобно определять в процентах. В целом там есть много разных опций, перечислять и запоминать которые не имеет смысла, так что рекомендую просто полистать документацию. Одна из этих опций – startLabel – понадобится нам для создания маленькой всплывающей подсказки с ценой.
const line = new LeaderLine(
LeaderLine.pointAnchor(building, { x: '50%', y: '50%' }),
LeaderLine.pointAnchor(info, { x: '50%', y: 0 }),
{
startLabel: LeaderLine.captionLabel(`${price}р/сутки`, {
fontFamily: 'Rubik Mono One',
fontWeight: 400,
offset: [-30, -50],
outlineColor: '#555'
}),
color: '#fff',
startPlug: 'arrow1',
endPlug: 'behind',
endSocket: 'top',
hide: true
}
);
Никто не мешает все подсказки рисовать в графическом редакторе. Если у них предполагается неизменный контент, то это может быть даже удобно. Особенно если есть желание задавать им разное положение для разных объектов.
Мы также можем добавить опцию hide, чтобы все линии не отображались в виде веника. Будем показывать их по одной при наведении мыши на здания, которым они соответствуют:
building.addEventListener('mouseover', () => {
line.show();
});
building.addEventListener('mouseout', () => {
line.hide();
});
Здесь же можно выводить дополнительную информацию (в нашем случае просто текущий статус объекта) в место для информации. Получится уже почти то, что нужно:
Такие штуки редко проектируют под мобильные устройства, но стоит вспомнить о том, что их часто делают на весь экран на десктопе, да еще и с какими-нибудь панелями сбоку для дополнительной информации и нужно все красиво растянуть. Как-то так к примеру:
svg {
width: 100%;
height: 100%;
}
При этом пропорции SVG-элемента определенно не будут совпадать с пропорциями картинки внутри. Что же делать?
Несовпадающие пропорции
Первая вещь, которая приходит на ум – это свойство object-fit:cover из CSS. Но есть один момент: оно совершенно не умеет работать с SVG. А даже если бы и работало, то дома по краям плана могли бы вылезти за края схемы и стать абсолютно недоступными. Так что здесь нужно пойти несколько более сложным путем.
Шаг первый. У SVG бывает атрибут preserveAspectRatio, который в некоторой степени аналогичен свойству object-fit (не совсем, конечно, но...). Задав preserveAspectRatio="xMinYMin slice"
для основного SVG-элемента нашего плана получим растянутую схему без пустот по краям и без искажений.
Шаг второй. Нужно сделать перетаскивание мышкой. Технически такая возможность у нас осталась. Вот тут уже задачка посложнее, особенно для новичков. В теории у нас есть стандартные события для мышки и тачскрина, которые можно обработать и получить значение, на сколько нужно подвинуть карту. Но на практике можно в этом увязнуть очень надолго. На помощь придет hammer.js – еще одна небольшая библиотека, которая берет всю внутреннюю кухню на себя и предоставляет простой интерфейс для работы с перетаскиваниями, свайпами и.т.д.
Нам нужно сделать перемещение слоя со зданиями и картинкой во все стороны. Сделать это несложно:
const buildingsLayer = map.querySelector('.buildings_layer');
const hammertime = new Hammer(buildingsLayer);
hammertime.get('pan').set({
direction: Hammer.DIRECTION_ALL
});
По умолчанию hammer.js включает распознание еще и свайпов, но они нам не нужны на карте, так что выключаем их сразу, чтобы не морочили голову:
hammertime.get('swipe').set({ enable: false });
Теперь нужно как-то понять, что именно нужно поститать, чтобы перемещать карту только до ее краев, но не дальше. Нехитрым представлением двух прямоугольников в голове понимаем, что для этого нужно узнать отступы слоя со зданиями от родительского элемента (SVG в нашем случае) со всех четырех сторон. На помощь приходит getBoundingClientRect:
const layer = buildingsLayer.getBoundingClientRect();
const parent = svg.getBoundingClientRect();
const offsets = {
top: layer.top - parent.top,
bottom: layer.bottom - parent.bottom,
right: layer.right - parent.right,
left: layer.left - parent.left,
};
И почему у нас до сих пор нет более цивилизованного (и стабильно работающего) способа это сделать? Каждый раз дергать getBoundingClientRect очень нехорошо в плане производительности, но выбор не очень богатый, да и на глаз заметить торможение практически невозможно, так что не будем придумывать преждевременные оптимизации там, где и так все работает приемлемо. Так или иначе, это позволяет нам сделать проверку положения слоя со зданиями и перемещать все только если это имеет смысл:
let translateX = 0;
let translateY = 0;
hammertime.on('pan', (e) => {
const layer = buildingsLayer.getBoundingClientRect();
const parent = svg.getBoundingClientRect();
const offsets = {
top: layer.top - parent.top,
bottom: layer.bottom - parent.bottom,
right: layer.right - parent.right,
left: layer.left - parent.left,
};
const speedX = e.velocityX * 10;
const speedY = e.velocityY * 10;
if (speedX > 0 && offsets.left < 0) {
// туда
} else if (speedX < 0 && offsets.right > 0) {
// сюда
}
if (speedY > 0 && offsets.top < 0) {
// туда
} else if (speedY < 0 && offsets.bottom > 0) {
// и сюда
}
buildingsLayer.setAttribute('transform', `translate(${translateX} ${translateY})`);
});
По краям обычно стоит замедлить движение, чтобы не было резких остановок или рывков. Таким образом все туда-сюда превращаются во что-то такое:
if (speedX < -offsets.left) {
translateX += speedX;
} else {
translateX += -offsets.left * speedX / 10;
}
Вариантов замедлений можно придумать много. Этот – самый простой. И да, он не очень красивый, но зато тупой как пробка и понятный. Коэффициенты в таких примерах обычно подбираются на глаз, в зависимости от желаемого поведения карты.
Если открыть браузер и поиграться с размером окна в инструментах разработчика, то можно обнаружить, что что-то пошло не так...
Нечистые силы
На десктопных устройствах все работает, а на мобильных происходит магия, а именно – вместо перемещения карты перемещается элемент body. У-у-у-у-уууу! Только приведения там не хватает. Хотя ладно, это бывает потому, что что-то где-то переполнилось и какой-то обертке не задали overflow: hidden. Но в нашем случае может случиться такое, что совсем ничего не двигается.
Загадка для зеленых верстальщиков: есть элемент g, внутри элемента svg, внутри элемента div, внутри элемента body, внутри элемента html. Doctype естественно html. Если для перетаскивания элемента g добавить к нему transform: translate(…), то на ноутбуке он переместится, как и задумано, а на телефоне — даже не шелохнется. Ошибок в консоли нет. Но баг определенно есть. Браузер – последний Хром и там и там. Вопрос – почему?
Предлагаю вам подумать минут 10 без гугла перед тем, как смотреть ответ.
Ха-ха! Я вас обманул. Точнее не так. Я описал то, что мы бы наблюдали при ручном тестировании. Но на самом деле все работает так, как и должно работать. Это не баг, а фича, связанная с CSS-свойством touch-action. В контексте нашей задачи (внезапно!) обнаруживается, что оно есть, и, более того, имеет некоторое значение, которое ломает всю логику взаимодействия с картой. Так что поступаем с ним очень грубо:
svg {
touch-action: none !important;
}
Но вернемся к нашим баранам и посмотрим на результат (лучше, конечно, открыть в отдельной вкладке):
Я решил не подгонять код под какой-то из модных фреймворков, чтобы он остался в виде нейтральной бесформенной заготовки, от которой можно отталкиваться при создании своих компонентов.
Что в итоге?
Потратив совсем немного времени мы сделали план, на котором есть растровая картинка, подсветка разных ее деталей, соединение несвязанных объектов стрелочками и реакции на мышку. Я надеюсь, что удалось передать основную идею того, как все это делается в “бюджетном” варианте. Как мы отметили в начале статьи, вариантов применения можно найти много разных, в том числе и не связанных с какими-то замороченными дизайнерскими сайтами (хотя и на них такой подход применяется очень часто). Ну а если вы ищете что почитать про интерактивные, но уже трехмерные штуки, то оставляю ссылку на статью по теме — Трехмерные презентации товаров на Three.js для самых маленьких.
pettay1992
Очень интересная статья, спасибо!