Как стать автором
Поиск
Написать публикацию
Обновить
71.08
iSpring
Платформа для корпоративного обучения

Математика и веб-разработка: как мы добавили интерактивную кривую Безье в редактор изображений

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров1.4K

Добрый день, меня зовут Богдан, я фронтенд-разработчик в компании iSpring. В статье расскажу про интерактивную стрелку в редакторе изображений. Вы узнаете: как строятся кривые Безье и какие полезные свойства имеют; как вычислить кривую Безье, проходящую через заданные точки; как найти ограничивающую площадь этой кривой. Рассмотрим плюсы и минусы реализаций на Canvas и SVG.

Редактор изображений является частью платформы для корпоративного обучения iSpring Learn и был создан, чтобы решить проблему пользователей: им приходилось использовать сторонние программы для выделения элементов на скр��ншотах в обучающих материалах. В статье описывается решение этих задач.

Интерфейс встроенного редактора изображений
Интерфейс встроенного редактора изображений

С помощью этого инструмента пользователь может добавлять на изображение различные вспомогательные элементы, например, прямоугольники, текст, нумерацию и стрелку, про которую и пойдет речь в этой статье.

Frontend нашего редактора изображений построен на React и Typescript. Само изображение рисуется на canvas’е, на нем же располагаются все добавляемые объекты.

Интерактивная стрелка в редакторе изображений

Стрелка — это не просто линия. На скриншотах ниже показаны два подхода: прямая стрелка и изогнутая.

Прямая стрелка — тривиальна, но не всегда подходит для сложных сценариев.

Изогнутая стрелка — визуально приятнее, особенно актуальна в обучающих материалах.

На рынке инструментов для редактирования изображений существуют две основные стратегии реализации изогнутых линий: пиксельно-точные настройки, как в Figma и Adobe, и автоматическая интерполяция на основе заданных точек. Мы выбрали второй вариант, поскольку нам важно, чтобы любой человек без навыков дизайна с помощью нашего продукта мог быстро получить привлекательный результат.

Кривая Безье

Мы использовали кривую Безье. Это достаточно простая, но очень важная вещь для современной фронтенд-разработки.

Кривая Безье —  это математический инструмент для создания гладких кривых, которая задается набором опорных точек

Обратим внимание на одно важное свойство кривой. Опорные точки не всегда лежат на кривой Безье. Первая и третья точки принадлежат кривой, так как являются ее концами. Но вторая точка явно кривой не принадлежит и находится где-то над ней.

А что, если все точки должны располагаться на прямой? 

Именно эту задачу нужно решить, чтобы реализовать достаточно уд��бное манипулирование интерактивной кривой. Все элементы управления должны располагаться непосредственно на стрелке.

Создание стрелки в редакторе
Создание стрелки в редакторе

Задача сводится к тому, чтобы по трем известным точкам найти такую опорную точку, чтобы кривая прошла через все три имеющиеся точки. В математике подобная операция называется интерполяцией.

Существует множество методов для решения этой задачи, например, полином Лагранжа или сплайн-интерполяция. Это наиболее общие методы решения, но в нашем случае мы смогли значительно упростить себе жизнь.

Параметрическое уравнение кривой Безье

Квадратичную кривую математически можно описать с помощью такого выражение:

P = (1−t)2P1 + 2(1−t)tP2 + t2P3

P1, P2 и P3 — это опорные точки, по которым строится кривая Безье.

P — это точка на кривой для заданного параметра t.

Нам известны точки  P1 и P3, так как они являются концами стрелки.

Нам известна точка P — это элемент управления кривизной стрелки. 

Выразим из уравнения точку P2: 

P2 = (P - (1−t)2P1 - t2P3) / (2(1−t)t)

Уравнение имеет множество решений, так как параметр t не зафиксирован. При любом значении t от 0 до 1 кривая проходит через три заданные точки, но её изгиб меняется. Это позволяет, например, регулировать степень изгиба стрелки в зависимости от параметра. Однако, чтобы упростить работу пользователю, мы зафиксировали t = 0.5 — в этом случае изгиб симметричен, что выглядит наиболее естественно.

Подставим t = 0.5:

P2 = 2P - 0.5(P1 + P3)

Готово! Теперь у нас формула для построения кривой Безье через три ��аданные точки.

Алгоритм редактирования

Мы научились рисовать стрелку. Теперь рассмотрим, как ее редактировать. Что будет происходить с изогнутой стрелкой, когда пользователь захочет её растянуть, сузить, или повернуть.

Проецируем точку на прямую

Здесь нам понадобится проекция точки на прямую, заданную двумя другими точками. Как её найти? Нам поможет линейная алгебра и операции над векторами.

