@aiquants/contact-map
v0.13.0
Published
Reusable contact-map feature package: shared types/schemas, React (Router v7 / v8) UI with virtual scrolling + tree view + incremental Web Worker + fuzzy search + resize panels, and a drizzle (mssql) server layer with DI ports.
Readme
@aiquants/contact-map
Reusable, host-agnostic business contact map and corporate directory feature package. 再利用可能かつホスト非依存の名刺・連絡先マップおよび組織ディレクトリ機能パッケージ。
Overview / 概要
@aiquants/contact-map provides a complete full-stack contact directory solution featuring:
@aiquants/contact-map は以下の特徴を備えたフルスタックの連絡先ディレクトリ機能を提供します:
Isomorphic Core (
@aiquants/contact-map):- Shared domain models (
ContactRecord), Zod wire-validation schemas, and NDJSON streaming serializers/deserializers. - 共有ドメインモデル (
ContactRecord)、Zod ワイヤ検証スキーマ、および NDJSON ストリーミングのシリアライザー/デシリアライザー。 - Incremental tree builder (
IncrementalTreeBuilder) and hierarchical Japanese department parser (StandardDepartmentParser,TitleBasedDepartmentParser). - インクリメンタルツリービルダー (
IncrementalTreeBuilder) および日本語組織階層パーサー。 - Deterministic layout geometry calculations with hysteresis deadband (
calculateResponsiveThresholds,getHysteresisBreakpointState). - ヒステリシス不感帯を備えた決定論的レイアウト幾何学計算。
- Shared domain models (
Client Layer (
@aiquants/contact-map/client):- Resilient NDJSON streaming client (
ContactMapStreamClient) with keyset cursor resume, exponential backoff, and offline awareness. - キーセットカーソル再開、指数バックオフ、オフライン復帰検知を備えた堅牢な NDJSON ストリームクライアント。
- High-performance virtualized list view with swipe gestures and detail popups.
- スワイプジェスチャおよび詳細ポップアップを備えた高性能仮想スクロール一覧ビュー。
- Dedicated background Web Worker (
treeWorkerCore) for incremental directory tree synthesis without UI blocking. - メインスレッドを阻害しない増分ディレクトリツリー合成用バックグラウンド Web Worker。
- Mathematical 2-column responsive layout with resize handles (
@aiquants/resize-panels). - リサイズハンドルを備えた数学的 2 カラムレスポンシブレイアウト。
- Multi-script fuzzy search and normalization (
@aiquants/fuzzy-search,@aiquants/select-box). - 多言語・クロス表記対応のあいまい検索および入力正規化。
- Resilient NDJSON streaming client (
Server Layer (
@aiquants/contact-map/server):- Dependency Injection (DI) port architecture decouples database connections, schemas, authentication, and loggers from host apps.
- DB接続、スキーマ、認証、ロガーをホストアプリから疎結合化する DI ポート設計。
- Streaming keyset pagination handler (
createContactMapStreamHandler) whose resume cursor keepscontacted_atto the 100 ns ofdatetime2(7), so no row at a chunk boundary is skipped or sent twice. - 再開カーソルが
contacted_atをdatetime2(7)の 100 ns まで保ち、チャンクの境界の行を読み飛ばしも重複もしないストリーミングキーセットパジネーションハンドラー。
Installation / インストール
pnpm add @aiquants/contact-mapPeer dependencies:
{
"peerDependencies": {
"@aiquants/directory-tree": "^4.1.1",
"@aiquants/fuzzy-search": "^3.2.0",
"@aiquants/resize-panels": "^2.1.0",
"@aiquants/select-box": "^0.16.3",
"@aiquants/swipe-overlay": "^1.2.5",
"@aiquants/virtualscroll": "^3.16.0",
"drizzle-orm": ">=1.0.0-beta.4 <2.0.0",
"react": ">=18",
"react-dom": ">=18",
"react-router": "^7.0.0 || ^8.0.0",
"zod": ">=3.25.0 <5.0.0"
}
}These are the ranges this version is published with. The repository's package.json writes the @aiquants ones as workspace:^, which publishing expands to ^ and the workspace's version, so pnpm view @aiquants/contact-map@<version> peerDependencies prints the ranges of any version.
これはこの版を公開したときの範囲。リポジトリの package.json は @aiquants のものを workspace:^ と書き、公開のときにワークスペースの版の ^ へ展開されるので、どの版の範囲も pnpm view @aiquants/contact-map@<version> peerDependencies で確かめられる。
Architecture & Entry Points / アーキテクチャとエントリポイント
| Entry Point | Target | Contents |
| --- | --- | --- |
| @aiquants/contact-map | Universal | Domain types, Zod schemas, wire protocols, geometry, parsers, tree builders, and search query normalization. |
| @aiquants/contact-map/client | Browser / SSR | React components (ContactMapSearchPage, ContactMapSearchListView, ContactMapSearchTreeView), hooks, and Worker clients. |
| @aiquants/contact-map/server | Node / Server | DI port interfaces (ContactMapServerPorts), streaming handlers, and Drizzle MSSQL service queries. |
| @aiquants/contact-map/worker/treeWorker | Web Worker (ES module) | The tree Worker script. The tree loads it from beside the client bundle (dist/worker/treeWorker.mjs) unless workerUrls.treeWorker points elsewhere or treeWorkerFactory builds the Worker. |
| @aiquants/contact-map/styles/contact-map.standalone.css | CSS | Pre-compiled Tailwind v4 utility styles for a host without Tailwind CSS (see Styling). |
Usage / 使用方法
1. Server Route Setup / サーバー側ストリーミングルートの設定
// app/routes/api.contact_map.stream.ts
import { createContactMapStreamHandler } from "@aiquants/contact-map/server"
import { db, ensureDbConnection } from "~/services/drizzle-db/client.server.ts"
import { contactTable } from "~/services/drizzle-db/schema/contacts.table.ts"
import { authenticateInLoader } from "~/services/auth.server.ts"
const handler = createContactMapStreamHandler({
db,
table: contactTable,
// Awaited before every query. A pool that is always connected passes `async () => {}`.
ensureConnection: ensureDbConnection,
// Called with { failureRedirect: null }; return the session cookie the call refreshed, if any.
authenticate: async (request, options) => {
const { user, cookie } = await authenticateInLoader(request, options)
return { user: user ?? null, cookie }
},
})
export async function loader({ request }: { request: Request }) {
return handler(request)
}The handler calls authenticate in one way only, authenticate(request, { failureRedirect: null }), so the arrow function above takes its parameter types from the setting, and a host function with overloads passes as it is when one of them takes { failureRedirect: null } (authenticate: authenticateInLoader).
ハンドラーは authenticate を 1 通りにだけ呼ぶ (authenticate(request, { failureRedirect: null }))。そのため上の矢印関数は引数の型を設定から得て、オーバーロードを持つホストの関数は、どれかが { failureRedirect: null } を受け取ればそのまま渡せる (authenticate: authenticateInLoader)。
db, table, ensureConnection and authenticate are required, and createContactMapStreamHandler / createContactMapServer throw a TypeError when db or table is missing, table.contacted_at is missing or is not a Drizzle column, ensureConnection or authenticate is not a function, or firstBatchSize / nextBatchSize is given but is not a number.
They throw a RangeError when table.contacted_at is not a datetime2 column (datetime2 or datetime2(0) to datetime2(7); a datetime, datetimeoffset or date column is refused) or firstBatchSize or nextBatchSize is a number but not an integer from 1 to Number.MAX_SAFE_INTEGER.
Each message names the setting and ends with the value given ([contact-map] server config authenticate must be the function that returns the request's user (…); got undefined, [contact-map] server config table.contacted_at must be a Drizzle datetime2 column; got "datetime"). A stream open to everyone says so with an authenticate that returns a user; leaving the setting out never serves the contacts unauthenticated.
The handler calls authenticate(request, { failureRedirect: null }) after checking the resume cursor (a malformed cursor gets 400 without authenticating), answers 401 when no user comes back, and sends a returned cookie as Set-Cookie on the 401 and on the stream response alike, so a session the call refreshed reaches the browser. When authenticate throws, the handler logs the error through logger and answers 500 with { "error": "authentication failed on the server" }, so nothing of the session infrastructure reaches the client. The stream is sent with Cache-Control: no-store, no-transform.
The resume cursor (StreamCursor: each chunk line's nextCursor, sent back as cursor_contacted_at / cursor_person_id) is written by the server and opaque to clients. Its contacted_at is the last row's value in UTC with exactly 7 fractional digits (YYYY-MM-DDThh:mm:ss.fffffffZ, the 100 ns of datetime2(7)), read from the database as text so that no digit is lost to the driver's millisecond Date, or null once the stream has reached the rows without one; its person_id is the last row's id, any safe integer (a signed key may be negative).
The chunk query checks both values by the rules below when it writes the cursor, so the next request accepts every cursor the server writes; a chunk whose last row's person_id is not a safe integer fails with an error naming the value.
The handler accepts only that form naming a real instant from year 1 on and a cursor_person_id holding a safe integer written in canonical decimal (0, or digits without a leading zero after an optional minus sign); anything else (-0, +5, 05, 1e3, …) gets 400 before authenticating.
The next chunk compares the bare column with the cursor cast to datetime2(7), so an index on (is_active, contacted_at, person_id) can seek. createContactMapServer().getContactsStreamChunk(cursor) checks the cursor by the same rules and throws a TypeError (a value of the wrong type or shape) or a RangeError (any other form) before connecting.
ContactMapStreamClient sends the cursor back unchanged. When a resumed request gets 400 (the cursor was written by a server of another version), it drops the cursor and loads from the beginning again once, keeping the records it has (they are keyed by person_id); a 400 to a request without a cursor, or a second 400 in the same load, ends the load at once with [contact-map] the server rejected the stream request (400); reload the page.
A 401 or 403 ends the load at once in phase auth-required with the error Authentication required, without retrying (retryNow() starts again from the last cursor, for example after the user signs in again). Any other failed response (the 500 of a throwing authenticate included) is retried with backoff (1 s, doubling up to 30 s; the annotation shows annotationReconnecting meanwhile) and sets the error to [contact-map] the stream request failed (status <status>). The sixth failure in a row ends the load in phase failed (retryNow() starts again from the last cursor).
db・table・ensureConnection・authenticate は必須で、db か table が無い、table.contacted_at が無いか Drizzle の列でない、ensureConnection か authenticate が関数でない、または firstBatchSize / nextBatchSize が数値でなければ createContactMapStreamHandler / createContactMapServer は TypeError を、table.contacted_at が datetime2 の列 (datetime2 か datetime2(0) から datetime2(7)。datetime・datetimeoffset・date の列は断る) でないか、firstBatchSize か nextBatchSize が 1 から Number.MAX_SAFE_INTEGER までの整数でない数値なら RangeError を投げる。
どの文言も設定を名指し、与えた値で終わる ([contact-map] server config authenticate must be the function that returns the request's user (…); got undefined、[contact-map] server config table.contacted_at must be a Drizzle datetime2 column; got "datetime")。誰にでも開くストリームは user を返す authenticate で明示し、設定を書き漏らしても認証なしで連絡先を配ることはない。ハンドラーは再開カーソルを検査してから (不正な形は認証せずに 400) authenticate(request, { failureRedirect: null }) を呼び、user が無ければ 401 を返し、返された cookie は 401 にもストリームの応答にも Set-Cookie で載せる (呼び出しが更新したセッションをブラウザへ届ける)。
authenticate が例外を投げると、ハンドラーはそのエラーを logger へ出し、500 ({ "error": "authentication failed on the server" }) を返す (セッションの基盤の詳細はクライアントへ出さない)。ストリームは Cache-Control: no-store, no-transform で送る。
再開カーソル (StreamCursor。各チャンク行の nextCursor で、cursor_contacted_at / cursor_person_id として送り返す) はサーバーが書き、クライアントにとって形は不透明。contacted_at は最後の行の値を UTC で小数部ちょうど 7 桁 (YYYY-MM-DDThh:mm:ss.fffffffZ、datetime2(7) の 100 ns) で表したもので、ドライバのミリ秒までの Date で桁を落とさないようデータベースから文字列で読む。日時の無い行へ進んだ後は null。person_id は最後の行の ID で、負を含む任意の安全な整数 (符号付きのキーは負にもなる)。
チャンクの照会はカーソルを書くときに両方の値を下の規則で確かめるので、サーバーが書いたカーソルは次の要求が必ず受け入れる。最後の行の person_id が安全な整数でないチャンクは、その値を示すエラーで失敗する。
ハンドラーは 1 年以降の実在する日時を表すこの形と、安全な整数を正規の 10 進 (0 か、省略できる負の符号に続く先頭が 0 でない数字) で書いた cursor_person_id だけを受け入れ、それ以外 (-0・+5・05・1e3 など) は認証の前に 400 で断る。次のチャンクは素の列と datetime2(7) へ CAST したカーソルを比べるので、(is_active, contacted_at, person_id) の索引でシークできる。createContactMapServer().getContactsStreamChunk(cursor) も同じ規則でカーソルを検査し、接続の前に TypeError (型や形の誤り) か RangeError (それ以外の形) を投げる。
ContactMapStreamClient はカーソルをそのまま送り返す。再開した要求が 400 を受けたら (別の版のサーバーが書いたカーソル)、カーソルを捨てて最初から 1 回だけ読み直し、受信済みのレコードは残す (person_id で重ねる)。カーソルの無い要求への 400 と、同じ読み込みでの 2 回目の 400 は、[contact-map] the server rejected the stream request (400); reload the page で即座に読み込みを終える。
401 と 403 は再試行せず、段階 auth-required・エラー Authentication required で即座に読み込みを終える (retryNow() で最終カーソルから読み直す。たとえば再ログインの後)。それ以外の失敗の応答 (authenticate が投げたときの 500 を含む) はバックオフ (1 秒から倍々に最大 30 秒。その間、注記は annotationReconnecting を表示) で再試行し、エラーを [contact-map] the stream request failed (status <status>) にする。続けて 6 回目の失敗で、段階 failed として読み込みを終える (retryNow() で最終カーソルから始め直す)。
2. Client Page Setup / クライアント側ページの設定
// app/routes/contact_map.tsx
import { ContactMapConfigProvider, ContactMapSearchPage, contactMapShouldRevalidate } from "@aiquants/contact-map/client"
export const shouldRevalidate = contactMapShouldRevalidate
export default function ContactMapRoute() {
return (
<ContactMapConfigProvider
config={{
streamEndpoint: "/api/contact_map/stream",
openEmailsWithGmail: true,
}}
>
<ContactMapSearchPage />
</ContactMapConfigProvider>
)
}Export contactMapShouldRevalidate as the route's shouldRevalidate. On the same path, a change of nothing but the page's own URL state (the list / tree tab view and the search term q) does not run the route's loader, since the records stream in on the client. Every other transition keeps React Router's decision (another path, another parameter, and the unchanged URL after an action, a revalidate() call or a link to the current page), so a loader that checks the session still runs then; a submission is decided by its URL the same way. It is built with @aiquants/route-revalidation.
ルートの shouldRevalidate には contactMapShouldRevalidate を公開する。同じパスでページ自身の URL の状態 (一覧とツリーのタブ view と検索語 q) だけが変わる遷移では、ルートのローダーを走らせない (レコードはクライアントへストリームで届く)。ほかの遷移 (別のパス・ほかのパラメータ・アクションの後や revalidate()・今のページへのリンクのような URL の変わらない場合) は React Router の判断を保つので、セッションを確かめるローダーはそのときも走る。送信も URL で同じに決める。@aiquants/route-revalidation で組んでいる。
The page needs its styles, which depend on the host: a host that builds with Tailwind CSS v4 generates the classes itself, and a host without it imports the standalone CSS (see Styling). ページにはスタイルが要り、その用意はホストによって変わる: Tailwind CSS v4 でビルドするホストは自分のビルドでクラスを生成し、Tailwind CSS を使わないホストはスタンドアロンの CSS を読み込む (スタイルの節を参照)。
3. Stream Endpoint / ストリームのエンドポイント
The stream endpoint is a setting like locale and workerUrls: set streamEndpoint on ContactMapAppView or on a surrounding ContactMapConfigProvider, and when both set it the view's prop wins. It has no default; the stream reads the one value that results.
- It must be a same-origin absolute path, such as
/api/contact_map/stream(a query string is allowed). The following throw aRangeError: an empty or blank value, a relative path, an absolute or protocol-relative URL (https://…,//host,/\host), whitespace, a control character, or#. A non-string throws aTypeError, and so does a stream that starts with no endpoint configured. - One module-lifetime client streams the endpoint for as long as the app stays loaded. Every contact-map view in the browser tab must use the same endpoint, on every route; a second, different endpoint throws an
Errorthat names both, and switching endpoints needs a full page reload. - The load is not tied to the page. Leaving the route does not abort it: it runs to completion, and the records stay in memory, so returning to the page shows them at once instead of loading again. Only
resetContactMapStreamClientForTesting()(tests) disposes the client; after changingstreamEndpointduring development, reload the page. ContactMapSearchPageconnects to the stream only when it is not givenstreamState. To render records you load yourself, pass astreamStatewhosecontactMapRecordMapis oneMapyou keep while it grows; the tree rebuilds its nodes from that map.
ストリームのエンドポイントは locale や workerUrls と同じ扱いの設定で、ContactMapAppView か外側の ContactMapConfigProvider の streamEndpoint に設定する。両方にあれば、ビューの prop が優先する。既定値は無く、ストリームはこうして決まった 1 つの値を読む。
- 同一オリジンの絶対パスでなければならない (例
/api/contact_map/stream。クエリ文字列は可)。次の場合はRangeError: 空や空白だけの値、相対パス、絶対 URL やプロトコル相対の URL (https://…、//host、/\host)、空白・制御文字・#を含む値。文字列でない値はTypeError。エンドポイントを設定しないままストリームを始めた場合もTypeError。 - 読み込んだアプリの寿命の間、モジュール寿命のクライアント 1 つがエンドポイントを読む。ブラウザのタブの contact-map のビューは、どのルートでもすべて同じエンドポイントを使う。2 つ目の別のエンドポイントは両方の値を示す
Errorになり、エンドポイントを替えるにはページの再読み込みが要る。 - 読み込みはページに縛られない。ルートを離れても中断せず、完了まで続き、レコードはメモリに残る。ページへ戻ると、読み込み直さずにその場で表示される。クライアントを破棄するのは
resetContactMapStreamClientForTesting()(テスト用) だけで、開発中にstreamEndpointを変えたらページを再読込する。 ContactMapSearchPageは、streamStateを渡されないときだけストリームへ接続する。自分で読み込んだレコードを描画するには、contactMapRecordMapに、増える間も同じMapを保ったstreamStateを渡す (ツリーはこのマップからノードを復元する)。
The tree is built in a Web Worker by one module-resident session, so returning to the tree shows its last snapshot at once. treeWorkerFactory (on the config) replaces the Worker construction for bundlers that need their own: it must return a new Worker on every call, the hook owns that Worker and terminates it on unmount, and each hook instance gets its own session, so no snapshot survives a remount. A view hidden in <Activity> (or StrictMode's replayed effects) only stops the Worker and keeps the tree on screen; showing it again rebuilds on a new Worker behind that tree.
The function may be inline (a new identity on each render): the session keeps its Worker and calls the latest factory when it needs a new one, and it does not read workerUrls.treeWorker. Leave it out (and point workerUrls.treeWorker at the Worker file if needed) to keep the module-resident session.
The module-resident session reads workerUrls.treeWorker once per browser tab: give every contact-map view the same value on every route (or none, for the bundled Worker file). A view that asks for another URL throws an Error naming both, a value that is not a string (a URL object, null) throws a TypeError, and an empty or blank URL throws a RangeError; after changing the URL, reload the page.
ContactMapConfigProvider checks the value's shape in every render, a server render included, like locale and labels, even while treeWorkerFactory is set, so a view that has not opened its tree yet still refuses it. The session itself, and with it the one-URL rule, exists only in the browser, so the rule holds per browser tab and not across a server's requests.
workerUrls on ContactMapAppView is layered over the provider's key by key: a key the view leaves out or sets to undefined keeps the provider's value (layerContactMapWorkerUrls). ContactMapSearchPage layers only indexWorker and levenshteinWorker the same way and refuses a treeWorker, because its tree reads the provider's; set that on the provider or the view. Errors name the surface that holds the bad value (workerUrls, ContactMapAppView workerUrls, ContactMapSearchPage workerUrls).
Every surface reads each own property of its workerUrls once per render and hands down only the checked copy: useContactMapConfig().workerUrls is a frozen object holding all three keys (undefined where none is set), so a getter that returns another value after the check, or a key inherited from Object.prototype, never reaches a Worker.
The same holds for every setting: useContactMapConfig() is a frozen object without a prototype that owns every setting (undefined where left out), inside a provider and outside one, so no setting is ever inherited, from a polluted Object.prototype either.
The provider reads only the documented settings its config owns, refuses a config that is not a plain object (TypeError), and keeps handing down the same object while the settings' values stay the same, so a host may rebuild config on every render.
The settings a view layers in (streamEndpoint, workerUrls, locale and labels of ContactMapAppView; workerUrls of ContactMapSearchPage) are read only when its props own them.
A Worker that cannot be created or fails is recreated twice, 250 ms and then 500 ms later (records that arrive during the wait are sent to the new Worker, which is not created any earlier). Each failure is logged as a [TreeWorker] warning with the error, and the third failure in a row as an error naming the setting the Workers came from and what brings the tree back: the page reloading for the module-resident session (workerUrls.treeWorker), the view remounting for a factory session (treeWorkerFactory). Until then the tree stops updating. A Worker that finishes a build resets the count.
ツリーは Web Worker の中で、モジュール常駐の 1 つのセッションが組み立てるので、ツリーへ戻ると直前のスナップショットがすぐに出る。設定の treeWorkerFactory は、Worker の作り方が決まっているバンドラー向けに Worker の生成を差し替える。呼ぶたびに新しい Worker を返すこと (フックがその Worker を所有し、外すときに止める)。フックのインスタンスごとにセッションを持つので、再マウントでスナップショットは残らない。<Activity> で隠したビュー (と StrictMode が繰り返す副作用) は Worker を止めるだけでツリーを画面に残し、再び表示すると、そのツリーの裏で新しい Worker に作り直す。描画のたびに別の関数になってよく (インラインの関数でよい)、セッションは Worker を持ち続けて、新しく作るときに最後の factory を呼ぶ (workerUrls.treeWorker は読まない)。モジュール常駐のセッションを使うなら指定せず、必要なら workerUrls.treeWorker で Worker のファイルを指す。
モジュール常駐のセッションは workerUrls.treeWorker をブラウザのタブにつき 1 回だけ読む。全ルートの全ビューに同じ値を渡す (同梱の Worker ファイルなら全て指定しない)。別の URL を求めたビューは両方の値を挙げた Error を、文字列でない値 (URL オブジェクト、null) は TypeError を、空や空白だけの URL は RangeError を投げる。URL を変えたらページを再読み込みする。値の形は ContactMapConfigProvider が locale / labels と同じくサーバーでの描画を含むどの描画でも検査し、treeWorkerFactory がある間も検査するので、まだツリーを開いていないビューでも拒む。セッション自体とその 1 つの URL の規則はブラウザにだけあるので、規則はサーバーの要求をまたがず、ブラウザのタブごとに効く。ContactMapAppView の workerUrls は外側のプロバイダーの値へキーごとに重ね、ビューが省略か undefined にしたキーはプロバイダーの値を保つ (layerContactMapWorkerUrls)。
ContactMapSearchPage は indexWorker と levenshteinWorker だけを同じく重ね、treeWorker は拒否する (ツリーはプロバイダーの値を読むので、プロバイダーかビューに設定する)。例外は誤った値を持つ設定 (workerUrls・ContactMapAppView workerUrls・ContactMapSearchPage workerUrls) を名指す。
どの面も workerUrls の各プロパティを描画ごとに 1 回だけ読み、検査した複製だけを下へ配る。useContactMapConfig().workerUrls は 3 つのキーを全て持つ凍結したオブジェクト (どこにも無いキーは undefined) なので、検査の後で別の値を返す getter や Object.prototype から継承したキーが Worker に届くことは無い。
これは全ての設定に当てはまる。useContactMapConfig() は、プロバイダーの中でも外でも、全ての設定を自身で持つ (省略したものは undefined) プロトタイプの無い凍結したオブジェクトなので、汚染された Object.prototype からも設定を継承しない。
プロバイダーは config が自身で持つ文書化された設定だけを読み、素のオブジェクトでない config は TypeError で拒み、設定の値が同じ間は同じオブジェクトを配り続ける (ホストは描画のたびに config を作り直してよい)。
ビューが重ねる設定 (ContactMapAppView の streamEndpoint・workerUrls・locale・labels、ContactMapSearchPage の workerUrls) も、props が自身で持つときだけ読む。
Worker を作れないか Worker が落ちると、250 ms 後と 500 ms 後の 2 回作り直す (待つ間に届いたレコードは新しい Worker へ送り、Worker を前倒しでは作らない)。障害ごとに [TreeWorker] の警告をエラーと共にログへ出し、続けて 3 回目の障害は、Worker の出どころの設定とツリーを戻すものを挙げたエラーとして出す。モジュール常駐のセッション (workerUrls.treeWorker) はページの再読み込み、factory のセッション (treeWorkerFactory) はビューの再マウントで戻り、それまでツリーの更新は止まる。Worker が構築を終えると回数は 0 に戻る。
The tree view starts its session when it mounts. A host that wants the tree finished before the tree tab opens feeds the module-resident session itself: inside the same provider (so the fallback names agree), call useTreeWorker with the stream's records, for example in the layout that frames the page. The tree view then finds the session holding those records and shows its snapshot at once. A treeWorkerFactory gives every hook its own session, so this works only without one.
ツリービューはマウントのときにセッションを始める。ツリーのタブを開く前にツリーを組み終えておきたいホストは、モジュール常駐のセッションへ自分でレコードを渡す: 同じプロバイダーの中で (代替名を揃えるため)、ページを囲むレイアウトなどからストリームのレコードで useTreeWorker を呼ぶ。ツリービューはそのレコードを持つセッションを見つけ、スナップショットをすぐに出す。treeWorkerFactory はフックごとにセッションを持たせるので、これが効くのは factory を指定しないときだけ。
import { useContactMapStream, useTreeWorker } from "@aiquants/contact-map/client"
// rendered inside ContactMapConfigProvider, next to the page
function PrebuildContactMapTree() {
const { records, isComplete } = useContactMapStream()
useTreeWorker(records, isComplete)
return null
}Styling / スタイル
The components are styled with Tailwind CSS v4 utility classes: the semantic colors of a shadcn/ui-style host (bg-background, text-muted-foreground, border-border, ...), read from the host's --background, --foreground, ... variables (HSL components), and the dark: variant of a dark class on an ancestor.
- A host that builds with Tailwind CSS v4 generates the classes in its own build: it adds an
@sourcefor the package's files (@source "<path to node_modules>/@aiquants/contact-map/dist";for the published package), keeps the semantic colors in its@theme, and defines theanimate-progress-indeterminateutility of the indeterminate progress bar (the definition below). It does not import the standalone CSS as well, whose copy of the utilities would compete with its own. - A host without Tailwind CSS imports
@aiquants/contact-map/styles/contact-map.standalone.css: the package's utilities, the semantic color mapping and the progress animation, compiled. It reads the host's--background, ... variables at run time. - Either way, the host imports the CSS of the embedded packages itself, which the standalone CSS leaves out so that an older copy baked into it never overrides the host's newer one:
@aiquants/virtualscroll/styles/virtualscroll.css,@aiquants/directory-tree/styles/directory-tree.cssand@aiquants/resize-panels/styles/resize-panels.css.
コンポーネントは Tailwind CSS v4 のユーティリティクラスで装飾する: shadcn/ui 流のホストの意味の色 (bg-background・text-muted-foreground・border-border など。ホストの --background・--foreground などの変数 (HSL の成分) から読む) と、祖先の dark クラスによる dark: バリアント。
- Tailwind CSS v4 でビルドするホストは、自分のビルドでクラスを生成する: パッケージのファイルへの
@sourceを足し (公開版なら@source "<node_modules への道>/@aiquants/contact-map/dist";)、意味の色を自分の@themeに置き、不確定の進捗バーのanimate-progress-indeterminateユーティリティを定義する (定義は下)。スタンドアロンの CSS は併せて読み込まない (その写しのユーティリティが自分のものと競り合う)。 - Tailwind CSS を使わないホストは
@aiquants/contact-map/styles/contact-map.standalone.cssを読み込む: パッケージのユーティリティ・意味の色の対応・進捗のアニメーションをコンパイルしたもの。ホストの--backgroundなどの変数は実行時に読む。 - どちらの場合も、埋め込みのパッケージの CSS はホストが自分で読み込む (スタンドアロンの CSS は含めないので、焼き込んだ古い写しがホストの新しい CSS を上書きしない):
@aiquants/virtualscroll/styles/virtualscroll.css・@aiquants/directory-tree/styles/directory-tree.css・@aiquants/resize-panels/styles/resize-panels.css。
A host that builds with Tailwind CSS v4 (in the CSS file its build reads; the @source path is relative to that file):
Tailwind CSS v4 でビルドするホスト (ビルドが読む CSS ファイルに書く。@source の道はそのファイルからの相対):
@source "../node_modules/@aiquants/contact-map/dist";
@theme {
--animate-progress-indeterminate: progress-indeterminate 2s ease-in-out infinite;
@keyframes progress-indeterminate {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(300%);
}
100% {
transform: translateX(-100%);
}
}
}A host without Tailwind CSS (once, for example in its root module): Tailwind CSS を使わないホスト (ルートのモジュールなどで 1 度だけ):
import "@aiquants/contact-map/styles/contact-map.standalone.css"The package reads these host CSS variables, each 0px while the host leaves it unset. It never names a variable of another package: a host binds them in its own layout layer.
パッケージは次のホストの CSS 変数を読み、ホストが設定しない間はどれも 0px。ほかのパッケージの変数は名指さないので、ホストが自分のレイアウトの層で結ぶ。
| Variable | Read by | Meaning |
| --- | --- | --- |
| --header-height | The list view's height (on the root element) | The list never reaches above this distance from the top of the viewport (a fixed header's height). |
| --footer-height | The list view's height (on the root element) | Taken from the bottom of the viewport (a fixed footer's height). |
| --scrollbar-height | The list view's height (on the root element) | Taken from the bottom of the viewport as well (room kept for a horizontal scrollbar). |
| --aqcm-overlay-right | The detail popup and the swipe overlay | Their right inset, such as the width of a menu pinned to the right edge: :root { --aqcm-overlay-right: var(--my-pinned-menu-width, 0px); }. |
| 変数 | 読むもの | 意味 |
| --- | --- | --- |
| --header-height | 一覧ビューの高さ (ルート要素で読む) | 一覧はビューポートの上端からこの距離より上へ出ない (固定したヘッダーの高さ)。 |
| --footer-height | 一覧ビューの高さ (ルート要素で読む) | ビューポートの下端から差し引く (固定したフッターの高さ)。 |
| --scrollbar-height | 一覧ビューの高さ (ルート要素で読む) | 同じくビューポートの下端から差し引く (横のスクロールバーのために空ける高さ)。 |
| --aqcm-overlay-right | 詳細のポップアップとスワイプのオーバーレイ | 右端の余白 (右端にピン留めしたメニューの幅など): :root { --aqcm-overlay-right: var(--my-pinned-menu-width, 0px); }。 |
Search Normalization / 検索語の正規化
The search box reads its text as keywords. Each whitespace-separated word is a keyword of its own, and the words between two quotation marks (", ", “, ”; any of them opens or closes a group, and a group left open runs to the end) are one keyword.
The page searches every keyword with @aiquants/fuzzy-search: useFuzzySearch's querySetsOf reads the term with parseQuerySets, and FuzzySearchManager.searchSets searches one query set per keyword. The AND / OR button next to the search box combines the keywords' answers: AND keeps the contacts every keyword finds (intersection), OR the contacts any keyword finds (union). The button starts at OR, and switching it searches the current term again without rebuilding the index.
A quoted keyword is matched by fuzzy-search's multi-word matching, whose operator is the settings tab's "Operator for words inside quotes" select (multiTermOperator, AND at first): AND requires every word of the group to appear in some search field, OR does not. A keyword of one word matches the same under either operator.
A "Maximum results" cap of the index or Levenshtein settings (no limit at first) applies to each keyword's answer before the keywords are combined, so with a cap an AND search can miss a contact that one keyword's capped answer left out.
The settings tab's "Input delay (ms)" (debounceMs, 300 at first) is the one wait between typing and searching: the page searches that long after the text last changed and hands the engine debounceMs: 0. "Reset all" returns every search setting to the page's initial ones (fuzzy-search's defaults, with AND for the words inside quotes) and keeps the Worker URLs.
The search page normalizes the typed text with normalizeSearchInput (exported from @aiquants/contact-map) into the fuzzy search term. The contact fields (name, company, department, title, email, phone numbers) are matched as stored. Every keyword is therefore only brought into a form those fields can hold:
- Invisible characters (U+200B, U+FEFF) are removed before the keywords are read, and each keyword's surrounding whitespace (U+3000 and U+00A0 included) is trimmed.
- Width, kana (hiragana → katakana) and case are unified with select-box's
normalizeText(ABC商事→abc商事,さとう→サトウ). Kana stays as written (つづき→ツヅキ). Its options areCONTACT_MAP_SEARCH_NORMALIZE_OPTIONS(exported from@aiquants/contact-map), which sets all eight of select-box'sNormalizeOptions, so a change of select-box's defaults never changes the search term:convertWidth,convertKanaandconvertCaseon,convertRomajioff (the next rule reads romaji), andnormalizeVariants,ignoreSpaces,ignoreSymbolsandignoreLongVowelsoff, because the fields hold spelling variants, spaces, symbols and prolonged sound marks as written (佐々木,自由ヶ丘,ーヒーanddon’tstay). - The keywords are replaced by their katakana readings only when every keyword is romaji that reads completely. Each must hold only Latin letters and whitespace, and select-box's
romajiToKatakanamust leave no letter untransliterated (tanaka→タナカ,yamada taro→ヤマダ タロ,"yamada taro" suzuki→"ヤマダ タロ" スズキ,tsuduki→ツヅキ). - Mixed text, digits and symbols, and words that do not read completely keep their width / kana / case form, and so does every other keyword of the text (
[email protected],営業部 tanaka,tanaka1,ke-su,sony,"yamada taro" sony). - The keywords are written back with fuzzy-search's
formatQuerySets: single spaces between them, a keyword holding whitespace in ASCII quotation marks ("ABC 商事"→"abc 商事"), and neither a blank keyword nor a keyword that normalization made equal to an earlier one.parseQuerySetstherefore reads exactly these keywords from the term. Text without quotation marks becomes its whitespace-separated words, each normalized as above and written once, separated by single spaces.
The result highlight and the exact-match ranking are one derivation from the text as typed. createSearchQueryPattern(query) finds the spellings that getSearchQuerySpellings(query) lists (select-box's getNormalizedVariants of each word of the text's keywords and of its normalized form's keywords, read with fuzzy-search's parseQuerySets, so a quotation mark is never part of a spelling, each followed by its half-width katakana spelling when every kana of it has one: タナカ after タナカ, サトウ after サトウ, the form many stored fields hold), longest first and case-insensitively.
HighlightedText marks its matches in mark elements, dark text on yellow and, under the dark class, light text on dark amber (both above the 7:1 contrast of WCAG 2.2 AAA), and createExactMatchRanker(query, fields) moves the results with a match in one of the fields to the top, keeping the fuzzy order within each group. A field is therefore highlighted exactly when it ranks its record as an exact match. A kana reading is marked only when every keyword reads as romaji.
The typed query is used because normalization can drop a spelling it holds. The width unification (Unicode NFKC) reads ㈱ as (株), so ㈱山田 becomes the search term (株)山田, while the highlight and the ranking still find ㈱山田 in a company stored with ㈱. The search outlet context carries both: searchQuery (as typed) and searchTerm (normalizeSearchInput(searchQuery)).
検索欄はテキストをキーワードとして読む。空白で区切った語はそれぞれ 1 つのキーワードで、2 つの引用符 ("・"・“・”。どれでも組を開き閉じ、開いたままの組は終わりまで続く) の間の語はまとめて 1 つのキーワード。ページはどのキーワードも @aiquants/fuzzy-search で検索する: useFuzzySearch の querySetsOf が parseQuerySets で検索語を読み、FuzzySearchManager.searchSets がキーワードごとに 1 つの問い合わせのセットを検索する。検索欄の隣の AND / OR ボタンはキーワードの答えをまとめ、AND はどのキーワードも見つけた連絡先 (積集合)、OR はどれかのキーワードが見つけた連絡先 (和集合) を残す。ボタンは OR で始まり、切り替えると索引を建て直さずに今の検索語を検索し直す。
引用符で囲んだキーワードは fuzzy-search の複数語の照合で照合し、その演算子は設定タブの「引用符の中の語の演算子」セレクト (multiTermOperator。最初は AND): AND は組のどの語もいずれかの検索フィールドに現れることを求め、OR は求めない。1 語のキーワードはどちらの演算子でも同じに照合する。
索引か Levenshtein の設定の「最大結果数」(最初は上限なし) はキーワードの答えをまとめる前に各キーワードの答えへ効くので、上限があると AND の検索は、あるキーワードの上限で切られた答えから漏れた連絡先を見落とすことがある。
設定タブの「入力遅延 (ms)」(debounceMs。最初は 300) が入力から検索までの唯一の待ちで、ページはテキストが最後に変わってからその時間の後に検索し、エンジンへは debounceMs: 0 を渡す。「すべてリセット」はどの検索設定もページの初期の設定 (fuzzy-search の既定に、引用符の中の語の AND を重ねたもの) へ戻し、Worker の URL は保つ。
検索ページは入力されたテキストを normalizeSearchInput (@aiquants/contact-map から公開) で正規化し、あいまい検索の検索語にする。連絡先のフィールド (氏名・会社・部署・役職・メール・電話番号) は保存されたままの値で照合する。そのため各キーワードは、それらのフィールドが持ちうる形に揃えるだけ:
- キーワードを読む前に不可視文字 (U+200B・U+FEFF) を除き、各キーワードの端の空白 (U+3000・U+00A0 を含む) を除く。
- select-box の
normalizeTextで全角/半角・かな (ひらがな → カタカナ)・大文字小文字を統一する (ABC商事→abc商事、さとう→サトウ)。かなは書いたまま (つづき→ツヅキ)。設定はCONTACT_MAP_SEARCH_NORMALIZE_OPTIONS(@aiquants/contact-mapから公開) で、select-box のNormalizeOptionsの 8 つをすべて指定するので、select-box の既定が変わっても検索語は変わらない:convertWidth・convertKana・convertCaseは有効、convertRomajiは無効 (ローマ字は次の規則で読む)、normalizeVariants・ignoreSpaces・ignoreSymbols・ignoreLongVowelsは無効 (フィールドは異体字・空白・記号・長音記号を書いたまま持つため。佐々木・自由ヶ丘・ーヒー・don’tはそのまま)。 - どのキーワードも完全に読めるローマ字のときだけ、各キーワードをカタカナの読みに置き換える。どれもラテン文字と空白だけで、select-box の
romajiToKatakanaが音訳しない文字を残さないことが条件 (tanaka→タナカ、yamada taro→ヤマダ タロ、"yamada taro" suzuki→"ヤマダ タロ" スズキ、tsuduki→ツヅキ)。 - 混在テキスト・数字や記号・完全には読めない語は全角/半角・かな・大文字小文字の形のままで、そのテキストのほかのキーワードも同じ (
[email protected]、営業部 tanaka、tanaka1、ke-su、sony、"yamada taro" sony)。 - キーワードは fuzzy-search の
formatQuerySetsで書き戻す: キーワードの間は 1 つの空白、空白を持つキーワードは ASCII の引用符で囲み ("ABC 商事"→"abc 商事")、空のキーワードと、正規化で先のキーワードと等しくなったキーワードは書かない。そのためparseQuerySetsは検索語からちょうどこれらのキーワードを読む。引用符の無いテキストは、空白で区切った各語を上のとおり正規化して 1 度ずつ並べ、1 つの空白で区切ったものになる。
検索結果のハイライトと完全一致による並べ替えは、入力されたままのテキストからの 1 つの導出。createSearchQueryPattern(query) は getSearchQuerySpellings(query) が並べる表記 (テキストのキーワードとその正規化形のキーワードの各語に対する select-box の getNormalizedVariants。語は fuzzy-search の parseQuerySets で読むため、引用符は表記の一部にならない。どの仮名も半角の形を持つ表記には、続けて半角カナの表記を置く: タナカ の次に タナカ、サトウ の次に サトウ。保存されたフィールドに多い形) を、最長のものから大文字小文字を区別せずに探す。HighlightedText はその一致に mark 要素で印を付け (明るいテーマは黄色の上の暗い文字、dark クラスの下は暗い琥珀色の上の明るい文字。どちらも WCAG 2.2 AAA の 7:1 を超える)、createExactMatchRanker(query, fields) はいずれかのフィールドに一致を持つ結果を先頭へ移す (各グループ内ではあいまい検索の順序を保つ)。そのため、フィールドにハイライトが付くのは、そのフィールドがレコードを完全一致として並べさせるときちょうど。かなの読みに印が付くのはどのキーワードもローマ字として読めるときだけ。
入力されたままの検索語を使うのは、正規化が検索語の持つ表記を落とすことがあるため。全角/半角の統一 (Unicode の NFKC) は ㈱ を (株) と読むので、㈱山田 は検索語 (株)山田 になるが、ハイライトと並べ替えは ㈱ で保存された会社の ㈱山田 を探し続ける。検索のアウトレットコンテキストは両方を運ぶ: searchQuery (入力されたまま) と searchTerm (normalizeSearchInput(searchQuery))。
Localization / UI 言語
Every string the client components render on their own (tabs, buttons, headings, status badges, column headers, empty states, aria-labels, the settings and statistics panels, and the fallback names of tree nodes without a company or a person name) comes from one label catalog per UI chrome locale. The embedded @aiquants/virtualscroll list and @aiquants/directory-tree tree receive the same locale and their part of the labels, and the tree Web Worker receives the fallback names, so the whole feature speaks one language.
クライアントコンポーネントが自身で描画する全文言 (タブ、ボタン、見出し、状態バッジ、列見出し、空状態、aria-label、設定 / 統計パネル、会社名や氏名の無いツリーノードの代替名) は UI クロームロケールごとの 1 つのラベルカタログから取得。埋込みの @aiquants/virtualscroll 一覧と @aiquants/directory-tree ツリーには同じロケールと対応部分のラベルを、ツリー Web Worker には代替名を渡すため、機能全体が 1 つの言語で表示される。
- Supported locales:
"en"(default) and"ja". There is no language negotiation: mapnavigator.language(or anything else) to one of them in the host. - 対応ロケールは
"en"(既定) と"ja"。言語ネゴシエーションは行わないため、navigator.language等からの対応付けはホスト側で行う。
Props / 設定項目
| Surface | Prop | Type | Notes |
| --- | --- | --- | --- |
| ContactMapConfigProvider | config | ContactMapConfig | A plain object (TypeError otherwise: [contact-map] config must be a plain object; got <value>). Only the documented settings it owns are read, once per render; the views read the frozen copy without a prototype (useContactMapConfig()), the same shape outside any provider, kept while the settings' values stay the same. |
| ContactMapConfigProvider | config.streamEndpoint | string | Same-origin absolute path of the NDJSON stream. No default; it or the view's streamEndpoint must be set (see Stream Endpoint). |
| ContactMapConfigProvider | config.locale | ContactMapLocale ("en" \| "ja") | Omitted ⇒ "en". At mount a non-string throws a TypeError and any other string a RangeError, both naming locale. |
| ContactMapConfigProvider | config.labels | ContactMapLabelOverrides | Partial overrides over CONTACT_MAP_LABEL_CATALOGS[locale], validated at mount; errors name labels. |
| ContactMapConfigProvider | config.openEmailsWithGmail | boolean | Initial value of the settings tab's Gmail toggle; omitted ⇒ false. While it is on, an email address links to Gmail's web compose window instead of mailto:, and double-clicking (or double-tapping) an address in the popup opens it. |
| ContactMapConfigProvider | config.treeWorkerFactory | () => Worker | Builds the tree Worker for a bundler that needs its own construction; every call must return a new Worker, and each hook then has its own session (see the tree paragraphs above). |
| ContactMapConfigProvider | config.workerUrls | ContactMapWorkerUrls | A plain object of only indexWorker, levenshteinWorker and treeWorker, each left out or a string with non-whitespace; checked in every render, a server render included (assertContactMapWorkerUrls: TypeError / RangeError); the views read only the checked copy. One treeWorker per browser tab (see the tree paragraph above). |
| ContactMapAppView | streamEndpoint | string | When given it wins over the surrounding provider's streamEndpoint. |
| ContactMapAppView | locale | ContactMapLocale | When given it wins over the surrounding provider's locale; its errors name ContactMapAppView locale. |
| ContactMapAppView | labels | ContactMapLabelOverrides | When given the whole object replaces the surrounding provider's labels (no key-wise merge); its errors name ContactMapAppView labels. |
| ContactMapAppView | workerUrls | ContactMapWorkerUrls | Checked like config.workerUrls, then layered over the surrounding provider's key by key: a key left out or undefined keeps the provider's value (layerContactMapWorkerUrls). |
| ContactMapAppView | title | string | Per-instance heading; omitted ⇒ the appTitle label. |
| ContactMapSearchPage | workerUrls | { indexWorker?, levenshteinWorker? } | Layered over the provider's like the view's; a treeWorker with a value throws a RangeError that says to set it on the provider or the view (treeWorker: undefined counts as left out). |
| SwipeOverlay | title | string | Per-instance dialog name; omitted ⇒ the overlayTitle label. |
Components rendered outside any provider use the en catalog. useContactMapLabels() returns the resolved labels of the nearest provider, for host chrome that should speak the same language (for example the component a custom renderHeader renders).
プロバイダー外で描画したコンポーネントは en カタログを使う。useContactMapLabels() は直近プロバイダーの解決済みラベルを返すフックで、同じ言語で表示したいホスト側のクローム (独自 renderHeader が描画するコンポーネントなど) 向け。
import { ContactMapAppView } from "@aiquants/contact-map/client"
export default function ContactMapRoute() {
return <ContactMapAppView streamEndpoint="/api/contact_map/stream" locale="ja" labels={{ appTitle: "連絡先マップ" }} />
}Resolver contract / 解決規則
resolveContactMapLabels(locale, overrides, surface?) is the single fold point (the provider calls it once per change; surface names the two settings in its errors, locale / labels when left out):
resolveContactMapLabels(locale, overrides, surface?) が唯一の畳込み点 (プロバイダーは変化時にだけ呼ぶ。surface は例外が 2 つの設定を呼ぶ名前で、省略時は locale / labels):
overridesomitted ⇒ the frozen catalog itself (same identity).overridesmust be a plain object (prototypeObject.prototypeornull); anything else (null, a primitive, an array, a class instance, an object inheriting from another object) throws aTypeError. Only own properties are read, each exactly once, and the label bag is built from the values that were checked.- An own key outside
CONTACT_MAP_LABEL_KEYSthrows a shortRangeErrorthat suggests the nearest key ([contact-map] labels has a key that is not a label key (did you mean "searchTab"? CONTACT_MAP_LABEL_KEYS lists every key); got "serchTab"). - A string key must be a string (else a
TypeError) with non-whitespace content (else aRangeError); a function key (the keys shown with arguments in the table below) must be a function (else aTypeError). An explicitundefinedkeeps the catalog value. - Every error the package throws starts with
[contact-map], with one exception: a cancelled query (countActiveContactsQuery,getContactsStreamChunkQuery) rejects with the standardDOMExceptionnamedAbortError, asfetchdoes, so tell a cancellation byerror.name === "AbortError". - A setting check (
locale,labels,streamEndpoint,workerUrls, the server config, row counts, the resume cursor given togetContactsStreamChunkQueryorcreateContactMapServer().getContactsStreamChunk(both namedgetContactsStreamChunkQuery cursorin the message), tree names) throws aTypeErrorfor a value of the wrong type or shape, a missing required setting included, and aRangeErrorfor a value of the right type that is not allowed, a key the setting does not take included. It names the setting as the surface calls it (labels,ContactMapAppView labels,server config firstBatchSize), and it ends with; got <value>: the rejected value, or the rejected key, written the same way everywhere (a quoted string,undefined,null,-0,10n,Symbol(x),[object URL]). - Two views of one browser tab that disagree on
streamEndpointorworkerUrls.treeWorker, and a CommonJS build withoutworkerUrls.treeWorker, throw a plainError(no single value is wrong). - A unit key (
recordUnit,companyUnit,resultUnit,timesUnit,itemsUnit,charactersUnit) receives the number exactly as it is displayed ("1","1,001","1.0"), so the English singular / plural follows what the reader sees: only a displayed"1"is singular ("1.0 items").timesUnit/itemsUnit/charactersUnit/throughputUnitare appended to the number without a separator and carry their own leading space. - The embedded keys (
VIRTUAL_SCROLL_LABEL_KEYSof@aiquants/virtualscrolland the row kind words of@aiquants/directory-tree, togetherDIRECTORY_TREE_LABEL_KEYS) are checked here under the same rule before they are handed to those packages, so their errors follow the package's rule too. - The result is frozen. The list VirtualScroll receives exactly the keys of
VIRTUAL_SCROLL_LABEL_KEYSand the DirectoryTree exactly those ofDIRECTORY_TREE_LABEL_KEYS, both kept value-stable, so an inlinelabelsobject recreated on every render does not rebuild the embedded scroll chrome. overrides未指定なら凍結カタログそのもの (同一 identity) を返す。overridesは素の object (プロトタイプがObject.prototypeかnull) に限り、それ以外 (null・プリミティブ・配列・クラスインスタンス・他 object 継承) はTypeError。読むのは自身のプロパティのみで、各プロパティをちょうど 1 回読み、ラベル集合は検査した値そのものから作る。CONTACT_MAP_LABEL_KEYS外の自身のキーは、最も近いキーを示す短いRangeError([contact-map] labels has a key that is not a label key (did you mean "searchTab"? CONTACT_MAP_LABEL_KEYS lists every key); got "serchTab")。- 文字列キーは文字列 (でなければ
TypeError) で空白以外を含むこと (でなければRangeError)、関数キー (下表で引数付きのキー) は関数であること (でなければTypeError) が必須。明示undefinedはカタログ値のまま。 - パッケージが投げる例外は全て
[contact-map]で始まる。例外は 1 つで、取り消した照会 (countActiveContactsQuery・getContactsStreamChunkQuery) はfetchと同じく名前がAbortErrorの標準のDOMExceptionで拒否するので、取消はerror.name === "AbortError"で見分ける。 - 設定の検査 (
locale・labels・streamEndpoint・workerUrls・サーバーの設定・行数・getContactsStreamChunkQueryかcreateContactMapServer().getContactsStreamChunkに渡す再開カーソル (文言ではどちらもgetContactsStreamChunkQuery cursor)・代替名) は、型や形の誤り (必須の設定が無い場合を含む) をTypeError、型は合うが許されない値 (設定が受け取らないキーを含む) をRangeErrorにする。設定は面の呼び名で名指し (labels・ContactMapAppView labels・server config firstBatchSize)、末尾は; got <値>で、拒否した値か拒否したキーをどこでも同じ表記で書く (引用符付きの文字列・undefined・null・-0・10n・Symbol(x)・[object URL])。 - ブラウザのタブの 2 つのビューが
streamEndpointかworkerUrls.treeWorkerで食い違う場合と、workerUrls.treeWorkerの無い CommonJS のビルドは、どの値 1 つが誤りでもないので素のError。 - 単位キー (
recordUnit、companyUnit、resultUnit、timesUnit、itemsUnit、charactersUnit) は表示どおりの数値文字列 ("1"、"1,001"、"1.0") を受け取るため、英語の単数 / 複数は読み手が見る値に従う (単数は表示"1"のときだけで"1.0 items")。timesUnit/itemsUnit/charactersUnit/throughputUnitは数値の直後に区切りなしで連結されるため、先頭の空白は単位側が持つ。 - 埋込みキー (
@aiquants/virtualscrollのVIRTUAL_SCROLL_LABEL_KEYSと@aiquants/directory-treeの行種別語。合わせてDIRECTORY_TREE_LABEL_KEYS) も、それらのパッケージへ渡す前にここで同じ規則で検査するので、その例外もパッケージの規則に従う。 - 結果は凍結される。一覧の VirtualScroll には
VIRTUAL_SCROLL_LABEL_KEYSのキーだけ、DirectoryTree にはDIRECTORY_TREE_LABEL_KEYSのキーだけを値安定のまま渡すため、毎レンダー作り直すインラインlabelsでも埋込みスクロールのクロームは再構築されない。
Chrome locale and number formatting / クロームロケールと数値書式
locale is the UI chrome language ("en" | "ja"), not a BCP 47 tag. contact-map has no separate formatting-locale prop (formatLocale): the chrome locale also fixes digit grouping through a spec-defined mapping, en → "en-US" and ja → "ja-JP". Every count the package renders (annotation, registered / result counts, tree node counts, member counts in the tree, statistics) is grouped with that tag, never with the runtime default locale, so the server render and the browser render agree.
locale は UI クロームの言語 ("en" | "ja") で BCP 47 タグではない。contact-map には書式ロケール専用の prop (formatLocale) は無く、クロームロケールが仕様で定めた対応 (en → "en-US"、ja → "ja-JP") で桁区切りも決める。パッケージが描画する件数 (注記、登録件数 / 検索結果件数、ツリーのノード数・所属人数、統計) はすべてこのタグで桁区切りし、実行環境の既定ロケールは使わないため、サーバー描画とブラウザ描画が一致する。
Dates / 日時
A record's timestamps (contacted_at, created_at, updated_at) are written as the database stores them. The driver reads a zone-less datetime2 value as UTC (the mssql driver's default, useUTC: true), so the stream carries a stored 2026-10-03 09:00:00 as 2026-10-03T09:00:00.000Z, and the default recordDateTime / recordDate labels (formatStoredDateTime / formatStoredDate, exported from @aiquants/contact-map) write its UTC fields without a zone (2026-10-03 09:00:00, 2026-10-03), the same in every locale and in every browser time zone.
The statistics panel's "Statistics updated at" is a moment of the browser session, so its default statsTimestamp label (formatLocalDateTime) writes the browser's local time. A host overrides any of the three through labels (each receives a Date); a timestamp that is not a date is shown as it came, and a missing one as -.
レコードの日時 (contacted_at・created_at・updated_at) はデータベースが保存しているとおりに書く。ドライバはゾーンの無い datetime2 の値を UTC として読む (mssql のドライバの既定 useUTC: true) ので、ストリームは保存された 2026-10-03 09:00:00 を 2026-10-03T09:00:00.000Z として運び、既定の recordDateTime / recordDate ラベル (formatStoredDateTime / formatStoredDate。@aiquants/contact-map から公開) は UTC の各欄をゾーンを付けずに書く (2026-10-03 09:00:00・2026-10-03)。どのロケールでも、ブラウザのどの時間帯でも同じ。統計パネルの「統計更新時刻」はブラウザのセッションの中の時刻なので、既定の statsTimestamp ラベル (formatLocalDateTime) はブラウザの現地時刻で書く。ホストは 3 つのどれも labels で上書きできる (どれも Date を受け取る)。日時として読めない値は届いたまま、無い値は - と表示する。
Tree fallback names / ツリーの代替名
The tree is built in a Web Worker by the isomorphic IncrementalTreeBuilder, which cannot receive functions, so the locale reaches it as data:
ツリーは Web Worker 上で同型の IncrementalTreeBuilder が組み立て、関数を受け取れないため、ロケールはデータとして届く:
useTreeWorkerreads the nearest provider's labels and sends{ uncategorizedCompany, unnamedPerson: noName }(ContactMapTreeNaming) in theRESET_TREEmessage that opens every worker generation. When the names change (a locale switch or alabelsoverride), the session opens a new generation and re-posts every record, so no node keeps a name in the previous language.- A contact without a company is placed under the
uncategorizedCompanynode ("Uncategorized"/"未分類"); a person without a name is callednoName("(No Name)"/"名前未設定") on every path — the tree row, its accessible name, the list, the detail panel and the popup. - Directory entries carry the bare company / department
nameplusmemberCount; the client composes the display name with thetreeDirectoryName(name, memberCount)label, so the member count is grouped in the chrome locale and a host can override the format. IncrementalTreeBuilder/buildDirectoryTreetake the names as a required argument and reject a non-string name with aTypeErrorand a blank one with aRangeError(assertContactMapTreeNaming).useTreeWorkerは直近プロバイダーのラベルを読み、各 Worker 世代を開くRESET_TREEメッセージで{ uncategorizedCompany, unnamedPerson: noName }(ContactMapTreeNaming) を送る。代替名が変わると (ロケール切替やlabelsの上書き) セッションは新しい世代を開いて全レコードを再送するため、旧言語の名前を持つノードは残らない。- 会社名の無い連絡先は
uncategorizedCompanyのノード ("Uncategorized"/"未分類") の下に置き、氏名の無い人物はツリー行・そのアクセシブルネーム・一覧・詳細パネル・ポップアップのすべてでnoName("(No Name)"/"名前未設定") と呼ぶ。 - ディレクトリエントリは会社名 / 部署名そのもの (
name) とmemberCountを持ち、表示名はクライアントがtreeDirectoryName(name, memberCount)ラベルで組み立てる (所属人数をクロームロケールで桁区切りし、書式をホストが上書きできる)。 IncrementalTreeBuilder/buildDirectoryTreeは代替名を必須引数で受け取り、文字列以外の代替名はTypeError、空白だけの代替名はRangeError(assertContactMapTreeNaming)。
Not part of the catalog / カタログ対象外:
- Raw record field names shown as-is in the detail panel's metadata tab (
person_id,is_active, ...) and the score chip abbreviationsB:/F:are identifiers, not language. - 詳細パネルのメタ情報タブに生のまま表示するレコード項目名 (
person_id、is_activeなど) とスコア表示の略号B:/F:は識別子であり言語ではない。
Catalog / カタログ
Function keys are shown with their arguments; the cells show sample output (count = 2, displayed = "2", loaded = 1200, name = "<name>", memberCount = 1200, fieldLabel = "<label>", value = new Date("2026-10-03T09:00:00Z") in a browser at UTC+9).
関数キーは引数付きで示し、セルには出力例 (count = 2、displayed = "2"、loaded = 1200、name = "<name>"、memberCount = 1200、fieldLabel = "<label>"、UTC+9 のブラウザで value = new Date("2026-10-03T09:00:00Z")) を載せる。
| Key | en | ja |
| --- | --- | --- |
| scrollUp | "Scroll up" | "上へスクロール" |
| scrollDown | "Scroll down" | "下へスクロール" |
| scrollLeft | "Scroll left" | "左へスクロール" |
| scrollRight | "Scroll right" | "右へスクロール" |
| verticalScrollBar | "Vertical" | "縦方向" |
| horizontalScrollBar | "Horizontal" | "横方向" |
| verticalScrollThumb | "Vertical scroll position" | "縦スクロール位置" |
| horizontalScrollThumb | "Horizontal scroll position" | "横スクロール位置" |
| scrollToTop | "Top" | "先頭へ" |
| scrollToBottom | "Bottom" | "末尾へ" |
| noItems | "No items" | "項目がありません" |
| entryKindDirectory | "directory" | "ディレクトリ" |
| entryKindFile | "file" | "ファイル" |
| appTitle | "Contact Map" | "Contact Map" |
| annotationTotalLabel | "Total:" | "総件数:" |
| annotationCompaniesLabel | "Companies:" | "企業数:" |
| annotationStreamingProgress({ loaded, total, percent }) | "Streaming (1,200 / 4,800 · 25%)" | "ストリーミング読み込み中 (1,200 / 4,800 件・25%)" |
| annotationStreamingCount(loaded) | "Streaming (1,200 loaded)" | "ストリーミング読み込み中 (1,200 件)" |
| annotationLoadFailed(loaded) | "Failed to load (1,200 received)" | "読み込みに失敗しました (1,200 件受信済)" |
| annotationReconnecting(loaded) | "Reconnecting (1,200 received)" | "再接続中 (1,200 件受信済)" |
| annotationAuthRequired | "Session expired: sign in again, then retry" | "セッションが切れました。サインインし直してから再試行してください" |
| annotationRetry | "Retry" | "再試行" |
| recordUnit(displayed) | "records" | "件" |
| companyUnit(displayed) | "companies" | "社" |
| searchTab | "Fuzzy search" | "あいまい検索" |
| searchSettings | "Search settings" | "検索設定" |
| searchStatistics | "Search statistics" | "検索統計" |
| operatorAndTitle | "AND search (all terms match)" | "AND 検索 (すべて一致)" |
| operatorOrTitle | "OR search (any term matches)" | "OR 検索 (いずれか一致)" |
| operatorAnd | "AND" | "AND" |
| operatorOr | "OR" | "OR" |
| operatorAndHint | "All terms" | "全語一致" |
| operatorOrHint | "Any term" | "いずれか一致" |
| searchPlaceholder | "Fuzzy search..." | "あいまい検索..." |
| searchBusyTitle | "Searching" | "検索処理中" |
| clearSearch | "Clear search" | "検索をクリア" |
| searching | "Searching..." | "検索中..." |
| registeredCountLabel | "Registered:" | "登録件数:" |
| rebuildingIndex | "Rebuilding index..." | "インデックス再構築中..." |
| errorLabel | "Error:" | "エラー:" |
| resultCountLabel | "Results:" | "検索結果:" |
| resultUnit(displayed) | "matches" | "件" |
| listViewTab | "List" | "一覧" |
| treeViewTab | "Tree" | "ツリー" |
| columnNo | "No" | "No" |
| columnId | "ID" | "ID" |
| columnScore | "Score" | "スコア" |
| columnName | "Name" | "氏名" |
| columnTitle | "Title" | "役職" |
| columnCompany | "Company" | "会社名" |
| columnDepartment | "Department" | "部署" |
| columnPhone | "Phone" | "電話番号" |
| columnEmail | "Email" | "メール" |
| columnUpdatedAt | "Updated" | "更新日時" |
| listRowNumber(rowNumber) | "No. 3" | "No. 3" |
| listNoSearchResults | "No search results found" | "検索結果が見つかりません" |
| listNoData | "No data" | "データがありません" |
| listResizeHandle | "Resize the border between the contact list and the detail pane" | "連絡先一覧と詳細ペインの境界のサイズ変更ハンドル" |
| detailHeading | "Contact details" | "連絡先詳細" |
| close | "Close" | "閉じる" |
| noName | "(No Name)" | "名前未設定" |
| treeNodeCountTitle | "Number of nodes built in the tree view" | "ツリービューで構築された全ノード数" |
| treeNodeCountLabel | "Nodes:" | "ノード:" |
| treeNodeCount(count) | "1,200" | "1,200 個" |
| uncategorizedCompany | "Uncategorized" | "未分類" |
| treeDirectoryName(name, memberCount) | "<name> (1,200)" | "<name> (1,200)" |
| treeStreamInterruptedTitle | "Streaming was interrupted. Only the data received so far is shown" | "ストリーミングが中断されました。受信済みデータのみ表示しています" |
| treeStreamInterrupted | "Interrupted" | "中断" |
| treeStreamLoadingTitle | "Receiving data streamed from the network" | "ネットワークからのデータストリーミングを受信中" |
| treeStreamLoading | "Loading..." | "読み込み中..." |
| treeBuildingTitle | "Building and expanding the directory tree from the received data" | "受取データを基にディレクトリツリー構造を最終構築・展開中" |
| treeBuilding | "Treeing..." | "ツリー構築中..." |
| treeCompleteTitle | "Every tree node has been built" | "ツリーノードの全件構築が完了しました" |
| treeComplete | "Complete" | "完了" |
| treeShowEntireCompanyTitle | "Show the whole company of every search hit" | "検索ヒットした会社全体を表示" |
| treeShowEntireCompany | "Whole company" | "全体表示" |
| treeExpandAll | "Expand all directories" | "全ディレクトリを展開" |
| treeExpandAllTitle | "Expand all" | "すべて展開" |
| treeCollapseAll | "Collapse all directories" | "全ディレクトリを折りたたみ" |
| treeCollapseAllTitle | "Collapse all" | "すべて折りたたみ" |
| treeNoMatches | "No contacts match the criteria" | "条件に一致する連絡先が見つかりません" |
| treeEmpty | "No contacts to display" | "表示する連絡先がありません" |
| treeResizeHandle | "Resize the border between the directory tree and the detail pane" | "ディレクトリツリーと詳細ペインの境界のサイズ変更ハンドル" |
| copyField(fieldLabel) | "Copy <label>" | "「<label>」をコピー" |
| detailEmptyTitle | "Select a contact to see its details" | "連絡先を選択すると詳細が表示されます" |
| detailEmptyHint | "Choose a person from the list" | "一覧から対象の人物を選択してください" |
| detailTabSummary | "Summary" | "サマリー" |
| detailTabBasic | "Basic info" | "基本情報" |
| detailBasicProfile | "Profile" | "基本プロフィール" |
| detailTabContact | "Contact" | "連絡先" |
| detailTabMeta | "Metadata" | "メタ情報" |
| preferred | "Preferred" | "優先" |
| summaryEmail | "Email" | "メール" |
| summaryPhone | "Phone" | "電話" |
| summaryLastContact | "Last contact" | "最終接触" |
| sectionEmail | "Email" | "メール" |
| sectionEmailEmpty | "No email addresses registered" | "登録されたメールアドレスはありません" |
| sectionPhone | "Phone / Fax" | "電話・FAX" |
| sectionPhoneEmpty | "No phone numbers registered" | "登録された電話番号はありません" |
| fieldFullName | "Name" | "氏名" |
| fieldFullNameReading | "Name (reading)" | "氏名 (かな)" |
| fieldTitle | "Title" | "役職" |
| fieldDepartment | "Department" | "部署" |
| fieldCompanyName | "Company" | "会社名" |
| fieldCompanyNameReading | "Company (reading)" | "会社名 (かな)" |
| fieldAddress | "Address" | "住所" |
| fieldPostalCode | "Postal code" | "郵便番号" |
| fieldUrl | "URL" | "URL" |
| fieldCustomEmail | "Custom email" | "カスタムメール" |
| fieldEmail | "Email" | "メール" |
| fieldCustomMobilePhone | "Custom mobile" | "カスタム携帯番号" |
| fieldMobilePhone | "Mobile" | "携帯番号" |
| fieldCompanyPhone | "Company phone" | "会社電話番号" |
| fieldCompanyFax | "Company fax" | "会社 FAX 番号" |
| fieldCreatedBy | "Created by" | "作成者" |
| fieldCreatedAt | "Created at" | "作成日時" |
| fieldUpdatedBy | "Updated by" | "更新者" |
| fieldUpdatedAt | "Updated at" | "更新日時" |
| recordDateTime(value) | "2026-10-03 09:00:00" | "2026-10-03 09:00:00" |
| recordDate(value) | "2026-10-03" | "2026-10-03" |
| popupCustomMobilePhone | "Mobile (custom)" | "携帯電話 (カスタム)" |
| popupMobilePhone | "Mobile" | "携帯電話" |
| popupCustomCompanyPhone | "Company phone (custom)" | "会社電話 (カスタム)" |
| popupCompanyPhone | "Company phone" | "会社電話" |
| popupCustomEmail | "Email (custom)" | "メールアドレス (カスタム)" |
| popupEmail | "Email" | "メールアドレス" |
| popupGmailHint | "Double-click or double-tap to open Gmail" | "ダブルクリックまたは二度タップで Gmail を開きます" |
| popupLastContactDate | "Last contact:" | "最終コンタクト日:" |
| popupNoContactMethods | "No contact information to display" | "表示できる連絡先情報がありません" |
| overlayTitle | "Details" | "詳細" |
| overlayBackdrop | "Tap the background to close" | "背景をタップして閉じる" |
| settingsResetAllTitle | "Reset all settings" | "すべての設定をリセット" |
| settingsResetAll | "Reset all" | "すべてリセット" |
| settingsTabsLabel | "Search option tabs" | "検索オプション設定タブ" |
| settingsBasicTab | "Basic settings" | "基本設定" |
| settingsAdvancedTab | "Advanced settings" | "詳細設定" |
| indexWorkerName | "Index Worker" | "Index Worker" |
| levenshteinWorkerName | "Levenshtein Worker" | "Levenshtein Worker" |
| currentValueLabel | "Current:" | "現在値:" |
| resetToDefaultsTitle | "Reset to defaults" | "デフォルト値にリセット" |
| reset | "Reset" | "リセット" |
| relevanceOnlyNotice | "Editable only when sorting by relevance" | "Relevance ソート時のみ変更可能" |
| relevanceWeightsHeading | "Relevance score weights" | "Relevance スコア重み" |
| fieldWeight | "Field weight" | "フィールド重み" |
| scoreTuningHeading | "Score tuning" | "スコア調整" |
| maxResults | "Maximum results" | "最大結果数" |
| noLimit | "No limit" | "上限なし" |
| basicThresholdHeading | "Final similarity threshold" | "最終類似度閾値" |
| basicThresholdLabel | "Adjust the minimum score" | "スコアの下限を調整" |
| basicThresholdRangeRelevance | "In relevance mode a score can exceed 1.0 (range: 0.0-1.5)" | "Relevance モードではスコアが 1.0 を超える場合があります (範囲: 0.0-1.5)" |
| basicThresholdRange | "Range: 0.0-1.0" | "範囲: 0.0-1.0" |
| basicDisplayHeading | "Display options" | "表示オプション" |
| basicCaseSensitive | "Case-sensitive" | "大文字小文字を区別" |
| basicHighlight | "Highlight matches" | "ハイライト表示" |
| basicOpenEmailsWithGmail | "Open email addresses in Gmail compose" | "メールアドレスを Gmail の作成画面で開く" |
| basicMultiTermOperator | "Operator for words inside quotes" | "引用符の中の語の演算子" |
| basicOperatorOr | "OR (any word matches)" | "OR (いずれか一致)" |
| basicOperatorAnd | "AND (all words match)" | "AND (すべて一致)" |
| basicMultiTermHint | 'How words between quotation marks ("tanaka taro") are combined (default: AND). Words outside quotation marks are separate keywords combined by the AND / OR button' | '引用符で囲んだ語 ("田中 太郎") の結合方法 (既定は AND)。引用符の外の語はそれぞれ別のキーワードで、AND / OR ボタンで結合する' |
| basicSortHeading | "Sorting" | "ソート設定" |
| basicSortBy | "Sort by" | "ソート基準" |
| basicSortByScore | "Score" | "スコア" |
| basicSortByRelevance | "Relevance" | "関連性" |
| basicSortByOriginal | "Original order" | "元の順序" |
| basicSortOrder | "Sort order" | "ソート順序" |
| basicSortDescending | "Descending" | "降順" |
| basicSortAscending | "Ascending" | "昇順" |
| basicEngineHeading | "Search engine" | "検索エンジン設定" |
| basicStrategy | "Parallel search strategy" | "並列検索戦略" |
| basicStrategyBalanced | "Balanced" | "バランス" |
| basicStrategyIndexFirst | "Index first" | "Index 優先" |
| basicStrategyLevenshteinFirst | "Levenshtein first" | "Levenshtein 優先" |
| basicIndexFiltering | "Index filtering" | "Index フィルタリング" |
| basicLevenshtein | "Levenshtein distance" | "Levenshtein 距離計算" |
| advancedLearningHeading | "Learning weight" | "学習重み" |
| advancedLearningLabel | "Weight factor" | "重み係数" |
| advancedLearningHint | "Weight applied when re-learning (higher favors past search results)" | "再学習時の重み付け (大きいほど過去の検索結果を優先)" |
| advancedDebounceHeading | "Debounce time" | "デバウンス時間" |
| advancedDebounceLabel | "Input delay (ms)" | "入力遅延 (ms)" |
| advancedDebounceHint | "How long input is awaited. Shorter is more real-time, longer reduces load" | "入力の待ち時間。短いほどリアルタイム、長いほど負荷軽減" |
| indexSettingsHeading | "Index Worker settings" | "Index Worker 設定" |
| indexBuildHeading | "Index build" | "インデックス構築" |
| indexNgramSize | "N-gram size" | "N-gram サイズ" |
| indexNgramSizeHint | "How many characters each chunk holds. Smaller is faster but less accurate" | "文字列を何文字単位で分割するか。小さいほど高速だが精度低下" |
| indexMinNgramOverlap | "Minimum N-gram matches" | "最小 N-gram 一致数" |
| indexMinNgramOverlapHint | "Candidates with fewer matches are excluded" | "一致数がこの値を下回る候補は除外" |
| indexCandidateHeading | "Candidate filtering" | "候補絞り込み" |
| indexThreshold | "Index threshold" | "Index 閾値" |
| indexThresholdHint | "Lower bound of the Jaro-Winkler similarity (range: 0.0-1.0)" | "Jaro-Winkler 類似度の下限 (範囲: 0.0-1.0)" |
| indexNgramOverlapThreshold | "N-gram overlap threshold" | "N-gram 重複度閾値" |
| indexNgramOverlapThresholdHint | `"How much N-grams must overlap. Higher narrows candidates mo
