19 May 2015

HTML-страница на Canvas

Canvas
Sandbox
Canvas интересный и перспективный HTML5 элемент. Он хорошо подходит как вспомогательный элемент на HTML странице, например, для отрисовки какой-нибудь простой по сюжету динамической заставки. Однако реализация функционально законченных комплексных решений с ориентацией на Canvas задача уже не такая простая.

Image - html page on canvas

Цель статьи:
  • Рассмотреть механизм реализации на основе Canvas полноценной HTML страницы с поддержкой динамического контента, ссылок и элементов управления.
  • Понять, можно ли использовать этот механизм, и стоит ли овчинка выделки.

Сильные стороны:
  • Canvas легко встраивается в HTML DOM, так как представляет собой типовой HTML элемент.
  • Canvas быстр, так как имеет низкоуровневое API и поддерживает обычный растровый формат.

Слабые стороны:
  • Canvas не поддерживает внутри себя HTML DOM, поэтому нельзя просто разместить на Canvas полезный контент, ссылки и элементы управления.
  • Низкоуровневый API слишком прост, а применение JavaScript библиотек ведёт к неизбежной потере производительности, что, в конечном счёте, ограничивает диапазон применения Canvas.

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

Canvas справочник
Canvas обучение
Canvas примеры
Интересная статья часть 1
Интересная статья часть 2

Код примера на GitHab-e (достаточно открыть htm файл в браузере с диска)
Демо примера (быстро посмотреть результат)

Рабочее окружение


Среда разработки:

В качестве основного браузера используется FF (Windows, Linux Mint).
Для проверки совместимости CR и IE (Windows).
Далее — доступные гаджеты (об этом в конце).

IDE под Widows — Visual Studio Community, Notepad. Студия бесплатна, хорошо форматирует код, приемлемо делает автодобавления и находит явные ошибки (например — пропущена скобка).
IDE под Linux — gedit.

Архитектурный подход:

Frontend, статический сервер.
Нативный API без внешних библиотек. Отсутствие библиотек не самоцель, просто пока можно обойтись без них.

Структура кода:

Код можно было бы запихнуть в один файл (кода не так много). Но, чтобы было понятнее, лучше его разнести по файлам:

HTML страница: html_page_on_canvas.htm
Общий код: html_page_on_canvas_main.js
Код управления моделью: html_page_on_canvas_model.js
Код управления отрисовкой на Canvas: html_page_on_canvas_canva.js

Глобальные переменные:

Глобальные переменные — это плохо. Без глобальной области видимости — жить трудно. В качестве компромисса создан глобальный объект APELSERG, в котором хранятся все функции и глобальные переменные:

MAIN: общие функции
MODEL: функции изменения модели
MODEL.DATA: данные модели
CANVA: функции отрисовки модели
CONFIG: глобальные переменные

Модель


В основе базовой структуры функционирования лежит классический MVC шаблон:

Роль Model выполняют данные MODEL.DATA
Роль View выполняют функции CANVA
Роль Controller выполняют функции MODEL

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

Каждая снежинка — это объект:

APELSERG.MODEL.Flake = function (flakeX, flakeY, flakeSize, flakeColor) {
    this.BaseX = flakeX;    
    this.X = flakeX;
    this.Y = flakeY;
    this.Size = flakeSize;
    this.Color = flakeColor;
}

Здесь важно учесть, что возможность динамики заложена внутри объекта — изменение свойств X и Y.

Для организации снегопада создаётся массив снежинок:

APELSERG.MODEL.MakeFlakes = function (flakeNum) {

    var flakes = [];
    var color = "white";

    for(var n = 0; n < flakeNum; n++) {

        var x = Math.round(Math.random() * APELSERG.CONFIG.SET.PicWidth);
        var y = Math.round(Math.random() * APELSERG.CONFIG.SET.PicHeight);
        var s = n % APELSERG.CONFIG.SET.FlakesSize;
        var flake = new APELSERG.MODEL.Flake(x, y, s, color);

        flakes.push(flake);
    }
    return flakes;
}

Для наглядности контент тоже будет динамичным. Для этого каждая строка будет храниться как отдельный объект:

APELSERG.MODEL.ContentLine = function (text, textX, textY, textColor) {
    this.Text = text;
    this.X = textX;
    this.Y = textY;
    this.Color = textColor;
    this.FontHeight = 0;
}

Здесь в динамику, помимо движения, заложена возможность изменения размера фонта — свойство FontHeight. Как вариант, пообъектно можно хранить отдельно слова и даже буквы. Так можно смоделировать самые разнообразные динамические визуальные эффекты.

