Оптимизируем использование атрибута span
Недавно, пришлось работать над разработкой системы для проектирования расписания занятий для одного ВУЗа. В процессе работы остро встал вопрос отображения данных, причем согласно ТЗ необходимо было отображать данные в формате близком к тому, который использовался в рукописном варианте.
Как известно, для отображения расписания наиболее популярным является табличное представление. И эта работа не стала исключением. Но видов таблиц было достаточно много (расписание групп, карточки преподавателей, аудиторные фонды и т. п.), а сверстать красиво хотелось для всех.
И если работу по красивому представлению можно было с чистой совестью свалить на дизайнеров, то с построением непосредственно сетки таблицы возникли небольшие трудности. Главная сложность заключалась в том чтобы отобразить факты одновременного проведения занятий у нескольких групп, либо использование одной аудитории в течении нескольких занятий и т. п.
Проще говоря, надо было решить проблему эффективного объединения ячеек. Как по горизонтали, так и по вертикали, иногда и целыми блоками ячеек. В результате был разработан достаточно элегантный способ подготовки данных для построение таблиц со сложной сеткой.
Теория
Начнем с линейных данных и рассмотрим строку данных:
["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>
Получится что-то такое:
На практике использование такого подхода оказалось весьма удобно. На этом все, до новых встреч!
Код с примерами доступен здесь: