| @技術/プログラミング

Archives ページでチャートのカテゴリー選択とセレクトボックスのカテゴリー選択が連動していなかったのを統合して連動するようにした。以前、やり方がわからなくてチャートのカテゴリーのレジェンドをクリックしたときにクリックされたカテゴリーをチャートから非表示にしつつ色をグレーアウトさせるのもできるようになった。どのカテゴリーの記事をいつ頃どのくらい書いていたかがわかるようになってめっちゃ便利。

Archives ページは React で作っていて、チャートとセレクトボックスでそれぞれに別々にカテゴリー一覧を API から取得していたのを一本化し、非表示とするカテゴリーも同じ state として管理するようにした。こういうのがサクッとしかも高速にできて React は便利。 jQuery でやるのは大変だった。

| @技術/プログラミング

Chart

Rechars という React のチャートライブラリを利用して、 Archive ページにカテゴリーごとに記事を集計してグラフ化する機能を作った。

グラフの Bar にカーソルを載せると Tooltip が表示されて、具体的な件数がわかる。

Chart Tooltip

カテゴリごとに表示・非表示を切り替えることも可能。グラフ下のカテゴリー名( Legend )をクリックして切り替えられる。

Chart Show-Hide Toggle

ただし残念なことに Bar を非表示にしたときに Legend の表示を変化させるのが難しくてできていない。

仕事で使ってる Looker とか Redash であれば Legend をクリックして表示・非表示を切り替えることができ、それに連動して Legend の色をトーンダウンさせたりする機能が付属しているが、利用した Recharts にはその機能がなかった。 Bar の表示・非表示切り替えも標準サポートされていなかったので、 GitHub の Issue の情報を頼りに無理矢理実装した。

コードはこんな感じ。結構汚い Hack で、 Bar の表示・非表示を、表示用のキー文字列に空白を追加するかしないかで切り替えている。

  // クリックされたアイテムが `this.state.disabled` 配列の中にすでに存在していれば除外し、
  // 存在してなければ追加する
  selectBar(event) {
    let dataKey = event.dataKey.trim()
    if (this.state.disabled.includes(dataKey)) {
      this.setState({ disabled: this.state.disabled.filter(item => item !== dataKey) })
    } else {
      this.setState({ disabled: this.state.disabled.concat([dataKey]) })
    }
  }

  render() {
    return (
      <ResponsiveContainer height={500}>
        <BarChart
          data={this.state.data}
          margin={{
            top: 20, right: 20, left: 0, bottom: 20,
          }}
          style={{ fontSize: '14px' }}
        >
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="year" />
          <YAxis />
          <Tooltip labelStyle={{ color: '#000', fontWeight: 'bold' }} itemStyle={{ margin: '0 2px 0 4px', padding: '0' }} />
          {// Legend クリック時のコールバックに `this.selectBar` を指定する }
          <Legend onClick={this.selectBar} />
          {/*
            `this.state.categories` 配列と `this.state.disabled` 配列の内容を比較し、
            `this.state.disabled` に追加済のカテゴリーは dataKey に空白を追加することで非表示に
          */}
          {this.state.categories.map((category, index) => {
            let dataKey = this.state.disabled.includes(category) ? category + " " : category
            let color = this.colors[index % this.colors.length]
            return(<Bar key={index} dataKey={dataKey} stackId="a" fill={color} />)
          })}
        </BarChart>
      </ResponsiveContainer>
    );
  }

| @技術/プログラミング

react-select-before-after.jpg

一個前の記事のキャプチャにあるように、従来テキストリンクだった年とカテゴリーの選択をセレクトボックスにした。 git log -S したところ去年( 2019 年)の 11 月頃に変更を行ったみたいだ。もうそろそろ 2020 年になろうとしていて、 2005 年からやっていて年の数が 16 個になろうとしていてさすがに多すぎると思ったので整理のためにセレクトボックス化してコンパクトにした。

利用したのは React Select というパッケージで、色々カスタマイズできるみたいだけど面倒だったので素のまま使ってる。

Archives ページのリファクタリング のときのように、子コンポーネントのイベントをトリガーに親コンポーネントの setState() を呼び出すような作りになっている。作るときは結構難儀したけどおかげでスマートフォンで見たときも年やカテゴリーだけでファーストビューが埋まるということがなくなった。

react-select-smartphone-view.jpg

現在のコードをまるっと貼り付けるとこんな感じ↓。

子コンポーネント(年のセレクトボックス)

import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import Select from 'react-select'
import PropTypes from 'prop-types'
import { withRouter } from 'react-router-dom'

import history from './history'

class YearSelect extends Component {
  constructor(props) {
    super(props)
    this.state = {
      data: [],
      selectedOption: null
    }
    this.handleChange = this.handleChange.bind(this)
  }

  static propTypes = {
    match: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    history: PropTypes.object.isRequired
  }

  async loadYearSelectFromServer() {
    const request = await fetch('/archives/years.json')
    const response = await request.json()
    this.setState({ data: response })
  }

  componentDidMount() {
    this.loadYearSelectFromServer()
  }

  handleChange(selectedOption) {
    const year = selectedOption ? selectedOption.value : null
    if (year) {
      this.props.history.push(`/archives/${year}`)
    } else {
      this.props.history.push("/archives")
    }
    this.setState(
      { selectedOption },
      () => { this.props.update(year) }
    )
  }

  render() {
    const options = this.state.data.map(year => {
      return { value: year, label: year }
    })
    return (
      <div className="year-list">
        <Select
          value={this.state.selectedOption}
          onChange={this.handleChange}
          options={options}
          placeholder="Year"
          isClearable
        />
      </div>
    )
  }
}

const YearList = withRouter(YearSelect)

export default YearList

親コンポーネント( App.js )

import React, { Component } from 'react'
import { BrowserRouter as Router, Switch, Route, Redirect } from 'react-router-dom'

import YearList from './YearList'
import CategoryList from './CategoryList'
import Archives from './Archives'

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      category: null,
      year: null,
      length: 0
    }
    this.updateCategory = this.updateCategory.bind(this)
    this.updateYear = this.updateYear.bind(this)
    this.setLength = this.setLength.bind(this)
  }

  updateCategory(category) {
    this.setState({ category })
  }

  updateYear(year) {
    this.setState({ year })
  }

  setLength(length) {
    this.setState({ length })
  }

  render() {
    return(
      <Router history={history}>
        <div className="archive-filter">
          <YearList update={this.updateYear} />
          <CategoryList update={this.updateCategory} activeCategory={this.state.category} />
          <div className="entry-length"><p>{this.state.length} entries</p></div>
        </div>
        <Switch>
          <Route
            exact path="/archives"
            render={(props) =>
              <Archives
                category={this.state.category}
                setLength={this.setLength}
                {...props}
              />
            }
          />
          <Route
            path="/archives/:year(\d{4})"
            render={(props) =>
              <Archives
                category={this.state.category}
                setLength={this.setLength}
                year={this.state.year}
                {...props}
              />
            }
          />
        </Switch>
      </Router>
    )
  }
}

