День 4. Расширение "Календарь" на основе D3: Часть 1
Extension "Календарь": постановка задачи
Третий экстеншен нашего интенсива, "Календарь", мы не будем разрабатывать с нуля, а кастомизируем готовый шаблон визуализации на D3. И да, на это у нас уйдет два дня интенсива - готовьтесь! =)

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

Функционал расширения:

  • Измерения будут принимать на вход только дату

  • Мера будет принимать одно выражение.

  • Дополнительные параметры вида:

    • Цвет показателя (градиент от меньшего к большему),

    • Свойства отображения легенды,

    • Управление подписями (во всплывающей подсказке)

  • По щелчку на ячейку должен ставиться фильтр по дате.

Знакомство с D3
D3 расшифровывается как data driven document, то есть документ управляемый данными. Эта библиотека ориентирована на работу с данными и их визуальное отображение. "Рисует" d3 с помощью svg (векторная графика). Она разделена на модули, что позволяет не грузить ее всю, а использовать только то, что требуется для конкретной визуализации.

https://d3js.org/ - ресурс, на котором есть документация по библиотеке, примеры, API - в общем, все, что нужно для разработчика.


D3 реализует цепочку методов:
То есть результат выполнения одного метода "по цепочке" передается следующему.В примере кода выше, мы:

  • выбираем элемент body на странице,
  • сообщаем, что хотим добавить в него svg-элемент,
  • в который хотим вставить элемент-text,
  • содержимым которого будет "Hello",
  • подвинуть это на 30 пикселей вправо и на 100 пикселей вниз,
  • покрасить в зеленый.

Мы используем свойство fill, а не color, т.к. svg-элементы красятся fill'ом, вне зависимости от того квадрат это или текст.


Может также возникнуть вопрос почему мы двигаем вниз значением 100, а не -100. Это связано с тем что система координат в svg ориентирована вниз:
Совсем подробно мы не будем вникать в svg, но кое-что все-таки стоит иметь ввиду.

Элемент svg представляет собой полотно, которое заполняется графическими объектами, описанными с помощью кода. Рассмотрим два примера, чтобы стало понятнее:



1. Покоординатное перемещение, рисующее произвольный объект:
Здесь:

  • M150 0 - становимся на точку с координатами x = 150, y = 0
  • L75 200 - рисуем линию в точку с координатами x = 75, y = 200
  • L225 200 - рисуем линию в точку с координатами x = 225, y = 200
  • Z - закрыть путь, т.е. соединяем конечную точку с начальной.
  • Поскольку нет никаких дополнительных параметров, по умолчанию получившаяся фигура заливается черным.



2. Фигура в стиле "Сделай мне круг вот такой вышины, вот такой ширины":
Здесь:

  • cx и cy - координаты центра окружности
  • r - радиус круга
  • fill -- цвет заливки.

Он-лайн тренажер, в котором можно поиграться с svg: https://www.w3schools.com/graphics/tryit.asp?filename=trysvg_path


По примерам выше становиться понятно, что основная проблема в svg - посчитать, по каким координатам должна рисоваться та или иная фигура. В этом как раз и помогает D3.js. Эта библиотека за нас посчитает координаты для графика, отмасштабирует его, построит оси если надо и т.п. Она имеет огромное множество разных методов и параметров и позволяет рисовать, что только душе угодно, но понадобится много времени на ее освоение. Поскольку у нас его нет, для нашего экстеншена мы воспользуемся заготовкой с официального сайта и отредактируем ее под наши нужды.
Знакомство с D3-шаблоном "календарь"
В качестве шаблона для нашего расширения мы возьмем вариант календаря с официального сайта D3: https://observablehq.com/@d3/calendar-view

Посмотрим на наш шаблон:
Все очень красиво, пока не доскроллил до кода. Как говорится, ничего не понял, но очень интересно.

Там мы видим:

  • блок chart, в котором отрисовывается сама диаграмма,
  • огромный json c данными
  • некоторое количество переменных, содержащих данные
  • функцию pathMonth, используемую в chart.

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

Создаем extension через DevHub (подробнее о создании extensions) смотрите в материалах первого дня, раздел "Создание «my_first_extension» и его анатомия".

