Знакомимся с Fabric.js - Часть 2

Reading time ~30 minutes

Вторая часть официальной документации по Fabric. Речь идет об анимации объектов на Canvas, применении стандартных фильтров к изображениям или создании пользовательских фильтров.

Вычитал и поправил некоторые орфографические ошибки. Продолжает радовать факт, как автор детально и внятно описывает работу каждой строки кода в сниппетах. Эту статью можно продолжать читать просто как общий легкий курс по JavaScript. )


Это вторая часть серии статей об открытой Javascript Canvas библиотеке Fabric.js, которую мы используем на printio.ru для редактора дизайнов.

В первой части этой серии мы ознакомились с самыми базовыми аспектами canvas библиотеки Fabric.js. Мы узнали, чем может быть полезна Fabric, рассмотрели ее объектную модель и иерархию объектов; увидели, что существуют как простые фигуры (прямоугольник, треугольник, круг), так и сложные (SVG). Научились выполнять простые операции над этими объектами.

Ну вот, разобрались с азами, давайте приступать к более интересным вещам!

Анимация

Любая уважающая себя canvas-библиотека в наше время включает в себя средства работы с анимацией. Fabric — не исключение. Ведь мы имеем мощную объектную модель и гибкие графические возможности. Было бы грех не уметь это приводить в движение.

Вы наверное помните, как менять атрибут у объекта. Просто вызываем метод

1
set
, передавая соответствующее значение:

rect.set('angle', 45);

Анимировать объект можно по такому же принципу и с такой же легкостью. Каждый объект в Fabric имеет метод

1
animate
(наследуя от
1
fabric.Object
) который … анимирует этот объект:

rect.animate( 'angle', 45, {
  onChange: canvas.renderAll.bind(canvas)
});

Первый аргумент - это атрибут, который хотим менять. Второй аргумент — финальное значение этого атрибута.

Например, если прямоугольник находится под углом

1
-15°
и мы указываем
1
45°
, то угол постепенно изменится с
1
-15°
до
1
45°
.

Ну, а последний аргумент — опциональный объект для более детальных настроек (длительность, вызовы, easing и т. д. )

1
animate
кстати имеет очень полезную функциональность — поддержку относительных значений. Например, если нужно подвинуть объект на
1
100px
вправо, то сделать это очень просто:

rect.animate('left', '+=100', { onChange: canvas.renderAll.bind(canvas) });

По такому же принципу, для поворота объекта на 5 градусов против часовой стрелки:

rect.animate('angle', '-=5', { onChange: canvas.renderAll.bind(canvas) });

Вы наверняка заметили, что мы постоянно указываем вызов

1
onChange
. Разве 3-й аргумент не опциональный? Да, именно так.

Дело в том, что как раз это вызывание

1
canvas.renderAll
на каждый кадр анимации позволяет видеть саму анимацию!

Mетод

1
animate
всего лишь изменяет значение атрибута в течении указанного времени и по определенному алгоритму (
1
easing
).

1
rect.animate('angle', 45)
изменяет значение угла, при этом не перерисовывая экран после каждого изменения. А перерисовка экрана нужна для того, чтобы увидеть анимацию.

Ну, а почему же

1
animate
не перерисовывает экран автоматически? Из-за производительности. Ведь на холсте могут находиться сотни или даже тысячи объектов.

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

1
requestAnimationFrame
для постоянной отрисовки холста, не вызывая
1
renderAll
для каждого объекта.

Однако, в большинстве случаев вы скорее всего будете использовать

1
canvas.renderAll
как
1
onChange
-вызов.

Возвращаясь к опциям для анимации - что же именно мы можем менять?

  • 1
    from:
    
    - позволяет менять начальное значение атрибута для анимации (если не хотим использовать текущее)
  • 1
    duration:
    
    - длительность анимации; по умолчанию - 500 ms
  • 1
    onComplete:
    
    - функция для вызова в конце анимации (callback)
  • 1
    easing:
    
    - функция easing (смягчение)

Все эти опции более менее очевидны, кроме наверное

1
easing
. Давайте посмотрим поближе.

По умолчанию,

