🎆

ようやく表計算ライブラリになりました

2023/04/24に公開

お久しぶりです😌
最後に記事を書いてから一年くらい空いてしまいましたが、gridsheetライブラリの更新は数ヶ月スパンでちょくちょくやってます👾

https://zenn.dev/righ/articles/736a91bd970305

サンプル

これが現在の状態です🤲

見た目はそれほど変わってないですね🦞

相変わらずcodesandboxの埋め込みだとセル選択できないことがあるので、もし気になった方はcodesandboxに直接アクセスするか、Examplesに訪問してください。
セル選択をできない件はやはりcodesandboxの埋め込みでHTML5のDrag API(特にdrag enter)が阻害されていることが原因みたいです。自分以外でもDrag APIを使っているサンプルコードがcodesandboxの埋め込みで動作しない例を確認しました。これはiframeが原因というわけではなさそうです。私のStorybookはiframeですが動いているので。阻害されている直接的な原因は不明で、たまにドラッグできるのは更に謎です。

今回のサンプルは前回の記事で上げたものをそのまま更新しているので比較しても動作は同じです🧸

変更

今回行った変更点について書きます🦍

オートフィルのサポート

御存知の通りオートフィルは対象範囲を引き伸ばして別の範囲のセルを埋める操作です。これに特に決まった仕様はないらしく、表計算ソフトによってその挙動は若干異なります🐡

gridsheetのオートフィルは若干Google SpreadsheetやExcelのそれと違い、MacのNumbersに近いです。

具体的な挙動

まず、引き伸ばした方向に対して値の型が連続しているセルをグループとして分割します。
たとえば、1,2,a,b,3,4,2だったら [1,2], [a,b], [3,4,2] という3グループに分割し、このグループごとに処理を行うことになります🗿

  • グループ内では要素の型が統一されているため、その型が数値、または日付の場合、先頭と2番めの要素の差分を取る。要素すべてがその差分の等差を満たしている場合、続く値は最後の値に等差を加えたものとなり、満たさない場合は繰り返しコピーする。

    • 1,2の次の値は3(等差1を満たすため等差数列パターンとなる)
    • 3,4,2の次の値は3(等差-1を満たさないため繰り返しパターンとなる)
    • Google Spreadsheetは範囲が連続している数値の場合は無理やりその等差を見出そうとして、よくわからない数値がコピーされる。たとえば、1, 3, 5, 6 に続く値は8, 9.7 となる。
  • それ以外の型では繰り返しパターンとなるが、文字列の場合は例外がある。

    • 例外1: 数式の場合
      • 数式に参照が含まれる場合、そのドラッグ方向によりすべての参照をスライドする。例えば、 =B3+1 を下にスライドした場合、続く値は =B4+1, =B5+1 のようになる。右にスライドした場合は =C3+1, =D3+1 が続きます。
        • 絶対参照(=B$3+1)を下にオートフィルした場合は参照はスライドされない。
    • 例外2: 文字列の後方が数値で終わる文字列の場合
      • 前方一致する文字列によりさらにグルーピングされ、数値部分に対して上記のように等差数列か繰り返しのパターンを当てる。
      • apple1, apple2, orange3, orange, orange5 だった場合
        • 内部的には [apple1, apple2], [orange3], [orange], [orange5] のようなグループとなる。
        • 続く値は apple3, apple4, orange3, orange, orange4 となる。
        • Google Spreadsheetの場合、連続していなくても数値の部分がインクリメントされる模様。
          • apple3, apple4, orange4, orange, orange5

当初はGoogle Spreadsheetのオートフィル仕様を満たし、+αで他の数列もサポートしようなどと考えていましたが、オートフィルに求めるものは状況に応じて異なり、システムはそれを判断することができないため単純でもそれほど変わらないだろうという結論に至りました😇

数式の参照

前回の記事で書いたように「行列の追加削除で自動的に参照が変わらない」という割と致命的な問題があり、これを修正しました🔧

これまでの単純な二重配列構造では行列が増減した場合にセルに入った参照をすべてずらさなければならず、これは実装も面倒だし何よりパフォーマンスの劣化が心配です。

