JavaScript
React

SPA経県マップをつくろう (3) ~アプリにして編集できるところまで

はじめに

前回、SPA経県マップをつくろう (2) ~県ごとに経県で色わけできるところまでで、経県値情報を元に県毎に色を塗っていきましたが、今回はアプリ化して編集できるようにします。
アプリ化の修正には以下の対応を行います。

  • 経県値&経県マップのマップの編集と同等の経県値編集機能を追加
  • ちゃいの経県値にあるように点数ごとの経県値表示機能を追加
  • Mapクラスの更新を効率的にする
  • リクエストパラメータを頂いて表示する機能を追加

編集機能はReactなので画面遷移しないで同一画面でやっていきます。
表示機能、編集機能は隠せるようにします。

環境

OS: Windows7,10 & CentOS 7.2(VM)
node: v8.9.4

アプリ更新

モジュール追加

前回作った時に設計ミスを。
Reactの setState は、setState({ aaa: "hoge" }) ってやると、bbbが state にいてもよしなにマージしてくれるんですよね。
でも二回層目 setState({ aaa: { aomori: 5} }) ってやっても二回層目をマージするわけではないですよねぇ(^ ^;

うーん、直していくと面倒なので、immutable-jsをつかうようにしましょう。
ちょうど更新の判定もいれていくので良いかなと。ついでに、immutable.jsにもMapがあり、今回使うので、今までのMapはKeikenMapへリネームします。

あと予想はしていたのですが、昔のサイトなので urlencode が CP932 なんですよね。どうしようかなーって npm 探していたら iconv-urlencode があったのでこれをいれて使いましょう。

アプリ化に伴い、リサイズ対応も入れておきましょう。何かいいのがないかと探したら、react-event-listenerなるものがあったのでこちらを使ってみます。

アプリとして表示するので、今回は Material-UI を使って表示・編集機能を追加しようと思います。

$ npm i --save immutable
$ npm i --save iconv-urlencode
$ npm i --save react-event-listener
$ npm i --save material-ui

定数定義修正

色を css だけじゃなくて React コンポーネントでも使いたいので、定数定義にいれるようにしました。cssからはばっさり削除します。
経県値を逆に表示することが多くなったので、反対にした配列も用意します。
また、タイトルの横にあったタイトルを簡単に入れるプルダウン的なものを作るような定数も定義します。

定数定義
constants.js
// 定数定義

export const EXPERIENCES = [
  {
    // 0
    name: "unexplored",
    mark: "×",
    text: "未踏",
    subtext: "未経県",
    color: "#f5f5f5",
  },
  {
    // 1
    name: "passage",
    mark: "▲",
    text: "通過",
    subtext: "通過した",
    color: "#00ffff",
  },
  {
    // 2
    name: "earthing",
    mark: "△",
    text: "接地",
    subtext: "降り立った",
    color: "#00ff00",
  },
  {
    // 3
    name: "visit",
    mark: "●",
    text: "訪問",
    subtext: "歩いた",
    color: "#ffff00",
  },
  {
    // 4
    name: "stay",
    mark: "○",
    text: "宿泊",
    subtext: "泊まった",
    color: "#ff0000",
  },
  {
    // 5
    name: "live",
    mark: "◎",
    text: "居住",
    subtext: "住んだ",
    color: "#ff00ff",
  }
];

export const EXPERIENCES_REVERSE = [ ...EXPERIENCES ].reverse();

export const TITLES = [
  '生涯経県値',
  '2018年の経県値',
  '2017年の経県値',
  '2016年の経県値',
  '2015年の経県値',
  'プライベートの経県値',
  'ビジネスの経県値',
  '夫婦同伴の経県値',
  '親子同伴の経県値',
  '彼女・彼氏との経県値',
  '大学卒業時の経県値',
  '高校卒業時の経県値',
  '中学校卒業時の経県値',
  '小学校卒業時の経県値',
  '鉄道利用の経県値',
  '車・バス利用の経県値',
  '都道府県庁所在地の経県値',
];

経県値表示機能

オリジナルの経県値&経県マップでは、こういう表示でした。

image.png

Material-UIでどうしようかなーって思いましたが、Cardで雰囲気にせて作りましょう。
経県値情報は Immutable.js 化を App クラスで行ったので、Immutableのクラスを受けているのが多いので、ちょっとわかりづらくなってるかもです。

県名一覧、経県値値データ、

javascript:KeikenDetailPanel.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Card, CardHeader, CardText } from 'material-ui/Card';
import Avatar from 'material-ui/Avatar';

