День 5. Расширение "Календарь" на основе D3: часть 2
Всем привет! Сегодня мы до конца разработаем третий экстеншен, "Календарь", и нам предстоит самое сложное: разобраться в шаблоне D3, поместить его в экстеншен и заставить работать.

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

Написание функции отрисовки интеграции данных

    Для начала разберемся в шаблоне D3 и поправим, что нужно.


    1. Продолжаем работать в calendar.js.

    Вставляем код шаблона календаря, который берем отсюда: https://observablehq.com/@d3/calendar-view в calendar.js.

    2. Объединяем шаблон d3-календаря с нашими данными.

    Для этого:

    • блок chart переделываем в функцию и вставляем в наш paint ниже данных,
    • между нашими данными и чартом вставим переменные из шаблона. Кроме data, потому что она у нас своя.
    • после функции chart очищаем $element и вставляем в него chart с нашими данными:
    $element[0].innerHTML = '';
    $element[0].appendChild(chart(data))

    3.
    Переписываем деструктурирующее присваивание.

    В коде шаблона календаря встречается деструктурирующее присваивание (одна из относительно новых возможностей js)
    ([key]) => key
    Их мы перепишем во вариант, который проще для восприятия человеку и на который не ругается Qlik Sense. Деструктурирующее присваивание и стрелочную функцию мы переписываем как обычную функцию.
    function(d){
      return d.key;
    }
    


    4. Правки функций форматирования в календаре.

    Еще в шаблоне присутствуют функции форматирования, такие как formatValue и formatClose.

    formatValue делает из числа значение с процентом и +, а нам они не нужны, так что:

    • меняем: var formatValue = d3.format("+.2%") на var formatValue = d3.format(".2f"),
    • удаляем formatClose, т.к. мы убрали это значение из данных.

    Получим что-то такое:
    const years = d3.nest()
      .key(function (d) {
        return d.date.getFullYear()
      })
      .entries(data)
      .reverse()
    
    var weekday = 'monday';
    var cellSize = 17;
    var width = 954;
    var height = cellSize * (weekday === "weekday" ? 7 : 9);
    var timeWeek = weekday === "sunday" ? d3.utcSunday : d3.utcMonday;
    var countDay = weekday === "sunday" ? d => d.getUTCDay() : d => (d.getUTCDay() + 6) % 7;
    
    function pathMonth(t) {
      const n = weekday === "weekday" ? 5 : 7;
      const d = Math.max(0, Math.min(n, countDay(t)));
      const w = timeWeek.count(d3.utcYear(t), t);
      return `${d === 0 ? `M${w * cellSize},0`
        : d === n ? `M${(w + 1) * cellSize},0`
          : `M${(w + 1) * cellSize},0V${d * cellSize}H${w * cellSize}`}V${n * cellSize}`;
    }
    
    var formatValue = d3.format(".2f")
    var formatDate = d3.utcFormat("%x")
    var formatDay = d => "SMTWTFS"[d.getUTCDay()]
    var formatMonth = d3.utcFormat("%b")
    var max = d3.max(data.map(d => Math.abs(d.value)))
    var color = d3.scaleSequential(d3.interpolatePiYG).domain([-max, +max]);
    
    function chart(years) {
      const svg = d3.create("svg")
        .attr("viewBox", [0, 0, width, height * years.length])
        .attr("font-family", "sans-serif")
        .attr("font-size", 10);
    
      const year = svg.selectAll("g")
        .data(years)
        .join("g")
        .attr("transform", (d, i) => `translate(40.5,${height * i + cellSize * 1.5})`);
    
      year.append("text")
        .attr("x", -5)
        .attr("y", -5)
        .attr("font-weight", "bold")
        .attr("text-anchor", "end")
        .text(function (d) {
          return d.key
        })
    
      year.append("g")
        .attr("text-anchor", "end")
        .selectAll("text")
        .data((d3.range(7)).map(i => new Date(1995, 0, i)))
        .join("text")
        .attr("x", -5)
        .attr("y", d => (countDay(d) + 0.5) * cellSize)
        .attr("dy", "0.31em")
        .text(formatDay);
    
      year.append("g")
        .selectAll("rect")
        .data(function (d) {
            return d.values
        })
        .join("rect")
        .attr("width", cellSize - 1)
        .attr("height", cellSize - 1)
        .attr("x", d => timeWeek.count(d3.utcYear(d.date), d.date) * cellSize + 0.5)
        .attr("y", d => countDay(d.date) * cellSize + 0.5)
        .attr("fill", d => color(d.value))
        .append("title")
        .text(d => `${formatDate(d.date)}
    ${formatValue(d.value)}`);
    
      const month = year.append("g")
        .selectAll("g")
        .data(function (d) {
          return d3.utcMonths(d3.utcMonth(d.values[0].date), d.values[d.values.length - 1].date)
        })
        .join("g");
    
      month.filter((d, i) => i).append("path")
        .attr("fill", "none")
        .attr("stroke", "#fff")
        .attr("stroke-width", 3)
        .attr("d", pathMonth);
    
      month.append("text")
        .attr("x", d => timeWeek.count(d3.utcYear(d), timeWeek.ceil(d)) * cellSize + 2)
        .attr("y", -5)
        .text(formatMonth);
    
      return svg.node();
    }
    
    $element[0].innerHTML = '';
    $element[0].appendChild(chart(years))
    
    5. Фиксим ошибки.

    А теперь обновляем страницу с экстеншеном и... наблюдаем, как ничего не работает. В таком случае обращаемся к консоли в инструментах разработчика Google Chrome, и, скорее всего, видим там: "d3.groups is not a function".

    Метод d3.groups у нас используется для группировки данных по годам, это важно, поэтому используем ключевой навык программиста - гуглинг.

    За пару кликов выясняется, что d3.groups - новая современная замена метода d3.nest(). И есть 2 варианта пофиксить ошибку, которая нам попалась:

    • попробовать написать тоже самое только с nest,
    • попробовать написать это самому, например, с помощью .reduce (нативный метод в js).


    В процессе гуглинга мне попалась реализация с nest(), так что я воспользуюсь ею, но вы можете попробовать написать сами.

    Заменим years с groups, на nest:
    const years = d3.nest()
      .key(function (d) {
        return d.date.getFullYear()
      })
      .entries(data)
      .reverse()
    Тут происходит группировка данных по годам.


    6. Зададим переменную для начала недели.

    В коде часто встречается переменная weekday, которая нигде не объявляется. На самом деле ее значение задается в селекторе (имеется в виду селектор на странице с шаблоном, а не у нас, у нас в коде выше видно где она создается), в самом верху шаблона. Нас интересует неделя с понедельника, так что создадим ее и выставим значение:
    var weekday = 'monday';
    Но вообще, раз она у нас константа, а на ней завязаны несколько условий, можно избавиться и от нее, и от условий, например:
    Было
    var height = cellSize * (weekday === "weekday" ? 7 : 9);
    Стало
    var height = cellSize * 9;


    7.
    Проверим.

    Если запустить код снова, то работать он опять не станет. В консоли видим, что теперь его не устраивает nest, это может быть связано с разницей версий d3 Qlik и той, которая нужна экстеншену.


    8. Подключаем бандл "calendar"

    Чтобы решить эту проблему, загрузим файл d3 отдельно: скачайте бандл "calendar".

    Итак, у нас теперь есть основной файл - calendar.js, и дополнительно берем из бандла и "цепляем" к calendar.js еще 4 файла: d3.js, d3-colors.js, d3-interpolate.js, d3-scale-chromatic.js

    Почему 4 файла, а не один? Так уж вышло, что кроме d3 требуется еще несколько модулей:

    • d3-colors,
    • d3-interpolate,
    • d3-scale-chromatic.

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

    Когда откроете бандл, вы увидите там все эти модули. Их тоже надо поместить в директорию экстеншена и подключить в calendar.js:
    define(["qlik",
      "text!./template.html",
      "./d3",
      "./d3-color",
      "./d3-interpolate",
      "./d3-scale-chromatic",
      "css!./calendar.css",
    ],
      function (qlik,
        template,
        d3,
      ) {
    
    Мы не прописываем все эти файлы в аргумент function, поскольку прямо к ним обращаться не планируем, они будут взаимодействовать между собой.


    9. Проверим! Ваши ставки?

    Да, и снова после обновления ничего не работает!

    Консоль в инспекторе порадует нас новыми ошибками. На самом деле порадует, потому что если что-то не работает, а ошибок нет, то придется еще труднее.
    "Cannot read property 'interpolateRgbBasis' of undefined" направит нас в модуль d3-scale-chromatic. Это значит что метод, который должен в него прийти из d3-interpolate, не пришел, и получается, что они друг друга не "видят".
    "Unexpected token '<'" ругается на то, что в js файле почему-то лежит html. Если посмотреть на этот файл, то можно обнаружить 2 проблемы:
    • в d3-color действительно html, и, если на него посмотреть, можно заметить, что он выглядит как какой-то шаблон для страницы Qlik. А это - явно не то, что мы ожидали там увидеть.
    • он находится не в директории нашего экстеншена, и его вообще там быть не должно.

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

    Проблема связана с тем, что несмотря на подключение в экстеншен, сами модули друг друга ищут где-то не там и нужно им явно прописать пути. Для этого нужно добавить специальный блок в первую строчку calendar.js:
    requirejs.config({
      paths: {
        'd3': '/extensions/calendar/d3',
        'd3-interpolate': '/extensions/calendar/d3-interpolate',
        'd3-color': '/extensions/calendar/d3-color',
        'd3-scale-chromatic': '/extensions/calendar/d3-scale-chromatic',
      }
    });
    
    Подключение файлов в Qlik Sense происходит с помощью require.js, подробней про него можно почитать на их официальном сайте: https://requirejs.org/


    10. Почти получилось.

    Теперь после обновления должна появиться наша долгожданная визуализация.
    Ура, получилось! Но сразу в глаза бросается то, что первый год у нас NaN: видимо, к нам пришло пустое значение. Пустые значения можно:

    • отключить в параметрах измерения,
    • обработать самим при формировании объекта данных при помощи такого кода:

    var data = layout.qHyperCube.qDataPages[0].qMatrix.map(function (d) {
      if (d[0].qText !== '-') {
        return {
          date: new Date(d[0].qText),
          value: d[1].qNum,
          tooltip: d[1].qAttrExps !== undefined && d[1].qAttrExps.qValues[0].qText !== undefined ? d[1].qAttrExps.qValues[0].qText : d[1].qText
        }
      }
    })
    
    
    Теперь если у нас не будет значения, то в массив будет приходить undefined. Надо бы от него избавиться, для этого прогоним массив данных через фильтр:
    data = data.filter(function (d) {//
      return d !== undefined;
    })

    Проверьте себя
       paint: function ($element, layout) {
        try {
      
          var data = layout.qHyperCube.qDataPages[0].qMatrix.map(function (d) {
            if (d[0].qText !== '-') {
              return {
                date: new Date(d[0].qText),
                value: d[1].qNum,
                tooltip: d[1].qAttrExps !== undefined && d[1].qAttrExps.qValues[0].qText !== undefined ? d[1].qAttrExps.qValues[0].qText : d[1].qText
              }
            }
          })
      data = data.filter(function (d) {//
        return d !== undefined;
      })
      
      
      
          const years = d3.nest()
            .key(function (d) {
              return d.date.getFullYear()
            })
            .entries(data)
            .reverse()
      
          var cellSize = 17;
          var width = 954;
          var height = cellSize * (weekday === "weekday" ? 7 : 9);
          var timeWeek = weekday === "sunday" ? d3.utcSunday : d3.utcMonday;
          var countDay = weekday === "sunday" ? d => d.getUTCDay() : d => (d.getUTCDay() + 6) % 7;
      
          function pathMonth(t) {
            const n = weekday === "weekday" ? 5 : 7;
            const d = Math.max(0, Math.min(n, countDay(t)));
            const w = timeWeek.count(d3.utcYear(t), t);
            return `${d === 0 ? `M${w * cellSize},0`
              : d === n ? `M${(w + 1) * cellSize},0`
                : `M${(w + 1) * cellSize},0V${d * cellSize}H${w * cellSize}`}V${n * cellSize}`;
          }
      
          var formatValue = d3.format(".2f")
          var formatDate = d3.utcFormat("%x")
          var formatDay = d => "SMTWTFS"[d.getUTCDay()]
          var formatMonth = d3.utcFormat("%b")
          max = d3.quantile(data.map(d => Math.abs(d.value)).sort(d3.ascending), 0.9975);
          var color = d3.scaleSequential(d3.interpolatePiYG).domain([-max, +max]);
      
          function chart(years) {
            const svg = d3.create("svg")
              .attr("viewBox", [0, 0, width, height * years.length])
              .attr("font-family", "sans-serif")
              .attr("font-size", 10);
      
            const year = svg.selectAll("g")
              .data(years)
              .join("g")
              .attr("transform", (d, i) => `translate(40.5,${height * i + cellSize * 1.5})`);
      
            year.append("text")
              .attr("x", -5)
              .attr("y", -5)
              .attr("font-weight", "bold")
              .attr("text-anchor", "end")
              .text(function (d) {
                return d.key
              })
      
            year.append("g")
              .attr("text-anchor", "end")
              .selectAll("text")
              .data((d3.range(7)).map(i => new Date(1995, 0, i)))
              .join("text")
              .attr("x", -5)
              .attr("y", d => (countDay(d) + 0.5) * cellSize)
              .attr("dy", "0.31em")
              .text(formatDay);
      
            year.append("g")
              .selectAll("rect")
              .data(function (d) {
                return d.values
              })
              .join("rect")
              .attr("width", cellSize - 1)
              .attr("height", cellSize - 1)
              .attr("x", d => timeWeek.count(d3.utcYear(d.date), d.date) * cellSize + 0.5)
              .attr("y", d => countDay(d.date) * cellSize + 0.5)
              .attr("fill", d => color(d.value))
              .append("title")
              .text(d => `${formatDate(d.date)}
      ${formatValue(d.value)}`);
      
            const month = year.append("g")
              .selectAll("g")
              .data(function (d) {
                return d3.utcMonths(d3.utcMonth(d.values[0].date), d.values[d.values.length - 1].date)
              })
              .join("g");
      
            month.filter((d, i) => i).append("path")
              .attr("fill", "none")
              .attr("stroke", "#fff")
              .attr("stroke-width", 3)
              .attr("d", pathMonth);
      
            month.append("text")
              .attr("x", d => timeWeek.count(d3.utcYear(d), timeWeek.ceil(d)) * cellSize + 2)
              .attr("y", -5)
              .text(formatMonth);
      
            return svg.node();
          }
      
          $element[0].innerHTML = '';
          $element[0].appendChild(chart(years))
      
        }
        catch (e) {
          console.log(e)
        }
        //needed for export
        return qlik.Promise.resolve();
      }
      
      Визуализация стала выглядеть лучше, когда мы убрали пустые значения, но пока не идеал.


      1. На лицо нехватка подписей месяцев. Это исправляется сортировкой массива данных:

      data.sort(function (a, b) {//
        return a.date >= b.date ? 1 : -1
      })
      2. Прекрасно, но есть еще кое-что. Диаграмма не помещается по высоте, поместим ее в скролящийся контейнер. Для этого:

      • в template.html добавим:
      <div class="calendar-wrapper">
        <div class="calendar-legend"> </div>
        <div class="calendar-container"></div>
      </div>
      • в calendar.css напишем размеры для него, и скажем скроллиться автоматически (скролл будет включаться, если контент больше контейнера):
      .calendar-container {
        height: 100%;
        width: 100%;
        overflow: auto;
      }
      • так как календарь у нас имеет скролл (другими словами не влезает в отведенную ему область, а даже если бы влезал, то его невозможно было бы изучать), то мы сделаем "внезапный поворот": поместим $element в контейнер (создадим переменную контейнер и скажем, что она равна $element):

        • в calendar.js сразу после try{ найдем контейнер и заменим очистку $element[0].innerHTML='' на очистку этого контейнера

        • в самом конце будем класть диаграмму не в $element (сам экстеншен), а в container (который <div class="calendar-container"></div>). Дело в том, что чтобы календарь скроллился, нужно добавить стилиу нас есть контейнер, который <div class="calendar-container"></div>, к которому мы обращаемся сейчас через js и до этого мы клали все в $element (сам экстеншн), а теперь в container.

      Какие манипуляции провести с кодом смотрите ниже:
      Вставляем в самом начале перед var data: 
      
      var wrapper = $element[0].querySelector('.calendar-wrapper')
      var container = $element[0].querySelector('.calendar-container')
      container.innerHTML = '';
      // остальной код
      Удаляем (в конце): $element[0].innerHTML=’’
      Удаляем: $element[0].appendChild(chart(years))
      Вместо него вставляем: container.appendChild(chart(years))


      3. Подключение параметров - начнем с цвета.

      Он задается у нас вот в этой строчке:
      var color = d3.scaleSequential(d3.interpolatePiYG).domain([-max, +max]);
      Нужно отредактировать ее так, чтобы можно было подставить свою цветовую палитру.

      • Для начала разберемся, что там вообще происходит:

        • При выводе в консоль color можно увидеть, что это функция, и используется он как функция: т.е. дальше в коде в нее передается значение, а результат выполнения устанавливается как цвет.

        • В domain передается диапазон значений,

        • В d3.interpolatePiYG передается цветовая палитра. Если ее поискать в интернетах, найдется описание к ней: https://github.com/d3/d3-scale-chromatic#diverging.

      • Дальше механизм простой: передаем в d3.scaleSequential() ту палитру, которая нравится. Там можно посмотреть, какие еще палитры есть, и выбрать те, которые вам понравятся.

      • Теперь составим объект соответствия "значение параметра цвета (из тех, которые мы сделали в параметрах экстеншена) - цветовая палитра d3":
      var palette = {
        'pink-green': d3.interpolatePiYG,
        'purple-green': d3.interpolatePRGn,
        'red-blue': d3.interpolateRdBu,
        'red-green': d3.interpolateRdYlGn,
        'green': d3.interpolateGreens,
        'red': d3.interpolateReds,
        'gray': d3.interpolateGreys
      }
      
      • Вставим ее перед объявлением years.

      • Теперь просто заменим d3.interpolatePiYG в коде, на значение настройки цвета.
      var color = d3.scaleSequential(palette[layout.props.palette]).domain([-max, +max]);
      
      Результат: теперь при переключении цвета в настройках экстеншена будет меняться цветовая схема календаря!


      Если вы хотите задать свои собственные цвета, то можно воспользоваться вот таким методом: d3.scaleSequential(d3.interpolateRgb("yellow", "green")), где yellow и green значение цвета.



      4. Подключение параметров - позиционирование легенды.

      • Для начала напишем, что вообще позиционировать будем, то есть легенду.
      • Легенда у нас будет в виде 2х свотчей, цвет - значение, соответствующее этому цвету.
      • Подправим наш template.html, добавив в тег с классом "calendar-legend" следующее:
      <div class="calendar-legend-item">
          <div class="calendar-legend-color"> </div>
          <div class="calendar-legend-label"> </div>
      </div>
      <div class="calendar-legend-item">
          <div class="calendar-legend-color"> </div>
          <div class="calendar-legend-label"> </div>
      </div>
      
      
      • Сейчас это пустые теги, которые мы заполним в calendar.js
      • В качестве значений у нас будет массив пограничных значений [-max, +max], что-то такое у нас уже было в color, чтобы не повторяться вынесем этот массив в переменную:
      var boundaryValues = [-max, +max];
      
      
      • Теперь найдем элементы calendar-legend-item в template.html. В каждом из них calendar-legend-color покрасим в соответствующий цвет, а в calendar-legend-label напишем значение, которое мы еще и приведем в подобающий вид ранее объявленной функцией formatValue:
      var items = wrapper.querySelectorAll('.calendar-legend-item');
      items.forEach(function (el, i) {
        el.querySelector('.calendar-legend-color').style.background = color(boundaryValues[i]);
         el.querySelector('.calendar-legend-label').innerHTML = formatValue(boundaryValues[i]);
      })
      
      
      
      • При обновлении страницы кроме значений ничего не отобразится, т.к. блоки с цветами размером 0x0, и нужно стилизовать их в calendar.css. Зададим размеры блокам цветов, спозиционируем их и их подписи в линию и добавим отступов по вкусу. Например, так (работаем в css):
      .calendar-wrapper {
        display: flex;
        height: 100%;
        flex-grow: 0;
      }
      
      .calendar-legend {
        display: flex;
        justify-content: flex-end;
      }
      
      .calendar-legend-item {
        display: flex;
        align-items: center;
        margin-right: 10px;
      }
      
      .calendar-legend-color {
        width: 60px;
        height: 20px;
        margin: 3px 6px;
      }
      
      .calendar-legend-label {
        font-size: 12px;
      }
      
      
      
      Ну это уже на что-то похоже.

      • Позиционирование легенды будем оформлять выставлением css-свойства, меняющего порядок того, что за чем идет: диаграмма за легендой или наоборот (в calendar.js указываем там, где легенда):
      wrapper.style.flexDirection = layout.props.legendPosition === 'up'
        ? 'column'
        : 'column-reverse';
      var items = wrapper.querySelectorAll('.calendar-legend-item');
      



      5. Подключение параметров - фильтр по клику на элемент диаграммы.

      Этот замечательный прием мы освоили на примере кнопок, нам понадобится следующий метод:
      self.backendApi.selectValues(dimIndex, [values], selectType);
      
      Сейчас из того, что нужно, у нас есть только индекс измерения, он равен 0, т.к. измерений у нас одно. Соответственно, нам нужны:

      • переменная self, для хранения this из paint'а,
      • values, которыми являются свойства,
      • qElemNumber из значений измерения,
      • статус фильтра, для определения типа фильтрации.


      • Чтобы достать все вышеперечисленное, нам придется снова модифицировать наш объект данных:
      var data = layout.qHyperCube.qDataPages[0].qMatrix.map(function (d) {
        if (d[0].qText !== '-') {
          return {
            date: new Date(d[0].qText),
             value: d[1].qNum,
            tooltip: d[1].qAttrExps !== undefined && d[1].qAttrExps.qValues[0].qText !== undefined ? d[1].qAttrExps.qValues[0].qText : d[1].qText,
            qElemNumber: d[0].qElemNumber,
            isSelected: d[0].qState === 'S'
          }
        }
      })
      
      
      • Добавим перед созданием переменной wrapper:
      var self = this;
      
      • Найдем в коде, где отрисовываются сами сектора:
      year.append("g")
        .selectAll("rect")
        .data(function (d) {
          return d.values
        })
        .join("rect")
        .attr("width", cellSize - 1)
        .attr("height", cellSize - 1)
        .attr("x", d => timeWeek.count(d3.utcYear(d.date), d.date) * cellSize + 0.5)
        .attr("y", d => countDay(d.date) * cellSize + 0.5)
        .attr("fill", d => color(d.value))
        .append("title")
        .text(d => `${formatDate(d.date)}
      ${formatValue(d.value)}`);
      
      • В коде выше часть с .join("rect") по .attr("fill", d => color(d.value)) - это наш сектор, и теперь нужно прицепить обработчик Qlik-а, который будет фильтровать:
      year.append("g")
        .selectAll("rect")
        .data(function (d) {
          return d.values
        })
        .join("rect")
        .attr("width", cellSize - 1)
        .attr("height", cellSize - 1)
        .on("click", function (d) {
          self.backendApi.selectValues(0, [d.qElemNumber], d.isSelected);
        })
        .attr("x", d => timeWeek.count(d3.utcYear(d.date), d.date) * cellSize + 0.5)
        .attr("y", d => countDay(d.date) * cellSize + 0.5)
        .attr("fill", d => color(d.value))
        .append("title")
        .text(d => `${formatDate(d.date)}
      ${formatValue(d.value)}`);
      
      
      "d" в этом случае - наш объект данных, привязанный к сектору, так что в пределах этой цепи мы имеем к нему доступ. В расширении №2 "Динамические кнопки" у нас был один общий обработчик, а тут мы сделаем иначе, хотя бы потому, что тут такой удобный "цепной" синтаксис.

      Проверим: фильтры прекрасно фильтруют, да так хорошо, что скрываются все остальные значения на диаграмме. Тут-то нам и пригодится запасная мера, в которой мы пропишем волшебное выражение для того, чтобы остальные значения не совсем исчезали:MaxString({<[OrderDate]=>}[OrderDate])

      Перед тем как пойдем дальше, определим с какими значениями вообще работает наш календарик:

      • значение отсутствует (null) оно совсем отсутствует, измерения с таким значением нет, календарь учитывает все дни, и если в данных, например, нет 1.09.2016, то клетка будет пустая;
      • равно 0, для этого значения есть дата (и если мы поставили фильтр на одну дату, другие равны 0);
      • значение больше и не равно 0 (ближе к max или ближе к min, если мы смотрим на дату, на которую поставили фильтр или фильтр вообще не стоит)

      Когда мы нажали на любой цветной квадрат в календаре, диаграмма не пропадает, квадратики на месте, но все кроме выбранного равны 0.

      Если мы уберем выражение MaxString({<[OrderDate]=>}[OrderDate]), то диаграмма "пропадает", квадратики исчезают, остается только выбранный.


      Почему же так произошло?
      Это связано со способом получения константы max:
      const max = d3.quantile(data.map(d => Math.abs(d.value)).sort(d3.ascending), 0.9975); 
      
      В переводе на человеческий: max - это такое число, которое больше, чем 99,75% массива, но меньше, чем 0,25%, а в случае если у нас есть только одно значение из, например, 500, то оно теряется.

      • Чтобы так не происходило сделаем максимальное значение действительно максимальным
      Удаляем: var max = d3.quantile(data.map(d => Math.abs(d.value)).sort(d3.ascending), 0.9975); 
      Вместо него вставляем: var max = d3.max(data.map(d => Math.abs(d.value))) 
      
      И еще кое-что: у нас вот большие положительные значения и полное отсутствие отрицательных. Из-за этого первая половина цветового диапазона теряется от слова совсем. Поменяем наше абсолютное максимальное значение на обычные минимальное и максимальное:
         var max = d3.max(data.map(d => d.value))
         var min = d3.min(data.map(d => d.value))
        

        Ну и наш массив пограничных значений поправим:
          var boundaryValues = [min, max];
          



          6. Подключение параметров - всплывающая подсказка.

          Если навести курсор на дату, то подсказка будет, она там предусмотрена, но она нам не подойдет, потому что задана элементом title, а его нельзя стилизовать так как мы хотели, значит сделаем свою. Убираем старую подсказку в "цепи" year
            year.append("g")
              .selectAll("rect")
              .data(function (d) {
                return d.values
              })
              join("rect")
              .attr("width", cellSize - 1)
              .attr("height", cellSize - 1)
              .on("click", function (d) {
                if (layout.props.isFilter)
                  self.backendApi.selectValues(0, [d.qElemNumber], d.isSelected);
                })
              .attr("x", d => timeWeek.count(d3.utcYear(d.date), d.date) * cellSize + 0.5)
              .attr("y", d => countDay(d.date) * cellSize + 0.5)
              .attr("fill", d => color(d.value))
            
            
            Перед функцией chart создадим элемент подсказки:
              var tooltip = d3.select("body").append("div")
                .attr("class", "calendar-tooltip")
                .style("opacity", 0);
              
              
              Здесь мы создали тег div, поместили его в тег body и задали ему класс calendar-tooltip.

              Возможно, вы зададитесь вопросом: "А чего это мы ее в body поместили, а не в $element?"
              Это связано с тем, что контейнер экстеншена обрезает все содержимое своими границами. Поэтому если наша подсказка окажется слишком большой, ее обрежет. А body - снаружи, так что подсказка теперь не в юрисдикции экстеншена.

              • Сейчас у нас где-то есть прозрачная пустая подсказка, которая при наведении на значение диаграммы будет:

                • заполняться текстом,

                • перемещаться к этому значению,

                • становиться видимой.

              Поэтому мы напишем:
                .on("mouseover", function (d) {
                  tooltip.transition()
                    .duration(200)
                    .style("opacity", 1);
                                    tooltip.html(formatDate(d.date) + "<br/>" + d.value)
                    .style("left", (d3.event.pageX) + 20 + "px")
                    .style("top", (d3.event.pageY - 20) + "px");
                  })
                .on("mouseout", function (d) {
                  tooltip.transition()
                  .duration(200)
                  .style("opacity", 0);
                });
                
                
                mouseover/mouseout - название событий, наведение мыши и убирание мыши соответственно.
                .duration(200) - значит, что подсказка будет появляться и исчезать не моментально, а в течении 2 миллисекунд.d3.event - данные о событии, из которых мы получаем координаты срабатывания события, т.е. позицию значения, на которое навелись.


                • Класс всплывающей подсказке мы задали не просто так, а чтобы задать ей стили в css-файле:
                  .calendar-tooltip {
                    position: absolute;
                    display: block;
                    max-width: 300px;
                    max-height: 400px;
                    background: #fff;
                    padding: 5px 3px;
                    overflow: hidden;
                    border: 1px solid #ccc;
                    z-index: 10;
                    white-space: nowrap;
                  }
                  
                  
                  • А теперь вернемся в calendar.js, туда, где создаем подсказку, и подцепим ей наши параметры и добавим ей еще стилей.
                  var tooltip = d3.select("body").append("div")
                    .attr("class", "calendar-tooltip")
                    .style("opacity", 0)
                    .style('font-size', layout.props.tooltipFontSize + 'px')
                    .style('color', layout.props.tooltipColor)
                    .style('background', layout.props.tooltipBg);
                  
                  
                  • Вроде как даже похоже на правду, но если потестировать и покликать по значениям, можно обнаружить, что наши подсказки размножаются и не исчезают:
                  Это потому, что каждый раз по клику создается новая диаграмма, а с ней - и новая подсказка. А до mouseout дело не доходит, т.к. значения, с которого должна уйти мышь, уже просто нет. Значит, когда мы создаем диаграмму, надо проверить, нет ли там подсказки с прошлого раза, и: - если она есть, захватить ее, - если нет, то создать новую.


                  • Стираем старое создание подсказки и пишем новое:
                  var tooltip = d3.select('.calendar-tooltip')
                  if (tooltip.node() === null) {
                    tooltip = d3.select("body").append("div")
                      .attr("class", "calendar-tooltip")
                      .style("opacity", 0)
                  } else {
                    tooltip.style.opacity = 0;
                  }
                  tooltip
                    .style('font-size', layout.props.tooltipFontSize + 'px')
                    .style('color', layout.props.tooltipColor)
                    .style('background', layout.props.tooltipBg)
                  • И еще кое-что: возможно, я придираюсь, но если навести на подсказку и поскроллить контейнер диаграммы, она не исчезает сразу. Обработаем эту ситуацию тоже:
                  d3.select('.calendar-container')
                    .on('scroll', function () {
                       tooltip.transition()
                         .duration(200)
                         .style("opacity", 0);
                     })
                  • Осталось убрать консоль логи, try-catch и дописать комментарии.


                  Поздравляю, вы великолепны!
                  Проверьте себя


                  Результирующий код для calendar.js
                  requirejs.config({
                    paths: {
                      'd3': '/extensions/calendar/d3',
                      'd3-interpolate': '/extensions/calendar/d3-interpolate',
                      'd3-color': '/extensions/calendar/d3-color',
                      'd3-scale-chromatic': '/extensions/calendar/d3-scale-chromatic'
                    }
                  });
                  
                  define(["qlik",
                    "text!./template.html",
                    "./d3",
                    "./d3-color",
                    "./d3-interpolate",
                    "./d3-scale-chromatic",
                    "css!./calendar.css"
                  ],
                    function (qlik,
                      template,
                      d3
                    ) {
                  
                      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: "ligntblue"
                                    },
                                  }
                                }
                  
                              }
                            }
                          }
                        },
                        support: {
                          snapshot: true,
                          export: true,
                          exportData: false
                        },
                        paint: function ($element, layout) {
                          var self = this;
                          // общий контейнер
                          var wrapper = $element[0].querySelector('.calendar-wrapper')
                          // контейнер диаграммы
                          var container = $element[0].querySelector('.calendar-container')
                          container.innerHTML = '';
                  
                          // данные диаграммы
                          var data = layout.qHyperCube.qDataPages[0].qMatrix.map(function (d) {
                            if (d[0].qText !== '-') {
                              return {
                                date: new Date(d[0].qText),
                                value: d[1].qNum,
                                tooltip: d[1].qAttrExps !== undefined && d[1].qAttrExps.qValues[0].qText !== undefined ? d[1].qAttrExps.qValues[0].qText : d[1].qText,
                                qElemNumber: d[0].qElemNumber,
                                isSelected: d[0].qState === 'S'
                              }
                            }
                          })
                          data = data.filter(function (d) {
                            return d !== undefined;
                          })
                          data.sort(function (a, b) {
                            return a.date >= b.date ? 1 : -1
                          })
                  
                          // цветовые схемы
                          var palette = {
                            'pink-green': d3.interpolatePiYG,
                            'purple-green': d3.interpolatePRGn,
                            'red-blue': d3.interpolateRdBu,
                            'red-green': d3.interpolateRdYlGn,
                            'green': d3.interpolateGreens,
                            'red': d3.interpolateReds,
                            'gray': d3.interpolateGreys
                          }
                  
                          // данные, сгруппированные по годам
                          const years = d3.nest()
                            .key(function (d) {
                              return d.date.getFullYear()
                            })
                            .entries(data)
                            .reverse()
                  
                          var cellSize = 17;
                          var width = 954;
                          var height = cellSize * 9;
                          var timeWeek = d3.utcMonday;
                          var countDay = d => (d.getUTCDay() + 6) % 7;
                  
                          // расчет отрисовки месяца
                          function pathMonth(t) {
                            const n = 7;
                            const d = Math.max(0, Math.min(n, countDay(t)));
                            const w = timeWeek.count(d3.utcYear(t), t);
                            return `${d === 0 ? `M${w * cellSize},0`
                              : d === n ? `M${(w + 1) * cellSize},0`
                                : `M${(w + 1) * cellSize},0V${d * cellSize}H${w * cellSize}`}V${n * cellSize}`;
                          }
                  
                          // функции форматирования
                          var formatValue = d3.format(".2f")
                          var formatDate = d3.utcFormat("%x")
                          var formatDay = d => "SMTWTFS"[d.getUTCDay()]
                          var formatMonth = d3.utcFormat("%b")
                  
                          // максимальное  и минимальное значения диаграммы
                          var max = d3.max(data.map(d => d.value))
                          var min = d3.min(data.map(d => d.value))
                  
                  
                          var boundaryValues = [min, max];
                  
                          // функция цвета диаграммы
                          var color = d3.scaleSequential(palette[layout.props.palette]).domain(boundaryValues);
                  
                          // легенда
                          wrapper.style.flexDirection = layout.props.legendPosition === 'up'
                            ? 'column'
                            : 'column-reverse';
                          var items = wrapper.querySelectorAll('.calendar-legend-item');
                  
                          items.forEach(function (el, i) {
                            el.querySelector('.calendar-legend-color').style.background = color(boundaryValues[i]);
                            el.querySelector('.calendar-legend-label').innerHTML = formatValue(boundaryValues[i]);
                          })
                  
                          // всплывающая подсказка
                          var tooltip = d3.select('.calendar-tooltip')
                          if (tooltip.node() === null) {
                            tooltip = d3.select("body").append("div")
                              .attr("class", "calendar-tooltip")
                              .style("opacity", 0)
                          } else {
                            tooltip.style.opacity = 0;
                          }
                          tooltip
                            .style('font-size', layout.props.tooltipFontSize + 'px')
                            .style('color', layout.props.tooltipColor)
                            .style('background', layout.props.tooltipBg)
                  
                          // функция отрисовки диаграммы
                          function chart(years) {
                            const svg = d3.create("svg")
                              .attr("viewBox", [0, 0, width, height * years.length])
                              .attr("font-family", "sans-serif")
                              .attr("font-size", 10);
                  
                            const year = svg.selectAll("g")
                              .data(years)
                              .join("g")
                              .attr("transform", (d, i) => `translate(40.5,${height * i + cellSize * 1.5})`);
                  
                            year.append("text")
                              .attr("x", -5)
                              .attr("y", -5)
                              .attr("font-weight", "bold")
                              .attr("text-anchor", "end")
                              .text(function (d) {
                                return d.key
                              })
                  
                            year.append("g")
                              .attr("text-anchor", "end")
                              .selectAll("text")
                              .data((d3.range(7)).map(i => new Date(1995, 0, i)))
                              .join("text")
                              .attr("x", -5)
                              .attr("y", d => (countDay(d) + 0.5) * cellSize)
                              .attr("dy", "0.31em")
                              .text(formatDay);
                  
                            year.append("g")
                              .selectAll("rect")
                              .data(function (d) {
                                return d.values
                              })
                              .join("rect")
                              .attr("width", cellSize - 1)
                              .attr("height", cellSize - 1)
                              .on("click", function (d) {
                                if (layout.props.isFilter)
                                  self.backendApi.selectValues(0, [d.qElemNumber], d.isSelected);
                              })
                              .attr("x", d => timeWeek.count(d3.utcYear(d.date), d.date) * cellSize + 0.5)
                              .attr("y", d => countDay(d.date) * cellSize + 0.5)
                              .attr("fill", d => color(d.value))
                              .on("mouseover", function (d) {
                                tooltip.transition()
                                  .duration(200)
                                  .style("opacity", 1);
                                tooltip.html(formatDate(d.date) + "<br/>" + d.value)
                                  .style("left", (d3.event.pageX) + 20 + "px")
                                  .style("top", (d3.event.pageY - 20) + "px");
                              })
                              .on("mouseout", function (d) {
                                tooltip.transition()
                                  .duration(200)
                                  .style("opacity", 0);
                              });
                  
                            d3.select('.calendar-container')
                              .on('scroll', function () {
                                tooltip.transition()
                                  .duration(200)
                                  .style("opacity", 0);
                              })
                  
                            const month = year.append("g")
                              .selectAll("g")
                              .data(function (d) {
                                return d3.utcMonths(d3.utcMonth(d.values[0].date), d.values[d.values.length - 1].date)
                              })
                              .join("g");
                  
                            month.filter((d, i) => i).append("path")
                              .attr("fill", "none")
                              .attr("stroke", "#fff")
                              .attr("stroke-width", 3)
                              .attr("d", pathMonth);
                  
                            month.append("text")
                              .attr("x", d => timeWeek.count(d3.utcYear(d), timeWeek.ceil(d)) * cellSize + 2)
                              .attr("y", -5)
                              .text(formatMonth);
                  
                            return svg.node();
                          }
                          container.appendChild(chart(years))
                  
                          //needed for export
                          return qlik.Promise.resolve();
                        }
                      };
                  
                    });
                  

                  Результирующий код для calendar.css
                  .calendar-container {
                    height: 100%;
                    width: 100%;
                    overflow: auto;
                  }
                  
                  .calendar-wrapper {
                    display: flex;
                    height: 100%;
                    flex-grow: 0;
                  }
                  
                  .calendar-legend {
                    display: flex;
                    justify-content: flex-end;
                  }
                  
                  .calendar-legend-item {
                    display: flex;
                    align-items: center;
                    margin-right: 10px;
                  }
                  
                  .calendar-legend-color {
                    width: 60px;
                    height: 20px;
                    margin: 3px 6px;
                  }
                  
                  .calendar-legend-label {
                    font-size: 12px;
                  }
                  
                  .calendar-tooltip {
                    position: absolute;
                    display: block;
                    max-width: 300px;
                    max-height: 400px;
                    background: #fff;
                    padding: 5px 3px;
                    overflow: hidden;
                    border: 1px solid #ccc;
                    z-index: 10;
                    white-space: nowrap;
                  }
                  

                  Результирующий код для template.html
                  <div class="calendar-wrapper">
                    <div class="calendar-legend">
                      <div class="calendar-legend-item">
                        <div class="calendar-legend-color"> </div>
                        <div class="calendar-legend-label"> </div>
                      </div>
                      <div class="calendar-legend-item">
                        <div class="calendar-legend-color"> </div>
                        <div class="calendar-legend-label"> </div>
                      </div>
                    </div>
                    <div class="calendar-container"></div>
                  </div>
                  
                  Что делать, если вы сами разработали интересный экстеншен?
                  Если вы разработали интересный экстеншен, и хотите им поделиться с Qlik-сообществом:

                  1. Разместите его на github
                  2. Разместите его в Qlik Garden: https://developer.qlik.com/garden
                  3. Напишите о нем в Qlik Branch: https://developer.qlik.com/blog/contribute
                  4. Если вы работаете в компании-партнере Qlik, присмотритесь к программе для Trusted Extension Developer (TED). По ней вы сможете аккредитовать разработанный экстеншен и он будет размещен на специальном ресурсе Qlik, а вы - войдете в историю Qlik: https://developer.qlik.com/ted/info
                  5. Пишите на qrug@atkcg.ru, чтобы рассказать о вашем экстенешене на ближайшем мероприятии Qlik-сообщества qRUG.

                  Домашнее задание. Урок 5
                  Если вы считаете, что недостаточно страдали сегодня, то можно:

                  • спозиционировать подсказку так, чтобы она не вылезала за пределы экрана если мы наводимся на крайние значения. (Для этого можно посчитать координаты в зависимости от размера окна и размеры подсказки)
                  • заставить экстеншн работать на IE 11
                  • стилизовать еще что-нибудь

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


                  Консультационная Группа АТК и Qlik-сообщество qRUG

                  2020

                  atkcg.ru
                  qrug.atkcg.ru