そこで以前の記事で書いたようなデータ構造に戻しました🧲
https://zenn.dev/righ/articles/f2415150f1baab#これまでの構造

まず、(A1等の)アドレスに代わる場所によって変動しないIDを導入し、各セルはこれによって一意に識別可能なものとします。
数式に参照が含まれる場合、アドレスをIDに書き換えて保存し数式を評価する際はIDをもとにセルの特定を行います。ユーザが数式を変更する場合はIDからアドレスに戻して表示することでユーザからはIDが指しているアドレスが動的に切り替わっているように見えるというわけです🦊

IDのみを格納した2重配列とこれらのIDをキー、セル値を値としたオブジェクトの2つに分けました。これにより行が操作されたとしても柔軟にデータ参照ができます。

手前味噌ですが参照の扱いについてはよくできていると思っていて、循環参照を検知でき、切り取ったセルも追跡されます🥷

Parser,RendererのMixin化

ParserやRendererはそれぞれ入力、出力を行うクラスで、これらを継承することでユーザは入出力をコントロールできました。これ自体は今でも可能ですが、JS(TS)は多重継承をサポートしていないため、クラスを組み合わせることができません😭
たとえば、数値を3桁区切りにするRendererクラスと真偽値をチェックボックスであらわすRendererクラスがあったとしてもこれらを同時に適用することはできず、自作しなければなりませんでした😰

これでは使いづらく、もったいないので、Mixinとして複数指定できるようにしました。

こんな感じになります。

<GridSheet
  initial={generateInitial({
    matrices: {
      A1: [[true, false]],
      B3: [[100], [200, 300], [400, 500, 600], [800, 900, 1000, 1100]],
    },
    cells: {
      default: {
        renderer: "extended",
      },
    },
    ensured: { numRows: 30, numCols: 20 },
  })}
  options={{
    renderers: {
      extended: new Renderer({
        mixins: [
          ThousandSeparatorRendererMixin,
          CheckboxRendererMixin,
        ]
      }),
    },
  }}
/>

ちなみにこれまで真偽値はデフォルトでチェックボックス表示でしたが、他の表計算ソフトに合わせてデフォルトはTRUE, FALSE表示にしました。チェックボックス表示にしたい場合は上記のようにMixinを指定したRendererを使用してください。Examplesに使用例があります🎃

時間のサポート

日付の連続データをオートフィルできるようにしたため、時間の差分を取り扱う必要が生じたため、これを取り扱うためのTimeDeltaというクラスを追加しました。

https://github.com/walkframe/gridsheet/blob/master/src/lib/time.ts

オートフィルだけでなく、Date型同士の減算、Date型とTimeDelta型の加減算ができるようにしました。まだ対応が足りていない関数があるかもしれませんが一旦これで。

react-windowとstyled-componentsの使用をやめた

パフォーマンス改善のために導入したreact-windowですが使うのをやめ、自前で仮想化を実現しました。そういえばこの「見えている領域のみを描画する」技術は仮想化(virtualization)というのが一般的みたいですね...(ずっとドヤ顔でwindowing言ってて恥ずかしいね🫠)
やめた理由としてはreact-windowはグリッドではあるもののテーブルではないということです🙅‍♀️
react-windowはスクロールに応じてdiv要素を絶対位置で敷き詰めることで表形式の表示を実現していますが、どうしてもtable要素を使いたかったのです。tableであればborder-collapseにより重なった線を共有できますが、divでは両側にスタイルを当てると2重になってしまいますし、ずらすと片側しか表示できません。これまでは片側のボーダーだけにスタイルをつけており、通常はこれで問題ありませんでしたが、開発者がスタイルをカスタマイズできるのにborderは自由にできないことはよくないので対応しました🧙‍♂️
まだ実装していませんが、table要素にしたことによりセルの結合がcolspanやrowspanで実現できるはずです。ただ、セル移動などの考慮が必要なのでいつか時間をとって対応する感じになると思います。

