Тепловая карта с кастомной стилизацией

Проблема

Поступил запрос на реализацию тепловой карты со следующими условиями по стилизации:

  1. Цвета ячеек зависят от ранга(значения в строке ранжируются от большего к меньшему, каждому значению присваивается ранг)
  2. При наведении на элемент в красной зоне в подсказке должен отображаться список показателей, требующих проверки пользователем

Реализация

Подготовка виджета и данных

Для реализации воспользуемся виджетом с типом “HTML” и библиотекой Apache Echarts. Examples - Apache ECharts

  1. Выбираем тип виджета, в блоке HTML подключаем библиотеку и создаем div-элемент для монтирования диаграммы:
  2. На вкладке JS будет реализовывать логику отрисовки диаграммы. В первую очередь создаем объект диаграммы через echarts.init. Также для автоматической перерисовки графика при изменении размера окна необходимо вызывать метод resize у экземпляра графика. Для этого зарегистрируем соответствующий обработчик события resize
// Инициализация виджета
var chart = echarts.init(document.getElementById('widget'));
window.addEventListener('resize', function() {
    chart.resize();
});
  1. Для конфигурирования графика используется метод chart.setOption(option). Данный метод будет вызываться в функции render при каждой отрисовке графика.
function render(){
    // здесь будем формировать объект option, в соответствии с требованиями

    chart.setOption(option);
}
  1. Перейдем к реализации требований заказчика. В первую очередь получим значения подписей элементов осей. Значения из модели можно получить через глобальный объект window.
  • в строки таблицы(группы) добавляем наименования полигонов. Это будут подписи по оси oX.
  • в столбцы таблицы(агрегаты) добавляем расчетные значения соответствующих метрик. Названия метрик будут отображаться по оси oY, значение агрегата - это значение в ячейке.
    Screenshot from 2023-07-10 15-42-33
  • так как получить значение агрегата в JS можно по названию поля(без кириллицы), то вводим два справочника, в которых пользователь самостоятельно будет задавать текущие используемые поля и их псевдонимы. При добавлении и удалении показателей потребуется внести изменения и в справочники.
// задаем соответствие между показателями и используемыми именами полей в модели
const fieldNamesDictionary = {
    group1: 'polygon',
    aggr1: 'avg_norm_kpef_z',
    aggr2: 'rang_z',
    aggr3: 'avg_norm_kpef_ch',
    aggr4: 'rang_ch',
}
// прописываем отображаемое название показателя на кириллице
const columnNamesMap = {
    [fieldNamesDictionary.aggr1]: 'По затратам (значение)',
    [fieldNamesDictionary.aggr2]: 'По затратам (ранг)',
    [fieldNamesDictionary.aggr3]: 'По численности (значение)',
    [fieldNamesDictionary.aggr4]: 'По численности (ранг)',
}

Через интерфейс объекта window получим значения подписей в константы oX и oY.

    // элементы осей
    const oX = window.DATA.data.map(item => {
        return item[fieldNamesDictionary.group1].value
  })
    const oY = window.WIDGET.columns
    .map(column => {
        if (column.field in columnNamesMap) {
            return columnNamesMap[column.field]   
        }
    })
    .filter(item => Boolean(item))

Преобразуем каждый элемент датасета к объекту с полями name(наименования полигона) и aggr{id}(значение агрегата)

    const eachItemToObject = window.DATA.data.map(item => {
        const obj = {
            name: item[fieldNamesDictionary.group1].value,
        };
        for (let key in fieldNamesDictionary) {
            if (key.includes('aggr')) {
                obj[columnNamesMap[fieldNamesDictionary[key]]] = item["level_agg_" + fieldNamesDictionary[key]]
            }
        }
        return obj;
    })

Условное форматирование ячеек

Для ранжирования значений ячеек создадим хранилище rangedData

const rangedData = {};
  for (let key in fieldNamesDictionary) {
      if (key.includes('aggr')) {
          // obj[columnNamesMap[fieldNamesDictionary[key]]] = item["level_agg_" + fieldNamesDictionary[key]]
          rangedData[columnNamesMap[fieldNamesDictionary[key]]] = [];
      }
  }

Текущие значения показателей добавляем в хранилище:

  eachItemToObject.forEach(item => {
      for (let key in item) {
          if (key !== 'name') {
              rangedData[key].push(item[key])
          }
      }
  });