Создайте новый extension:

● с названием «calendar»
● на основе шаблона "Basic Visualization Template."

Нужно, чтобы готовый extension содержал следующие файлы:

  • calendar.js
  • calendar.qext
  • wbfolder.wbl
  • calendar.css -- пустой
  • template.html -- пустой



2. Подключим пустые файлы "calendar.css" и "template.html" в calendar.js, а также в calendar.js добавим try-catch для вывода ошибок.

Теперь код в calendar.js у нас выглядит следующим образом:


define(["qlik",
  "text!./template.html",
  "css!./calendar.css",
],
  function (qlik,
    template,
  ) {

    return {
      template: template,
      support: {
        snapshot: true,
        export: true,
        exportData: false
      },
      paint: function ($element,layout) {
        try {

          //код будет тут

        }
        catch (e) {
          console.log(e)
        }
        //needed for export
        return qlik.Promise.resolve();
      }
    };
  });
Написание блока параметров
1. Для начала посмотрим, какие данные принимает этот календарь.

В D3-шаблоне это массив объектов, каждый из которых содержит: дату, значение на эту дату и еще какое-то значение специфичное для их конкретных данных, нам оно не требуется.


Соответственно, нам нужны:

  • 1 измерение (дата)
  • 2 меры (первая - значение, а вторая на новый год понадобится позже).

Добавим их в настройки calendar.js:
define(["qlik",
  "text!./template.html",
  "css!./calendar.css",
],
  function (qlik,
    template,
  ) {

    return {
      template: template,
      initialProperties: {
        qHyperCubeDef: {
          qDimensions: [],
          qMeasures: [],
          qInitialDataFetch: [{
            qWidth: 5,
            qHeight: 1000
          }]
        }
      },
      definition: {
        type: "items",
        component: "accordion",
        items: {
          dimensions: {
            uses: "dimensions",
            min: 1,
            max: 1,
          },
          measures: {
            uses: "measures",
            min: 1,
            max: 2,
          },
          sorting: {
            uses: "sorting"
          },
        }
      }
      support: {
        snapshot: true,
        export: true,
        exportData: false
      },
      paint: function ($element,layout) {
        try {

          //код будет тут

        }
        catch (e) {
          console.log(e)
        }
        //needed for export
        return qlik.Promise.resolve();
      }
    };
  });
3. Теперь перейдем к параметрам - цвет.

Цвета этом шаблоне задаются с помощью интерполирующей функции, т.е. явно задать градиент от одного цвета к другому не получится, но в библиотеке предложены цветовые схемы, которыми можно воспользоваться. Учитывая это, сделаем выпадающий список, в котором можно выбрать одну из цветовых схем.Под блок настроек sorting добавим:
settings: {
            uses: "settings",
            items: {
              view: {
                label: "Вид",
                type: "items",
                items: {}
              }
            }
          }
и в пустой объект items внутри view пропишем параметр:
palette: {
 type: 'string',
 label: "Палитра",
 ref: "props.palette",
 component: 'dropdown',
 options: [
   {
     label: 'Розово-зеленая',
     value: 'PiYG'
   },
   {
     label: 'Фиолетово-зеленая',
     value: 'PRGn'
   },
   {
     label: 'Коричнево-голубая',
     value: 'BrBG'
   },
   {
     label: 'Красно-синяя',
     value: 'RdBu'
   },
   {
     label: 'Красно-зеленая',
     value: 'RdYlGn'
   },
   {
     label: 'Серая',
     value: 'Greys'
   },
   {
     label: 'Зеленая',
     value: 'Greens'
   },
 ],
 defaultValue: 'PiYG'
},



3. Параметр Легенда.

Добавим варианты расположения легенды (вверху/внизу)
legendPosition: {
  type: 'string',
  label: "Позиция легенды",
  ref: "props.legendPosition",
  component: 'dropdown',
  options: [{
    label: 'Вверху',
    value: 'up' 
  },
  {
    label: 'Внизу',
    value: 'down'
    }],
  defaultValue: 'up'
}



4. Фильтрация по клику.