また、CSSinJSを自前で実装し、styled-componentsが不要になりました。これはstyled-componentsが結構重いからです。特に表計算のような大量のコンポーネントが存在するライブラリでは重大な問題です。仮想化しているので1スクロール内に存在するセルの数が多いときしか実感できませんがやめたことで体感的に50%ほどの高速化を実現できました👺

これでDependenciesはdate-fnsとdate-fns-timezoneだけになります🧞‍♂️

データの書き換え方法

操作履歴をTableクラスに統合しデータの変更がTableクラスの操作だけで可能となったことに伴い、外部からデータをいじるときにこのクラスを操作させる方針としました。
以前は Gridsheetコンポーネントのchanges propsで変更差分をわたしていましたが、適用タイミングがコントロールできませんでした。この方針変更により適用タイミングのコントロールと柔軟な操作ができるようになったので良い変更だと思っています🐙

具体的にはcreateTableRefによって取得したtableRefを介してtableクラスに直接アクセスし、データを変更します。dispatchによりstoreに適用するといった具合です。

export default function App() {
  const tableRef = createTableRef();
  React.useEffect(() => {
    const { dispatch, table } = tableRef.current;
    dispatch(table.write({point: {x: 1, y: 1}, value: "test"});
  }, [something]);
  // ...
};

以前はUserTableという操作用のクラスを型として使っていましたが interface となりました。まぁ、これは利用者目線ではそんなに変わらないですね👽

変更差分の検出方法

以前はonChangeDiffを使いオブジェクト形式で変更差分を検出していましたが、データ構造の見直しに伴い削除しました💥

onchangeで渡されるtableクラスの getXXX系メソッドにてchangedAtでフィルタする関数を渡してあげれば同様の操作を実現できます。
https://github.com/walkframe/gridsheet/blob/master/.storybook/examples/events/onchange.stories.tsx#L39-L43

テスト

まだ数は少ないですが、ひとまず数式の関数の一部のユニットテストを書きました。(distに入っちゃってるんですがそれは...)
https://github.com/walkframe/gridsheet/tree/master/src/formula/functions

これらの関数はユニットテストを追加する予定ですが、コンポーネントの見た目はユニットテストではなくE2Eでやるつもりです。

現在動作確認で使っているStorybookとPlaywrightをうまく組み合わせてCIでテストしたいなーと思ってます🎭
知見はほぼないですがまあなんとかなるでしょう🐤

名前

良い区切りなのでライブラリの名前を react-gridsheet から @gridsheet/react-core に改名しました。プラグインとか作るかもしれないので名前空間を作りました🌵

vueとかsvelteのサポートに関しては、多分自発的にはやらないと思いますが考慮はしています。もしやりたいという(物好きな)方がいらっしゃったら声をかけてください🤟

感想

こんなことを書くと開発が終わったような感じになってしまいますが、次にいつこれに関する記事を書くかわからないので忘れないうちに書いておこうと思います🙄

正直、壁が見つかるたびに「どうすんだこれ」とか「もうやめたい」なんて思ってました。直近でやったデータ構造の変更、数式サポート、自前での仮想化実装、オートフィルサポートあたりは本当に辛かった。私の場合は自分のために作っていたので諦めるという選択肢はありませんでした。作りたいから作るというよりは目的を達成するために作る、早く乗り越えようみたいなモチベーションですね😈

最初からこれを作ろうとしたら間違いなく挫折していましたが、少しずつやれば意外となんとかなるもんだなーということと、Reactはなんとなく書けるという自信になりました。

ちなみに前回の業務委託先では最初に書いたzennの記事を見てくれて「これ作れるなら大丈夫だろう」ということで契約してくれたと最終日に聞きましたwちゃんとポートフォリオになってくれていたのですね。その節はお世話になりました。やっぱりエンジニアは何かしらのアウトプットあると何かと良いですね。でも表計算作るのはホント大変なんでオススメしません👻

最後に成果物について
昔は表計算に似た何かでしたが、ようやく表計算といっても差し支えないレベルになったんじゃないでしょうか。ぜひ感想ください🥺
あとはセルの保護とテストの拡充ができたらv1リリースします。またしばらく放置するので少し先になりそうですが今年中に達成したいです🙂

https://github.com/walkframe/gridsheet

Discussion