Сортируем массивы значений:

  for (let key in rangedData) {
      rangedData[key].sort((a, b) => {
          a = Number(a)
          b = Number(b)
          if (a > b) {
              return 1
          } else if (a < b) {
              return -1
          } else return 0
      });
  };

Логику определения цвета для ячейки реализуем в отдельной функции. Аргументами в функцию передаются название показателя и значение. В примере представлена функция для определения цвета из 16 значений разделенных по 4 группам. Также дополнительно введена проверка для поля ранга.

function getColor(name, value) {
      const colorsByIndex = {
          1: '#6ce1c3',
          2: '#fff1c9',
          3: '#fabc74',
          4: '#fa5274'
      }
      const currentIndex = rangedData[name].indexOf(value);
      let colorGroup;
      let isRank = false;
      if (name.indexOf('(ранг)') !== -1) {
          isRank = true
      }
      if (currentIndex < 4) {
          colorGroup = isRank ? 1 : 4
      } else if (currentIndex < 8) {
          colorGroup = isRank ? 2 : 3
      } else if (currentIndex < 12) {
          colorGroup = isRank ? 3 : 2
      } else {
          colorGroup = isRank ? 4 : 1
      }

      return {
          color: colorsByIndex[colorGroup]
      }
    }

Итоговые значения ячеек для передачи в объект option определим в массиве data. Каждая ячейка представлена в виде:

{
  value: [значение по oX, значение по oY, значение ячейки],
  itemStyle: getColor(значение по oY, значение ячейки)
}
const data = [];
eachItemToObject.forEach(item => {
    for (let key in item) {
        if (key !== 'name') {
            data.push({
                value: [item.name, key, item[key]],
                itemStyle: getColor(key, item[key])
            })
        }
    }
})

Карта будет окрашена в следующем виде:

Стилизация подсказки

Для определения содержимого подсказки при наведении на элемент у объекта option есть свойство tooltip. В свою очередь у tooltip есть коллбек-свойство formatter, которое аргументом принимает выбранный элемент(на который наведен курсор мыши).

tooltip: {
            position: 'bottom',
            formatter: (item) => {
                if (item.data.itemStyle.color === '#fa5274') {
                return `<div>${messageForLowRank}</div>` 
                }
                return `<div>${item.data.value[0]} ${item.data.value[1]} ${item.data.value[2]}</div>`
            }
        },

Проверим попадает ли элемент в “красную зону”(if (item.data.itemStyle.color === ‘#fa5274’)). Если да, то выведем преднастроенный html-шаблон messageForLowRank.

const messageForLowRank = `<span style="color: #fa5274;">Анализ СЗП в регионе <br></span>
<li>Сравнение СЗП по регионам <br></li>
<li>Сравнение СЗП в отрасли <br></li>
<li>Сравнение СЗП в динамике по регионам <br></li>
<li>Сравнение СЗП в динамике по подразделению ЖД <br></li>

<span style="color: #fa5274;">Тарифная часть <br></span>
<li>Уровень оплаты труда (КСОТ) <br></li>
<li>Уровень особых условий труда <br></li>

<span style="color: #fa5274;">Анализ заработной платы <br></span>
<li>Динамика заработной платы за период <br></li>
<li>Динамика заработной платы по видам оплат</li>`

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

Итоговая конфигурация

    option = {
        tooltip: {
            position: 'bottom',
            formatter: (item) => {
                if (item.data.itemStyle.color === '#fa5274') {
                return `<div>${messageForLowRank}</div>` 
                }
                return `<div>${item.data.value[0]} ${item.data.value[1]} ${item.data.value[2]}</div>`
            }
        },
        grid: {
            height: '50%',
            top: '0%',
            left:"200px", 
            right:"150px"
        },
        xAxis: {
            type: 'category',
            data: oX,
            splitArea: {
            show: true
            }
        },
        yAxis: {
            type: 'category',
            
            data: oY,
            splitArea: {
            show: true
            }
        },
        series: [
            {
            // name: 'Punch Card',
            type: 'heatmap',
            data: data,
            label: {
                show: true
            },
            emphasis: {
                itemStyle: {
                shadowBlur: 10,
                shadowColor: 'rgba(0, 0, 0, 0.5)'
                }
            }
            }
        ]
    };