Добавим флажок, по которому будет включаться/отключаться фильтрация по клику на значение:
  isFilter: {
    type: 'boolean',
    label: "Фильтровать по значению",
    ref: "props.isFilter",
    component: 'switch',
    options: [
      {
        label: 'Да',
        value: true
      },
      {
        label: 'Нет',
        value: false
      },
    ],
    defaultValue: false
  },



5. Всплывающие подсказки.

Поскольку для каждого значения подпись своя, мы не можем этот параметр задать как обычный параметр меры. Для таких ситуаций есть вычисляемый параметр для каждого значения меры. Выглядеть он будет так:
tooltip: {
  label: "Всплывающая подсказка",
  type: "string",
  ref: 'qAttributeExpressions.0.qExpression',
  component: "expression",
  defaultValue: "",
},

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



6. Стили для всплывающих подсказок.

Их мы можем задать там же, где и общие стили, в settings. Мы хотим менять:

  • цвет подсказки,
  • размер шрифта,
  • фон.

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

Параметр palette был в блоке view. Рекомендуем стили для всплывающей подсказки вынести в новый блок tooltip, располагающийся по-соседству с view. Группировка свойств по тому, к чему они относятся, сделает панель легче для восприятия и избавит нас от необходимости написания тяжеловесных лейблов вида "Цвет текста всплывающей подсказки".

Выглядеть это будет так:
view: {
   label: "Вид",
  type: "items",
  items: {
    // общие параметры
  }
},
tooltip: {
  label: "Всплывающая подсказка",
  type: "items",
  items: {
    // параметры для всплывающей подсказки
  }
}

Проверьте себя
tooltip: {
  label: "Всплывающая подсказка",
  type: "items",
  items: {
    tooltipColor: {
      label: "Цвет текста",
      type: "string",
      ref: 'props.tooltipColor',
      expression: "optional",
      defaultValue: "gray"
    },
    tooltipFontSize: {
      label: "Размер текста",
      type: 'number',
      ref: 'props.tooltipFontSize',
      expression: "optional",
      defaultValue: 12
    },
    tooltipBg: {
      label: "Цвет фона",
      type: "string",
      ref: 'props.tooltipBg',
      expression: "optional",
      defaultValue: "lightblue"
    },
  }
}



7. Теперь внутри paint, там, где планируется основной код, можно вывести в консоль наши данные:console.log('layout', layout);



8. Разместим экземпляр экстеншена в приложенном к интенсиву приложении АТК_course_extensions.qvf на листе "Расширение № 3. Календарь".


При этом:

  • измерение заполним выражением из таблицы слева (OrderDate),
  • меру - продажами (Sum(Sales)),
  • в мере у нас появился параметр с подписью, пока сделаем его таким же как и сама мера. Но если мы захотим задать какое-то другое значение или дать дополнительную информацию, мы сможем это сделать, не сломав сам экстеншен.