Для контента выбраны детские стишки на зимнюю тематику:

ROOT.MODEL.MakeContent = function () {

    var color = "white";
    var pointX = 0;
    var pointY = ROOT.CONFIG.PROC.CanvaID.height; // расположение внизу страницы
    var addY = 30; // для автоматического позиционирования
    var Cnt = 0;   // для автоматического позиционирования

    var content = [];

    content.push(new ROOT.MODEL.ContentLine("В лесу родилась ёлочка", pointX, pointY + addY * Cnt++, color));
    content.push(new ROOT.MODEL.ContentLine("В лесу она росла", pointX, pointY + addY * Cnt++, color));
    content.push(new ROOT.MODEL.ContentLine("Зимой и летом стройная", pointX, pointY + addY * Cnt++, color));
    content.push(new ROOT.MODEL.ContentLine("Зелёная была", pointX, pointY + addY * Cnt++, color));

    return content;
}

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

Анимация


Для анимации в современных браузерах есть специальная функция — window.requestAnimationFrame. Это типовой код её активации:

//-- инициализация с учётом браузерной совместимости

window.MyRequestAnimationFrame = (function (callback) {
    return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
          function (callback) {
        window.setTimeout(callback, 1000 / 60);
    };
})();

//-- старт анимации

MyAnimation = function () {

    //-- изменение модели

    //-- отрисовки модели на Canvas

    //-- следующий цикл
    window.MyRequestAnimationFrame(function () {
        MyAnimation();
    });
}

Из примера видно, что requestAnimationFrame в функциональной основе просто стартует по таймауту 60 раз в секунду. Но он хорош тем, что в отличие от setTimeout синхронизирован с циклами перерисовки браузера и умеет подстраиваться под ресурсы устройства, на котором запущен. Более подробно о requestAnimationFrame здесь.

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

При помощи переменной CONFIG.SET.CntHandle реализован простой механизм разделения процессов изменения модели и отрисовки по разным циклам requestAnimationFrame. Механизм не идеальный, но зато легко управляемый. Критерием качества служит субъективное зрительное восприятие: если смотрится комфортно — значит, всё нормально. Остаётся подобрать правильное значение для CONFIG.SET.CntHandle.

В примере window.requestAnimationFrame используется без обёртки совместимости. В результате получается функция управления всеми процессами анимации:

APELSERG.MAIN.Animation = function () {

    (APELSERG.CONFIG.SET.CntHandle > APELSERG.CONFIG.PROC.CntHandle)
        ? (APELSERG.CONFIG.PROC.CntHandle++)
        : (APELSERG.CONFIG.PROC.CntHandle = 0);

    if (APELSERG.CONFIG.PROC.CntHandle == 0) APELSERG.CANVA.Rewrite();
    if (APELSERG.CONFIG.PROC.CntHandle == 1) APELSERG.MODEL.UpdateContent();
    if (APELSERG.CONFIG.PROC.CntHandle == 2) APELSERG.MODEL.UpdateFlakes();

    window.requestAnimationFrame(function () {
        APELSERG.MAIN.Animation();
    });
}


Несколько замечаний:
  • Критерием следующей отрисовки всегда является изменение модели. То есть, если модель не изменилась, то нет необходимости и в перерисовке на Canvas. Например, анимировать шахматную партию имеет смысл только после очередного хода. Если это учитывать, то, в ряде случаев можно значительно разгрузить центральный процессор.
  • RequestAnimationFrame устроен так, что не гарантирует время между вызовами, так как предназначен, в первую очередь, для обеспечения наглядности процесса анимации. А для модели часто бывает важна именно стабильность временных интервалов. Цикл изменения модели можно реализовать на setTimeout (в примере не показано).

Критерии для анимации снега и контента — простота и компактность кода.

Анимация снежинок (изменение модели):

APELSERG.MODEL.UpdateFlakes = function () {

    for (var n = 0 in APELSERG.MODEL.DATA.Flakes) {

        var flake = APELSERG.MODEL.DATA.Flakes[n];

        var dir = 1;
        if (Math.round(Math.random() * 100) % 2 == 0) dir = -1;

        var shift = Math.round(Math.random() * 100) % 3;
        var move = Math.round(Math.random() * 100) % 2 + APELSERG.CONFIG.SET.FlakesMove;

        // горизонтальное колебание снежинки
        if (((flake.X + shift * dir) < (flake.BaseX + 10)) && ((flake.X + shift * dir) > (flake.BaseX - 10))) {
            flake.X += shift * dir;
        }

        // размер снежинки
        flake.Size += dir;
        if (flake.Size > APELSERG.CONFIG.SET.FlakesSize) flake.Size = APELSERG.CONFIG.SET.FlakesSize;
        if (flake.Size < 0) flake.Size = 0;

        // падение снежинки
        flake.Y += move;
        if (flake.Y > APELSERG.CONFIG.SET.PicHeight) flake.Y = 1;
    }
}