1
animate
используют
1
easeInSine
-функцию для смягчения анимации. Если такой вариант не подходит, в Fabric имеется большой набор популярных easing-функций (доступных через объект
1
fabric.util.ease
).

Например, вот так можно подвинуть объект вправо, при этом “отпружинивая”” в конце:

rect.animate('left', 500, {
  onChange: canvas.renderAll.bind(canvas),
  duration: 1000,
  easing: fabric.util.ease.easeOutBounce
});

Заметьте, что мы используем

1
fabric.util.ease.easeOutBounce
как опцию смягчения. Есть и другие популярные функции —
1
easeInCubic
,
1
easeOutCubic
,
1
easeInElastic
,
1
easeOutElastic
,
1
easeInBounce
,
1
easeOutExpo
и т. д.

Вот в принципе и все, что нужно знать о анимации. Теперь можно с легкостью делать интересные вещи — менять угол объекта, чтобы сделать его вращающимся; анимировать

1
left/top
чтобы его двигать; анимировать
1
width/height
для увеличения/уменьшения; анимировать
1
opacity
для появления/исчезания и т. д.

Фильтры изображений

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

1
fabric.Image
-конструктор, передавая в него
1
<img>
-элемент.

Также есть метод

1
fabric.Image.fromURL
, с помощью которого можно создать объект прямо из строки URL. И конечно же эти
1
fabric.Image
-объекты можно кинуть на холст, где они отобразятся как и все остальное.

Работать с изображениями прикольно, а с фильтрами изображений — еще веселей! Fabric уже имеет несколько фильтров, а также позволяет легко определять свои.

Некоторые фильтры из Fabric вам наверное знакомы — удаление белого фона, перевод в черно-белый, негатив или яркость. А некоторые менее популярны — градиентная прозрачность, сепия, шум.

Так как же применить фильтр к изображению? Каждый

1
fabric.Image
-объект имеет
1
filters
-атрибут, который просто является массивом фильтров. Каждый элемент в этом массиве — или один из существующих в Fabric или собственный фильтр.

Ну вот, к примеру, сделаем картинку черно-белой:

fabric.Image.fromURL( 'pug.jpg', function ( img ) {
  
  // добавляем фильтр
  img.filters.push( new fabric.Image.filters.Grayscale() );
  
  // применяем фильтры и перерисовываем canvas после применения
  img.applyFilters( canvas.renderAll.bind( canvas ) );
  
  // добавляем изображения на холст
  canvas.add( img );

});

Fabric Image

А вот так можно сделать сепию:

fabric.Image.fromURL( 'pug.jpg', function ( img ) {
  
  img.filters.push( new fabric.Image.filters.Sepia() );
  
  img.applyFilters( canvas.renderAll.bind( canvas ) );
  
  canvas.add( img );

});

Fabric Image

С атрибутом

1
filters
можно делать все тоже что и с обычным массивом — удалить фильтр (с помощью
1
pop
,
1
splice
, или
1
shift
), добавить фильтр (с помощью
1
push
,
1
splice
,
1
unshift
) или даже соединить несколько фильтров.

Когда вызывается

1
applyFilters
, все фильтры в массиве применяются к картинке по очереди. Вот, например, давайте создадим картинку с увеличенной яркостью и с эффектом сепии:

fabric.Image.fromURL( 'pug.jpg', function ( img ) {

  img.filters.push(
    new fabric.Image.filters.Sepia(),
    new fabric.Image.filters.Brightness({ brightness: 100 }));

  img.applyFilters(canvas.renderAll.bind( canvas ));
  
  canvas.add( img );

});

Fabric Image

Заметьте, что мы передали

1
{ brightness: 100 }
-объект в Brightness-фильтр. Это потому, что некоторым фильтрам ничего дополнительного не нужно; а некоторым (например -
1
grayscale
,
1
invert
,
1
sepia
) надо указать определенные параметры.

Для фильтра яркости это собственно само значение яркости (

1
0-255
). У фильтра шума, это значение шума (
1
0-1000
). А у фильтра удаления белого фона (‘remove white’) есть порог (‘threshold’) и расстояние (‘distance’).

Ну вот разобрались с фильтрами; пора создать свой!

