Предисловием мне хотелось бы поздравить одного хабраюзера с днём рождения. Расти большим, будь умным, и допили уже наконец свой canvas-фреймворк Graphics2D до того состояния, которое считаешь приемлемым.
С днём рождения, я. :P


Этим летом мне пришла в голову интересная мысль: если бы я писал микробиблиотеку для canvas в 100 строк, что бы я туда уместил?.. Самый развёрнутый ответ можно написать за 1 вечер. А потом пришла и идея этой статьи.

Предлагаю реализовать ООП, события и анимацию на canvas — самые часто нужные (имхо) вещи… и всё это в 100 строк. Часть первая.

Дисклеймер: тут вас (иногда) поджидают совсем ненужные извращения для экономии пары символов кода. Автор (а это я) считает, что в микробиблиотеках так можно, и очень часто делается. Если это не нарушает производительность, конечно.

Рад видеть вас под катом ;)


Начнём с идеи (а первым делом — ООП). 3 главных объекта: пути, изображения, текст. Нет никакой нужды реализовывать, например, прямоугольники и круги в минибиблиотеке: они легко создаются через путь. Как и спрайты — через картинки. И т.п.
Первый аргумент объекта — его содержание.
Второй — стили, которые устанавливаются на canvas перед рисованием.

Я назову это Rat :P
Rat = function(context){
    this.context = context;
};


Пути


Как-то так будет неплохо:

var path = rat.path([
  ['moveTo', 10, 10],
  ['lineTo', 100, 100],
  ['lineTo', 10, 100],
  ['closePath']
], {
  fillStyle: 'red',
  strokeStyle: 'green',
  lineWidth: 4
});


У всех 3 объектов нужно установить свойством контекст, объект для стилей и т.п… Так что:
Rat.init = function(cls, arg){
    cls.opt = arg[0];
    cls.style = arg[1] || {};
    cls.context = arg[2];
    cls.draw(arg[2].context);
};

Вроде бы всё понятно? У каждого объекта есть 3 свойства: opt (1 аргумент), style (2й) и context (контекст), а также функция draw(ctx), рисующая этот объект.

Наш класс:
Rat.Path = function(opt, style, context){
    Rat.init(this, arguments);
};

Да, как ни странно, конструктор — всё.

Самое главное: отрисовка:
Rat.Path.prototype = {
    draw: function(ctx){
        this.process(function(ctx){
            if(this.style.fillStyle)
                ctx.fill();
            if(this.style.strokeStyle)
                ctx.stroke();
        }, ctx);
    },
    process: function(callback, ctx){
        ctx = ctx || this.context.context;
        Rat.style(ctx, this.style);
        ctx.beginPath();
        this.opt.forEach(function(func){
            ctx[func[0]].apply(ctx, func.slice(1));
        });
        var result = callback.call(this, ctx);
        ctx.restore();
        return result;
    }
};

Функция process тут вовсе неспроста: она понадобится ещё кое-где:
    isPointIn: function(x,y, ctx){
        return this.process(function(ctx){ return ctx.isPointInPath(x, y); }, ctx);
    }

Зачем callback? Хм… Для красоты.

Функция Rat.style, также общая для всех 3 объектов, просто переносит свойства на canvas. Не забываем, что нам также хочется трансформаций:
// не смотрите на меня так, в микробиблиотеках иногда можно так извращаться
// иногда
Rat.notStyle = "translate0rotate0transform0scale".split(0);
Rat.style = function(ctx, style){
    ctx.save();
    style.origin && ctx.translate.apply(ctx, style.origin);
    style.rotate && ctx.rotate(style.rotate);
    style.scale && ctx.scale.apply(ctx, style.scale);
    style.origin && ctx.translate(-style.origin[0], -style.origin[1]);
    style.translate && ctx.translate.apply(ctx, style.translate); // интересно, это лучше до или после origin?
    style.transform && ctx.transform.apply(ctx, style.transform);
    Object.keys(style).forEach(function(key){
        if(!~Rat.notStyle.indexOf(key))
            ctx[key] = style[key];
    });
};


Ай, не бейте, я все объясню. !~Rat.notStyle.indexOf(key) — тоже самое, что и Rat.notStyle.indexOf(key) != -1. Это микробиблиотека всё же.

Ну и, наконец, функция контекста, создающая и возвращающая экземпляр нашего класса:
Rat.prototype = {
    path : function(opt, style){ return new Rat.Path(opt, style, this); },
};


Всё, можно рисовать пути. Ура!

И, помимо основных стилей, присутствуют, как можно было заметить в Rat.style, трансформации:

var path = rat.path([
  ['moveTo', 10, 10],
  ['lineTo', 100, 100],
  ['lineTo', 10, 100],
  ['closePath']
], {
  fillStyle: 'red',
  strokeStyle: 'green',
  lineWidth: 4,
  rotate: 45 / 180 * Math.PI,
  origin: [55, 55]
});
Картинка обрезана, т.к. нарисована в нулевых координатах.

Картинки


Следуя дальше принципу 2 аргументов, мы хотим воот такой вот класс:
var img = new Image();
img.src = "image.jpg";
img.onload = function(){
  rat.image(img);
}

Помимо этого, в стилях можно передавать параметры width, height и crop (массив из 4 чисел). Всё так же, как в оригинальной drawImage CanvasRendering2DContext-а.

Снова конструктор класса:
Rat.Image = function(opt, style, context){
    Rat.init(this, arguments);
};


Отрисовка выглядит как-то так:
Rat.Image.prototype.draw = function(ctx){
    Rat.style(ctx, this.style);
    if(this.style.crop)
        ctx.drawImage.apply(ctx, [this.opt, 0, 0].concat(this.style.crop));
    else
        ctx.drawImage(this.opt, 0, 0, this.style.width || this.opt.width, this.style.height || this.opt.height);
    ctx.restore();
};

Всё, вроде бы, просто.

И последнее, конечно же:
Rat.prototype = {
    ...
    image : function(opt, style){ return new Rat.Image(opt, style, this); },
};


Ура, и картинки есть.

Текст


3й глобальный объект:
var text = rat.text("Hello, world!", {
  fillStyle: 'blue'
});
Также есть свойство maxWidth.

Конструктор:
Rat.Text = function(){
    Rat.init(this, arguments);
};


Отрисовка очень простая. А решение, как всегда, не очень чистое, зато работающее ).
Rat.Text.prototype.draw = function(ctx){
    Rat.style(ctx, this.style);
    if(this.style.fillStyle)
        ctx.fillText(this.opt, 0, 0, this.style.maxWidth || 999999999999999);
    if(this.style.strokeStyle)
        ctx.strokeText(this.opt, 0, 0, this.style.maxWidth || 9999999999999999);
    ctx.restore();
};


А ещё текст на canvas-е можно мерить. Ширину, да. Высота определяется размером шрифта.
Rat.Text.prototype.measure = function(){
    var ctx = this.context.context;
    Rat.style(ctx, this.style);
    var w = ctx.measureText(this.opt).width;
    ctx.restore();
    return w;
};


Не забываем:
Rat.prototype = {
    ...
    image : function(opt, style){ return new Rat.Image(opt, style, this); },
};


По мелочи


Иногда нужно простить, забыть, выкинуть всё и начать с чистого листа. Для таких случаев есть функция clear:
Rat.prototype = {
...
    clear: function(){
        var cnv = this.context.canvas;
        this.context.clearRect(0, 0, cnv.width, cnv.height);
    }
};

Для всего остального есть draw, рисующий все объекты из массива:
Rat.prototype = {
...
    draw: function(elements){
        var ctx = this.context;
        elements.forEach(function(element){
            element.draw(ctx);
        });
    }
};


Примеры:


Ну а теперь… Давайте, например, накодим кнопку на canvas-е (самое простое, что придумалось):
// квадратик
var path = rat.path([
	['moveTo', 10, 10],
	['lineTo', 100, 10],
	['lineTo', 100, 40],
	['lineTo', 10, 40],
	['closePath']
], {
	fillStyle: '#eee',
	strokeStyle: '#aaa',
	lineWidth: 2
});

// текст
var text = rat.text("Hello, world", {
	translate: [55, 28],
	textAlign: 'center',
	fillStyle: 'black'
});



И пуусть… При наведении мыши она подсвечивается:
var bounds = ctx.canvas.getBoundingClientRect();
var hover = false;
ctx.canvas.addEventListener('mousemove', function(e){
	var x = e.clientX - bounds.left,
		y = e.clientY - bounds.top;
	if(x > 10 && x < 100 && y > 10 && y < 40){
		if(hover)
			return;
		hover = true;
		path.style.fillStyle = '#ccc';
		rat.clear();
		rat.draw([path, text]);
	}
	else if(hover){
		hover = false;
		path.style.fillStyle = '#eee';
		rat.clear();
		rat.draw([path, text]);
	}
});



А зачем?