import { EXPERIENCES_REVERSE } from './constants';

// 1経県情報表示のためのクラス
class KeikenDetailOnePanel extends Component {

  static propTypes = {
    prefs: PropTypes.object.isRequired, // Immutable.List
    prefData: PropTypes.object.isRequired, // Immutable.Map
    exp: PropTypes.object.isRequired, // Object
    experience: PropTypes.number.isRequired,
  };

  shouldComponentUpdate(nextProps, nextState) {
    return !this.props.prefs.equals(nextProps.prefs)
      // || !this.props.prefData.equals(nextProps.prefData) 内部クラスで、全体比較はKeikenDetailPanelでやってるので省略 
      || this.props.exp !== nextProps.exp
      || this.experience !== nextProps.experience;
  }

  render() {
    const { exp, prefs } = this.props;
    return (
      <Card key={exp.name} style={{ width: "100%" }}>
        <CardHeader title={`${exp.mark}${exp.text} (${exp.subtext})`}
          subtitle={ `${this.props.experience}点 x ${prefs.size}` }
          avatar={<Avatar backgroundColor={ exp.color }>{exp.mark}</Avatar>} />
        <CardText>
          { prefs.map(name => <span key={name} style={{ marginRight: "10px" }}>{this.props.prefData.get(name).get('local')}</span>) }
        </CardText>
      </Card>
    );
  }
}

// ◎~×までを表示するコンポーネント
class KeikenDetailPanel extends Component {

  static propTypes = {
    prefNames: PropTypes.object.isRequired,
    prefData: PropTypes.object.isRequired,
  };

  shouldComponentUpdate(nextProps, nextState) {
    return !this.props.prefNames.equals(nextProps.prefNames)
      || !this.props.prefData.equals(nextProps.prefData);
  }

  _createCard(exp, experience) {
    const prefs = this.props.prefNames.filter(name => this.props.prefData.get(name).get('experience') === experience);
    return <KeikenDetailOnePanel key={exp.name} prefs={prefs} prefData={this.props.prefData} exp={exp} experience={experience} />;
  }

  _createCards() {
    return EXPERIENCES_REVERSE.map((exp, idx) => this._createCard(exp, EXPERIENCES_REVERSE.length - idx - 1));
  }

  render() {
    console.log('#### KeikenDetailPanel render ####')
    return <div>{ this._createCards() }</div>;
  }
}

export default KeikenDetailPanel;

こんな感じのパネルが出来上がりました。

image.png

経県値編集機能

オリジナルの経県値&経県マップでは、こういう表示でした。

image.png