Образец для создания фильтров будет довольно прост. Нам нужно создать ‘класс’’ и написать метод

1
applyTo
. Опционально мы можем дать фильтру
1
toJSON
-метод (поддержка JSON-сериализации) и/или
1
initialize
(если фильтр имеет дополнительные параметры):

fabric.Image.filters.Redify = fabric.util.createClass({

  type: 'Redify',

  applyTo: function ( canvasEl ) {
    var context = canvasEl.getContext( '2d' ),
        imageData = context.getImageData( 0, 0, canvasEl.width, canvasEl.height ),
        data = imageData.data;

    for ( var i = 0, len = data.length; i < len; i += 4 ) {
      data[ i + 1 ] = 0;
      data[ i + 2 ] = 0;
    }

    context.putImageData( imageData, 0, 0 );
  }
});

fabric.Image.filters.Redify.fromObject = function ( object ) {
  return new fabric.Image.filters.Redify( object );
};

Fabric Image

Не вникая сильно в подробности кода, стоит заметить, что самое главное происходит в цикле, где мы меняем зеленую (

1
data[i+1]
) и голубую (
1
data[i+2]
) компоненты каждого пикселя на
1
0
, по сути дела удаляя их.

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

Примечание чтеца: для желающих - более подробно аналог представленного выше кода описан в моей статье Canvas - Raw Pixel.

Как видите,

1
applyTo
-метод получает в себя canvas-элемент, который представляет собой изображение. Имея такой canvas, мы можем пройтись по всем пикселям изображения (
1
getImageData().data
) изменяя их как нам угодно.

Цвета

Независимо от того, с чем вам удобней работать — hex, RGB, или RGBA-форматами цвета — Fabric упрощает утомительные операции и переводы из одного формата в другой.

Давайте посмотрим на несколько способов определить цвет в Fabric:

new fabric.Color( '#f55' );
new fabric.Color( '#123123' );
new fabric.Color( '356735' );
new fabric.Color( 'rgb( 100, 0, 100 )' );
new fabric.Color( 'rgba( 10, 20, 30, 0.5 )' );

Перевод формата происходит очень просто. Метод

1
toHex()
переводит цвет в
1
hex
. Метод
1
toRgb()
— в
1
RGB
, а метод
1
toRgba()
— в
1
RGB
с альфа каналом (прозрачностью):

new fabric.Color( '#f55' ).toRgb(); // => "rgb( 255, 85, 85 )"
new fabric.Color( 'rgb( 100, 100, 100 )' ).toHex(); // => "646464"
new fabric.Color( 'fff' ).toHex(); // => "FFFFFF"

Кстати, можно делать не только перевод. Можно “накладывать” цвета один на другой или делать из них черно-белый вариант:

var redish = new fabric.Color('#f55');
var greenish = new fabric.Color('#5f5');

redish.overlayWith(greenish).toHex(); // => "AAAA55"
redish.toGrayscale().toHex(); // => "A1A1A1"

Градиенты

Еще более экспрессивный способ работы с цветами — используя градиенты. Градиенты позволяет плавно смешать один цвет с другим, открывая возможность довольно изумительным эффектам.

Fabric поддерживает градиенты с помощью метода setGradient (setGradientFill до 1.1.0 версии), который присутствует на всех объектах.

Вызывание

1
setGradient( 'fill', { ... } )
- это почти как выставление значения ‘fill’ у объекта, только вместо цвета используется градиент:

var circle = new fabric.Circle({
  left: 100,
  top: 100,
  radius: 50
});

circle.setGradient( 'fill', {
  x1: 0,
  y1: -circle.height / 2,
  x2: 0,
  y2: circle.height / 2,
  colorStops: {
    0: '#000',
    1: '#fff'
  }
});

Fabric Image

В этом примере мы создаем круг в точке

1
100/100
с радиусом в
1
50px
. Потом выставляем ему градиент, идущий по всей высоте объекта, от черного к белому.

Как видите, метод получает в себя конфигурационный объект, в котором могут присутствовать 2 пары координат (

1
x1/y1
и
1
x2/y2
) и объект ‘colorStops’.

Координаты указывают, где градиент начинается и где он заканчивается.