Анимация контента (изменение модели):

APELSERG.MODEL.UpdateContent = function () {

    for (var n = 0; APELSERG.MODEL.DATA.Content.length > n; n++) {

        var contentLine = APELSERG.MODEL.DATA.Content[n];

        // сдвиг строки
        contentLine.Y -= APELSERG.CONFIG.SET.ContentMove;

        // изменение размера шрифта строки
        if (contentLine.Y > 150 && contentLine.Y < APELSERG.CONFIG.PROC.CanvaID.height - 50) {

            if (contentLine.FontHeight < APELSERG.CONFIG.SET.ContentFontSize) {
                contentLine.FontHeight++;
            }
        }
        else {
            if (contentLine.FontHeight > 0) contentLine.FontHeight--;
        }
    }
}

После изменения модели происходит её отрисовка на Canvas. Фоном может быть любое подходящее фото. Всю отрисовку выполняет одна простая функция:

APELSERG.CANVA.Rewrite = function () {

    var ctx = APELSERG.CONFIG.PROC.Ctx;

    // фон
    ctx.drawImage(APELSERG.CONFIG.PROC.Img, 0, 0);

    // контент
    for (var n = 0 in APELSERG.MODEL.DATA.Content) {

        var content = APELSERG.MODEL.DATA.Content[n];

        if (content.X >= 0 && content.Y >= 0 && content.FontHeight > 0) {
            ctx.font = content.FontHeight.toString() + "px Arial";
            ctx.textAlign = "center";
            ctx.fillStyle = content.Color;
            ctx.fillText(content.Text, APELSERG.CONFIG.PROC.CanvaID.width / 2, content.Y);
        }
    }

    // снег (поверх контента)
    for (var n = 0 in APELSERG.MODEL.DATA.Flakes) {

        var flake = APELSERG.MODEL.DATA.Flakes[n];

        ctx.beginPath();
        ctx.arc(flake.X, flake.Y, flake.Size / 2, 0, 2 * Math.PI);
        ctx.fillStyle = flake.Color;
        ctx.fill();
    }
}

Добавление функциональных кнопок и ссылок


Функциональные кнопки и ссылки — это тоже динамические объекты (похожие на снег и контент). Но динамика этих объектов не движение, а изменение цвета при наведении на них мыши или клике.

Объект описывающий команду:

APELSERG.MODEL.Command = function (cmdCode, cmdName, cmdX, cmdY, lengthX, lengthY, cmdColor) {
    this.Code = cmdCode;
    this.Name = cmdName;
    this.X = cmdX;
    this.Y = cmdY;
    this.LengthX = lengthX;
    this.LengthY = lengthY;
    this.Color = cmdColor;
    this.SelectColor = 'red';
    this.SelectCnt = 0;
    this.SelectName = false;
    this.ShowBorder = true;
    this.FontHeight = 20;
}

Объект описывающий ссылку:

APELSERG.MODEL.Link = function (linkUrl, linkName, linkX, linkY, lengthX, lengthY, linkColor) {
    this.Url = linkUrl;
    this.Name = linkName;
    this.X = linkX;
    this.Y = linkY;
    this.LengthX = lengthX;
    this.LengthY = lengthY;
    this.Color = linkColor;
    this.SelectColor = 'lightblue';
    this.SelectCnt = 0;
    this.SelectName = false;
    this.ShowBorder = false;
    this.FontHeight = 20;
}

Хотя объекты по структуре похожи, для удобства восприятия они описаны как разные сущности.

При движении мыши её координаты сохраняются в глобальных переменных:

APELSERG.CONFIG.PROC.CanvaID.addEventListener('mousemove', function (event) {
    APELSERG.CONFIG.PROC.MouseMoveX = event.clientX - APELSERG.CONFIG.PROC.CanvaID.offsetLeft - APELSERG.CONFIG.SET.PicBorder;
    APELSERG.CONFIG.PROC.MouseMoveY = event.clientY - APELSERG.CONFIG.PROC.CanvaID.offsetTop - APELSERG.CONFIG.SET.PicBorder;
});

