Недавно, пришлось работать над разработкой системы для проектирования расписания занятий для одного ВУЗа. В процессе работы остро встал вопрос отображения данных, причем согласно ТЗ необходимо было отображать данные в формате близком к тому, который использовался в рукописном варианте.

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

И если работу по красивому представлению можно было с чистой совестью свалить на дизайнеров, то с построением непосредственно сетки таблицы возникли небольшие трудности. Главная сложность заключалась в том чтобы отобразить факты одновременного проведения занятий у нескольких групп, либо использование одной аудитории в течении нескольких занятий и т. п.

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

Теория

Начнем с линейных данных и рассмотрим строку данных:

["A", "B", "B", "B", "C", "C", "D"]

Для примера буду использовать представление в html:

<tr>
    <td>A</td>
    <td>B</td>
    <td>B</td>
    <td>B</td>
    <td>C</td>
    <td>C</td>
    <td>D</td>
</tr>

Наша цель: минимизировать количество повторов, сохранив при этом количество ячеек в таблице. Для достижения этой цели в нашем примере
надо объединить ячейки с поряд идущими повторами:

["A", "B", "", "", "C", "", "D"]

В html мы б добавили colspan, а ячейки которые хранят повторы – скроем:

<tr>
    <td>A</td>
    <td colspan="3">B</td>
    <!-- <td>B</td> -->
    <!-- <td>B</td> -->
    <td colspan="2">C</td>
    <!-- <td>C</td> -->
    <td>D</td>
</tr>

Как отразить этот факт?

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

То есть для примера выше получится такая маска:

[1, 3, null, null, 2, null]

Таким образом, чтобы связать данные с маской, мы должны преобразовать данные к следующему виду:

{
   value: "A",
   span: "1"  // количество объединенных ячеек
}

Если же элемент массива является повтором, то мы его просто занулим, подставим вместо него null.
Так, наш массив с данными с наложенной маской будет выглядеть вот так:

[
    { value: "A", span: "1" },
    { value: "B", span: "3" },
    null,
    null,
    { value: "C", span: "2" },
    null
]

И при отображении данных нам надо просто проверять значение массива на null-ёвость.


 

Теперь давайте рассмотрим, как нам добиться объединения одновременно по вертикали и горизонтали. Пусть у нас дано:

[
    ["A", "C", "D", "A"],
    ["D", "B", "B", "B"],
    ["A", "B", "B", "B"],
    ["D", "A", "C", "B"],
]

Его html представление:

<tr>
    <td>A</td><td>С</td><td>D</td><td>A</td>
</tr>
<tr>
    <td>D</td><td>B</td><td>B</td><td>B</td>
</tr>
<tr>
    <td>D</td><td>B</td><td>B</td><td>B</td>
</tr>
<tr>
    <td>D</td><td>A</td><td>C</td><td>B</td>
</tr>

Для реализации объединения ячеек целым блоком, сначала построим маску построчно, в результате получим:

[
    [ 1,  1,  1,  1],
    [ 1,  3, null, null],
    [ 1,  3, null, null],
    [ 1,  1,  1,  1],
]

Если применить эту маску к данным, получим матрицу следующего вида:

[
    [{ value: "A", colspan: "1" }, { value: "C", colspan: "1" }, { value: "D", colspan: "1" }, { value: "A", colspan: "1" }],
    [{ value: "D", colspan: "1" }, { value: "B", colspan: "3" }, null, null],
    [{ value: "A", colspan: "1" }, { value: "B", colspan: "3" }, null, null],
    [{ value: "D", colspan: "1" }, { value: "A", colspan: "1" }, { value: "C", colspan: "1" }, { value: "B", colspan: "1" }],
]

Её html представление:

<tr>
    <td>A</td><td>С</td><td>D</td><td>A</td>
</tr>
<tr>
    <td>D</td><td colspan="3">B</td>
</tr>
<tr>
    <td>A</td><td colspan="3">B</td>
</tr>
<tr>
    <td>D</td><td>A</td><td>C</td><td>B</td>
</tr>

Чтобы корректно создать вторую маску и чтобы эта маска учитывала данные в первой маске, надо преобразовать данные с наложенной маской к виду:

[
    ["A_1", "C_1", "D_1", "A_1"],
    ["D_1", "B_3", null, null],
    ["A_1", "B_3", null, null],
    ["D_1", "A_1", "C_1", "B_1"],
]

