@aiquants/spreadsheet 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,8 +10,12 @@
10
10
  * PAINTED format = base ⊕ conditional formats), lazyCellText (the once-only text thunk = the
11
11
  * eighth read of a cell), resolveEditText (the editor seed — formula source or canonical text,
12
12
  * fail-fast on a formula that does not start with "=") and aspectCapabilities (the write gate's
13
- * single reader of the declared aspects). Every capability is a DECLARATION: absence is a code
14
- * branch that reproduces the v0.7 behaviour exactly, never a value fallback.
13
+ * single reader of the declared aspects). v0.10 adds the 11th optional member `isCellReadOnly`
14
+ * (a NON-aspect capability), the refusal context `ReadOnlyRejection` and the read-only helper
15
+ * quartet (readOnlyDeclared / isReadOnlyCell / rangesContainReadOnly / rectContainsReadOnly) —
16
+ * the ONLY production module where the member name appears (layer-2 census, ADR-27). Every
17
+ * capability is a DECLARATION: absence is a code branch that reproduces the previous behaviour
18
+ * exactly, never a value fallback.
15
19
  * グリッドと consumer の唯一のデータ境界 (v0.8 — ADR-13 リッチセル / ADR-16 書込語彙): 8 つの
16
20
  * 任意リッチセル能力読みを持つアダプタ型、グリッドが書くセル単位パッチ (CellPatch / CellEdit —
17
21
  * 未指定キーは「触らない」、`null` は「消す」)、全 applyEdits が受け取る判別可能な書込文脈
@@ -158,6 +162,41 @@ void plainContextNeverPaste
158
162
  // 混入いずれも即赤)
159
163
  export type EditRejection = { source: "edit"; editSession: "kept" | "discarded" } | { source: "paste" | "fill" | "delete" | "format" | "calc" | "annotate" }
160
164
 
165
+ /**
166
+ * Context of a READ-ONLY refusal (v0.10, delivered by onReadOnlyRejected): fired once per
167
+ * refused gesture — the adapter was never called, so this is neither an {@link EditRejection}
168
+ * (applyEdits never threw) nor an operation limit. `kind: "edit"` is a refused single-cell
169
+ * text-write entry or commit and carries the cell; `kind: "operation"` is a refused whole
170
+ * operation with its producer's own overlap-naive `cellCount` arithmetic (the same numbers the
171
+ * limit channel reports — never a dedup count).
172
+ * 読み取り専用拒否の文脈 (v0.10 — onReadOnlyRejected が届ける): 拒否されたジェスチャ毎に 1 回
173
+ * 発火。adapter は呼ばれていないため {@link EditRejection} (applyEdits の throw) でも操作上限
174
+ * でもない。`kind: "edit"` はセル単位テキスト書込の入口 / 確定の拒否でセル座標を運び、
175
+ * `kind: "operation"` は操作全体の拒否で、`cellCount` はプロデューサ自身の overlap-naive 算術
176
+ * (上限チャネルと同じ数 — dedup 数ではない)。
177
+ */
178
+ // 明示リテラル union で宣言する (api-extractor は Exclude<SpreadsheetOperation, ...> 等の lib 汎用型を
179
+ // d.ts ロールアップできない — EditRejection と同じ制約)。op の 7 リテラル (copy / cut / resize を除いた
180
+ // 集合 + "cut") と editSession の 3 リテラルは dataAdapter.spec.ts の型パリティガードが双方向に機械封印する
181
+ export type ReadOnlyRejection =
182
+ | {
183
+ readonly kind: "edit"
184
+ readonly cell: CellCoord
185
+ /**
186
+ * Fate of the edit session THIS refusal adjudicated: "none" = no session commit was
187
+ * adjudicated (no session existed at the refusing gate, or the gate does not commit
188
+ * sessions — an untouched session may still exist, e.g. the G15 list chord during a
189
+ * flipped session); "kept" / "discarded" = G16's caller-declared fate.
190
+ * この拒否が裁いた編集セッションの帰結。none = セッション確定を裁いていない。
191
+ */
192
+ readonly editSession: "none" | "kept" | "discarded"
193
+ }
194
+ | {
195
+ readonly kind: "operation"
196
+ readonly op: "cut" | "delete" | "paste" | "fill" | "format" | "calc" | "annotate"
197
+ readonly cellCount: number
198
+ }
199
+
161
200
  /**
162
201
  * The single data boundary between the grid and the consumer. Reads via getCellText (renderCell
163
202
  * — a SpreadsheetProps prop, not an adapter member — customizes only the presentation of that
@@ -344,6 +383,21 @@ export type SpreadsheetDataAdapter = {
344
383
  * PasteEditContext.conditionalFormats で届く)。未宣言 = base 書式をそのまま塗る。
345
384
  */
346
385
  getConditionalFormats?: () => readonly ConditionalFormatRule[]