export default App

| @技術/プログラミング

Archives ページ記事絞り込み

Archives ページに記事数を表示するようにした。一昨日、年を未指定の場合は全件読み込むようにしたので Archives ページを訪れると総記事数が確認できるようになった。年やカテゴリーで絞り込むと絞り込み後の件数を確認できる。カテゴリーごとの記事の偏り具合が確認できて便利。

さらにカテゴリーのセレクターは記事件数順でソートするようにした。これまでは categories.id でソートしていたため、件数の多いカテゴリーがセレクトボックスの下の方に埋もれたりしていてあまり良い絞り込み体験ではなかった。

こうしてカテゴリーごとに記事を絞り込んでみてみると、意外と違和感のある分類になっていることに気がつく。記事のカテゴリーに関してはあまり意味ないというか分類が難しいものもあると思う。ブログというカテゴリーに分類してみたものの、実際の記事内容はプログラミング寄りだったりして、読者が読みたい記事を探す際の指針とはなり得ないなと思う。 TF-IDF のような機械的な関連判定が記事のグルーピングには有効だと思う。勝手にカテゴリー付けしてくれたら便利だと思う。

技術的な話をすると、選択したカテゴリーで記事を絞り込む都合上、件数のカウントはフロントエンドで行う必要があった。実は 2 年くらい前の Archives ページにも絞り込み時の件数表示機能はあったのだが、当時は React の機能を活用できておらず、記事の絞り込みは Virtual DOM の操作ではなく CSS の display: none; でやっていて jQuery と大差なかった。記事の絞り込みを React の機能を使ってやるようになってから件数の表示ができていなかったのをできるようにしたのが今回というわけだ。

お爺さんコンポーネントで定義した関数をひ孫コンポーネントまで伝搬させて、結果が変わるとお爺さんコンポーネントの state が書き変わる。ちょっと setState() を使いすぎていて微妙に画面がチラつく( state を更新するたびにレンダリングが行われるため)。もっとうまいやり方があるのかもしれない。

これをやるにあたってこれまで関数として実装していたコンポーネントをクラス化した。 return() の前処理でやっていたゴニョゴニョを componentDidMount() に移す必要が出たが、実は componentDidUpdate() でもやらなければならなかったり、 componentDidMount() 時にはまだ props が渡されてきてないので componentDidUpdate() で実行する必要があったりと、結構ハマりどころがあった。コンポーネントはなるべくクラス化するのが格好いいような気がしていたけど、凝ったことをやる必要がないコンポーネントは関数のままの方がよいと思った。この辺も改めて React はよくできていると感心する。