При обработке модели координаты мыши сравниваются с расположением объекта ссылки или команды:

APELSERG.MODEL.CheckMoveFrame = function (frame) {

    if ((APELSERG.CONFIG.PROC.MouseMoveX > frame.X)
        && (APELSERG.CONFIG.PROC.MouseMoveX < frame.X + frame.LengthX)
        && (APELSERG.CONFIG.PROC.MouseMoveY > frame.Y)
        && (APELSERG.CONFIG.PROC.MouseMoveY < frame.Y + frame.LengthY)) {

        return true;
    }
    return false;
}

Если курсор мыши попал на объект, то устанавливается свойство SelectName:

command.SelectName = APELSERG.MODEL.CheckMoveFrame(command);

Похожим образом обрабатывается клик:

APELSERG.CONFIG.PROC.CanvaID.addEventListener('click', function (event) {
    APELSERG.CONFIG.PROC.MouseClickX = event.clientX - APELSERG.CONFIG.PROC.CanvaID.offsetLeft - APELSERG.CONFIG.SET.PicBorder;
    APELSERG.CONFIG.PROC.MouseClickY = event.clientY - APELSERG.CONFIG.PROC.CanvaID.offsetTop - APELSERG.CONFIG.SET.PicBorder;
});

APELSERG.MODEL.CheckClickFrame = function (frame) {

    if ((APELSERG.CONFIG.PROC.MouseClickX > frame.X)
        && (APELSERG.CONFIG.PROC.MouseClickX < frame.X + frame.LengthX)
        && (APELSERG.CONFIG.PROC.MouseClickY > frame.Y)
        && (APELSERG.CONFIG.PROC.MouseClickY < frame.Y + frame.LengthY)){

        return true;
    }
    return false;
}

Если клик попал на объект, то устанавливается свойство SelectCnt:

if (APELSERG.MODEL.CheckClickFrame(command)) command.SelectCnt = APELSERG.CONFIG.SET.CntSelect;

SelectCnt задаёт количество циклов анимации, в течении которых будет подсвечиваться рамка.

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

Имеет смысл коротко описать реализацию остановки и возобновления цикла анимации.

В самом начале, цикл анимации стартует при загрузке формы (APELSERG.MAIN.Animation() в функции APELSERG.MAIN.OnLoad()). Для остановки надо просто прекратить возобновление цикла анимации по requestAnimationFrame. Остановка происходит при установке флага APELSERG.CONFIG.PROC.Stop. Применять для отмены cancelAnimationFrame, в едином цикле с requestAnimationFrame, не имеет смысла, так как при этом отменяется уже выполняющийся цикл, что бессмысленно (новый цикл всё-равно стартует). Для того, чтобы возобновить цикл анимации, надо сбросить флаг APELSERG.CONFIG.PROC.Stop и стартовать APELSERG.MAIN.Animation(). Это происходит по двойному клику мыши или «F2» на клавиатуре.

Если нажать «F1» в режиме анимации, то тоже произойдёт остановка, но уже с использованием cancelAnimationFrame и без установки флага APELSERG.CONFIG.PROC.Stop. Возобновить по двойному клику анимацию не удастся, этому будет мешать сброшенный флаг APELSERG.CONFIG.PROC.Stop. Механизм возобновления анимации, для этого случая, предусмотрен только по нажатию «F2». Остановка по «F1» добавлена специально для демонстрации механизма работы cancelAnimationFrame:

APELSERG.CONFIG.PROC.CanvaID.addEventListener('dblclick', function (event) {
    if (APELSERG.CONFIG.PROC.Stop) {
        APELSERG.CONFIG.PROC.Stop = false;
        APELSERG.CONFIG.PROC.ShowCommands = false; //-- для защиты от ложных срабатываний
        APELSERG.MAIN.Animation();
    }
});

window.addEventListener('keydown', function (event) {

    if (event.keyCode == APELSERG.CONFIG.KEY.F1) {
        window.cancelAnimationFrame(APELSERG.CONFIG.PROC.TimeoutID);
    }

    if (event.keyCode == APELSERG.CONFIG.KEY.F2) {

        APELSERG.CONFIG.PROC.Stop = true;
        window.cancelAnimationFrame(APELSERG.CONFIG.PROC.TimeoutID);

        if(APELSERG.CONFIG.PROC.Stop) {
            APELSERG.CONFIG.PROC.Stop = false;
            APELSERG.CONFIG.PROC.ShowCommands = false; //-- для защиты от ложных срабатываний
            APELSERG.MAIN.Animation();
        }
    }
});


