Ссылка на канвас ниндзю в конце.

Конкретно речь пойдёт об элементе Path и как его реализовать на canvas.

Как мы помним path в svg умеет рисовать кривые безье, сплайны из этих кривых, а так же окружности. У канваса в этом плане возможностей куда меньше, так что будем работать с ним. Для начала научимся рисовать кривые. В svg как и в canvas кривые ограничены лишь 3-мя степенями, это сделано ради оптимизации, мы же будем использовать каноничное уравнение для их вычисления, так что кривые у нас будут любого порядка.

Ничего сложного в расчёте кривых нет. Ниже код функции, которая возвращает массив вида [x,y] — это точка на кривой. Входные параметры для функции: массив контрольных точек и коэффициент смещения относительно начала кривой. Смещение — это значение в процентах от 0% до 100% (0 — начало кривой, 100 — конец), надеюсь этот момент понятен.

// так же понадобится найти факториал
function factorial (number) {
        var result = 1;
        while(number){
            result *= number--;
        }
        return result;
}

function getPointOnCurve (shift, points) {
        var result = [0,0];
        var powerOfCurve = points.length - 1;
        shift = shift/100;
        for(var i = 0;points[i];i++){
            var polynom = (factorial(powerOfCurve)/(factorial(i)*factorial(powerOfCurve - i))) *
                Math.pow(shift,i) *
                Math.pow(1-shift,powerOfCurve - i);
            result[0] += points[i][0] * polynom;
            result[1] += points[i][1] * polynom;
        }
        return result;
}

getPointOnCurve(60, [[0,0],[100,0],[100,100]]); // -> [84, 36]

Весь фокус здесь состоит в полиноме, который играет роль коэффициента для вычисления точки. Я уже писал когда-то о кривых, только высчитывал их через рекурсию (вот здесь).

Следующая остановка — сплайны.

Вообще с помощью предыдущей функции можно отрисовать произвольный участок кривой, но со сплайнами всё сложнее, чтобы нарисовать только часть сплайна нужно знать его длину, а соответственно и длины его кривых. Этого не потребуется, если мы будем выводить сплайн целиком, но кто же ищет лёгких путей?

Теперь массив контрольных точек будет иметь такой вид:

[[0,0],[100,0],[100,100,true],[200,100],[200,0]]

Точки с флагом true — опорные.

Код функции
function getCenterToPointDistance (coordinates){
    return Math.sqrt(Math.pow(coordinates[0],2) + Math.pow(coordinates[1],2));
}

function getLengthOfCurve (points, step) {
    step = step || 1;
    var result = 0;
    var lastPoint = points[0];
    for(var sift = 0;sift <= 100;sift += step){
        var coord = getPointOnCurve(sift,points);
        result += getCenterToPointDistance([
            coord[0] - lastPoint[0],
            coord[1] - lastPoint[1]
        ]);
        lastPoint = coord;
    }
    return result;
};

function getMapOfSpline (points, step) {
    var map = [[]];
    var index = 0;
    for(var i = 0;points[i];i++){
        var curvePointsCount = map[index].length;
        map[index][+curvePointsCount] = points[i];
        if(points[i][2] && i != points.length - 1){
            map[index] = getLengthOfCurve(map[index],step);
            index++;
            map[index] = [points[i]];
        }
    }
    map[index] = getLengthOfCurve(map[index],step);
    return map;
};

function getPointOnSpline (shift, points, services) {
    var shiftLength = services.length / 100 * shift;
    if(shift >= 100){
        shiftLength = services.length;
    }
    var counter = 0;
    var lastControlPoint = 0;
    var controlPointsCounter = 0;
    var checkedCurve = [];
    for(; 
        services.map[lastControlPoint] && 
        counter + services.map[lastControlPoint] < shiftLength; 
        lastControlPoint++
    ){
        counter += services.map[lastControlPoint];
    }
    for(
        var pointIndex = 0; 
        points[pointIndex] && controlPointsCounter <= lastControlPoint; 
        pointIndex++
    ){
        if(points[pointIndex][2] === true){
            controlPointsCounter++;
        }
        if(controlPointsCounter >= lastControlPoint){
            checkedCurve.push(points[pointIndex]);
        }
    }
    return getPointOnCurve(
        (shiftLength - counter) / (services.map[lastControlPoint] / 100),
        checkedCurve
    );
};

var points = [[0,0],[100,0],[100,100,true],[200,100],[200,0]];
var services = {};
services.map = getMapOfSpline(points);

services.length= 0;
for(var key in services.map){
    services.length += services.map[key];
}

getPointOnSpline(60, points, services); // -> [136, 95.(9)]


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

Для дуги в path SVG предусматривает 7 параметров, что на мой взгляд излишне, вот эти параметры:
A rx ry x-axis-rotation large-arc-flag sweep-flag x y

Расшифровка:

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

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

Для дуги нам нужно знать:
  • полуоси эллипса
  • углы начала и конца дуги
  • наклон эллипса


Всего 5 параметров.

Итак логика будет такая, следим за руками. Началом нашей дуги эллипса (не путать с его центром) будет предыдущая точка в массиве или начало координат, если предыдущей точки нет. Конец дуги — опорная точка для дальнейшего построения пути. С помощью параметров, дуга принимает любую форму и направляется куда угодно, всё чинно. Опять же нужно посчитать длину всех отрезков для построения лишь части пути.

Вот как будет выглядеть набор точек с включённым в него эллипсом:

[
    [x1,y1],
    [x2,y2],
    [x3,y3],
    [radiusX,radiusY,startRadian,endRadian,tilt], // tilt - optional
    [x5,y5],
    ...
    [xN,yN]
]

Код
function getPointOnEllipse (radiusX,radiusY,shift,tilt,centerX,centerY){
        tilt    = tilt || 0;
        tilt    *= -1;
        centerX = centerX || 0;
        centerY = centerY || 0;

        var x1  = radiusX*Math.cos(+shift),
            y1  = radiusY*Math.sin(+shift),
            x2  = x1 * Math.cos(tilt) + y1 * Math.sin(tilt),
            y2  = -x1 * Math.sin(tilt) + y1 * Math.cos(tilt);

        return [x2 + centerX,y2 + centerY];
 }

function getLengthOfEllipticArc (radiusX, radiusY, startRadian, endRadian, step) {
    step = step || 1;
    var length = 0;
    var lastPoint = getPointOnEllipse(radiusX,radiusY,startRadian);
    var radianPercent = (endRadian - startRadian) / 100;
    for(var i = 0;i<=100;i+=step){
        var radian = startRadian + radianPercent * i;
        var point = getPointOnEllipse(radiusX,radiusY,radian);
        length += getCenterToPointDistance([point[0]-lastPoint[0],point[1]-lastPoint[1]]);
        lastPoint = point;
    }
    return length;
};

function getMapOfPath (points, step) {
    var map = [[]];
    var index = 0;
    var lastPoint = [];
    for(var i = 0;points[i];i++){
        var point = points[i];
        if(point.length > 3){
            map[index] = getLengthOfEllipticArc(point[0], point[1], point[2], point[3], step);
            if(!points[i+1]){continue}
            var centerOfArc = getPointOnEllipse(point[0], point[1], point[2] + Math.PI, point[4], lastPoint[0], lastPoint[1]);
            var endOfArc = getPointOnEllipse(point[0], point[1], point[3], point[4], centerOfArc[0], centerOfArc[1]);
            index++;
            map[index] = [endOfArc];
            lastPoint = endOfArc;
            continue;
        }
        map[index].push(point);
        if(point[2] === true || (points[i+1] && points[i+1].length > 3)){
            map[index] = getLengthOfCurve(map[index],step);
            index++;
            map[index] = [point];
        }
        lastPoint = point;
    }
    if(typeof map[index] !== 'number'){map[index] = getLengthOfCurve(map[index],step);}
    return map;
};

function getPointOnPath (shift, points, services) {
    var shiftLength = services.length / 100 * shift;
    if(shift >= 100){
        shiftLength = services.length;
    }
    var counter = 0;
    var lastControlPoint = 0;
    var controlPointsCounter = 0;
    var checkedCurve = [];
    for(; services.map[lastControlPoint] && counter + services.map[lastControlPoint] < shiftLength; lastControlPoint++){
        counter += services.map[lastControlPoint];
    }
    var lastPoint = [];
    for(var pointIndex = 0; points[pointIndex] && controlPointsCounter <= lastControlPoint; pointIndex++){
        var point = points[pointIndex];
        if(point.length > 3){
            var centerOfArc = getPointOnEllipse(point[0], point[1], point[2] + Math.PI, point[4], lastPoint[0], lastPoint[1]);
            if(controlPointsCounter === lastControlPoint){
                var percent = (shiftLength - counter) / (services.map[lastControlPoint] / 100);
                var resultRadian = point[2] + ((point[3] - point[2])/100*percent);
                return getPointOnEllipse(point[0], point[1], resultRadian, point[4], centerOfArc[0], centerOfArc[1]);
            }
            lastPoint = getPointOnEllipse(point[0], point[1], point[3], point[4], centerOfArc[0], centerOfArc[1]);
            controlPointsCounter++;
            if(controlPointsCounter === lastControlPoint){
                checkedCurve.push(lastPoint);
            }
            continue
        }
        if(point[2] === true || (points[pointIndex+1] && points[pointIndex+1].length > 3)){
            controlPointsCounter++;
        }
        if(controlPointsCounter >= lastControlPoint){
            checkedCurve.push(point);
        }
        lastPoint = point;
    }
    return getPointOnCurve(
        (shiftLength - counter) / (services.map[lastControlPoint] / 100),
        checkedCurve
    );
};

var points = [[0,0],[100,0],[100,100],[20,20,0,Math.PI],[200,100],[200,0]];
var services = {};
services.map = getMapOfPath(points);

services.length= 0;
for(var key in services.map){
    services.length += services.map[key];
}

getPointOnPath(60, points, services); // -> [96.495, 98.036]


Ну вот теперь мы крутые чуваки, много чего умеем.
Ссылка на примеры вот.
UPD. Не пугайтесь лишних линий в примере — это путь с включёнными в него эллипсами, т.к. ниндзя нарисован только сплайнами.
Поделиться с друзьями
-->

Комментарии (4)


  1. evgeniy2194
    03.07.2017 16:22
    +1

    Ссылка на живые примеры вот.

    Я один не нашел ни одного живого примера?


    1. takovoy
      03.07.2017 16:25

      Извиняюсь, это жеж репозиторий :)


      1. GlukKazan
        03.07.2017 18:13
        +1

        1. takovoy
          03.07.2017 19:49
          +1

          Спасибо за подсказку! Иногда очень полезные вещи лежат под носом.