Точки A и B задают прямую. Нужно найти проекцию точки P на прямую AB.

С помощью вычитания точек найдем векторы u и v:

Теперь воспользуемся готовой формулой проецирования одного вектора на другой. Мы проецируем вектор u на вектор v и получаем вектор t.

Скалярное произведение векторов
Скалярное произведение векторов

Здесь используется скалярное произведение векторов

И теперь несложно найти точку, которую мы искали. Необходимо к точке A прибавить полученный вектор t. Как только мы получили проекцию, мы можем оценить, как далеко эта проекция находится от концов стрелки.

Зачем нам это нужно? Оказывается, если при изменении длины стрелки мы будем сохранять эти отношения, то изменение будет выглядеть достаточно естественно:

arr3.png

Тут есть один нюанс: проекция точки может не попасть на отрезок. В этом случае будем перемещать розовую точку в противоположную сторону:

squeeze-arrow.gif

Выпрямляем стрелку

Мы разработали общий подход, который отлично работает. Пользователь с легкостью изогнул стрелку, потянув за розовую точку. Но что, если он передумал и захотел сделать стрелку снова прямой?

Конечно, при должном умении можно вернуть точку в исходное положение, но сделать это идеально вряд ли получится.

Улучшим пользовательский опыт с помощью магнетизации. Так как мы научились находить проекцию точки на прямую, сделать это будет достаточно просто. Расстояние от точки до прямой равно расстоянию от точки до ее проекции на эту прямую. И когда расстояние от точки до прямой достаточно мало, возвращаем точку в исходное положение, и получаем прямую стрелку. Вот так это выглядит:

magnetization.gif

Отрисовка в браузере

Что в первую очередь приходит в голову, когда нужно нарисовать какой-то графический элемент?

Две основные технологии — это SVG и Canvas.

Отрисовка на Canvas

Если вы используете HTML5, у вас из коробки есть метод для рисования квадратичной кривой Безье с помощью Canvas 2D API: quadraticCurveTo

const ctx = canvas.getContext('2d')
ctx.moveTo(from.x, from.y)
ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, to.x, to.y)

Теперь нужно отрисовать наконечник этой стрелы. Наконечник мы рисуем просто двумя линиями. Единственный вопрос, куда он должен быть направлен. Когда пользователь меняет форму стрелки, наконечник должен поворачиваться в разные стороны.

ctx.moveTo(to.x, to.y)
ctx.lineTo(headPoint1.x, headPoint1.y)
ctx.moveTo(to.x, to.y)
ctx.lineTo(headPoint2.x, headPoint2.y)

Мы получим достаточно приятный результат, если примем направление стрелки за направление второго пунктирного отрезка: от второй опорной точки до наконечника. Получается, что мы строим направление наконечника с помощью невидимой точки, которая лежит где-то над стрелкой.

Проблема с экранным сглаживанием

При отрисовке стрелки на Canvas мы столкнулись с проблемой c тенями и экранным сглаживанием

Скорее всего, дело в том, что браузер отключает экранное сглаживание при использовании теней из-за того, что добавление тени — достаточно дорогая операция, и браузер оптимизирует отрисовку, не производя сглаживание краев.

Вторая проблема — сложная реализация интерактивности. Когда мы используем HTML-элементы, мы можем нативно подписаться на события кликов по ним, но с объектами, нарисованными на Canvas, так сделать не получится. Хотя математические вычисления остаются возможным решением, предпочтительнее использовать нативные браузерные механизмы для обработки кликов. В этом контексте SVG предлагает более удобный подход.

Отрисовка на SVG

Все элементы SVG поддерживают обработку пользовательских событий (например, кликов) без дополнительных вычислений. Чтобы реализовать отрисовку стрелки, достаточно использовать тег <path>, где геометрия задается командами, аналогичными методам Canvas, но в сокращенной форме:

ctx.moveTo(from.x, from.y)
ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, to.x, to.y)
<path d={`
    M ${from.x} ${from.y}
    Q ${controlPoint.x} ${controlPoint.y}, ${to.x} ${to.y}
`}/>

Проблема с автоматическим замыканием контуров

При отрисовке стрелки таким образом возникает неожиданный эффект: браузер автоматически замыкает открытые пути, соединяя конечную точку с начальной. Это приводит к заливке внутренней области, даже если контур не был замкнут явно.

Решение:

Добавление fill="none" отключает заливку, но область остается кликабельной, что может мешать взаимодействию с другими элементами.

Нам нужно вручную замкнуть отрисованный путь, проведя кривую в обратную сторону:

<path d={`
    M ${from.x} ${from.y}
    Q ${controlPoint.x} ${controlPoint.y}, ${to.x} ${to.y}
    Q ${controlPoint.x} ${controlPoint.y}, ${from.x} ${from.y}
`}/>