Добавление элементов управления, отвечающих за ввод данных


С элементами управления, отвечающими за ввод данных, не так всё однозначно. Простой элемент, например увеличение/уменьшение значения, можно реализовать вышеописанным способом (в примере это кнопки "+" и "-").

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

В этом случае может помочь так называемый «гибридный» подход. То есть, для ввода данных, в дополнение к Canvas, используются стандартные HTML DOM элементы. Элементы можно создавать статически и прятать их под Canvas при помощи абсолютного позиционирования и Z индексирования. Или создавать динамически в момент ввода. В примере показано динамическое создание двух элементов ввода. Один элемент ввода располагается поверх Canvas (функция APELSERG.MAIN.ShowSettingsTextSpeed()). Другой — за пределами Canvas (функция APELSERG.MAIN.ShowSettingsThemeSelect()). Функционально оба варианта работают, но тот элемент, который располагается поверх Canvas имеет проблемы при масштабировании (Ctrl+, Ctrl-).

Динамическая адаптивность


После того, как был разработан основной пример (Снегопад), он показался не очень интересным. Поэтому пример расширен темами. Для фона тем специально были выбраны фото разных размеров, чтобы проверить и отладить динамику Canvas.

За динамику отвечает функция:

APELSERG.MAIN.CanvasSize = function () {

    if (APELSERG.CONFIG.SET.PicWidth < Math.round(window.innerWidth * 0.9) && APELSERG.CONFIG.PROC.CanvaID.width != APELSERG.CONFIG.SET.PicWidth) {
        APELSERG.CONFIG.PROC.CanvaID.width = APELSERG.CONFIG.SET.PicWidth;
    }
    else if (APELSERG.CONFIG.SET.PicWidth > Math.round(window.innerWidth * 0.9) && APELSERG.CONFIG.PROC.CanvaID.width != Math.round(window.innerWidth * 0.9)) {
        APELSERG.CONFIG.PROC.CanvaID.width = Math.round(window.innerWidth * 0.9);
    }

    if (APELSERG.CONFIG.SET.PicHeight < Math.round(window.innerHeight * 0.8) && APELSERG.CONFIG.PROC.CanvaID.height != APELSERG.CONFIG.SET.PicHeight) {
        APELSERG.CONFIG.PROC.CanvaID.height = APELSERG.CONFIG.SET.PicHeight;
    }
    else if (APELSERG.CONFIG.SET.PicHeight > Math.round(window.innerHeight * 0.8) && APELSERG.CONFIG.PROC.CanvaID.height != Math.round(window.innerHeight * 0.8)) {
        APELSERG.CONFIG.PROC.CanvaID.height = Math.round(window.innerHeight * 0.8)
    }
}

Функция работает по простому алгоритму — если фото фона меньше размера окна (с учётом элементов прокрутки), то Canvas устанавливается по размеру фото, иначе Canvas устанавливается по размеру окна.

Совместимость


Canvas (как и HTML5 в целом) элемент относительно новый и не все устройства и браузеры его поддерживают. А HTML страница должна отображаться всегда, везде и так, чтобы было удобно с ней работать. Совместимость подразумевает не только технологическую поддержку очередной новой фичи. Решающими факторами могут стать, например, размер и/или разрешение экрана, наличие элементов управления (есть только тачскрин), производительность процессора. Не до новых технологий и красот, когда основные функции выполнить нельзя.

Когда проектируется HTML страница, должны быть предусмотрены механизмы, обеспечивающие необходимый уровень совместимости. Это утверждение относится не только к HTML5 и Canvas.

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

Сперва, в процессе загрузки, определяются ключевые признаки совместимости:

APELSERG.MAIN.CheckCompatible = function () {

    if (!window.requestAnimationFrame || screen.width < 1000) {

        APELSERG.CONFIG.SET.CompatibleType = 1;
    }
}

И, если отображение Canvas невозможно или нежелательно, формируется простая HTML страница функцией APELSERG.MODEL.ContentAsHtmlText().

Проблемы Canvas


Canvas периодически «лагает». Лаги проявляются даже на слабой нагрузке. Субъективно — время и периодичность лагов зависят от конкретного устройства, ОС и браузера. Суть проблемы, видимо, кроется в однопоточном event loop-е и/или в garbage collector-е.
Tags:canvashtml5frontend
Hubs: Canvas
+5
25.6k 117
Comments 13