386
+ /**
387
+ * Read-only declaration for one cell (v0.10 read-only cells). `true` = the cell can never be
388
+ * written by this grid: edit-mode entry is refused, an already-open session's commit re-checks
389
+ * this predicate live, and every write operation whose target set contains the cell is rejected
390
+ * WHOLE before the adapter is called. Consulted live per gesture (never memoised); publish
391
+ * changes via `dataVersion`. The grid only ever calls this with in-grid coordinates. Host cost
392
+ * budget: O(1) and allocation-free — the same budget as `getCellText` (it also runs once per
393
+ * painted read-only-capable cell). Absence = every cell editable = the previous version's
394
+ * behaviour byte-identically.
395
+ * 読み取り専用宣言 (v0.10)。true のセルは編集モードに入れず、開いているセッションの確定も
396
+ * ライブ再検査され、対象に含む書込操作は adapter 到達前に全体拒否。ジェスチャ毎に生読み、
397
+ * 変更は dataVersion で公開。座標は常にグリッド内。ホスト側コストは O(1)・割当なし
398
+ * (getCellText と同格、描画パスでも呼ばれるため)。未宣言 = 全セル編集可 = 従来挙動とバイト同一。
399
+ */
400
+ isCellReadOnly?: (row: number, col: number) => boolean
347
401
  /**
348
402
  * OPTIONAL capability (v0.8): the numeric minimum / maximum over a range, for colorScale
349
403
  * rules whose end points omit `value`; null = the range holds no numeric value. Memoised by
@@ -476,3 +530,68 @@ export const aspectCapabilities = (adapter: SpreadsheetDataAdapter): AspectCapab
476
530
  cf: adapter.getConditionalFormats !== undefined,
477
531
  formula: adapter.getCellFormula !== undefined,
478
532
  })
533
+
534
+ /**
535
+ * Whether the read-only capability is declared (v0.10 ADR-27). Used only where
536
+ * declaration-presence itself is the semantic — the announcement payload key and the popover
537
+ * context key: a declared host gets an explicit `false` for editable cells, an undeclared host
538
+ * gets no key at all (byte-identical payloads).
539
+ * 読み取り専用能力が宣言されているか (v0.10 ADR-27)。宣言の有無そのものが意味になる場所
540
+ * (announcement payload キー / ポップオーバー context キー) 専用 — 宣言ホストは編集可セルに
541
+ * 明示 `false`、未宣言ホストはキー自体なし (payload はバイト同一)。
542
+ */
543
+ export const readOnlyDeclared = (adapter: SpreadsheetDataAdapter): boolean => adapter.isCellReadOnly !== undefined
544
+
545
+ /**
546
+ * Live read-only answer for one cell: `false` in O(1) without calling anything when the
547
+ * capability is absent (the byte-identity lever — every gate and stamp goes through this
548
+ * helper, never the member; v0.10 ADR-27 / ADR-31). A throw from the host predicate is a host
549
+ * bug and propagates (no try/catch — Strict No-Fallback).
550
+ * セル 1 つの読み取り専用ライブ判定: 能力未宣言なら何も呼ばず O(1) で `false` (バイト恒等の
551
+ * 支点 — 全ゲート / スタンプはメンバー直接読みでなく必ず本ヘルパを通る。v0.10 ADR-27 /
552
+ * ADR-31)。ホスト述語の throw はホストのバグとして素通し (try/catch しない — Strict No-Fallback)。
553
+ */
554
+ export const isReadOnlyCell = (adapter: SpreadsheetDataAdapter, row: number, col: number): boolean => adapter.isCellReadOnly?.(row, col) === true
555
+
556
+ /**
557
+ * Whether a grid-clamped rect contains at least one read-only cell: rectangular walk with
558
+ * short-circuit on first hit; `false` in O(1) when the capability is absent. For the
559
+ * paste / cut / cut-move rect walls (v0.10 ADR-27).
560
+ * グリッド内 clamp 済み矩形に読み取り専用セルが 1 つでも含まれるか: 最初のヒットで短絡する
561
+ * 矩形 walk。能力未宣言は O(1) で `false`。paste / cut / cut-move の矩形壁用 (v0.10 ADR-27)。
562
+ */
563
+ export const rectContainsReadOnly = (adapter: SpreadsheetDataAdapter, rect: CellRange): boolean => {
564
+ // 能力分岐: 未宣言は述語 0 呼びの O(1) false (v0.9 挙動とバイト同一)
565
+ const predicate = adapter.isCellReadOnly
566
+ if (predicate === undefined) {
567
+ return false
568
+ }
569
+ // 矩形 walk — 最初のヒットで短絡 (割当なし。上限ゲートが先に走るため walk は有界)
570
+ for (let row = rect.startRow; row <= rect.endRow; row += 1) {
571
+ for (let col = rect.startCol; col <= rect.endCol; col += 1) {
572
+ if (predicate(row, col)) {
573
+ return true
574
+ }
575
+ }
576
+ }
577
+ return false
578
+ }
579
+
580
+ /**
581
+ * Whether a range set contains at least one read-only cell: plain nested-loop enumeration with
582
+ * short-circuit on first hit and NO dedup (re-testing an overlapped cell of a pure predicate is
583
+ * harmless; a dedup Set would add O(cells) allocation inside a gate that must be
584
+ * allocation-free); `false` in O(1) when the capability is absent (v0.10 ADR-27).
585
+ * レンジ集合に読み取り専用セルが 1 つでも含まれるか: 素朴 2 重ループ列挙 + 最初のヒットで短絡、
586
+ * dedup なし (純述語の重複再検査は無害。dedup Set は割当ゼロであるべきゲートに O(セル数) の
587
+ * 割当を持ち込む)。能力未宣言は O(1) で `false` (v0.10 ADR-27)。
588
+ */
589
+ export const rangesContainReadOnly = (adapter: SpreadsheetDataAdapter, ranges: readonly CellRange[]): boolean => {
590
+ // 矩形 walk は rectContainsReadOnly と単一実装を共有 (SSOT — 能力分岐も同関数の冒頭 1 箇所)
591
+ for (const range of ranges) {
592
+ if (rectContainsReadOnly(adapter, range)) {
593
+ return true
594
+ }
595
+ }
596
+ return false
597
+ }
@@ -1,25 +1,82 @@
1
1
  /**
2
- * @fileoverview Frozen-axis coordinate partition (v0.5 plan §2 = columns; v0.6 plan §2 = rows).
3
- * The engine paints frozen leading cells at their raw tree coordinate and scroll-band cells at
4
- * tree − scroll, so a viewport ↔ index mapping splits at the frozen prefix size F_px: the
5
- * forward map is `i < F ? offsetOf(i) : offsetOf(i) − scroll`, the inverse map (guarded on
6
- * F_px > 0) is `F_px > 0 && p < F_px ? indexAt(p) : indexAt(p + scroll)`. Pure, AXIS-NEUTRAL
7
- * internals (frozenSizeOf / toViewportPos / viewportPosToIndex) with column- and row-named
8
- * thin wrappers — the freshness discipline (F/scroll from the handle) stays at the call sites.
2
+ * @fileoverview Frozen-axis coordinate partition, three-way (v0.9 plan §2.1; leading columns =
3
+ * v0.5 plan §2, leading rows = v0.6 plan §2). The engine paints leading frozen cells at their
4
+ * raw tree coordinate, trailing frozen cells anchored to the viewport far edge (extent), and
5
+ * scroll-band cells at tree − scroll — so the viewport ↔ index mapping splits three ways with
6
+ * the branch precedence LEADING > TRAILING > MIDDLE (ADR-19-4: at overlap a pixel resolves to
7
+ * the band that paints there). Pure, AXIS-NEUTRAL internals (frozenSizeOf / trailingSizeOf /
8
+ * toViewportPos / viewportPosToIndex) with column- and row-named thin wrappers taking one
9
+ * REQUIRED per-axis context object (AxisForwardCtx / AxisInverseCtx — no optionals, no
10
+ * defaults; clean cut-over from the v0.8 positional form).
9
11
  *
10
- * 凍結軸の座標パーティション (v0.5 計画 §2 = 列、v0.6 計画 §2 = 行)。エンジンは先頭凍結セルを
11
- * 木座標の生値・スクロール帯セルを 木座標 − scroll で描くため、viewport ↔ index の写像は凍結
12
- * 接頭辞幅 F_px で分割される。純関数の**軸中立**内部 (frozenSizeOf / toViewportPos /
13
- * viewportPosToIndex) に列 / 行の名前付き薄ラッパーを重ねる — 鮮度規律 (F / scroll はハンドル
14
- * 読み) は呼び出しサイトの責務。
12
+ * Registered rules (v0.9 plan §2.1 — binding on every call site):
13
+ * 1. Ctx objects are built FRESH per judgment from `handle.getFrozenSize()` trailing fields +
14
+ * `handle.getViewportSize()`, INLINE at each judgment site — a shared cross-site ctx-builder
15
+ * helper is prohibited (the §5.3 census scanner enforces this mechanically).
16
+ * 2. The canonical source of `AxisInverseCtx.trailingSize` is the TREE field
17
+ * (`getFrozenSize().trailingWidth` / `.trailingHeight`) — never the vis field. The two are
18
+ * classification-equivalent in the inverse, which is exactly why the choice is NAMED here:
19
+ * the §5.3 scanner pins the source field text-level.
20
+ * 3. Extent-0 degenerate — explicit, not emergent: the trailing branch of BOTH maps carries
21
+ * `extent > 0`. Pre-measure / hidden-host judgments (`getViewportSize()` = {0, 0}) leave the
22
+ * trailing branches inert, so the inverse resolves `p ≥ F_px` scroll-relative and the
23
+ * forward maps trailing indices scroll-relative (never to the negative far-edge anchor
24
+ * 0 − tail suffix) — byte-matching v0.8. This is a degenerate-state definition in the style
25
+ * of the `frozenSize > 0` guard, NOT a fallback; the leading branch never reads extent and
26
+ * is untouched.
27
+ * 4. Behaviour pins: `p ≥ extent` with trailingSize > 0 clamps into the LAST trailing track
28
+ * (Excel nearest-visible mirror of the negative-p leading clamp); at trailingSize = 0 every
29
+ * p keeps the v0.4 / v0.5 scroll-relative form (T = 0 identity twin of the F_px = 0 guard);
30
+ * the boundary pixel `p = extent − trailingSize` classifies TRAILING (the `>=`, mirroring
31
+ * the leading p = W_F scroll-side pin — FZ21 class); round-trip domain exclusion: scroll
32
+ * tracks whose viewport px ≥ extent − T_px are outside the partition's domain (mirror of
33
+ * the `p < W_F` exclusion).
34
+ *
35
+ * 凍結軸の 3 分岐座標パーティション (v0.9 計画 §2.1。先頭列 = v0.5 計画 §2、先頭行 = v0.6
36
+ * 計画 §2)。エンジンは先頭凍結セルを木座標の生値、末尾凍結セルを viewport 遠端 (extent)
37
+ * アンカー、スクロール帯セルを 木座標 − scroll で描くため、viewport ↔ index の写像は
38
+ * 先頭 > 末尾 > 中央 の分岐優先順で 3 分割される (ADR-19-4: overlap 画素はそこに描画する帯へ
39
+ * 解決)。純関数の**軸中立**内部に、必須 ctx オブジェクト (AxisForwardCtx / AxisInverseCtx —
40
+ * optional / default なし、v0.8 位置引数形からのクリーンカットオーバー) を取る列 / 行の
41
+ * 名前付き薄ラッパーを重ねる構成。登記規則 4 点: (1) ctx は判断毎にハンドル読みからその場
42
+ * インライン構築 (跨サイト共有ビルダ禁止 — §5.3 スキャナが機械強制)、(2) 逆写像 trailingSize
43
+ * の正準は TREE フィールド (`trailingWidth` / `trailingHeight` — vis 代替は分類等価ゆえ命名で
44
+ * ピン)、(3) extent-0 縮退は明示の縮退状態定義 (フォールバックではない — 順逆両写像の末尾分岐
45
+ * のみ不活性で順写像の末尾 index も scroll 相対、leading は extent を読まない)、(4) 挙動ピン
46
+ * (p ≥ extent は最終末尾トラックへクランプ /
47
+ * T = 0 恒等 / 境界画素は末尾分類 / 往復のドメイン除外)。
15
48
  */