Самое интересное, что на базовом canvas можно накодить примерно то же примерно тем же количеством кода.
Скрытый текст
// квадратик
var path = {
	fill: '#eee',
	draw: function(){
		ctx.moveTo(10, 10);
		ctx.lineTo(100, 10);
		ctx.lineTo(100, 40);
		ctx.lineTo(10, 40);
		ctx.closePath();

		ctx.fillStyle = this.fill;
		ctx.strokeStyle = '#aaa';
		ctx.lineWidth = 2;
		ctx.fill();
		ctx.stroke();
	}
};
// текст
var text = {
	draw: function(){
		ctx.textAlign = 'center';
		ctx.fillStyle = 'black';
		ctx.fillText("Hello, world",  55, 28);
	}
};
path.draw();
text.draw();

var bounds = ctx.canvas.getBoundingClientRect();
var hover = false;
ctx.canvas.addEventListener('mousemove', function(e){
	var x = e.clientX - bounds.left,
		y = e.clientY - bounds.top;
	if(x > 10 && x < 100 && y > 10 && y < 40){
		if(hover)
			return;
		hover = true;
		path.fill = '#ccc';
		ctx.clearRect(0, 0, 800, 400);
		path.draw();
		text.draw();
	}
	else if(hover){
		hover = false;
		path.fill = '#eee';
		ctx.clearRect(0, 0, 800, 400);
		path.draw();
		text.draw();
	}
});

Но это стало очевидно только после того, как 100 строк написаны…
github.com/keyten/Rat.js/blob/master/rat.js

Ну что ж… В следующей части (если хабрахабру будет интересна эта тема) я покажу реализацию обработки мыши, и 3 часть — анимация. Всё снова в 100 строк (посмотрим, получится ли).
Пойду праздновать день рождения.

