Nuxt.jsで縦横見出しが固定されたタイムテーブルを作った話

# はじめに
[駒場祭](https://www.komabasai.net/70/visitor/)という場所でウェブサイト制作担当をしていました。プログラミング・コーディング的なものは大学始めで、HTMLのHの字も分からないところからのスタートでした。（というのは流石に嘘で、中学くらいのときにJavaScriptを勉強しようとして挫折した経験があるので、HTMLのTの字くらいからのスタートだったかもしれません）

# つくったもの
[タイムテーブルページ](https://www.komabasai.net/70/visitor/timetable)を作りました。学園祭といえばステージ！ステージといえばタイムテーブル！くらいのノリで重要なサイトですが、僕は初心者なりにめちゃくちゃ苦労しました。先日、[「学園祭サイトになくてはならないタイムテーブルのつくりかた」](https://qiita.com/pensuke/items/f768522cadd71c451aba)という記事を読んで、「あ！！これ！！ぼくもやったやつ！！」という気分になったので、学園祭アドベントカレンダー、急遽参加します。
### 開発環境
良い記事を書くには開発環境の記述が不可欠らしいですが、正直どこを書けばいいのか分からない。

- Nuxt.js(ver 2.0.0)
- @nuxtjs/axios(ver 5.3.6)
- pug
- scss

# できたもの
<img width="1440" alt="タイムテーブルページ写真" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547107/3dbc1bcc-9de4-781e-42f1-8cb6d4267c6a.png">
こんな感じになりました。別委員が作ったAPIからステージの遅れ時間を表示したり、現在公演中の企画に薄ーく色をつけたり（駒場祭期間のみの機能です）…、といろんな機能がついています。（写真はフォントが実際とちょっと違います）

# どうやってつくったか
<img width="1320" alt="実装途中のタイムテーブルページ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547107/a80c3c47-f16a-5a43-5d5a-87a3ff469c8a.png">
できた！

とは言ってもこれは「サーバーからデータを引く」とか「コンポーネント」とかあんまり考えずに作った、HTMLとCSSだけの仮置きです。このころはまだ気持ちに余裕があるので、「200分遅れ」というふざけをしています。（遅れ時間表示の下にある「このステージを固定」の機能は、いわゆる「アイデアの段階ではあったけど、実装が全然間に合わなかったのでいつのまにか消滅した機能」ってやつです。）

肝心の横線が並んでいる部分は、全部divタグで作っていて、30分に対して、「幅79px＋1pxの横線」の80pxが対応するような作りにしました。CSSで言うと下みたいな感じ。

```scss:30分に対応するdiv要素

.timelines{
    height: 79px;
    border-top: 1px dashed $orange;
    border-bottom: 1px solid $orange;
    margin-bottom: 79px;
  }
```

この``timelines``クラスのdiv要素の集合体である、``timebody``というdiv要素の上に、``position: absolute;``で公演情報を並べていく感じになりそうです。

## 各企画の情報をコンポーネント化する
<img width="131" alt="企画情報のコンポーネント" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547107/fe95d840-1cce-9697-2b65-4f7ce85bcb95.png">
これ。
当時「コンポーネント」という言葉を覚えたてくらいだった僕でも、流石にコンポーネント化したほうがいいことは分かります。

``position: absolute;``でこのコンポーネントを並べることを考えると、``top``と``height``が難関になってくる予感がします。

### コンポーネントに渡すデータを決める
```vue:TimeTableBox.vue
<template lang="pug">
    button.timetable-box(:style="boxStyle" :class="nowClass")
        .content-box
            .start-time {{kikaku.startTime.str}}
            .title(v-html="kikaku.title[l]" :style="kikaku.style[l]")
            .end-time {{kikaku.endTime.str}}        
</template>
<script>
export default {
    computed:{
        boxStyle(){
            let box_height = (this.kikaku.endTime.h-this.kikaku.startTime.h)*160+(this.kikaku.endTime.m-this.kikaku.startTime.m)*160/60;
            let box_top = (this.kikaku.startTime.h-8)*160+(this.kikaku.startTime.m-30)*160/60;
            return "top: "+box_top+"px;"+"height: "+box_height+"px;" + "left: "+ this.box_left+"px;";
        },
        nowClass(){
            if(this.kikaku.startTime.str == this.nowData){
                return "now"
            }
            else{
                return ""
            }
        },
    },
    props:{
        kikaku: Object,
        nowData: String
    }
}
</script>
<style>
/* 省略 */
</style>
```
とりあえずコンポーネントの構造はできました。
``top``と``height``は、開始時間と終了時間のデータから「30分：80px」の比例関係を使って、px数を計算するようにしました。
ここから引数kikakuに渡すオブジェクトを決めます。

```json:kikakuに渡したオブジェクト
{
"id": 114,
"title": {
   "ja": "特別講演会<br>「東大式・誤答の美学<br>〜クイズ王はまた<br>間違える〜」",
    "en": "Special Lecture Izawa Takushi"
},
"startTime": {
   "h": 14,
   "m": 0,
   "str": "14:00"
},
"endTime": {
   "h": 15,
   "m": 30,
   "str": "15:30"
},
"style": {
   "ja": "font-size:10px; letter-spacing:-0.04em;",
   "en": ""
}
}
```
特に注目したいポイントは以下。
#### 「公演中」機能をつける
やっぱり、「今やっている企画が何か」は一目で分かるようになっていてほしいものです。フロント的には``now``のクラスさえ付いちゃえば、あとはどうにでもデザインできますが、どのようにして``now``クラスをつけるかが難しいところでした。
今回は、「現在ステージで公演中の企画の基本情報」を教えてくれる委員会のAPIから引いてきたデータを利用して、「現代公演中の企画の開始時間の文字列」と「コンポーネントに渡されたデータのstartTimeの文字列」を比較し、``now``クラスをつけました。
学園祭のステージって「同じサークルが毎日何度も出演する」みたいなこと多いので、企画IDみたいな情報で比較するよりも、開始時間or終了時間で比較した方が、より正確に判定できると思われます。
#### 企画名を美しく配置する
講演会の企画などは、企画名が長くなってしまうものです。しかし、できることなら「企画名の改行」や「ちょうど収まる感じの文字サイズ、行高にしたい」というデザインのこだわりも捨て切れません。

 - ``v-html``と``<br>``を用いて、気持ちいい感じの改行にする。
 - コンポーネントに渡す企画データすべてにstyleのデータをつけて、手作業で``font-size``, ``line-height``, ``letter-spacing``などを調整できるようにする。

大量の企画を一つ一つチェックして文字配置を調整する手間が要りましたが、おかげでより綺麗なタイムテーブルページができました。（こういう面倒な作業に限って、同じウェブサイト担当の同期S君に丸投げしちゃいました。ごめんね！）

## 「ステージ名」と「時間の目盛」を固定する
僕個人が絶対つけたい機能でした。
詳しくは[タイムテーブルページ](https://www.komabasai.net/70/visitor/timetable)で縦横スクロールしてもらえると分かると思うのですが、縦横スクロールしたときにExcelみたいに見出しの行・列が固定されるような機能を実装しました。
### ``overflow: scroll;``で良さそうだが
「ステージ名」「時間の目盛」両方を固定するとなると、うまくいきません。ひとまず、``overflow-y: scroll;``で「時間の目盛」が固定されるようにしました。
### ステージ名をどうやって固定する？？？
#### ページの上の方にいるとき
<img width="1440" alt="ページの上の方にいるとき" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547107/76693d43-eb8e-63c9-a7d8-072f60e17454.png">
#### 「ステージ名」が固定されたあと
<img width="1440" alt="「ステージ名」が固定されたあと" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547107/5b7c63bd-d10d-0698-f63f-73e1ff32bc24.png">
スクロール量で、キワまで行ったどうかを判定し、「ステージ名」のdiv要素が上まで行ったら``position: fixed;``を使って、その場でdiv要素を固定することにしました。
同じタイミングで、「タイムテーブル本体（上画像青）」の方に「ステージ名（上画像赤）」と同じ高さの``margin-top``を設定し、いかにも固定されているように見せる、という作戦です。

```vue:「ステージ名」の固定
<script>
export default {
methods:{
  fixStageName : function(){
    if(window.pageYOffset > 334){
      this.stage_fix = "fixed"
      // 「ステージ名」のdiv要素に「fixed」というクラスをつけている。
      this.timeline_margin="margin-top: 80px;"
      // 「タイムテーブル本体」にmargin-topをつけている。
      if(window.pageYOffset > 1850){
        this.stage_disappear = "display: none;"
        // 結構下の方まで行ったら「ステージ名」のdiv要素を消しちゃう。
      }else{
        this.stage_disappear = ""
      }
    }else{
    this.stage_fix = ""
    this.timeline_margin=""
    }
  }
},
mounted(){
  window.addEventListener("scroll", this.fixStageName());
}
}
</script>
```

## レスポンシブデザインへ
スマホでもパソコンでもアクセスされる学園祭ホームページ、レスポンシブデザインであることが必須です。CSS部をレスポンシブにする分にはメディアクエリをどうにかすればいいので簡単ですが、Vue.jsを使ってバインディングしているstyle属性をレスポンシブにするのには骨が折れました。

### window.matchmediaの利用
をしました。[「.matchMedia()でJSでもメディアクエリを使って条件分岐する | SPYWEB」](https://spyweb.media/2018/01/10/css-media-queries-in-js-matchmedia/)というサイトを参考にしました。

```javascript:window.matchmedia()
data() {
    return {
      media_query: [false,false,false]
    }
},
beforeMount() {
    let mq_pc_list = window.matchMedia("(min-width: 961px)");
    let mq_tab_list = window.matchMedia("(min-width: 641px) and (max-width: 960px)");
    let mq_sp_list = window.matchMedia("(max-width: 640px)");
    this.media_query[0] = mq_pc_list.matches;
    this.media_query[1] = mq_tab_list.matches;
    this.media_query[2] = mq_sp_list.matches;
    window.matchMedia("(min-width: 961px)").addListener(mql => {this.media_query[0] = mql.matches;});
    window.matchMedia("(min-width: 641px) and (max-width: 960px)").addListener(mql => {this.media_query[1] = mql.matches;});
    window.matchMedia("(max-width: 640px)").addListener(mql => {this.media_query[2] = mql.matches; });
}
```

``media_query``というデバイスの横幅のサイズを判定する配列を用意し、これを利用して「JavaScript版メディアクエリ」を実現しました。

これを利用して書き換えた先の``fixStageName``メソッドが以下です。

```javascript:fixStageNameメソッド
fixStageName : function(){
  if(this.media_query[0]){
    if(window.pageYOffset > 334){
    this.stage_fix = "fixed"
    // 「ステージ名」のdiv要素に「fixed」というクラスをつけている。
    this.timeline_padding="margin-top: 80px;"
    // 「タイムテーブル本体」にmargin-topをつけている。
      if(window.pageYOffset > 1850){
        this.stage_disappear = "display: none;"
        // 結構下の方まで行ったら「ステージ名」のdiv要素を消しちゃう。
      }else{
        this.stage_disappear = ""
      }
    }else{
    this.stage_fix = ""
    this.timeline_padding=""
    }
  }else if(this.media_query[1]){
    if(window.pageYOffset > 277){
    this.stage_fix = "fixed"
    this.timeline_padding="margin-top: 80px;"
    // 「タイムテーブル本体」にmargin-topをつけている。
      if(window.pageYOffset > 1793){
        this.stage_disappear = "display: none;"
        this.stage_fix = ""
      }else{
        this.stage_disappear = ""
      }
    }else{
    this.stage_fix = ""
    this.timeline_padding=""
    }
  }else{
    if(window.pageYOffset > 234){
    this.stage_fix = "fixed"
    this.timeline_padding="margin-top: 80px;"
    // 「タイムテーブル本体」にmargin-topをつけている。
      if(window.pageYOffset > 1760){
        this.stage_disappear = "display: none;"
        this.stage_fix = ""
      }else{
        this.stage_disappear = ""
      }
    }else{
    this.stage_fix = ""
    this.timeline_padding=""
    }
  }
}
```

``matchmedia``メソッドのおかげで、スマホやタブレットでも「ステージ名」が固定できるようになりました。

# おわりに
ロックフェスティバルとかのタイムテーブルページ、案外画像ファイル埋め込みだったりPDFダウンロードだったりするんですよね。それでも青空の下の熱気溢れるステージを思い浮かべてCSSでちまちまタイムテーブルを実装するのが学園祭プログラマーの性なのかもしれません。（当日は大雨でした。）