Добавляем элементы управления

В нашем редакторе используются одинаковые элементы управления как для стрелки, так и для других графических объектов, например, прямоугольника и текста. Логично использовать общую реализацию. Однако не все компоненты редактора реализованы на SVG.

Тег <foreignObject> позволяет встраивать HTML-контент в SVG. Например, React-компонент ControlPoint можно реализовать на HTML и встроить внутрь SVG:

<svg xmlns="http://www.w3.org/2000/svg">
    <path d={...} />
    <foreignObject>
        <div>
            <ControlPoint />
            <ControlPoint />
            <ControlPoint />
         </div>
    </foreignObject>
</svg>

Однако в Safari это работает некорректно — элементы управления смещаются относительно целевого объекта.

После небольшого исследования оказалось, что проблема находится на стороне Safari. Браузер неправильно обрабатывает тег <foreignObject>. Более подробно проблема описана здесь.

Меняем подход:

На этом мы не остановились и продолжили искать решение. В документации нашли такую интересную вещь:

С помощью тега <g> мы можем использовать вложенные <svg> теги.

Такая группировка позволяет использовать SVG-элементы управления как в SVG, так и в HTML-контексте. Например:

<svg xmlns="http://www.w3.org/2000/svg">
    <g>
        <path d={...} />    
        <svg>
            <ControlPoint />
            <ControlPoint />
            <ControlPoint />
        </svg>
    </g>
</svg>

Расчет размеров SVG для кривых Безье

SVG требует явного указания размеров (viewBox), которые не могут определяться автоматически. Для кривых Безье это создает сложности: плавные кривые могут выходить за границы, если рассчитывать их по трём известным точкам:

Варианты решения:

  1. Математический расчет

Первое, что пришло на ум — нахождение экстремумов кривой через производную уравнения Безье (для координат X и Y). Продифференциируем уравнение по координате x, приравняем производную нулю и выразим параметр t:

x′(t) = (−2x0+2x1)+2(x0−2x1​ +x2)t

(−2x0+2x1)+2(x0−2x1​ +x2)t = 0

t = (x0−x1) / (x0−2x1​ +x2)

При 0 < t < 1 экстремум найден.

  1. Использование выпуклой оболочки

Но можно ли проще? Оказывается, можно. Изучим еще одно полезное свойство кривой Безье:

Кривая Безье всегда находится внутри выпуклой оболочки, образованной ее опорными точками.

Существует множество алгоритмов построения выпуклой оболочки, например, алгоритм Грэхема

graham-speed.gif

Легче не стало. На самом деле, в нашем случае можем немного схитрить. Мы имеем квадратичную кривую Безье, то есть используются всего три опорные точки. Три точки образуют треугольник, а треугольник — это всегда выпуклая фигура. Как бы мы эти точки не соединили — всегда получим выпуклую оболочку. Теперь мы можем расчитывать размер SVG по опорным точкам кривой Безье, и стрелка гарантированно будет находиться внутри этого размера:

Сравним подходы:

Объединим подходы

Чтобы объединить плюсы обоих подходов, мы использовали гибридное решение. Мы отрисовали визуал стрелки на canvas, а интерак��ивную часть (хитбокс) и элементы управления сделали с помощью невидимого SVG, который накладывается сверху на стрелку. Стрелка рисуется в два слоя. Вот так это выглядит, если покрасить SVG в прозрачный голубой цвет:

Хитбокс больше стрелки, потому что это помогает пользователю меньше промахиваться.

Итог

Создание, казалось бы, простого элемента — интерактивной стрелки — оказалось полноценной исследовательской задачей.

Мы начали с основ, изучив кривые Безье и алгоритмы их построения. Избавились от сложностей интерполяции, отыскав элегантное решение через параметрическое уравнение. 

Добавление стрелки в редактор изображений заняло 70 часов разработки одним человеком. За это время успели попробовать несколько вариантов управления стрелкой, потому что не всё получается с первого раза.

Самым серьезным вызовом оказался выбор технологий для отрисовки и сама ее реализация. Мы столкнулись с проблемами сглаживания и интерактивности в Canvas, особенностями рендеринга SVG в некоторых браузерах. В итоге обточили решение и пришли к гибридному подходу.

Второй по сложности и потраченному времени задачей стала поддержка Safari.

Мы отнюдь не исключаем множество других вариантов реализации. Будем рады, если поделитесь вашими идеями в комментариях.

Теги:
Хабы:
+9
Комментарии2

Публикации

Информация

Сайт
www.ispring.ru
Дата регистрации
Дата основания
2001
Численность
201–500 человек
Местоположение
Россия
Представитель
Приёмко Андрей