16
49
 
17
50
  import type { AxisModel } from "./axisModel.ts"
18
51
 
19
52
  /**
20
- * Axis-neutral tree-prefix size of the frozen band (0 when frozen <= 0 or empty axis; totalSize
21
- * when frozen covers every track). Both frozenWidthOf and frozenHeightOf delegate here.
22
- * 軸中立の凍結帯木接頭辞サイズ (frozen ≤ 0 / 空軸は 0、全トラック凍結は totalSize)。
53
+ * REQUIRED forward-map context — count-named fields carry the unit (tracks, not px), keeping
54
+ * the type structurally disjoint from AxisInverseCtx (a wrong-object pass is a tsc error; a
55
+ * value transposition inside a correctly-typed literal is caught by the §5.2 mutation set +
56
+ * asymmetric-fixture discipline, not by the type system — stated honestly, v0.9 plan §1.3).
57
+ * `extent` is the viewport size on this axis from `handle.getViewportSize()` at judgment time.
58
+ * 必須の順写像 ctx — 件数系フィールド名が単位 (トラック数) を運び、AxisInverseCtx と構造的に
59
+ * 不交 (オブジェクト取り違えは tsc エラー。正しく型付いたリテラル内の値転置は §5.2 の変異集合 +
60
+ * 非対称フィクスチャ規律が守る — 型システムではない、v0.9 計画 §1.3)。`extent` は判断時
61
+ * `handle.getViewportSize()` のこの軸の viewport 寸法。
62
+ */
63
+ export type AxisForwardCtx = { scroll: number; frozenCount: number; trailingCount: number; extent: number }
64
+
65
+ /**
66
+ * REQUIRED inverse-map context — size-named fields carry the unit (logical px), structurally
67
+ * disjoint from AxisForwardCtx. `trailingSize` is canonically the TREE field
68
+ * (`getFrozenSize().trailingWidth` / `.trailingHeight` — registered rule 2 above); `extent` is
69
+ * the judgment-time viewport size on this axis.
70
+ * 必須の逆写像 ctx — 寸法系フィールド名が単位 (論理 px) を運び、AxisForwardCtx と構造的に不交。
71
+ * `trailingSize` の正準は TREE フィールド (`getFrozenSize().trailingWidth` / `.trailingHeight` —
72
+ * 上記登記規則 2)。`extent` は判断時のこの軸の viewport 寸法。
73
+ */
74
+ export type AxisInverseCtx = { scroll: number; frozenSize: number; trailingSize: number; extent: number }
75
+
76
+ /**
77
+ * Axis-neutral tree-prefix size of the leading frozen band (0 when frozen <= 0 or empty axis;
78
+ * totalSize when frozen covers every track). Both frozenWidthOf and frozenHeightOf delegate here.
79
+ * 軸中立の先頭凍結帯木接頭辞サイズ (frozen ≤ 0 / 空軸は 0、全トラック凍結は totalSize)。
23
80
  * frozenWidthOf / frozenHeightOf の共通実体。
24
81
  */
25
82
  const frozenSizeOf = (axis: AxisModel, frozen: number): number => {
@@ -30,71 +87,138 @@ const frozenSizeOf = (axis: AxisModel, frozen: number): number => {
30
87
  }
31
88
 
32
89
  /**
33
- * Axis-neutral forward map: index to viewport position. Frozen leading tracks are static; scroll
34
- * tracks subtract the scroll offset.
35
- * 軸中立の順写像: index → viewport 位置。先頭凍結トラックは静的、スクロールトラックは scroll を引く。
90
+ * Axis-neutral tree-suffix size of the trailing frozen band (0 when trailing <= 0 or empty
91
+ * axis; totalSize when trailing covers every track; else the tail prefix sum — hidden tracks
92
+ * contribute 0 px through the prefix-sum arithmetic). Both trailingWidthOf and
93
+ * trailingHeightOf delegate here (v0.9 plan §2.1).
94
+ * 軸中立の末尾凍結帯木接尾辞サイズ (trailing ≤ 0 / 空軸は 0、全トラック凍結は totalSize、
95
+ * それ以外は末尾接頭和 — 非表示トラックは接頭和算術を通じて 0 寄与)。trailingWidthOf /
96
+ * trailingHeightOf の共通実体 (v0.9 計画 §2.1)。
36
97
  */
37
- const toViewportPos = (axis: AxisModel, index: number, scroll: number, frozen: number): number => (index < frozen ? axis.offsetOf(index) : axis.offsetOf(index) - scroll)
98
+ const trailingSizeOf = (axis: AxisModel, trailing: number): number => {
99
+ if (trailing <= 0 || axis.count === 0) {
100
+ return 0
101
+ }
102
+ // 末尾接尾和 = totalSize − offsetOf(count − trailing) (末尾 T トラックの Σ — 導出: §2.1)
103
+ return trailing >= axis.count ? axis.totalSize() : axis.totalSize() - axis.offsetOf(axis.count - trailing)
104
+ }
38
105
 
39
106
  /**
40
- * Axis-neutral inverse map: viewport position to index. The frozen branch exists ONLY when
41
- * F_px > 0 — below F_px it resolves at the raw tree coordinate (negative p clamps into the
42
- * leading frozen track, Excel-like nearest visible); at/after F_px the scroll band continues at
43
- * tree = p + scroll. At F_px = 0 every p (incl. negative) keeps the v0.4 scroll-relative form
44
- * (guard prevents an unguarded p < 0 from sinking into indexAt(p) = track 0).
45
- * 軸中立の逆写像: viewport 位置 → index。凍結分岐は F_px > 0 のときのみ — F_px 未満は木座標の
46
- * 生値 (負 p は先頭凍結トラックへクランプ)、以上はスクロール帯 (木 = p + scroll)。F_px = 0 は
47
- * 負 p も含め全域 v0.4 形 (無ガードの p < 0 → indexAt(p) = トラック 0 の吸込を防ぐ)。
107
+ * Axis-neutral three-way forward map: index to viewport position (v0.9 plan §2.1). Leading
108
+ * frozen tracks are static at the raw tree offset; trailing frozen tracks are static anchored
109
+ * to the far edge (extent − tail suffix — scroll-independent); scroll tracks subtract scroll.
110
+ * Branch precedence LEADING > TRAILING > MIDDLE. trailingCount = 0 short-circuits to the exact
111
+ * v0.8 two-branch form (T = 0 identity as a capability branch); the trailing branch also
112
+ * carries `extent > 0` (registered rule 3 — pre-measure {0, 0} keeps trailing indices
113
+ * scroll-relative, byte-identical to v0.8, instead of the negative far-edge anchor).
114
+ * 軸中立の 3 分岐順写像: index → viewport 位置 (v0.9 計画 §2.1)。先頭凍結トラックは木座標の
115
+ * 生値で静的、末尾凍結トラックは遠端アンカー (extent − 末尾接尾和 — scroll 非依存) で静的、
116
+ * スクロールトラックは scroll を引く。分岐優先順は 先頭 > 末尾 > 中央。trailingCount = 0 は
117
+ * v0.8 の 2 分岐形へそのまま短絡 (T = 0 恒等 = 能力分岐)。末尾分岐は `extent > 0` も持つ
118
+ * (登記規則 3 — 未測定 {0, 0} では末尾 index も scroll 相対で v0.8 と byte 一致、負の
119
+ * 遠端アンカーへは写らない)。
48
120
  */
49
- const viewportPosToIndex = (axis: AxisModel, p: number, scroll: number, frozenSize: number): number => (frozenSize > 0 && p < frozenSize ? axis.indexAt(p) : axis.indexAt(p + scroll))
121
+ const toViewportPos = (axis: AxisModel, index: number, { scroll, frozenCount, trailingCount, extent }: AxisForwardCtx): number =>
122
+ index < frozenCount
123
+ ? // 先頭: 木座標の生値 (scroll 非依存の静的位置)
124
+ axis.offsetOf(index)
125
+ : trailingCount > 0 && extent > 0 && index >= axis.count - trailingCount
126
+ ? // 末尾: extent − 末尾接尾和 (totalSize − offsetOf(index) = index 以降の Σ)。遠端アンカーで静的 (ADR-19-4)
127
+ extent - (axis.totalSize() - axis.offsetOf(index))
128
+ : // 中央: 木座標 − scroll (v0.8 恒等形)
129
+ axis.offsetOf(index) - scroll
50
130
 
51
131
  /**
52
- * Tree-prefix width of the frozen band on this axis (0 when F <= 0; totalSize when F covers
53
- * every column). The render-surface twin of the engine's getFrozenSize().width — the pair-call
54
- * discipline keeps the two equal.
55
- * この軸の凍結帯の木接頭辞幅 (F ≤ 0 は 0、全列凍結は totalSize)。エンジン getFrozenSize().width
56
- * の render 面双子 — 対呼び出し規律が両者を一致させる。
132
+ * Axis-neutral three-way inverse map: viewport position to index (v0.9 plan §2.1). Precedence
133
+ * LEADING > TRAILING > MIDDLE (registered rule: at overlap the pixel resolves to the band that
134
+ * paints there). The leading branch exists only when frozenSize > 0 (negative p clamps into the
135
+ * leading track — Excel nearest visible); the trailing branch only when trailingSize > 0 AND
136
+ * extent > 0 (registered rule 3 — pre-measure {0, 0} keeps v0.8 conduct byte-identical; without
137
+ * the term every p ≥ −T_px would claim trailing); the boundary pixel p = extent − trailingSize
138
+ * classifies TRAILING (`>=` — FZ21 mirror), and p ≥ extent clamps into the LAST trailing track
139
+ * through indexAt's tree clamp (tree = totalSize + (p − extent) ≥ totalSize). Everything else
140
+ * continues scroll-relative at tree = p + scroll.
141
+ * 軸中立の 3 分岐逆写像: viewport 位置 → index (v0.9 計画 §2.1)。優先順は 先頭 > 末尾 > 中央
142
+ * (overlap 画素はそこに描画する帯へ解決 — 登記規則)。先頭分岐は frozenSize > 0 のときのみ
143
+ * (負 p は先頭トラックへクランプ — Excel 最寄り可視)、末尾分岐は trailingSize > 0 かつ
144
+ * extent > 0 のときのみ (登記規則 3 — 未測定 {0, 0} では v0.8 挙動と byte 一致。この項が
145
+ * なければ全 p ≥ −T_px が末尾を主張する)。境界画素 p = extent − trailingSize は末尾に分類
146
+ * (`>=` — FZ21 ミラー)、p ≥ extent は 木 = totalSize + (p − extent) ≥ totalSize となり
147
+ * indexAt の木クランプで最終末尾トラックへ着地。残余は 木 = p + scroll のスクロール相対。
148
+ */
149
+ const viewportPosToIndex = (axis: AxisModel, p: number, { scroll, frozenSize, trailingSize, extent }: AxisInverseCtx): number =>
150
+ frozenSize > 0 && p < frozenSize
151
+ ? // 先頭が最優先: 木座標の生値 (負 p は indexAt の下限クランプで先頭トラックへ)
152
+ axis.indexAt(p)
153
+ : trailingSize > 0 && extent > 0 && p >= extent - trailingSize
154
+ ? // 末尾が第二 (ADR-19-4): 木 = totalSize − (extent − p) (遠端からの残距離を木末尾から引く)
155
+ axis.indexAt(axis.totalSize() - (extent - p))
156
+ : // 中央が最後: 木 = p + scroll (v0.8 恒等形)
157
+ axis.indexAt(p + scroll)
158
+
159
+ /**
160
+ * Tree-prefix width of the leading frozen band on this axis (0 when F <= 0; totalSize when F
161
+ * covers every column). The render-surface twin of the engine's getFrozenSize().width — the
162
+ * pair-call discipline keeps the two equal.
163
+ * この軸の先頭凍結帯の木接頭辞幅 (F ≤ 0 は 0、全列凍結は totalSize)。エンジン
164
+ * getFrozenSize().width の render 面双子 — 対呼び出し規律が両者を一致させる。
57
165
  */
58
166
  export const frozenWidthOf = (axis: AxisModel, frozenCols: number): number => frozenSizeOf(axis, frozenCols)
59
167
 
60
168
  /**
61
- * Frozen-band tree-prefix HEIGHT H_F (row twin of frozenWidthOf — v0.6 plan §2).
62
- * 凍結帯の木接頭辞高 H_F (frozenWidthOf の行双子 — v0.6 計画 §2)。
169
+ * Leading frozen-band tree-prefix HEIGHT H_F (row twin of frozenWidthOf — v0.6 plan §2).
170
+ * 先頭凍結帯の木接頭辞高 H_F (frozenWidthOf の行双子 — v0.6 計画 §2)。
63
171
  */
64
172
  export const frozenHeightOf = (axis: AxisModel, frozenRows: number): number => frozenSizeOf(axis, frozenRows)
65
173
 
66
174
  /**
67
- * Forward map: column index to viewport x (the left edge). Frozen columns are static; scroll
68
- * columns subtract hx (v0.5 plan §2).
69
- * 順写像: 列 index → viewport x (左端)。凍結列は静的、スクロール列は hx を引く。
175
+ * Tree-suffix WIDTH W_T of the trailing frozen band (v0.9 plan §1.3). The render-surface twin
176
+ * of the engine's getFrozenSize().trailingWidth — feed it the clamped effective count
177
+ * (min(max(0, T), count − effectiveLeading) — leading wins, byte-matching the engine clamp).
178
+ * 末尾凍結帯の木接尾辞幅 W_T (v0.9 計画 §1.3)。エンジン getFrozenSize().trailingWidth の
179
+ * render 面双子 — クランプ済み実効件数 (min(max(0, T), count − effectiveLeading) —
180
+ * leading 勝ち、エンジンクランプと byte 一致) を渡す。
181
+ */
182
+ export const trailingWidthOf = (axis: AxisModel, trailingCols: number): number => trailingSizeOf(axis, trailingCols)
183
+
184
+ /**
185
+ * Trailing frozen-band tree-suffix HEIGHT H_T (row twin of trailingWidthOf — v0.9 plan §1.3).
186
+ * 末尾凍結帯の木接尾辞高 H_T (trailingWidthOf の行双子 — v0.9 計画 §1.3)。
187
+ */
188
+ export const trailingHeightOf = (axis: AxisModel, trailingRows: number): number => trailingSizeOf(axis, trailingRows)
189
+
190
+ /**
191
+ * Forward map: column index to viewport x (the left edge), three-way (v0.9 plan §2.1). Leading
192
+ * frozen columns static; trailing frozen columns anchored to ctx.extent; scroll columns
193
+ * subtract ctx.scroll. Takes the REQUIRED AxisForwardCtx built fresh at the judgment site
194
+ * (the extent > 0 term keeps pre-measure conduct byte-identical to v0.8 — registered rule 3).
195
+ * 順写像: 列 index → viewport x (左端) の 3 分岐 (v0.9 計画 §2.1)。先頭凍結列は静的、末尾
196
+ * 凍結列は ctx.extent アンカー、スクロール列は ctx.scroll を引く。判断サイトでその場構築した
197
+ * 必須 AxisForwardCtx を取る (extent > 0 項が未測定挙動を v0.8 と byte 一致に保つ — 登記
198
+ * 規則 3)。
70
199
  */
71
- export const colToViewportX = (axis: AxisModel, col: number, hx: number, frozenCols: number): number => toViewportPos(axis, col, hx, frozenCols)
200
+ export const colToViewportX = (axis: AxisModel, col: number, ctx: AxisForwardCtx): number => toViewportPos(axis, col, ctx)
72
201
 
73
202
  /**
74
- * Forward map: row index to viewport y (the top edge). Frozen rows static; scroll rows subtract vy (v0.6 plan §2).
75
- * 順写像: 行 index → viewport y (上端)。凍結行は静的、スクロール行は vy を引く (v0.6 計画 §2)。
203
+ * Forward map: row index to viewport y (the top edge) — row twin of colToViewportX (v0.9 plan §2.1).
204
+ * 順写像: 行 index → viewport y (上端) — colToViewportX の行双子 (v0.9 計画 §2.1)。
76
205
  */
77
- export const rowToViewportY = (axis: AxisModel, row: number, vy: number, frozenRows: number): number => toViewportPos(axis, row, vy, frozenRows)
206
+ export const rowToViewportY = (axis: AxisModel, row: number, ctx: AxisForwardCtx): number => toViewportPos(axis, row, ctx)
78
207
 
79
208
  /**
80
- * Inverse map: viewport x to column index. The frozen branch exists ONLY when W_F > 0: below
81
- * W_F the frozen band resolves at the raw tree coordinate (negative px clamps into the leading
82
- * frozen column — Excel-like nearest visible); at or right of W_F the scroll band continues at
83
- * tree = px + hx. At W_F = 0 every px — including negative (pointer left of the cells area
84
- * during a leftward drag) — keeps the v0.4 hx-relative form (R2 fix: an unguarded `px < 0`
85
- * would swallow negative px into indexAt(px) → column 0, breaking the F = 0 identity).
86
- * 逆写像: viewport x → 列 index。凍結分岐は W_F > 0 のときのみ存在: W_F 未満は凍結帯
87
- * (木座標の生値 — 負の px は先頭凍結列へクランプ。Excel 同型の最寄り可視)、W_F 以上は
88
- * スクロール帯 (木座標 = px + hx)。W_F = 0 は負の px (左ドラッグでセル領域外へ出た
89
- * ポインタ) も含め全域が v0.4 の hx 相対形 (R2 是正: 無ガードの `px < 0` は負 px を
90
- * indexAt(px) = 列 0 へ吸い込み、F = 0 恒等を破る)。
209
+ * Inverse map: viewport x to column index, three-way with precedence LEADING > TRAILING >
210
+ * MIDDLE (v0.9 plan §2.1). Takes the REQUIRED AxisInverseCtx built fresh at the judgment site
211
+ * (trailingSize from the TREE field — registered rule 2; extent from the judgment-time
212
+ * getViewportSize() read — the extent > 0 term keeps pre-measure conduct byte-identical to v0.8).
213
+ * 逆写像: viewport x → 列 index の 3 分岐 (優先順 先頭 > 末尾 > 中央 — v0.9 計画 §2.1)。判断
214
+ * サイトでその場構築した必須 AxisInverseCtx を取る (trailingSize は TREE フィールド — 登記
215
+ * 規則 2。extent は判断時 getViewportSize() 読み — extent > 0 項が未測定挙動を v0.8 と byte
216
+ * 一致に保つ)。
91
217
  */
92
- export const viewportXToCol = (axis: AxisModel, px: number, hx: number, frozenWidth: number): number => viewportPosToIndex(axis, px, hx, frozenWidth)
218
+ export const viewportXToCol = (axis: AxisModel, px: number, ctx: AxisInverseCtx): number => viewportPosToIndex(axis, px, ctx)
93
219
 
94
220
  /**
95
- * Inverse map: viewport y to row index (row twin of viewportXToCol — v0.6 plan §2). Guarded on
96
- * H_F > 0; H_F = 0 keeps the v0.4 vy-relative form for all py including negative.
97
- * 逆写像: viewport y → 行 index (viewportXToCol の行双子 — v0.6 計画 §2)。H_F > 0 ガードつき、
98
- * H_F = 0 は負 py も含め v0.4 の vy 相対形。
221
+ * Inverse map: viewport y to row index — row twin of viewportXToCol (v0.9 plan §2.1).
222
+ * 逆写像: viewport y → 行 index — viewportXToCol の行双子 (v0.9 計画 §2.1)。
99
223
  */
100
- export const viewportYToRow = (axis: AxisModel, py: number, vy: number, frozenHeight: number): number => viewportPosToIndex(axis, py, vy, frozenHeight)
224
+ export const viewportYToRow = (axis: AxisModel, py: number, ctx: AxisInverseCtx): number => viewportPosToIndex(axis, py, ctx)
@@ -45,6 +45,8 @@ export type SpreadsheetLabels = {
45
45
  noteEscapeHint: string
46
46
  /** Note popover hint in the rejected state (Ctrl+Enter retries, Escape closes without saving). / 拒否状態のノートヒント。 */
47
47
  noteRejectedHint: string
48
+ /** Note popover hint in the v0.10 read-only viewer mode (Escape closes without saving — the commit affordance is disabled, so the hint must not promise a save). / 読み取り専用ビューアモード (v0.10) のノートヒント (Escape は保存せず閉じる — 確定操作は disabled のため保存を約束しない)。 */
49
+ noteViewerHint: string
48
50
  /** Comment popover heading. / コメントポップオーバーの見出し。 */
49
51
  commentTitle: string
50
52
  /** Comment reply field label. / 返信入力欄のラベル。 */
@@ -103,6 +105,7 @@ export const LABEL_KEYS = [
103
105
  "noteSave",
104
106
  "noteEscapeHint",
105
107
  "noteRejectedHint",
108
+ "noteViewerHint",
106
109
  "commentTitle",
107
110
  "commentReply",
108
111
  "commentPost",
@@ -129,10 +132,12 @@ void labelKeysCoverEveryKey
129
132
 
130
133
  /**
131
134
  * English defaults for every key. `sinkAriaLabel` / `rootAriaLabel` are byte-identical to the
132
- * v0.7 prop defaults (the fold is DOM-identical); `noteEscapeHint` = "Escape saves and closes";
135
+ * v0.7 prop defaults (the fold is DOM-identical); `noteEscapeHint` = "Escape saves and closes"
136
+ * and its v0.10 viewer counterpart `noteViewerHint` = "Escape closes without saving";
133
137
  * `commentCount` and `listMore` pluralise ("1 comment" / "n comments", "1 more value" / "n more values"). Frozen.
134
138
  * 全キーの英語既定。`sinkAriaLabel` / `rootAriaLabel` は v0.7 の prop 既定と byte 同一 (畳込みは
135
- * DOM 恒等)、`noteEscapeHint` = "Escape saves and closes"、`commentCount` / `listMore` は単複を区別。凍結済み。
139
+ * DOM 恒等)、`noteEscapeHint` = "Escape saves and closes" とその v0.10 ビューア対応
140
+ * `noteViewerHint` = "Escape closes without saving"、`commentCount` / `listMore` は単複を区別。凍結済み。
136
141
  */
137
142
  export const DEFAULT_LABELS: SpreadsheetLabels = Object.freeze({
138
143
  sinkAriaLabel: "Spreadsheet grid. Press Escape to move focus out of the grid.",
@@ -141,6 +146,7 @@ export const DEFAULT_LABELS: SpreadsheetLabels = Object.freeze({
141
146
  noteSave: "Save",
142
147
  noteEscapeHint: "Escape saves and closes",
143
148
  noteRejectedHint: "The note was rejected. Press Ctrl+Enter to retry or Escape to close without saving",
149
+ noteViewerHint: "Escape closes without saving",
144
150
  commentTitle: "Comments",
145
151
  commentReply: "Reply",
146
152
  commentPost: "Post",