LoginSignup
1
1

More than 1 year has passed since last update.

TypeScript + Reactを勉強するために数独を解くアプリを作った話(画面作る編)

Last updated at Posted at 2021-06-20

TypeScriptとReactを勉強するための題材として、
解けなくてイラついていた 数独の問題を解かせてみようという試みのアウトプットです。
この記事では画面を作るところまで書いています。

解くアルゴリズムとしてバックトラック法総当りがありますが、
TypeScriptをできるだけ書きたいので、自分の思考をトレースする様なプログラムでいきたいなぁと思っています。

プロジェクト作成

npx create-react-app --template typescript sudoku

Reactの型定義をインストール

npm install --save @types/react

これをやっておかないと、後でxlsxのインストールをしたときに
「import React from ‘react’ モジュール ‘react’ の宣言ファイルが見つかりませんでした。」と言われます。

画面部品を作る

数独は9つのグループがあり、1つのグループには9つのマスという構成です。

また、各マスで取りうる可能性が消えていくのを見てニヤつきたいので、
1マス内の9か所に残った可能性を表示できるようにします。

テンキーの様に9か所に配置できる部品として共通化したら楽と思ったので作ります。

image.png

nineTable.tsx
import "./nine-table.css"

/** propsの型定義 */
type NineTableProps = {
    /** 並べたい部品 */
    components: JSX.Element[];
};

/**
 * 9か所に部品を配置する部品
 * @param props 配置する9つのコンポーネント
 * @returns 
 */
export default function NineTable(props: NineTableProps) {
    const components = props.components;
    return (
        <div className="nine-table">
            <div className="nine-table-row">
                <div className="one nine-table-item">
                    {components[0]}
                </div>
                <div className="two nine-table-item">
                    {components[1]}
                </div>
                <div className="three nine-table-item">
                    {components[2]}
                </div>
            </div>
            <div className="nine-table-row">
                <div className="four nine-table-item">
                    {components[3]}
                </div>
                <div className="five nine-table-item">
                    {components[4]}
                </div>
                <div className="six nine-table-item">
                    {components[5]}
                </div>
            </div>

            <div className="nine-table-row">
                <div className="seven nine-table-item">
                    {components[6]}
                </div>
                <div className="eight nine-table-item">
                    {components[7]}
                </div>
                <div className="nine nine-table-item">
                    {components[8]}
                </div>
            </div>
        </div>

    );
}
nine-table.css
.nine-table {
    display: table;
}

.nine-table-row {
    display: table-row;
}

.nine-table-item {
    display: table-cell;
}

定数クラス

1~9の整数を持つ配列をよく使うので、名前空間に定数クラスっぽいものを作っておきます

const.tsx
export namespace Const {
    export const NINE_VALUES:number[] = [1,2,3,4,5,6,7,8,9];
}

1マスを作る

消えた可能性や確定した値といった状態を持つCellDataクラスを受け取り、
CellDataクラスの中身を描画するCellクラスを作成します

ViewModeプロパティで、残りの可能性を表示するモードと、確定した値を表示するモードを切り替えれる様にします

image.png

cell.tsx
import React from "react";
import CellData from "../models/cellData";
import ViewMode from "../models/viewMode";
import NineTable from "./nineTable"
import './cell.css'
import { Const } from "../models/const";

/** Cellのprops型定義 */
type CellProps = {
    /** Cellに表示する1マス内の状態 */
    cellData: CellData;
};

/**
 * 1マスを表示する部品
 */
export default class Cell extends React.Component<CellProps>{

    /** 
     * CellDataの内容を表示
     * ViewModeによって表示を切り替え
     */
    render() {
        const cellData: CellData = this.props.cellData;
        return (
            <div className="cell-content">
                { cellData.viewMode === ViewMode.Value ? this.showValue() : this.showPossibles()}
            </div>
        );
    }

    /** 確定値を表示 */
    private showValue = () => {
        const value = this.props.cellData.value ? this.props.cellData.value : 0;
        return (
            <div className={`cell-value ${value ? null : "hidden"}`}>
                { value }
            </div>
        );
    }

