薬剤師のためのautocompleteを、結局初めから作ることになった話

# 薬剤師のためのautocompleteを、結局初めから作ることになった話

## 作りたいもの

薬歴を書く際に、薬品名を簡単に入力するための補助アプリを作りたいと考えました。以下の機能を目指します。

- インプットに薬品名の数文字を入力すると、自動的に候補が補完される。
- キー操作でクリップボードに文字列をコピーできる。
- オフライン環境でも動作する。

## 基本戦略
- 技術スタック: Electron + Vite + React
- 言語: TypeScript


## 問題

- ネットで調べた結果、\<input> と \<datalist> を組み合わせたシンプルな実装が便利そうだったので、試してみました。しかし、以下の問題が発生しました。

- 候補数が多すぎてレイアウトが崩れる。
</br>
医薬品名は似たような名前が多く、特にジェネリック医薬品は種類が多いです。
例えば、ドネペジルという成分名の薬だけで123品目もあります。
結果として、\<datalist> を使うと候補が大量に表示され、レイアウトが崩れ、最悪の場合クラッシュしてしまうことが判明しました。

例:
</br>	
<dd>ドネペジル塩酸塩０．５％細粒  </dd>
<dd>ドネペジル塩酸塩３ｍｇ錠  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「ＤＳＥＰ」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「ＦＦＰ」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「ＪＧ」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「ＮＰ」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「ＴＳＵ」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「アメル」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「オーハラ」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「杏林」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「ケミファ」  </dd>
<dd>ドネペジル塩酸塩錠３ｍｇ「サワイ」</dd>
<dd> …(以下略)</dd>
       
</br>

- \<datalist> では柔軟な部分一致検索が難しい。
 	例えば「ドネぺ3」と入力しても、期待した候補が表示されない。
<br/>
<dd>いくつかのautocomple系ライブラリを試してみたが、私の技術では、うまくいかなかった。</dd>
</dl>
   


## 開発方針

- 入力したキーワード（例:「ドネぺ3」）に対応するため、検索ロジックを自作する。
- 候補が長くなってもレイアウトが崩れないよう、スクロールバーを付ける。
- \<datalist> に似た使用感を維持する。


## 実装

### 検索
- ライブラリ：fuse.js
- oprion: 3文字以上で検索を開始
- 手順：検索文字を一文字ずつ分解して、順次マッチする文字列をピックアップしていく

  - ``` typescript
	// 検索の開始地点
  	public matchingbyEachCharactor(query: string) {
		// queryの正規化
		const normalized = this.normalizeQuery(query); 
		const chars = normalized.split("");
		for (const c of chars) {
			this.matchingCore(this.selectedDatas, c);
			this.selectedDatas = this.extractSuffixByKeyword(
				c,
				this.selectedDatas,
			);
		}
	}

	protected matchingCore(_data: DrugData[], query: string) {
		const normalized = this.normalizeQuery(query);
		const fuse = new Fuse(_data, this.options);
		this.selectedDatas = fuse.search(normalized).map((r) => r.item);
	}

	protected extractSuffixByKeyword(
		keyword: string,
		_datas: DrugData[],
	): DrugData[] {
		const normalized = this.normalizeQuery(keyword);
		
		// ここで、正規表現を使って、アルファベットのみかどうかを判定
		const isAlphabet = /^[a-zA-Z]+$/.test(normalized);

		return _datas.map((d) => {
			
			let newData= "";
			let index = -1;
			if (isAlphabet) {
				const normalizedLowerCase = normalized.toLowerCase();
				index = d.val.toLowerCase().indexOf(normalizedLowerCase);
				
			} else {
				index = d.val.indexOf(normalized);
			}
			if (index !== -1) {
				newData = d.val.substring(index + 1);
			} 

			return {
				...d,
				val: newData,
			};
		});
	}
	```
	
	
### 表示部分
- \<select> を使用して候補リストを表示
- スクロールバーで候補を快適に閲覧可能にする。

### キーバインド実装
- onKeyDown イベントを使用してキー入力に対応
- 変換中の入力と、リストハイライト用のキー操作を分離して処理する

	``` typescript
	<input
		ref={inputRef}
		id="drugName"
		type="text"
		value={searchTerm}
		onChange={handleInput}
		onKeyDown={handleKeyDown}
		onCompositionEnd={compositionEndHandler}
		onCompositionStart={() => setDidEndComposition(false)}
		className="w-full px-3 py-2 border border-gray-300 rounded 
		focus:outline focus:outline-1 focus:outline-white"
		placeholder="入力してください"
	/>
	```   
- つまづきポイント 
  - 入力中の変換キーイベントと、候補リスト移動用のキーイベントを区別する必要があった。
    - onCompositionStart と onCompositionEnd を活用して状態を適切に管理。
  


## 成果物
- 全くリファクタリングしていないので、読みにくいが、成果物として、githubに挙げておく
-  [Github > autocompleteForDrugName](https://github.com/object-kazu/autocopleteForDrugName.git)
</br>

-  ![demo_20240223.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6903/a17b515b-786c-494f-a803-92f3c09c3c5b.gif)