Теперь можно посмотреть в консоли инспектора, что получилось.
Внутри гипер-куба (если закопаться в значение меры) можно увидеть новое свойство qAttrExps, вот там и хранится значение нашего нового вычисляемого параметра.
Проверьте себя
define(["qlik",
  "text!./template.html",
  "css!./calendar.css",
],
  function (qlik,
    template,
  ) {

    return {
      template: template,
      initialProperties: {
        qHyperCubeDef: {
          qDimensions: [],
          qMeasures: [],
          qInitialDataFetch: [{
            qWidth: 5,
            qHeight: 1000
          }]
        }
      },
      definition: {
        type: "items",
        component: "accordion",
        items: {
          dimensions: {
            uses: "dimensions",
            min: 1,
            max: 1,
          },
          measures: {
            uses: "measures",
            min: 1,
            max: 2,
            items: {
              tooltip: {
                label: "Всплывающая подсказка",
                type: "string",
                ref: 'qAttributeExpressions.0.qExpression',
                component: "expression",
                defaultValue: "",
              },
            }
          },
          sorting: {
            uses: "sorting"
          },
          settings: {
            uses: "settings",
            items: {
              view: {
                label: "Вид",
                type: "items",
                items: {
                  palette: {
                    type: 'string',
                    label: "Палитра",
                    ref: "props.palette",
                    component: 'dropdown',
                    options: [
                      {
                        label: 'Розово-зеленая',
                        value: 'pink-green'
                      },
                      {
                        label: 'Фиолетово-зеленая',
                        value: 'purple-green'
                      },
                      {
                        label: 'Красно-синяя',
                        value: 'red-blue'
                      },
                      {
                        label: 'Красно-зеленая',
                        value: 'red-green'
                      },
                      {
                        label: 'Зеленая',
                        value: 'green'
                      },
                      {
                        label: 'Красная',
                        value: 'red'
                      },
                      {
                        label: 'Серая',
                        value: 'gray'
                      },
                    ],
                    defaultValue: 'pink-green'
                  },
                  legendPosition: {
                    type: 'string',
                    label: "Позиция легенды",
                    ref: "props.legendPosition",
                    component: 'dropdown',
                    options: [
                      {
                        label: 'Вверху',
                        value: 'up'
                      },
                      {
                        label: 'Внизу',
                        value: 'down'
                      }],
                    defaultValue: 'up'
                  },
                  isFilter: {
                    type: 'boolean',
                    label: "Фильтровать по значению",
                    ref: "props.isFilter",
                    component: 'switch',
                    options: [
                      {
                        label: 'Да',
                        value: true
                      },
                      {
                        label: 'Нет',
                        value: false
                      },
                    ],
                    defaultValue: false
                  },
                }
              },

              tooltip: {
                label: "Всплывающая подсказка",
                type: "items",
                items: {
                  tooltipColor: {
                    label: "Цвет текста",
                    type: "string",
                    ref: 'props.tooltipColor',
                    expression: "optional",
                    defaultValue: "gray"
                  },
                  tooltipFontSize: {
                    label: "Размер текста",
                    type: 'number',
                    ref: 'props.tooltipFontSize',
                    expression: "optional",
                    defaultValue: 12
                  },
                  tooltipBg: {
                    label: "Цвет фона",
                    type: "string",
                    ref: 'props.tooltipBg',
                    expression: "optional",
                    defaultValue: "lightblue"
                  },
                }
              }

            }
          }
        }
      },
      support: {
        snapshot: true,
        export: true,
        exportData: false
      },
      paint: function ($element, layout) {
        try {
          //код будет тут
        }
        catch (e) {
          console.log(e)
        }
        //needed for export
        return qlik.Promise.resolve();
      }
    };
  });
Форматирование данных в объект для отрисовки
Ну тут все уже просто для вас:

  • нулевая строка в qMatrix - дата,
  • первая строка в qMatrix - значение,

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


Работая с датами обязательно учитывайте формат, new Date() парсит разные форматы, но они могут не совпадать в разных браузерах, например то что правильно распарсится в Chrome не обязательно распарсится в Safari.

Конкретно для нашей диаграммы формат даты должен быть следующий: MM/DD/YYYY

Если вы попробуете задать другой формат, то упс… все сломается. Это конечно же можно доработать, о чем мы расскажем в продвинутом тренинге, но для нашей задачи минимум нам достаточно и этого.
Домашнее задание. Урок 4
Завтра мы будем разбираться в шаблоне D3 и помещать его в Qlik extension, поэтому давайте подготовимся к этому.

Сегодня домашнее задание - поближе познакомиться с D3 на основе этих материалов:

  1. Хорошая старая (но от этого не менее актуальная) статья на Хабре: Введение в D3
  2. Frontender Magazine: Введение в d3.js (у сайта временные неполадки, которые в скором времени устранят)
Итоги дня
Итак, сегодня мы разобрали базовую теорию по D3 и svg-графике, которая нам очень пригодится завтра при финализации расширения "Календарь". Сегодня мы сделали важную подготовительную работу по разработке последнего экстеншена этого интенсива и дошли до форматирования данных в объект для отрисовки. Завтра будем кастомизировать шаблон календаря и тестировать готовый Qlik extension. Надеемся, вы начинаете влюбляться в разработку расширений:
Консультационная Группа АТК и Qlik-сообщество qRUG

2020

atkcg.ru
qrug.atkcg.ru