1
colorStops
указывают - из каких цветов он состоит.

Вы можете определить сколько угодно цветов; главное, чтобы их позиции находились в интервале от 0 до 1 (например 0, 0.1, 0.3, 0.5, 0.75, 1). 0 представляет начало градиента, 1 — его конец.

Чтобы понять, как мы определили координаты, посмотрим на изображение ниже:

Fabric Image

Так как координаты относительны центру объекта, то верхней точкой является

1
-circle.height / 2
, а нижней -
1
circle.height / 2
. Координаты по ширине (
1
x1/x2
) определяем точно так же.

Вот пример красно-голубого градиента, идущего слева направо:

circle.setGradient( 'fill', {
  x1: -circle.width / 2,
  y1: 0,
  x2: circle.width / 2,
  y2: 0,
  colorStops: {
    0: "red",
    1: "blue"
  }
});

Fabric Image

А вот 5-ти шаговый градиент-радуга, с цветами, занимающими по 20% всей длины:

circle.setGradient( 'fill', {
  x1: -circle.width / 2,
  y1: 0,
  x2: circle.width / 2,
  y2: 0,
  colorStops: {
    0: "red",
    0.2: "orange",
    0.4: "yellow",
    0.6: "green",
    0.8: "blue",
    1: "purple"
  }
});

Fabric Image

А вы можете придумать что-нибудь интересное?

Текст

Что если нужно отобразить не только картинки и векторные формы на холсте, а еще и текст? Fabric умеет и это! Встречайте

1
fabric.Text
.

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

Ведь canvas имеет встроенные методы

1
fillText
и
1
strokeText
.

Во-первых, для того чтобы можно было работать с текстом как с объектами. Выстроенные canvas-методы — как обычно — позволяют вывести текст на очень низком уровне.

А вот создав объект типа

1
fabric.Text
, мы можем работать с ним как и с любым другим объектом на холсте — двигать его, масштабировать, менять атрибуты, и т. д.

Вторая причина — чтобы иметь более богатый функционал, чем то что дает нам canvas. Некоторые вещи, которые есть в Fabric, но нет в родных методах:

  • Многострочность - Родные методы позволяют написать только одну линию, игнорируя переходы строки
  • Выравнивание текста - Влево, по центру, вправо. Полезно во время работы с многострочным текстом
  • Фон текста - Фон выводится только под самим текстом, в зависимости от выравнивания
  • Декорация текста - Подчеркивание, надчеркивание, перечеркивание
  • Высота строки - Полезно во время работы с многострочным текстом

Ну что ж, давайте посмотрим на вездесущий “hello world”?

var text = new fabric.Text( 'hello world', { left: 100, top: 100 });
canvas.add( text );

Вот и все! Для показа текста необходимо всего лишь добавить объект типа

1
fabric.Text
на холст, указывая нужную позицию.

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

1
left
,
1
top
,
1
fill
,
1
opacity
, и т. д.

Помимо обычных атрибутов, у текстовых объектов конечно же есть и свои, относящиеся к тексту. Вкратце, об этих атрибутах:

fontFamily

По умолчанию - “Times New Roman”. Позволяет менять семейство шрифта для текста:

var comicSansText = new fabric.Text( "I'm in Comic Sans", {
  fontFamily: 'Comic Sans'
});

Fabric Image

fontSize

Контролирует размер текста. Заметьте, что в отличие от других объектов в Fabric, мы не можем менять размер текста с помощью width/height. Вместо этого как раз и используется fontSize и конечно же

1
scaleX/scaleY
:

var text40 = new fabric.Text( "I'm at fontSize 40", {
  fontSize: 40
});
var text20 = new fabric.Text( "I'm at fontSize 20", {
  fontSize: 20
});

Fabric Image

fontWeight

Позволяет сделать текст жирнее или тоньше. Точно также как в CSS, можно использовать или слова (“normal”, “bold”) или номерные значения (100, 200, 400, 600, 800).

Важно понимать, что для определенной толщины нужно иметь соответствующий шрифт. Если в шрифте не присутствует “bold” (жирный) вариант, например, то жирный текст может не отобразиться:

var normalText = new fabric.Text( "I'm a normal text", {
  fontWeight: 'normal'
});
var boldText = new fabric.Text( "I'm a bold text", {
  fontWeight: 'bold'
});

Fabric Image

textDecoration

Позволяет добавить тексту перечеркивание, надчеркивание или подчеркивание. Опять же, эта декларация работает также, как в CSS.

Однако Fabric умеет даже немного больше, позволяя использовать эти декорации вместе (например, подчеркивание И перечеркивание), просто перечисляя их через пробел:

var underlineText = new fabric.Text( "I'm an underlined text", {
  textDecoration: 'underline'
});
var strokeThroughText = new fabric.Text( "I'm a stroke-through text", {
  textDecoration: 'line-through'
});
var overlineText = new fabric.Text( "I'm an overline text", {
  textDecoration: 'overline'
});

Fabric Image

shadow

До версии 1.3.0 этот атрибут назывался “textShadow”.

Тень для текста. Состоит из 4-х компонент: цвет, горизонтальный отступ, вертикальный отступ, и размер размытия.

Это все должно быть знакомо, если вы до этого работали с тенями в CSS. Меняя эти 4 опции, можно добиться многих интересных эффектов:

var shadowText1 = new fabric.Text( "I'm a text with shadow", {
  shadow: 'rgba(0,0,0,0.3) 5px 5px 5px'
});

var shadowText2 = new fabric.Text( "And another shadow", {
  shadow: 'rgba(0,0,0,0.2) 0 0 5px'
});

var shadowText3 = new fabric.Text( "Lorem ipsum dolor sit", {
  shadow: 'green -5px -5px 3px'
});

Fabric Image

fontStyle

Стиль текста. Может быть только один из двух:

1
normal
или
1
italic
. Опять же, работает так же, как и в CSS:

var italicText = new fabric.Text( "A very fancy italic text", {
  fontStyle: 'italic',
  fontFamily: 'Delicious'
});

var anotherItalicText = new fabric.Text( "another italic text", {
  fontStyle: 'italic',
  fontFamily: 'Hoefler Text'
});

Fabric Image

stroke и strokeWidth

Соединяя

1
stroke
(цвет наружнего штриха) и
1
strokeWidth
(ширину наружнего штриха), можно достичь довольно интересных эффектов.

Вот пара примеров:

var textWithStroke = new fabric.Text( "Text with a stroke", {
  stroke: '#ff1318',
  strokeWidth: 1
});

var loremIpsumDolor = new fabric.Text( "Lorem ipsum dolor", {
  fontFamily: 'Impact',
  stroke: '#c3bfbf',
  strokeWidth: 3
});

Fabric Image

Стоит отметить, что

1
stroke
был назван
1
strokeStyle
до версии 1.1.6

textAlign

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

Возможные значения:

1
left
,
1
center
,
1
right
и
1
justify
:

var text = 'this is\na multiline\ntext\naligned right!';
var alignedRightText = new fabric.Text( text, {
  textAlign: 'right'
});

Fabric Image

lineHeight

Еще один атрибут, скорее всего знакомый из CSS —

1
lineHeight
(высота строки).

Позволяет менять расстояние между строк в многострочном тексте. Вот пример текста с ‘lineHeight 3’ и второй с ‘lineHeight 1’:

var lineHeight3 = new fabric.Text( 'Lorem ipsum ...', {
  lineHeight: 3
});
var lineHeight1 = new fabric.Text( 'Lorem ipsum ...', {
  lineHeight: 1
});

Fabric Image

textBackgroundColor

И наконец, дать тексту фон можно с помощью

1
textBackgroundColor
. Заметьте, что фон заполняется только под самим текстом, а не на всю “коробку”.

Чтобы закрасить весь текстовый объект, можно использовать атрибут

1
backgroundColor
. Также видно, что фон зависит от выравнивания текста и
1
lineHeight
.

Если

1
lineHeight
очень большой, фон будет видно только под текстом:

var text = 'this is\na multiline\ntext\nwith\ncustom lineheight\n&background';
var textWithBackground = new fabric.Text( text, {
  textBackgroundColor: 'rgb(0,200,0)'
});

Fabric Image