    /** まだ残っている可能性の表示 */
    private showPossibles = () => {
        const components = Const.NINE_VALUES.map( n => {
                const value =  this.props.cellData.possibles.includes(n) ? n : 0 ;
                return <span className={`possible-value ${value ? null : "hidden"}`}>{value}</span>
            }
        );

        return(
            <NineTable components={components} ></NineTable>
        );
    }
}
cell.css
.possible-value {
    font-size: 20px;
}

.cell-content > .nine-table , .cell-value {
    width: 85px;
    height: 85px;
    text-align: center;
    vertical-align: middle;
}

.cell-value {
    display: table-cell;
    font-size: 40px;
}

.cell-content{
    border: 1px solid gray;
}

.hidden {
    color: white;
}

cellData.tsx
import ViewMode from "./viewMode";

/**
 * 1マスの状態を管理する
 */
export default class CellData {

    /** 1マスを特定するためのid */
    id: number;

    /** 所属するグループのID */
    groupId:number;

    /** 行位置を示す(1~9) */
    rowId:number;

    /** 列位置を示す(1~9) */
    columnId:number;

    /** 1マスの値/可能性 表示モード */
    viewMode: ViewMode = ViewMode.Value;

    /** 確定した値(0は未確定) */
    value: number = 0;

    /** 取りうる可能性 */
    possibles: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9];

    constructor(id: number, groupId:number, rowId:number, columnId:number){
        this.id = id;
        this.groupId = groupId;
        this.rowId = rowId;
        this.columnId = columnId;
    }

    /** 可能性を消す */
    erasePossible(n: number){
        this.possibles = this.possibles.filter( p => p !== n);
    }
}
viewMode.tsx
/** 1マスの表示モードを列挙する */
enum ViewMode {

    /** 値表示 */
    Value, 

    /** 可能性表示 */
    Possibles
};

export default ViewMode;

export default enum とは書けないらしい
また、使われていないenumが存在する時、トランスパイラが不要と判断できず削除軽量化してくれないので、
enumは使わない方が良いとのこと。使えばよかろうなのだ

1グループ作る

9つのCellDataを受け取り、
先に作ったNinePanelを利用して1グループを表示する部品を作ります。

group.tsx
import CellData from "../models/cellData";
import Cell from "./cell";
import NineTable from "./nineTable";
import "./group.css";

/** Groupのprops型定義 */
type GroupProps = {

    /** 表示する各マスの情報 */
    cellDatas: CellData[];
}

/**
 * 1グループを表示する部品
 * @param props
 * @returns 
 */
export default function Group(props: GroupProps) {

    /** 9マスを生成 */
    const cells = props.cellDatas.map(c => (
        <div className="cell">
            <Cell cellData={c}></Cell>
        </div>
    ));

    return (
        <div className="group">
            <NineTable components={cells}></NineTable>
        </div>
    );
}
group.css
.group{
    border: 2px solid black;
}

メイン画面を作る

プロジェクト作成時に作られたApp.tsxを改造してメイン画面を作ります
初期画面は、適当にCellDataを81個生成してGroupに渡します

App.tsx
import './App.css';
import NineTable from './components/nineTable';
import CellData from './models/cellData';
import Group from './components/group';
import React from 'react';
import ViewMode from './models/viewMode';
import {Const} from './models/const';

export default class App extends React.Component < {}, {cellDatas: CellData[]}> {

  constructor(props: any){
    super(props);
    //作成したダミーのCellDataを保持
    this.state = { cellDatas: this.createDummyCellDatas() }
  }

  render() {

    //groupId毎にGroupを生成
    let groups = Const.NINE_VALUES.map( n => this.state.cellDatas.filter(c=> c.groupId === n))
    .map( cells => <Group cellDatas={cells} />);

    return (
      <div className="App">
        <NineTable components={groups} />

        <div>
          <input type="file" onChange={this.handleChange} accept=".xlsx" />
        </div>

        <div className="controller">
          <button onClick={this.changeViewMode} className="control-button">Change</button>
          <button onClick={this.executeNext}  className="control-button">Execute</button>
        </div>
      </div>
    );
  }