| @技術/プログラミング

Archive ページ をリファクタリングした。

これまで gulp をビルドに利用していた(Archive ページを React Router 化)が、 webpack を使うように変えた。

React のコードも見直して、 DOM の状態に依存して表示・非表示を切り替えるコードがあったりして( 🐙 Archive ページにカテゴリごとの記事件数を表示する機能を追加 )ごちゃごちゃしてたので DOM を直接ごちゃごちゃ操作するのをやめて React で管理するように変えた。親コンポーネント、子コンポーネント、孫コンポーネント、子コンポーネントの兄弟コンポーネント間で状態を共有する必要があって、結構難儀した。

Archives React Component 1.png

実際の画面を見るとこんな感じ。

Archives React Component 2.png

App というコンポーネントがルートにあって、子に CategoryListCategoryList の子コンポーネント( App からすると孫)に Category コンポーネントがある。記事一覧自体は CategoryList と兄弟コンポーネントである Archive コンポーネントが担当している。

Archives React Component 3.png

こんな感じで特定の Category が選ばれたことを Category のクリックイベントをトリガーに CategoryList に伝達し、 CategoryList はさらにそれを App コンポーネントに伝える。その結果が App から Archive コンポーネントに伝えられ、表示内容が変更される。

この辺を参考にして実装した。コールバック関数を props として引き回し、状態を回収する感じ。

ただこういう込み入った状態の管理を React で行う場合は Redux などを利用するのが良いようだった。

前職のとき、 Redux とか Flux が出てきた頃に F/E のエンジニアの人たちが熱狂してたけど自分はいまいち理解できなくて、傍観するだけだったが、いまさらにして何となく Flux アーキテクチャの概要的なものを把握することができた気がする。ただ自分の場合は深みに入り込まず極力シンプルに作りたいと思っていたので Redux などには手を出さず、 Callback で愚直に状態を親コンポーネントに伝達していく方法をとった。

React 、やっぱり大分良いものだとという感じがした。 jQuery でクラスや CSS で show - hide を Toggle していた頃とは隔世の感がある。

| @技術/プログラミング

Archive ページにカテゴリごとの記事件数を表示する機能を追加した。

API でカテゴリごとの記事件数を返すのではなく、 JavaScript だけで entries.length を数えるようにしている。 React で関連コンポーネントが描画されたあとにいい感じに数える処理をトリガーする方法が分からず、描画完了後 1000msec 以内で 100msec ごとに記事数を数える処理を実行している。あまり賢い実装方法ではないと思うけど、一番記事数が多い 2006 年でも 600msec くらいで Ajax のレスポンスは返ってくるのでまぁ実用上問題はない。

こうして見ると、 2011 年から一切映画についてブログに書いてないことが分かる。さすがに年に一本も映画を見ないということはなくて、年に二、三本は絶対見ていると思う。以前は基本的に映画館で見た映画の感想を書くようにしていたので、映画館に行かなくなった(行けなくなった)せいで映画の記事を書かなくなったんだろうなぁと思う。

自分のブログに機能を追加することでここ数年の生活の傾向が分かるのが面白い。実は昨日、 Sunset Live の記事を書いたのも音楽について全然書いてないことに気づいたからなのであった。

| @技術/プログラミング

Archive ページに React Router を導入して、年を切り換えたときにページ遷移なしで表示を切り替えられるようにした。あわせてカテゴリ一覧を表示して記事を絞り込み表示するようにした。

React はこれまで cdnjs からダウンロードしていて JSX の変換は Babel の browser.js を使ってブラウザーで行っていたけど JS の開発環境もちょびっとだけモダンにして npm モジュールをインストールして手元でいろいろやるようにした。一枚のスクリプトに書いていたコードはコンポーネントごとに分割して browserify で連結するようにした。 React を自前で配信するとファイルサイズも結構無視できない感じになったので minify なんかもするようにした。

F/E の情報収集、きらきらネームが多くて調べるのにかなり時間がかかった。 npm モジュールの名前とそれが何をやるためのものなのかがすぐに分からない。じいちゃんばあちゃんが横文字を覚えられないのに似てる。 Ruby もきらきらネーム gem は多いけど寿命が長いのである程度触ってれば覚えられる。 JS はライブラリの寿命が 1 年くらいだから雨後のたけのこのようにどんどん新しいやつが出てきて全然覚えられない。自分が高齢化してきてるだけで若い人だったらすんなり頭に入ってくるんだろうか。