Всем интересного кода!

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


  1. Zenitchik
    05.11.2015 22:55
    +1

    Больше всего понравилось название =('.')=
    На втором месте !~Rat.notStyle.indexOf(key)
    Сейчас набегут адепты читабельности и заминусят меня. Но, что поделать, я не друг тем людям, для которых смысл этой записи очевиден.
    ~(-1) === 0, а !0 === true. Без этих знаний можно только скриптики для сайтиков писать, а к толстому клиенту не лучше подходить.

    Теперь по существу:
    У описанной части есть только одно полезное свойство — графика описывается данными. Данные можно запомнить, а потом отрисовать полностью или частично, в одном canvase или в нескольких. Это открывает возможность разработке в парадигме MVC.
    Код смотреть пока не досуг. С удовольствием почитаю следующую статью.
    Была бы очень в тему поддержка добавления своих функций отрисовки, доступных по тому же алгоритму.


    1. Keyten
      05.11.2015 23:16
      +1

      Да, про данные подумал не сразу. Но, в целом, можно и data-ориентированный код на чистом 2dcontext писать, разница есть, но не сильно заметная. Ну, мне так кажется, очень надеюсь, что я ошибаюсь.
      В любом случае, именно на этих объектах будут строиться следующие части статьи.

      Про добавление своих функций отрисовки чуть-чуть не понял, имеется в виду что-то подобное?:

      var rect = rat.rect([10, 10, 200, 200], {
          fillStyle: 'red'
      });
      

      Скрытый текст
      Rat.Rect = function(opt, style, context){
          Rat.init(this, arguments);
      };
      Rat.Rect.prototype = {
          draw: function(ctx){
              ctx = ctx || this.context.context;
              Rat.style(ctx, this.style);
              ctx.rect.apply(ctx, this.opt);
              if(this.style.fillStyle)
                      ctx.fill();
              if(this.style.strokeStyle)
                      ctx.stroke();
              ctx.restore();
          }
      };
      
      Rat.prototype.rect = function(opt, style){
          return new Rat.Rect(opt, style, this);
      };
      


      1. Zenitchik
        06.11.2015 15:59

        Это я сперва не понял идею.
        data-ориентированность тут не до конца. Ну, не беда, можно допилить. Жаль, времени пока нет в коде копаться…


        1. Keyten
          07.11.2015 00:52

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


          1. Zenitchik
            09.11.2015 13:14

            Код посмотрел. Некоторые идеи есть, но кодить пока некогда. А на естественном языке мне мысли о коде выражать труднее, чем на JS.
            Будет окно в своих скриптах — предложу свой коммит для Rat.
            А пока мне интересно, как Вы думаете реализовать события. Я сам очень мало работал с canvas и рассчитываю с помощью какого-то из Ваших фреймворков ликвидировать безграмотность.


    1. Zenitchik
      06.11.2015 14:59
      +1

      Пройдёмся по опечаткам (дюже они страшные):
      *я не друг тем людям, для которых смысл этой записи не очевиден.
      *а к толстому клиенту не лучше не подходить

      Вот что бывает, когда в конце рабочего дня комменты пишешь…


      1. Keyten
        06.11.2015 15:40

        Ну и ещё недосуг слитно.

        На первой опечатке ненадолго завис, но в итоге смысл понял, вторая просто незаметна.


  1. kentilini
    06.11.2015 08:40

    Попробуйте нарисовать подряд большую картинку и потом треугольник, и будете приятно удивлены


    1. Keyten
      06.11.2015 09:08
      +2

      Ну да, картинка же рисуется по событию onload, которое случается после основного потока… Поэтому она окажется нарисована позже (и выше, соответственно). Если вы об этом.

      var path = rat.path([
        ['moveTo', 10, 10],
        ['lineTo', 100, 100],
        ['lineTo', 10, 100],
        ['closePath']
      ], {
        fillStyle: 'red',
        strokeStyle: 'green',
        lineWidth: 4
      });
      
      var img = new Image();
      img.src = "image.jpg";
      img.onload = function(){
        img = rat.image(img);
        path.draw(); // внимание сюда
      }
      


      1. Zenitchik
        06.11.2015 16:12

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


        1. Keyten
          07.11.2015 00:50

          Ну да: достаточно сделать массив элементов в Rat, и в функциях Rat.prototype.path, image, text, пушать элемент в массив. Можно даже и сам Rat от массива унаследовать. Дальше будет обработка событий, и я, возможно, приду к чему-нибудь подобному.

          Такой подход я реализовал в, собственно, Graphics2D ( Github ), а Rat — чисто поиграть-поэкспериментировать, что уложится в 100 строк :).
          В G2D всё то же самое реализуется таким кодом:

          var path = ctx.path([
            // можно точно так же указывать функции ( ['lineTo', x, y] )
            [10, 10], // но по умолчанию -- 2 аргумента => lineTo
            [100, 100], // а в первом -- moveTo
            [10, 100],
            true // closePath
          ], 'red', 'green 4px');
          
          var img = ctx.image('image.jpg', x, y);
          

          Всё перерисовывается, обрабатывается и т.п.


          1. kentilini
            08.11.2015 16:26

            Мы делали очередь, и ждали пока не будет доступны для отрисовки все ниже стоящие слои. Еще аналог 'z-index'a навешивали, для удобства использования.


          1. Zenitchik
            09.11.2015 13:11

            Я бы сделал сервис загрузки картинок, который бы принимал {string}url и возвращал {Image}. Заодно исключив повторную загрузку с одного и того же url.


          1. Zenitchik
            09.11.2015 13:18

            UPD: Я бы Rat к массиву не привязывал. Возможно, один и тот же массив придётся отрисовывать в разных canvas, а canvas уже привязан к экземпляру Rat.


  1. Nadoedalo
    06.11.2015 17:40

    Мне всегда нравился другой подход:

    var context = document.get('canvas').getContext('2d') //псевдокод
        h_context = new function(){
            this.chain = function(method, args){
                context[method].apply(context, args); 
                return this;
            }
            return this;
       }
     h_context
        .chain('beginPath')
        .chain('fillRect', [0, 0, window.innerWidth, window.innerHeight]);
    //ну или 
     h_context
        .chain('beginPath')
        .chain('arc', [/*some, arguments*/])
        .chain('fill')
    

    Как-то оно нагляднее что-ли показывает процесс. Аргументы всё-равно достаточно редко нужны общие для всех рисунков. Хотя, эту обёртку я в основном писал что бы уменьшить количество строк при рисовании фигур, но выглядит достаточно удобно и более гибко.
    PS форматирование хромает =(


    1. Keyten
      07.11.2015 00:37

      Мне это больше всего напоминает EaselJS:

      var circle = new createjs.Shape();
      circle.graphics.beginFill("DeepSkyBlue").drawCircle(0, 0, 50);
      circle.x = 100;
      circle.y = 100;
      stage.addChild(circle);
      

      Может быть, это и удобно. Но… моё чувство эстетики (эстетичности? как правильно?) всеми руками протестует против чеининга свойств контекста:
      // зачем делать так??
      context.fillStyle('red').fillRect(10, 10, 200, 200);
      
      // когда можно вот так?
      context.fillRect(10, 10, 200, 200, 'red');
      

      И при этом в каком-то виде возможна реализация ООП, и соблюдается «чистота» — свойства ранее отрисованных объектов не переползают на позднее рисуемые.