PCだと横幅考えるとこういうのが一番入力しやすいかなぁ。
テーブルのレスポンシブなデザインでヘッダも出してくれるようなcss定義とかちょっとしらないので、やめましょう(^ ^;
スマホとかで表示できれば良いので、上から順にならべていきましょう。テーブルヘッダが遠くなっちゃうとわからなくなっちゃいますね。
んー RadioButton にアイコンが使えるので、その機能で代替してみましょう。

KeikenInputPanel.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Paper from 'material-ui/Paper';
import {RadioButton, RadioButtonGroup} from 'material-ui/RadioButton';
import FontIcon from 'material-ui/FontIcon';

import { EXPERIENCES_REVERSE } from './constants';

const uncheckedIconStyles = {
  width: "30px",
  textAlign: "center",
};
const checkedIconStyles = {
  ...uncheckedIconStyles,
  border: "solid 2px",
  borderRadius: "4px"
};
const pstyle = {
  margin: 10,
  textAlign: 'left',
  paddingBottom: "10px",
};
const dstyle = {
  display: "inline-block",
  textAlign: 'center',
  width: '100px',
};
const rbxstyle = {
  display: 'inline-block',
};
const rbstyle = {
  display: 'inline-block',
  width: "30px",
  marginRight: 5,
};

class KeikenInputOnePanel extends Component {
  static propTypes = {
    name: PropTypes.string.isRequired,
    pref: PropTypes.object.isRequired, // Immutable Map
    onSelect: PropTypes.func.isRequired,
  };

  shouldComponentUpdate(nextProps, nextState) {
    return !this.props.pref.equals(nextProps.pref)
      // || this.props.onSelect !== nextProps.onSelect
      || this.props.name !== nextProps.name;
  }

  render() {
    const { name, pref } = this.props;
    return (
      <Paper key={name} style={{ ...pstyle }} zDepth={1}>
        <div style={{ ...dstyle }} ><span style={{ whiteSpace: "nowrap" }}>{pref.get('local')}</span></div>
        <RadioButtonGroup name={name} style={{ ...rbxstyle }} defaultSelected={pref.get('experience')}
            onChange={(e, v) => this.props.onSelect(name, v)}>
          { EXPERIENCES_REVERSE.map((e, idx) =>
            <RadioButton style={{ ...rbstyle }} key={e.name} value={EXPERIENCES_REVERSE.length - idx - 1}
              checkedIcon={ <FontIcon style={{ ...checkedIconStyles, }}>{ e.mark }</FontIcon> }
              uncheckedIcon={ <FontIcon style={{ ...uncheckedIconStyles }}>{ e.mark }</FontIcon> } /> ) }
        </RadioButtonGroup>
      </Paper>
    );
  }
}

class KeikenInputPanel extends Component {

  static propTypes = {
    prefNames: PropTypes.object.isRequired,
    prefData: PropTypes.object.isRequired,
    onSelect: PropTypes.func.isRequired,
  };

  shouldComponentUpdate(nextProps, nextState) {
    return !this.props.prefNames.equals(nextProps.prefNames)
      // || this.props.onSelect !== nextProps.onSelect
      || !this.props.prefData.equals(nextProps.prefData);
  }

  _createPref(name) {
    const pref = this.props.prefData.get(name);
    return <KeikenInputOnePanel key={name} name={name} pref={pref} onSelect={this.props.onSelect} />
  }

  _createPrefs() {
    return this.props.prefNames.map(p => this._createPref(p))
  }

  render() {
    console.log('#### KeikenInputPanel render ####')
    return (<div>{ this._createPrefs() }</div>);
  }
}

export default KeikenInputPanel;

image.png

こんな感じに入力するエリアができました。

Mapクラスの更新を効率的にする

今回 Immutable.js の Map を使うので、こちらのクラスは KeikenMap にします。
それほど修正はなかったので閉じた状態でクラス部だけを。

修正内容は、

  • 前回は経県値情報を全てもらっていましたが、その中の県単位の情報だけ Immutable.Mapで受けるように修正
  • componentWillReceiveProps / shouldComponentUpdate を実装し、更新を減らすように修正
  • 画面全体の更新以外に、一部の経県値更新に対応(ボタンクリックのイベントでの一部の修正)
  • styleをクラスからインラインスタイルに変更

です。

経県値マップクラス
KeikenMap.js
class KeikenMap extends Component {

  static propTypes = {
    width: PropTypes.number.isRequired,
    height: PropTypes.number.isRequired,
    scale: PropTypes.number.isRequired,
    prefData: PropTypes.object.isRequired,
  };

  constructor() {
    super();
    this.state = { };
  }

  componentWillReceiveProps(nextProps) {
    const diffPrefs = [ ...nextProps.prefData
      .filter((v, name) => this.props.prefData.get(name).get('experience') !== v.get('experience'))
      .keys() ];
    this.setState({ updated: diffPrefs });
  }

  shouldComponentUpdate(nextProps, nextState) {
    return this.props.width !== nextProps.width
      || this.props.height !== nextProps.height
      || this.props.scale !== nextProps.scale
      || !this.props.prefData.equals(nextProps.prefData);
  }

  componentDidMount() {
    this._drawKeikenMap()
  }

  componentDidUpdate() {
    this._updateKeikenMap()
  }

  _updateKeikenMap() {
    if (this.state.updated === undefined) return;

    // update
    if (this.state.updated.length) { // 経験値の修正
      const svg = d3.select(this.node);
      this.state.updated.forEach((name) => {
        svg.select(`#${name}`).attr("style", d => this._name2Style(name))
      });
    } else { // windows size修正
      this._drawKeikenMap();
    }
  }

  _drawKeikenMap() {
    const w = this.props.width;
    const h = this.props.height;

    const jpn = mapJson;
    const geoJp = topojson.feature(jpn, jpn.objects['-']);

    const center = d3.geoCentroid(geoJp);

    // 地図の投影図法を設定する.
    var projection = d3.geoMercator()
        .center(center)
        .scale(this.props.scale)
        .translate([w / 2, h / 2]);

    // GeoJSONからpath要素を作る.
    var path = d3.geoPath().projection(projection);

    const svg = d3.select(this.node);
    // clear
    svg.selectAll('rect').remove();
    svg.selectAll('path').remove();
    // draw
    svg.append('rect') // うーみ
      .attr('x', 0)
      .attr('y', 0)
      .attr('width', w)
      .attr('height', h)
      .attr('style', 'fill: #3987c9;');
    svg.attr('height', h) // にっぽん
      .attr('width', w)
      .selectAll("path")
      .data(geoJp.features)
      .enter()
      .append("path")
        .attr("id", d => d.properties.name)
        .attr("style", d => this._name2Style(d.properties.name))
        .attr("d", path);
  }

  _name2Style(name) {
    return `fill: ${EXPERIENCES[this.props.prefData.get(name).get('experience')].color};`;
  }

  render() {
    console.log('#### KeikenMap render ####')
    return <svg ref={node => this.node = node} />
  }
}

Appクラスでパラメータ処理・更新処理を行う

アプリ化ということで一番修正しましたが、以下の対応を行いました。

  • 必須でした、トータルの追加。忘れてました(笑)
  • クエリパラメータを受けるように対応。デコードは CP932 で。
  • 表示・編集パネルの開閉状態をstateで管理。また今までの経県値情報を Immutable に変更
  • 画面リサイズのイベントで再表示するように修正
  • 編集時にURLを書き換え
  • ニックネーム、タイトルの入力欄を作成(フローティングラベルがいい感じのラベル代わり)
    • ニックネームは TextField で作成
    • タイトルは AutoComplete でドロップダウンっぽくする
App.js
import { EXPERIENCES, TITLES } from './constants';

// geo2topo output
import japanKeikenMap from './japan_geo2topo.json';

const CHARCODE = 'CP932';

class App extends Component {

  constructor() {
    super();
    this.state = {
      data: Immutable.fromJS(this._createStateFromQuery()),
      width: 1300,
      detailExpanded: true, // 表示部は開いた状態
      inputExpanded: false, // 編集部は閉じた状態
    };
  }

  componentDidMount() {
    this._updateWidth();
  }

  componentDidUpdate() {
    this._updateWidth();
  }

  _updateWidth() {
    if (this.state.width !== this.titleDiv.offsetWidth) {
      this.setState({ width: this.titleDiv.offsetWidth});
    }
  }

  // URLからqueryをデコードして経県値情報を作成
  _createStateFromQuery() {
    const pat = /^(MAP|NAM|CAT)$/;
    const param = window.location.search.substr(1).split('&')
      .map(kv => kv.split('=', 2))
      .filter(kvArr => kvArr.length === 2 && pat.test(kvArr[0]))
      .reduce((acc, cur) => (acc[cur[0]] = iconv.decode(cur[1], CHARCODE), acc), {}); // eslint-disable-line no-sequences
    return this._createState(param.MAP || '', param.NAM || '', param.CAT || '');
  }

  // 経県値情報からクエリ文字列を作成
  _createQuery(data = this.state.data) {
    return Immutable.Map({
      MAP: data.get('prefNames').map(name => data.get('prefData').get(name).get('experience') + '').join(''),
      NAM: data.get('name'),
      CAT: data.get('title'),
    }).map((v, k) => `${k}=${iconv.encode(v, CHARCODE)}`).join('&');
  }

  _createState(prefStateStr = '', name = '', title = '') {
    let total = 0;
    const st = {
      prefNames: null, // 北海道~沖縄までのID順の配列
      name, // your name
      title, // title
      total, // total score
      prefData: { // nameをキーにした状態KeikenMap
        // id: "ID_xx",
        // local: "青森県",
        // experience: 0-5,
      }
    };
    st.prefNames = japanKeikenMap.objects['-'].geometries
      .sort((a, b) => b.properties.iso_3166_2.substr(2) - a.properties.iso_3166_2.substr(2) ) // 'ID-xx' の xx の数値でソート
      .map((pref, idx) => {
        const name = pref.properties.name;
        const exp = this._extractExperience(prefStateStr, idx);
        st.prefData[name] = {
          id: pref.properties.iso_3166_2,
          local: pref.properties.name_local,
          experience: exp,
        };
        total += exp;
        return name;
      });
    st.total = total;
    return st;
  }

  _extractExperience(prefStateStr, idx) {
    let c;
    return idx < prefStateStr.length && !isNaN(c = prefStateStr.substr(idx, 1)) ? ((c - 0) % EXPERIENCES.length) : 0; // eslint-disable-line no-cond-assign
  }

  _changeExperience(name, experience) {
    const before = this.state.data.getIn(['prefData', name, 'experience'])
    const data = this.state.data.updateIn(['prefData', name, 'experience'], () => experience) // 新経県値
                                .updateIn(['total'], (total) => total + experience - before); // トータル更新
    this._setData(data);
  }

  // 経県値情報を設定。合わせてURLを更新
  _setData(data) {
    this.setState({ data });
    const url = window.location.href;
    const result = /.*\/([^?]*)(\?.*)$/.exec(url);
    if (result) {
      window.history.replaceState('', '', `${result[1]}?${this._createQuery(data)}`);
    } else {
      window.history.replaceState('', '', `?${this._createQuery(data)}`);
    }
  }

  render() {
    const data = this.state.data;
    const title = data.get('title');
    const total = data.get('total');
    const name = data.get('name');
    const prefData = data.get('prefData');
    const prefNames = data.get('prefNames');
    const w = this.state.width;
    const h = Math.floor(960 * w / 1300);
    const s = Math.floor(2000 * w / 1300);
    return (
      <MuiThemeProvider muiTheme={getMuiTheme()}>
        <div>
          <div ref={titleDiv => {this.titleDiv = titleDiv}} style={{ width: "100%" }}>
            <span style={{ float: "right" }}>{`経県値:${total}点`} </span>
            <span>{`『${name}』さんの経県値&経県マップ【${title}】`} </span>
            <EventListener target="window" onResize={() => this._updateWidth()} />
          </div>
          <KeikenMap width={w} height={h} scale={s} prefData={prefData} />
          <Card expanded={this.state.detailExpanded} onExpandChange={ (detailExpanded) => { console.log(detailExpanded); this.setState({ detailExpanded }); } }>
            <CardHeader title="詳細" showExpandableButton={true} actAsExpander={true} />
            <CardText expandable={true}>
              <KeikenDetailPanel prefNames={prefNames} prefData={prefData} />
            </CardText>
          </Card>
          <Card expanded={this.state.inputExpanded} onExpandChange={ (inputExpanded) => { console.log(inputExpanded); this.setState({ inputExpanded }); } }>
            <CardHeader title="編集" showExpandableButton={true} actAsExpander={true} />
            <CardText expandable={true}>
              <TextField floatingLabelText="ニックネーム" value={ name }
                onChange={ (ev, t) => { this._setData(data.updateIn(['name'], () => t)); } } />
              <AutoComplete floatingLabelText="タイトル" searchText={ title } filter={AutoComplete.noFilter} openOnFocus={true} dataSource={ TITLES }
                onUpdateInput={ (t) => { this._setData(data.updateIn(['title'], () => t)); } } />
              <KeikenInputPanel prefNames={prefNames} prefData={prefData}
                onSelect={ (name, e) => this._changeExperience(name, e) } />
            </CardText>
          </Card>

        </div>
      </MuiThemeProvider>
    );
  }
}

chromeの開発者ツールで Mobile 表示させてみた感じではこんな風に表示できるようになりました(^ ^)
次は最後で、パブリッシングに関してです。

image.png