バー表示で"いい感じ"に折り返す技術
はじめに
「らくしふ」では、とあるページで、いい感じにバーをドラッグしたり、リサイズしたりすることができます。
私がこの画面を実装したのですが、その際の仕様としては、「1行に複数のバーを配置することはできるが、それぞれが重なることはない」というものでした。つまりシンプルに前後のバーの位置を算出し、それらを操作可能最小・最大領域として設定しておけば、ドラッグやリサイズの制御ができたのです。
しかし、今回新たな別の機能を作る際に求められたのは、「1行で複数バーが重なることができ、その際には"いい感じ"で折り返される」というものでした。
さて、これで一気に難易度が上がります。しかしフロント側の実装を得意としてやってきた私には、なかなかに腕が鳴る案件です。話が最初に来た段階では、これまたどうしていいか全く検討もつきませんでした。この記事は、「そんな私がこの機能をどうやって実装したか」をまとめた、ちょっとした備忘録です。
結論としては
"いい感じ"にうまくいきました。
ここでいう"いい感じ"を改めて整理すると、以下のようになります。
- 一つ前のものと重なっていない場合は、折り返さずにその横に表示する
- 一つ前のものと重なっていても、その1行前のスペースが空いていれば次ではなく前の行に折り返す
- それ以外の場合は単純に次の行に折り返して表示する
つまり「一つ手前のものと重なっているからといって、常に次に折り返していくのではなく、前の行にスペースが存在する場合は、そこに埋めていく」というアプローチがキモになるわけです。
NGな例 | OKな例 |
---|---|
ダメなアプローチ
以下のように対象のアイテムと重なっている他のアイテムを絞り込み、そこから高さを算出する方法です。
実際はTypeScriptで書いていますが、型に関して以下のサンプルコードでは省略しています。
const DEFAULT_TOP = 8
const BAR_HEIGHT = 20
// サンプルデータ(開始時間でソート済みな前提)
const targetItem = { id: 3, start: 20, end: 30 } // id = 2 と重なる
const otherItems = [
{ id: 1, start: 10, end: 20 },
{ id: 2, start: 15, end: 25 },
]
const overlappedItems = otherItems.filter((item) => targetItem.start < item.end && item.start < targetItem.end);
if (overlappedItems.length === 0) return DEFAULT_TOP; // 何も重なっているアイテムがない時はデフォルト値を返す
const sortedItems = sortBy([targetItem, ...overlappedItems], "start", "end", "id"); // 開始時間 > 終了時間 > idの優先度で昇順に並び替え。同じ開始時間、終了時間のものがある可能性がある。lodash/sortByを利用
const orderIndex = sortedItems.findIndex((si) => si.id === targetItem.id);
return orderIndex * BAR_HEIGHT + DEFAULT_TOP;
これがなぜダメかというと、自身が重なっているものだけを考慮しているので、以下のようになってしまいます。
BにとってはAが重なっているので、Bは2行目に改行されますが、次にCにとってもB一つのみ重なっているので、Cも2行目に表示されてしまうのです。重なるチェックをしたとしても、このやりかただとCにとってはAが重なっている対象ではないため、そこまで含めた考慮ができないのです。
うまく行った方法
それではどうやったかというと、事前に行indexをマッピングしたオブジェクトを作り、それを参照して高さを計算するという方法です。
// サンプルデータ(開始時間でソート済みな前提)
const items = [
{ id: 1, start: 10, end: 20 },
{ id: 2, start: 15, end: 25 },
{ id: 3, start: 20, end: 30 },
{ id: 4, start: 20, end: 50 },
{ id: 5, start: 30, end: 40 },
];
// itemsから行の位置情報をマッピング処理するメソッド
// ① それぞれに行indexキーを追加する↓↓
// [
// { id: 1, start: 10, end: 20, index: 0 },
// { id: 2, start: 15, end: 25, index: 1 },
// { id: 3, start: 20, end: 30, index: 0 },
// { id: 4, start: 20, end: 50, index: 2 },
// { id: 5, start: 30, end: 40, index: 0 },
// ];
// ② 変換してマッピングしたidをキーとし、行indexを値とするオブジェクトを返す↓↓
// { 1: 0, 2: 1, 3: 0, 4: 2, 5: 0 }
export const generateRowIndexMap = (items) => {
const inspectedItems = []; // 行インデックスを付与したitems ①
items.forEach((item, i) => {
let currentIndex = 0; // 行インデックス
if (i === 0) {
return inspectedItems.push({ ...item, index: currentIndex });
}
// 最大でもitemsの数だけしかループはいらない(=最大行数)
while (currentIndex <= items.length - 1) {
// 同じ行indexのアイテムを絞り込み
const sameIndexItems = inspectedItems.filter((inspected) => inspected.index === currentIndex);
// 絞り込んだ中から一番最後のものを取得
const lastItemWithSameIndex = sameIndexItems.reverse()[0];
// 同じindexのアイテムがない場合(=新しいindexに到達した時) or 同じindexのものがあるが時間が被っていない場合
if (!lastItemWithSameIndex || lastItemWithSameIndex.end <= item.start) {
inspectedItems.push({ ...item, index: currentIndex });
break;
}
// 時間が被っている場合
currentIndex += 1;
}
});
// 配列をオブジェクトに変換 ②
const positionMap = inspectedItems.reduce((acc, ta) => {
return { ...acc, [item.id]: item.index };
}, {});
return positionMap;
};
const rowIndexMap = generateRowIndexMap(items); // 各アイテムの行index情報
// あとはitemsをループで回してtop値を算出すれば良いだけ
上の行から存在する他のアイテムを見ていき、重なっていないようであれば、そのまま横に表示する。重なっているようであればその次の行を見る。このロジックを繰り返していって、最終的にマッピングオブジェクトを作るという感じになります。すると上部でお見せしたGIFのように綺麗に折り返しが実現できるのです。
ちなみに、ソートされた配列においてのみ先頭の要素から見ていくことによって、比べるのは同じ行に存在しうる最後の要素だけでよくなっており、計算量も減らせております。
我ながらナイスアプローチ
さいごに
株式会社クロスビットでは、デスクレスワーカーのためのHR管理プラットフォームを開発しています。一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。
Discussion