И строить уже маску по столбцам. В итоге получим маску следующего вида:

[
    [1, 1, 1, 1],
    [1, 2, null, null],
    [1, null, null, null],
    [1, 1, 1, 1],
]

А теперь, чтобы получить общее объединение, мы объединим две маски так, что первая отвечает за colspan а вторая за rowspan.
Причем, если хотя бы одна маска имеет значение null, то ячейка не выводится. Применив обе маски, получим следующий результат:

[
    [
        { value: "A", colspan: "1", rowspan: "1" },
        { value: "C", colspan: "1", rowspan: "1" },
        { value: "D", colspan: "1", rowspan: "1" },
        { value: "A", colspan: "1", rowspan: "1" }
    ],
    [
        { value: "D", colspan: "1", rowspan: "1" },
        { value: "B", colspan: "3", rowspan: "2"},
        null,
        null
    ],
    [
        { value: "A", colspan: "1", rowspan: "1" },
        null,
        null,
        null
    ],
    [
        { value: "D", colspan: "1", rowspan: "1" },
        { value: "A", colspan: "1", rowspan: "1" },
        { value: "C", colspan: "1", rowspan: "1" },
        { value: "B", colspan: "1", rowspan: "1" }
    ],
]

Результатом такого преобразования будет следующий html-код:

<tr>
    <td>A</td><td>С</td><td>D</td><td>A</td>
</tr>
<tr>
    <td>D</td><td colspan="3" rowspan="2">B</td>
</tr>
<tr>
    <td>A</td>
</tr>
<tr>
    <td>D</td><td>A</td><td>C</td><td>B</td>
</tr>

Итак, теорию заложили, теперь давайте возведем практическое строение.

При разработке нашей системы использовались javascript и python. И хотя, данный подход является независимым от языка, я покажу как это можно реализовать с использованием javascript и фреймворка vue.js.

Прежде всего нам понадобится функция для построения маски:

Функция для создания маски

// helpers.js

// строит маску по массиву данных
function spanMaskRow(iterable) {
  let lastIndex;
  let lastValue;
  let out = [];
  iterable.forEach((i, index) => {
    let item = null;

    if (lastIndex == null || out[lastIndex] != null && i !== lastValue) {
      item = 1;
      lastIndex = index;
    } else {
      out[lastIndex] += 1;
    }
    lastValue = i;
    out[index] = item;
  });
  return out;
}

// строит маску по столбцу
function spanMaskColumn(iterable, column) {
  return spanMaskRow(iterable.map(i => i[column]))
}

// вычисляет наложение маски на строку
function applyMaskRow(iterable, mask, {spanField = 'span'} = {}) {
  return iterable.map((i, idx) => mask[idx] == null ? null : {value: i, [spanField]: mask[idx]})
}

// вычисляет наложение маски на столбец
function applyMaskColumn(iterable, column, mask, {spanField = 'span'} = {}) {
  return iterable.map((i, idx) => mask[idx] == null ? null : {value: i[column], [spanField]: mask[idx]})
}

// транспонирование матрицы
function transpose(m) {
  return m[0].map((x,i) => m.map(x => x[i]))
}

module.exports = {
    spanMaskRow,
    spanMaskColumn,
    applyMaskRow,
    applyMaskColumn
}

А теперь, собственно, сам код:

Склейка по столбцам

<template>
  <div>
    <h2>Склейка столбцов</h2>
    <table>
      <tr v-for="row in colspanned">
        <td v-for="c in row" v-if="c" :colspan="c.span">
          {{c}}
        </td>
      </tr>
    </table>
  </div>
</template>

<script>
  import helpers from './helpers';

  export default {
    name: 'app',
    data() {
      return {
        data: [
          ["A", "C", "D", "A"],
          ["D", "B", "B", "B"],
          ["A", "B", "B", "B"],
          ["D", "A", "C", "B"],
        ]
      }
    },
    computed: {
      colspanned() {
        // проходим по всем строкам
        return this.data.map(row => {
          // формируем маску
          let mask = helpers.spanMaskRow(row);
          // накладываем маску, возвращаем преобразованную строку
          return helpers.applyMaskRow(row, mask);
        });
      },
    }
  }
</script>

<style lang="scss">
  table {
    border-collapse: collapse;
    td {
      border: 1px solid silver;
      padding: 1em;
    }
  }
</style>

Получим вот такой результат:

Склейка по строкам

Реализуется не сложнее:

<template>
  <div>
    <!-- ... -->
    <h2>Склейка строк</h2>
    <table>
      <tr v-for="row in rowspanned">
        <td v-for="c in row" v-if="c" :rowspan="c.span">
          {{c}}
        </td>
      </tr>
    </table>
  </div>
</template>

<script>
  import helpers from './helpers';

  export default {
    // ...
    computed: {
      colspanned() { /* ... */},
      rowspanned() {
        // проходим по всем столбцам
        let out = this.data[0].map((_, col_idx) => {
          // формируем маску по столбцам
          let mask = helpers.spanMaskColumn(this.data, col_idx);
          // накладываем маску, возвращаем преобразованный массив в виде строки
          return helpers.applyMaskColumn(this.data, col_idx, mask)
        });
        // тонкий момент, транспонируем результат
        return helpers.transpose(out);
      },
    }
  }
</script>

Получим такой результат:

Маска блоками

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

<template>
  <div>
    <!-- ... -->
    <h2>Склейка блоками</h2>
    <table>
      <tr v-for="row in fullspanned">
        <td v-for="c in row" v-if="c" :rowspan="c.rowspan" :colspan="c.colspan">
          {{c}}
        </td>
      </tr>
    </table>
  </div>
</template>

<script>
  import helpers from './helpers';

  export default {
    // ...
    computed: {
      colspanned() {/*...*/},
      rowspanned() {/*...*/},
      fullspanned() {
        // строим маску по строкам
        let rowMask = this.data.map(row => helpers.spanMaskRow(row));

        /* создаем промежуточную матрицу, по которой будет строится маска по столбцам
           сделано это чтобы маска по столбцам учитывала маску по строкам */
        let preprocessedData = this.data.map((row, i) => {
          return row.map((v, j) => rowMask[i][j] != null ? `${v}_${rowMask[i][j]}` : null)
        });

        // строим маску по столбцам
        let columnMask = preprocessedData[0].map(
          (_, col_idx) => helpers.spanMaskColumn(preprocessedData, col_idx)
        );

        // накладываем обе маски, возвращаем результат
        return this.data.map((row, i) => {
          return row.map((value, j) => {
            // вместо транспонирования маски по столбцам, можно просто поменять порядок индексов
            // там где rowMask[i][j] у columnMask будет обратный порядок columnMask[j][i]
            return rowMask[i][j] != null && columnMask[j][i] != null ? {
              value: value,
              colspan: rowMask[i][j],
              rowspan: columnMask[j][i],
            } : null
          })
        });
      }
    }
  }
</script>

Результат:

Маска по полю

Этот же подход можно использовать, если вы работаете не с матрицами значений, а со списком объектов, тогда маску с наложением по отдельному полю можно сделать таким же привычным способом:

<template>
  <div>
    <!-- ... -->
    <h2>Склейка по полю</h2>
    <table>
      <tr v-for="row in fieldspanned">
        <td>{{row.field1}}</td>
        <td v-if="row.field2" :rowspan="row.field2.span">{{row.field2}}</td>
        <td>{{row.field3}}</td>
        <td>{{row.field4}}</td>
      </tr>
    </table>
  </div>
</template>

<script>
  import helpers from './helpers';

  export default {
    // ...
    computed: {
      colspanned() {/*...*/},
      rowspanned() {/*...*/},
      fullspanned() {/*...*/},
      fieldspanned() {
        let data = [
          {field1: "A", field2: "C", field3: "D", field4: "A"},
          {field1: "D", field2: "B", field3: "B", field4: "B"},
          {field1: "A", field2: "B", field3: "B", field4: "B"},
          {field1: "D", field2: "A", field3: "C", field4: "B"},
        ];
        // создаем маску
        let mask = helpers.spanMaskColumn(data, 'field2');

        // накладываем на поле объекта
        return data.map((row, i) => {
          return Object.assign({}, row, {
            field2:  mask[i] == null ? null : {
              value: row.field2,
              span: mask[i]
            }
          })
        });
      }
    }
  }
</script>

Получится что-то такое:

На практике использование такого подхода оказалось весьма удобно. На этом все, до новых встреч!

Код с примерами доступен здесь:

https://codepen.io/SevenLines/pen/xjygyY