События

События — незаменимый инструмент для создания сложных приложений. Для удобства пользования и более детальной настройки, Fabric имеет обширную систему событий; начиная от низкоуровневых событий мыши и вплоть до высокоуровневых событий объектов.

События позволяют нам “поймать” различные моменты, когда что-то происходит на холсте.

Хотим узнать, когда была нажата мышка? Следим за событием

1
mouse:down
.

Как насчет, когда объект был добавлен на холст? Для этого есть

1
object:added
.

Ну, а что насчет перерисовки холста? Используем

1
after:render
.

API событий очень прост и похож на то, к чему вы скорее всего привыкли в jQuery, Underscore.js или других популярных JS-библиотеках.

Есть метод on для инициализации слушателя событий и есть метод off для его удаления.

Давайте посмотрим на пример:

var canvas = new fabric.Canvas('...');
canvas.on( 'mouse:down', function( options ) {
  console.log( options.e.clientX, options.e.clientY );
});

Мы добавили слушатель события

1
mouse:down
на canvas-объекте и указали обработчика, который будет записывать координаты, где произошло это событие.

Таким образом, мы можем видеть, где именно произошел клик на холсте. Обработчик событий получает options-объект с двумя параметрами:

1
e
— оригинальное событие и
1
target
— Fabric-объект на холсте, если он найден.

Первый параметр присутствует всегда, а вот

1
target
- только если клик произошел на объекте. Ну и конечно же,
1
target
передается только обработчикам тех событий, где это имеет смысл.

Например, для

1
mouse:down
но не для
1
after:render
(так как это событие не “имеет” никаких объектов, а просто обозначает что холст был перерисован):

canvas.on( 'mouse:down', function( options ) {
  if (options.target) {
    console.log('an object was clicked! ', options.target.type);
  }
});

Этот пример выведет ‘an object was clicked!’ если мы нажмем на объект. Также покажется тип этого объекта.

Какие еще события доступны в Fabric?

На уровне мышки, у нас есть

1
mouse:down
,
1
mouse:move
и
1
mouse:up
.

Из общих есть

1
after:render
.

Есть события, касающиеся выбора объектов:

1
before:selection:cleared
,
1
selection:created
,
1
selection:cleared
.

Ну и конечно же, события объектов:

1
object:modified
,
1
object:selected
,
1
object:moving
,
1
object:scaling
,
1
object:rotating
,
1
object:added
и
1
object:removed
.

Стоит заметить, что события типа

1
object:moving
(или
1
object:scaling
) происходят постоянно, во время движения или масштабирования объекта, даже если на один пиксель.

В то же время, события типа

1
object:modified
или
1
selection:created
происходят только в конце действия (изменение объекта, создание группы объектов, и т. д.).

В предыдущих примерах мы присоединяли слушателя на canvas объект (

1
canvas.on( 'mouse:down', ... )
).

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

Для удобства, Fabric позволяет добавлять слушатели прямо на Fabric объекты!

var rect = new fabric.Rect({ width: 100, height: 50, fill: 'green' });
rect.on( 'selected', function () {
  console.log('selected a rectangle');
});

var circle = new fabric.Circle({ radius: 75, fill: 'blue' });
circle.on( 'selected', function () {
  console.log('selected a circle');
});

В этом примере слушатели “присоединяются” прямо к прямоугольнику и кругу. Вместо

1
object:selected
мы используем событие
1
selected
.

По такому же принципу, можно использовать событие

1
modified
(
1
object:modified
когда “вешаем” на холст),
1
rotating
(аналог
1
object:rotating
) и т. д.

Вы можете ознакомиться с событиями поближе и прямо в реальном времени вот в этой демо.

На этом 2-я часть подошла к концу. Столько всего нового, но это еще не все! В 3-й части мы рассмотрим группы объектов, сериализацию/десериализацию холста и формат JSON, SVG-парсер, а также создание подклассов.


MongoDB - создание документа

![MongoDB]({{site.url}}/images/uploads/2017/05/mongodb-logo.jpg "MongoDB")Приступили к самому основному - операциям создания, чтения, изм...… Continue reading

MongoDB - документы

Published on May 23, 2017