  /**
   * 1マスの表示切替
   */
  private changeViewMode = () => {
    let cellDatas: CellData[] = this.state.cellDatas;
    cellDatas.forEach( c => c.viewMode = c.viewMode === ViewMode.Value ? ViewMode.Possibles : ViewMode.Value);
    this.setState({cellDatas: cellDatas});
  }

  /** 適当にダミーのCellDataを81個作る */
  private createDummyCellDatas = (): CellData[] => {
    let value = 1;
    return Array.from(Array(81), (_,k) => k).map( i => {
      let groupId = Math.floor(i/9) + 1;
      let cellData = new CellData(i+1, groupId, 0, 0);
      cellData.value = value;
      cellData.erasePossible(value); // 表示のテスト用に一つだけ可能性を消している
      value = value === 9 ? 1 : value+1;
      return cellData;
    });
  }

  /** ファイル選択時イベント 後で実装 */
  private handleChange = (e: any) => {}

  /**
   * 確定値の探索
   * データ処理編で実装する
   */
  private executeNext = () : void => {}
}
App.css
.App {
    margin: 10px 0px 0px 10px;
    width: 800px;
}

.controller {
    text-align: right;
    padding-top: 20px;
}

.control-button{
    width: 70px;
    height: 70px;
}

.controller :first-child{
    margin-right: 5px;
}

実行

npm start

初期表示
image.png

Changeで切り替え
image.png

ファイル読み込み

サボっていたファイル読み込みを実装します
データの作成はExcelで作るのが楽と思ったので、Excelファイル(xlsx)を読み込む前提です

xlsxのインストール

npm install xlsx
App.tsx
import XLSX , {utils} from 'xlsx'; //追加

export default class App extends React.Component < {}, {cellDatas: CellData[]}> {

  private groupIds = [1,4,7,28,31,34,55,58,61].map( x => [x,x+1,x+2,x+9,x+10,x+11,x+18,x+19,x+20] ); //追加

  constructor(props: any){
   ...

    //追加
    this.handleChange = this.handleChange.bind(this);
    this.readXlsxFile = this.readXlsxFile.bind(this);
  }

  /**
   * ファイル選択時イベント
   * @param e 
   * @returns 
   */
  private handleChange(e: any) {
    if(!e || !e.target){ return; }
    const files = e.target.files;
    if(files && files[0]) this.readXlsxFile(files[0]);
  };


  /** XLSXファイルを読み込み、CellDataを生成してstateに格納する */
  private readXlsxFile= (file: File) => {
    const reader = new FileReader();
    reader.onload = (e) => {
      if(!e || !e.target){ return; }

      const bstr =  e.target.result;
      const wb = XLSX.read(bstr, {type: 'array'});
      const ws = wb.Sheets[wb.SheetNames[0]]; //1シート目を読み込む

      let cellId = 1;
      let cells: CellData[] = [];

      Const.NINE_VALUES.forEach( rowId => 
        Const.NINE_VALUES.forEach( columnId => {
          //xlsxは0ベース
          const adress = utils.encode_cell({ c: columnId - 1, r: rowId -1 });
          const targetCell = ws[adress];

          let value: number = 0;
          if(targetCell){
            value = targetCell.v;
          };
          let groupId = this.getGroupId(cellId);
          let cell = new CellData(cellId, groupId, rowId, columnId);
          cell.value = value;
          if(value){
            cell.possibles = [value];
          }
          cells.push(cell);
          cellId ++;
        })
      );

      this.setState({ cellDatas: cells });
    }
    reader.readAsArrayBuffer(file);
  }


  /**
   * セルIDからGroup IDを取得する
   * @param cellId セルID
   * @returns 
   */
   private getGroupId(cellId: number): number {
    for(let i=0;i<9;i++){
      if(this.groupIds[i].includes(cellId)){
        return i + 1;
      }      
    }
    return -1;
  }

ファイル読み込み実行

以下の様なファイルを選択すると
image.png

このような表示になります

image.png

image.png

以上で画面作成が完了です。stateにあるCellData[]を更新すれば画面に反映されます。
処理編に続きます。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1