@vmscloud/moz-ui-grid-vue 0.2.8 → 0.2.10

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.
@@ -1,15 +1,21 @@
1
1
  # moz-ui-grid-core 레퍼런스 (Config · 타입 · API · 이벤트)
2
2
 
3
3
  > 패키지: `@vmscloud/moz-ui-grid-core` (export 클래스명은 호환을 위해 `PureSheet` 유지)
4
- > 버전: 0.1.41
5
- > 마지막 업데이트: 2026-09-02
4
+ > 버전: 0.1.48 (소스 기준: `0481bd5`)
5
+ > 마지막 업데이트: 2026-10-19
6
6
 
7
7
  > 📋 **사용 예제 코드는 별도 파일**: [examples.md](examples.md) — Sub-API별 사용, Reactive React 통합, 이벤트, 선택 모드, Excel I/O 등 약 30개 시나리오
8
8
 
9
+ ## 런타임 일반 옵션
10
+
11
+ `await grid.setRuntimeOptions({ timeZone, preventKeyEdit, rowHeightChangeThreshold, migrateConfig })`는 지정한 키만 갱신합니다. 명시적 `undefined`는 각각 시간대 미지정, `true`, `2`, 콜백 없음으로 되돌립니다. React/Vue `coreConfig`에서도 이 키의 변경·제거는 공통 설정 큐에서 같은 인스턴스에 적용합니다. 열린 편집은 기존 설정으로 먼저 확정하며 확정 불가 시 변경을 거절합니다. 높이 임계값 변경은 측정된 높이를 지우지 않고 다음 측정부터 적용합니다. `preventKeyEdit`는 기존 옵션 의미만 바꾸며 keyFields identity 이전을 제공하지 않습니다.
12
+
13
+ `keyFields`, `enableReferCache`, `engine`, `useWorker`, `workerFactory`, `undoStack` 등 생성/소유권 경계의 runtime 지원을 뜻하지 않습니다. 실제 인스턴스 교체 시에는 어댑터가 `GridChrome.captureReinitStateAsync()`를 기다린 뒤 이전 인스턴스를 파기합니다. 동기 `captureReinitState()`는 기존 직접 호출자용으로 남습니다.
14
+
9
15
  ## 날짜 시간대 계약 (`timeZone`)
10
16
 
11
17
  ```ts
12
- const grid = new PureSheet(container, { fields, data, timeZone: 'Asia/Seoul' });
18
+ const grid = new PureSheet(container, { fields, data, enableReferCache: true, timeZone: 'Asia/Seoul' });
13
19
  await grid.io.saveExcelToFile({ suggestedName: 'seoul.xlsx' });
14
20
  await grid.io.fromExcel(legacyFile, { timeZone: 'UTC' });
15
21
  ```
@@ -21,7 +27,7 @@ await grid.io.fromExcel(legacyFile, { timeZone: 'UTC' });
21
27
  - 날짜만 표시하는 mask는 **시간 값을 자르지 않습니다**. XLSX 소수부에 시·분·초·밀리초가 유지됩니다. 임의의 날짜 문자열은 호스트 의존 파싱을 하지 않고 원문으로 남깁니다.
22
28
  - DST 해석은 Temporal `compatible`: 중복 시각은 **더 이른 instant**, 존재하지 않는 시각은 **gap 길이만큼 앞으로** 이동합니다. 따라서 DST 중복 시각의 두 instant를 Excel serial만으로 구분할 수 없습니다. 필요하면 UTC IO override를 사용하세요.
23
29
  - 잘못된 zone(빈 문자열 포함)은 `RangeError`입니다. UTC나 브라우저 zone으로 조용히 폴백하지 않습니다.
24
- - 코어에서는 생성 설정입니다. React/Vue provider 및 `coreConfig.timeZone` 변경은 기존 상태보존 재생성 경로로 반영됩니다.
30
+ - 코어의 `await grid.setRuntimeOptions({ timeZone })` 및 React/Vue provider/`coreConfig.timeZone` 변경은 같은 인스턴스에 반영됩니다. 데이터 원본·dirty·undo 소유권은 교체하지 않습니다. 명시적 `undefined`는 기존 시간대 기본값으로 되돌립니다.
25
31
 
26
32
  ## 목차
27
33
 
@@ -120,6 +126,7 @@ import '@vmscloud/moz-ui-grid-wrapper/style.css';
120
126
 
121
127
  const grid = new PureSheet(container, {
122
128
  mode: 'flat',
129
+ enableReferCache: true, // keyFields 생략 → 내부 자동 행 ID (data.id를 키로 추론하지 않음)
123
130
  fields: [
124
131
  { id: 'name', header: '이름', dataType: 'string', width: 200 },
125
132
  { id: 'age', header: '나이', dataType: 'number', width: 100 },
@@ -196,11 +203,12 @@ grid.on('row:click', ({ rowIndex }) => {
196
203
  | `data` | `Row[]` | — | 데이터 배열 |
197
204
  | `loader` | `LoaderConfig` | — | Worker 기반 데이터 로더 설정 |
198
205
  | `fields` | `FieldDef[]` | 필수 | 필드(컬럼) 정의 |
199
- | `keyFields` | `string[]` | `['id']` | 행 고유 ID 식별 필드 목록. 복합 키도 가능(예: `['exchange', 'symbol']`) |
200
- | `enableReferCache` | `boolean` | `false` | 행 객체 참조 캐시 활성화 — 행 객체 기반 API(`updateCell(row, ...)`, `deleteRow(row)` 등) 사용 시 필수 |
206
+ | `keyFields` | `string[]` | — | 업무 키 필드 목록. 복합 키도 가능(예: `['exchange', 'symbol']`). 생략 시 `enableReferCache: true`가 필요하며 내부 자동 행 ID를 사용한다. 빈 배열은 허용하지 않는다. 데이터의 `id` 필드를 암묵적으로 업무 키로 사용하지 않는다. |
207
+ | `enableReferCache` | `boolean` | `false` | 행 객체 참조 캐시 활성화 — 행 객체 기반 API 및 `keyFields` 생략 시 필수. 키와 캐시 설정을 모두 생략하면 초기화 오류가 발생한다. 자동 ID는 원본 데이터 필드에 주입하지 않으며 전체 데이터 교체 시 새로 발급된다. |
201
208
  | **엔진** | | | |
202
209
  | `engine` | `'native'` | `'native'` | 데이터 처리 엔진. **`'aq'`(Arquero)는 제거됨** — 단일 엔진(native)만 남았습니다(ITSM-2026-002190) |
203
210
  | `useWorker` | `boolean` | `false` | Web Worker 사용 여부 |
211
+ | `workerFactory` | `() => Worker` | — | `useWorker: true`에서 앱이 빌드한 신뢰 Worker entry 생성. `@vmscloud/moz-ui-grid-core/worker`의 `registerWorkerFunctions`와 `installProcessorWorker`로 함수·loader를 정적으로 등록한다. |
204
212
  | **저장본 호환** | | | |
205
213
  | `migrateConfig` | `GridConfigMigrator` | — | `applyConfig()` 가 옛 버전 저장본을 받았을 때 호출할 변환기. core 는 **호출 시점·순서만** 책임지고 변환 내용은 소비처(wrapper)가 소유 (→ [저장 스키마 버전](#저장-스키마-버전--gridconfigmigrator)) |
206
214
  | **UI** | | | |
@@ -210,12 +218,12 @@ grid.on('row:click', ({ rowIndex }) => {
210
218
  | `rowHeightChangeThreshold` | `number` | `2` | 높이 변경 감지 임계값 (px) |
211
219
  | `headerHeight` | `number` | — | 헤더 높이 (px) |
212
220
  | `showHeader` | `boolean` | `true` | 헤더 표시 여부 |
213
- | `rowHeader` | `boolean \| RowHeaderConfig` | `false` | 좌측 행 헤더 컬럼(`_rowHeader`) 표시. `true`면 빈 셀(너비 40, 체크박스 트리거 시 체크박스), 객체면 `width`/`cellRenderer`/`headerRenderer`로 커스터마이즈 (→ [RowHeaderConfig](#rowheaderconfig)). `rowSelection.trigger`가 `'rowHeader'`/`'checkbox'`면 미지정이어도 자동 ON. (구 `showRowHeader` — deprecated alias 유지) |
221
+ | `rowHeader` | `boolean \| RowHeaderConfig` | `false` | 좌측 행 헤더 컬럼(`_rowHeader`) 표시. `true`면 빈 셀(너비 40, 체크박스 트리거 시 체크박스), 객체면 `width`/`cellRenderer`/`headerRenderer`/`includeGroupRows`로 커스터마이즈 (→ [RowHeaderConfig](#rowheaderconfig)). `rowSelection.trigger`가 `'rowHeader'`/`'checkbox'`면 미지정이어도 자동 ON. (구 `showRowHeader` — deprecated alias 유지) |
214
222
  | `showStatusBar` | `boolean` | `true` | 상태 바 표시 |
215
223
  | `summaryRow` | `'none' \| 'pinnedTop' \| 'pinnedBottom'` | `'none'` | 집계(총합계) 행 표시 위치. `FieldDef.aggregate` 가 지정된 컬럼을 모아 core 가 행을 직접 소유하며, `getConfig()` 에 실려 레이아웃 프리셋으로 저장·복원된다 |
216
224
  | `alternatingRows` | `boolean` | `false` | 줄무늬 행 스타일 |
217
- | `rowStyle` | `string` | — | 행 CSS 스타일 |
218
- | `headerStyle` | `string` | — | 헤더 CSS 스타일 |
225
+ | `rowStyle` | `string` | — | 행 높이 alias: `height:Npx`만 추출 (일반 CSS 적용 아님). truthy `rowHeight`가 우선 |
226
+ | `headerStyle` | `string` | — | 헤더 높이 alias: `height:Npx`만 추출 (일반 CSS 적용 아님). truthy `headerHeight`가 우선 |
219
227
  | **컬럼 동작** | | | |
220
228
  | `resizableColumns` | `boolean` | `true` | 컬럼 리사이즈 허용 |
221
229
  | `reorderableColumns` | `boolean` | `true` | 컬럼 재정렬 허용 |
@@ -248,6 +256,33 @@ grid.on('row:click', ({ rowIndex }) => {
248
256
  | ~~`onCellClick`~~ | — | — | **제거됨.** `grid.on('cell:click', ...)` 이벤트 구독으로 대체 (→ [이벤트](#이벤트)) |
249
257
  | ~~`onGroupToggle`~~ | — | — | **제거됨.** 로우 그룹 토글은 이벤트로 노출됩니다 — `rowGroup:toggle`(단일), `rowGroup:batchToggle`(일괄: `scope`로 `level`/`toLevel`/`all` 구분). `grid.on('rowGroup:toggle', ...)` 등으로 구독 (→ [이벤트](#이벤트)) |
250
258
 
259
+ #### 행 identity: 업무 키와 자동 ID
260
+
261
+ - 명시한 `keyFields`는 비어 있지 않은 문자열 배열이어야 합니다. `null`, 배열 아닌 값, 빈 문자열 원소, 비문자열 원소, sparse hole은 초기화 오류입니다. 문자열을 trim하지 않으며 공백도 실제 원본 필드명입니다. 화면 `fields`에 없는 source-only 키도 허용합니다.
262
+ - 자동 ID 전체 교체 성공 시 행·셀·그룹 선택과 active/focus를 비우며 core `data:loaded` 관찰자도 초기화된 상태를 봅니다. 실패하거나 stale인 load 완료로 현재 선택을 재초기화하지 않습니다. 같은 세션의 정렬·필터와는 다른 경계입니다.
263
+ - `captureDataSession()` / `restoreDataSession(token)`은 일반 loadData와 다른 세션 이전 API입니다. [지원 범위와 실패 조건](runtime-contracts.md)을 참조하세요.
264
+
265
+ - **업무 키 모드**: `keyFields: ['id']`처럼 비어 있지 않은 배열을 명시합니다. `enableReferCache` 기본값은 `false`이며, 자동 ID 모드가 누락된 업무 키 값을 대신 채워주지는 않습니다.
266
+ - **자동 ID 모드**: `keyFields`를 **생략**하고 `enableReferCache: true`를 지정합니다. `keyFields: []`는 캐시 설정과 관계없이 초기화 오류이며, 키 생략 + 캐시 미지정/`false`도 빈 데이터부터 초기화 오류입니다. 생성 후 `await grid.waitForReady()`로 초기화 성공/실패를 확인하세요.
267
+ - ID는 값이나 원본 객체가 아니라 **행 occurrence(배열에 등장한 각각의 행)** 에 속합니다. 값이 같거나 동일 객체를 두 번 넣어도 서로 다른 ID가 발급됩니다. frozen 원본 객체에도 ID 필드를 주입하지 않습니다.
268
+ - 정렬·필터·재정렬, dirty 편집·추가·삭제 복원·discard, undo/redo 및 commit은 같은 occurrence의 ID를 유지합니다. 반면 `loadData()`로 전체 데이터를 교체하면 동일 객체를 다시 넘겨도 새 ID가 발급됩니다. 서버 키나 재조회 간 영속 식별자로 저장하지 마세요.
269
+ - 자동 identity는 원본/조회 데이터의 속성이나 Symbol이 아니라 내부 메타데이터로 관리됩니다. JSON 및 도메인 데이터 export에 자동 ID 필드가 추가되지 않습니다. 행 접근자의 `id`, 선택 ID 또는 `addRow()` 반환값으로 ID를 얻고, 원본 객체에 ID를 만들어 넣거나 문자열 형태를 해석하지 마세요.
270
+ - 자동 모드의 객체 기반 API에는 그리드가 반환한 행 데이터를 사용합니다. 원본 객체나 `{ ...row }`/JSON 복사본은 occurrence를 식별하는 핸들이 아닙니다. ID를 별도로 가진 `changes.updateCellById(id, field, value)` 등으로 수정하세요. raw keyless patch만으로 대상을 추론하지 않습니다([트랜잭션](#트랜잭션--rowtransaction--transactionoptions--transactionresult)).
271
+ - Worker도 occurrence ID를 데이터와 분리해 전송하지만 **기존 동기 조회 제한은 유지**됩니다. clean Worker 행은 `rows.getByIndex()`/`observeByIndex()`가 실패하거나 `getById()`/reactive snapshot/`all()`/`where()`가 비어 있을 수 있습니다. `await grid.getRowById(id)` 등 비동기 조회를 사용하세요. 비동기 조회가 전체 동기 캐시를 채우지는 않습니다. Worker의 기존 loader/data 전달 제한도 바뀌지 않습니다.
272
+
273
+ ```typescript
274
+ // 메인 스레드 예제: 행 데이터에 ID를 추가하지 않고 ID를 별도로 사용
275
+ const automaticGrid = new PureSheet(container, {
276
+ enableReferCache: true,
277
+ fields: [{ id: 'name', header: '이름', dataType: 'string' }],
278
+ data: [{ name: '홍길동' }, { name: '홍길동' }],
279
+ });
280
+ await automaticGrid.waitForReady();
281
+ const rowId = automaticGrid.rows.getByIndex(0).id;
282
+ await automaticGrid.changes.updateCellById(rowId, 'name', '김철수');
283
+ await automaticGrid.changes.commit(); // 같은 rowId 유지
284
+ ```
285
+
251
286
  ### FlatModeConfig
252
287
 
253
288
  `PureSheetConfigBase`에 추가:
@@ -371,7 +406,7 @@ grid.on('row:click', ({ rowIndex }) => {
371
406
  | 속성 | 타입 | 설명 |
372
407
  |------|------|------|
373
408
  | `mask` | `MaskConfig` | 마스킹 설정 |
374
- | `aggregate` | `AggregateFunc` | 그룹/피벗 집계 함수. wrapper의 `useSummaryFooter: true`에서는 컬럼 하단 집계에도 사용한다(기본 OFF). 표시를 꺼도 함수는 유지된다 |
409
+ | `aggregate` | `AggregateFunc \| (values, ctx) => CellValue` | 그룹/피벗 집계 함수. wrapper의 `useSummaryFooter: true`에서는 컬럼 하단 집계에도 사용한다(기본 OFF). 표시를 꺼도 함수는 유지된다. 함수형은 그룹행 자기 칸·고정 합계 행 값을 계산한다 — [그룹 집계 표시 자리와 함수형 집계](#그룹-집계-표시-자리와-함수형-집계) |
375
410
 
376
411
  ### 그룹 행
377
412
 
@@ -459,7 +494,7 @@ PureSheet 인스턴스에서 직접 호출하는 메서드들입니다. Sub-API
459
494
  |--------|------|------|
460
495
  | `loadData(data)` | `Promise<void>` | 데이터 로드 (기존 데이터 교체) |
461
496
  | `getAllData()` | `Promise<RowData[]>` | 전체 데이터 조회 |
462
- | `getVisibleData()` | `Promise<RowData[]>` | 필터/정렬 적용된 processor 데이터 조회. 하위 호환 API라 rowGroup/tree collapse로 renderer에서 숨긴 자식 행은 제외하지 않음 |
497
+ | `getVisibleData()` | `Promise<RowData[]>` | 필터/정렬 적용된 processor 데이터 조회. 하위 호환 API라 rowGroup/tree collapse로 renderer에서 숨긴 자식 행은 제외하지 않음. 커밋 전 드래그 재정렬도 반영하지 않음(원래 순서) — 화면 순서는 `getDisplayedData()` |
463
498
  | `getDisplayedData()` | `Promise<RowData[]>` | 현재 renderer view-index 순서의 순수 도메인 데이터 행. rowGroup/tree collapse 반영(접힌 자식 제외), group header/subtotal/grand-total/master-detail 구조 행 제외, DOM/viewport 가상화와 무관 |
464
499
  | `getDisplayedRowIds()` | `Promise<RowId[]>` | `getDisplayedData()`와 같은 순서의 도메인 rowId 목록 |
465
500
  | `rows.displayedCount()` | `number` | displayed 좌표계 총 행 수(그룹/트리 collapse 반영). traversal 경계 계산용 |
@@ -651,8 +686,8 @@ grid.clearSearch(); // 종료
651
686
 
652
687
  | 메서드 | 반환 | 설명 |
653
688
  |--------|------|------|
654
- | `getConfig()` | `GridConfigExport` | 현재 그리드 상태 + reset baseline 을 `{ current, snapshot }` bundle 로 반환 |
655
- | `applyConfig(cfg, options?)` | `Promise<void>` | Export bundle 을 현재 그리드에 적용. `cfg.snapshot` 이 있고 `options.applySnapshot !== false` 면 baseline 도 함께 복원 |
689
+ | `getConfig()` | `GridConfigExport` | 현재 그리드 상태 + reset baseline 을 `{ version, current, snapshot }` bundle 로 반환. 생성 시점 config 가 아니라 **현재 값**(UI 옵션·행 그룹·컬럼 그룹 포함) |
690
+ | `applyConfig(cfg, options?)` | `Promise<void>` | Export bundle 을 **원자적으로** 적용. 입력 전체를 먼저 검증해 잘못되면 아무것도 바꾸지 않고 `PS903`/`PS901` 을 던지고, 적용 도중 실패하면 직전 상태로 되돌린 뒤 `PS904` 를 던진다. `options`: `{ applySnapshot?: boolean; missing?: 'reset' \| 'keep' }` |
656
691
 
657
692
  **`GridConfigExport` bundle 구조**
658
693
 
@@ -675,6 +710,48 @@ interface GridConfigExport {
675
710
  - `true` (default) — `cfg.snapshot` 이 있으면 baseline 도 복원. 라운드트립 보장
676
711
  - `false` — `cfg.snapshot` 이 있어도 무시. `current` 만 임시 layout 으로 적용 (기존 baseline 유지)
677
712
 
713
+ **`options.missing` — 저장본에 빠진 항목의 해석** (F04)
714
+
715
+ | 값 | 의미 | 빠진 항목 |
716
+ |---|---|---|
717
+ | `'reset'` | 전체 상태(snapshot) | **기본 보기**로 초기화 — 아래 표 |
718
+ | `'keep'` | 덮어쓰기(overlay) | **현재 값 유지** — 컬럼 `aggregate`/`pinned`, `summaryRow` 도 예외 없이 유지 |
719
+ | 미지정 | 버전 판정 | 원래(마이그레이션 전) `version` 이 `SNAPSHOT_CONFIG_VERSION`(`'0.5'`) 이상이면 `'reset'`, 아니면(버전 없음 포함) `'keep'` |
720
+
721
+ `getConfig()` 결과는 항상 `0.5` 이상이고 "없음" 도 명시값(`rowGroupConfig: null`, `columnGroups: []`, `treeConfig: null`, `masterDetailConfig: null`, 해제된 컬럼 고정·집계는 키 생략)으로 싣기 때문에 라운드트립은 정확히 그 시점 상태로 돌아갑니다. 버전 없는 부분 객체(`{ current: { mode: 'flat', rowHeight: 32 } }`)는 덮어쓰기로 해석되어 기존 호출자의 의미가 유지됩니다.
722
+
723
+ `'reset'` 의 기본값 — **앱의 기본 보기 = 그리드 생성 시점 상태**(생성자 끝에서 캡처):
724
+
725
+ | 항목 | reset 기본값 |
726
+ |---|---|
727
+ | `fields` 키 전체 누락 | 컬럼 reset baseline(`columns.getInitialSnapshotDefs()`, `applySnapshot` 이면 `cfg.snapshot.fields`)의 너비·숨김·고정·집계·순서 |
728
+ | 필드의 `width` / `hidden` 누락 | 같은 컬럼의 baseline 값 |
729
+ | 필드의 `pinned` / `aggregate` 누락 | 해제 (`getConfig` 가 해제를 키 생략으로 싣는다) |
730
+ | `fields` 에 없는 컬럼 | baseline 값, 순서는 저장본 컬럼 뒤(baseline 순) |
731
+ | `sorts`/`sortGroups`, `filters`/`filterGroups` | 생성 시점(빈 정렬·필터) |
732
+ | `rowGroupConfig`, `columnGroups`, `treeConfig`, `masterDetailConfig` | 생성 시 config 값 |
733
+ | UI 옵션(아래 목록), `summaryRow`, `locale`, `translations`, `rowModel`, `endReachedThresholdRows` | 생성 시 값(미지정이면 라이브러리 기본값) |
734
+ | `mode` | 생성 시 모드 |
735
+ | 피벗 하위 옵션(`rowFields`/`valueFields` 를 준 경우) | 라이브러리 기본값 — 피벗 정의는 `pivot.setConfig` 로 통째 교체되는 한 단위. `keep` 이면 현재 피벗 설정 값 유지 |
736
+
737
+ `rowGroupConfig: null` 은 "빠진 항목" 이 아니라 명시값입니다 — 그룹 컬럼·집계만 비우고 그룹 옵션(`selectable` 등)은 두 모드 모두 휴면 보존합니다.
738
+
739
+ **검증과 원자성** (F06)
740
+
741
+ - 진입부에서 `options` → 마이그레이션 → bundle 전체 순으로 검사하고, 계획(빠진 항목 채우기·컬럼별 목표 상태·피벗 설정 병합)을 미리 계산한 뒤에만 상태를 바꿉니다.
742
+ - 최소 형태 검증: `mode` ∈ `flat`/`pivot`, `fields[]` 원소는 문자열 `id` 를 가진 객체(`width` 양수, `hidden` boolean, `pinned` ∈ `left`/`right`/`none`/null), 정렬 `direction` ∈ `asc`/`desc`, `summaryRow`·`tooltipMode`·선택 `mode` 열거값, `rowGroupConfig.columns` 문자열 배열, 피벗 축 원소는 문자열 `field` 를 가진 객체, UI 옵션 타입 등. 값의 의미(모르는 컬럼 id)는 검사하지 않고 적용 단계에서 건너뜁니다.
743
+ - 오류: `PS903`(형태 — 메시지에 `current.fields[3].id` 같은 경로), `PS901`(마이그레이션 실패 — 예전처럼 원본으로 계속하지 않음), `PS904`(적용 도중 실패 → 직전 `getConfig()` 상태로 되돌림, 원인은 `error.cause`), 피벗 설정이 없는데 `rowFields`/`valueFields` 없이 `mode: 'pivot'` 이면 `PS903`.
744
+ - **셀 편집 중이면 먼저 확정**: 검증을 통과한 뒤 상태를 바꾸기 직전에, 열린 셀 편집기를 다른 셀을 클릭했을 때와 같은 경로로 확정합니다(`changes`/Undo 기록, `cell:editCommitted` 발행). 확정이 막히면(min/max/step 위반, `inputMask` 미완성, `cell:editCommitting` 에서 `cancel()`) `PS906` 으로 거절하고 아무것도 바꾸지 않습니다. 편집기와 입력값도 그대로 남습니다. 검증에 실패한 호출(`PS903`/`PS901`)은 편집을 건드리지 않습니다. 오버레이 편집기(드롭다운·날짜 선택·멀티선택)는 대상이 아닙니다.
745
+ - **다른 설정·뷰 API도 같은 규칙**: 행 화면을 다시 그리는 API — `columns.pinLeft`/`pinRight`/`unpin`/`setOrder`/`move`/`moveGroup`/`hide`/`show`/`toggle`/`setHidden`/`load`/`reset`/`resetAll`, `columns.update`(`hidden`·`pinned`·`frozen` 포함 시), `headers.*`(접기·펼치기, `setColumnGroups`), `updateFields`, `sort`/`toggleSort`/`setSortGroup`/`filter`/`setFilterGroup`/`setViewState`, `rowGroup.setConfig`/`groupBy`/`clear`/`addColumn`/`removeColumn`/접기·펼치기, `tree.setConfig`/`setState`/접기·펼치기, `pivot.setMode`, `rows.pinTop*`/`pinBottom*`/`unpin`/`unpinById`/`clearPinned` — 도 상태를 바꾸기 전에 열린 편집을 확정하고, 확정이 막히면 `PS906` 으로 거절합니다(동기 API는 예외를 던지고, 비동기 API는 Promise 가 거부됩니다). 너비·`autoFit`·헤더 이름처럼 행을 다시 그리지 않는 변경은 편집을 그대로 둡니다. 데이터 API(`loadData`, `applyTransaction`, `changes.addRow`/`deleteRow*`/`updateCell*`, `undoManager.undo`/`redo`)와 스크롤은 아직 대상이 아닙니다.
746
+
747
+ **UI 옵션 런타임 복원** (F02)
748
+
749
+ `getConfig()` 는 아래 옵션의 **현재 값**을 싣고 `applyConfig()` 는 런타임에 실제로 되돌립니다: `theme`, `rowHeight`(가상 스크롤·동적 높이 캐시·Multi-Row·고정 행·병합 float 재계산), `headerHeight`, `rowSelection`/`cellSelection`/`cellSelectionImpliesRows`(바뀔 때만 재설정 — 현재 선택은 비움), `rowHeader`(행 헤더 컬럼 추가/제거, 선택 트리거가 요구하면 자동 활성), `editable`(편집기·붙여넣기·Delete 단축키), `tooltipMode`, `alternatingRows`, `showHeader`, `resizableColumns`/`reorderableColumns`(헤더 재빌드), `autoFitColumns`, `showStatusBar`, `summaryRow`, `locale`, `translations`.
750
+
751
+ `rowTemplate`은 `setRowTemplate(template | null)` 또는 `applyConfig`로 flat 레이아웃만 교체하고 데이터·원본·변경 기록·undo를 유지한다. `reorderableRows`/`showDragHandleColumn`은 `dragDrop.setConfig` 또는 `applyConfig`로 현재 인스턴스에 반영한다. 현재 값만 내보내고 적용하지 않는 키(값이 다르면 경고 `PS905`)는 `keyFields`이며, 행 식별자 변경 정책은 별도 검토 대상이다.
752
+
753
+ **피벗 왕복** (F01) — flat → pivot 진입 직전의 flat 런타임 상태(컬럼 너비·고정·숨김·순서, 정렬/필터, 행 그룹)를 저장해 두었다가 flat 복귀(`pivot.setMode('flat')`, pivot→flat `applyConfig`) 시 그대로 되살리고 컬럼 그룹 헤더도 재빌드합니다. flat 저장본을 함께 적용하면 저장본 값이 이깁니다.
754
+
678
755
  **`current` / `snapshot` 에 포함되는 항목 — `GridConfigSnapshot` 형식**
679
756
 
680
757
  | 카테고리 | 내용 |
@@ -683,7 +760,7 @@ interface GridConfigExport {
683
760
  | **컬럼(flat)** | `fields[]` — 각 필드의 `id`, `width`, `hidden`, `pinned`, 사용자 정의 순서 |
684
761
  | **컬럼 그룹(flat)** | `columnGroups` |
685
762
  | **행 템플릿(flat)** | `rowTemplate` (Multi-Row 레이아웃) |
686
- | **그룹핑(flat)** | `group` |
763
+ | **그룹핑(flat)** | `rowGroupConfig` (현재 값, 없으면 `null`) |
687
764
  | **피벗** | `rowFields`, `columnFields`, `valueFields`, 모든 subtotal/grandTotal 옵션, `rowDisplayMode`, `outlineColumnWidth`, `outlineIndentSize`, `showZeros`, `trackSourceRows`, `rowCollapsible`, `columnCollapsible` 등 |
688
765
  | **뷰 상태** | `sorts`, `filters` |
689
766
  | **서버/수동 row-model** | `rowModel`, `endReachedThresholdRows` |
@@ -702,7 +779,7 @@ interface GridConfigExport {
702
779
  **특징**
703
780
 
704
781
  - **라운드트립 보장** — `applyConfig(getConfig())` 가 항상 동일한 상태로 복원 (baseline 포함)
705
- - **`current.fields` 등 부분 적용** — `current` 안의 키들은 `Partial` 형태로 처리 — 누락된 키는 그리드 기존 값 유지
782
+ - **누락 키** — `options.missing` 에 따른다: 버전 없는 부분 객체는 덮어쓰기(누락 = 기존 값 유지), `getConfig()` 저장본(0.5+)은 전체 상태(누락 = 기본 보기)
706
783
  - **모드 전환 자동 처리** — `current.mode` 가 현재와 다르면 `pivot.setMode()` 자동 호출
707
784
  - **순서 복원** — flat 모드의 `current.fields` 배열 순서 그대로 컬럼이 재정렬됨
708
785
  - **getConfig 는 sync, applyConfig 는 async** — 복원 중 엔진 재계산(정렬/필터/피벗)이 포함되기 때문
@@ -894,7 +971,8 @@ PureSheet 인스턴스는 기능별 Sub-API 네임스페이스를 제공합니
894
971
  |--------|------|------|
895
972
  | `getSelectedCellIds()` | `{rowId, columnId}[]` | 선택된 셀 목록 (셀 API 에는 `selected()` 가 없다 — 행 API `grid.rows.selected()` 와 다름) |
896
973
  | `getSelectedCells()` | `SelectedCell[]` | 선택된 셀 상세 (행 핸들·값 포함) |
897
- | `getActiveCell()` | `SelectedCell \| null` | 현재 활성 셀 |
974
+ | `getActiveCell()` | `SelectedCell \| null` | 현재 활성 셀 — `selection.getActiveCell()`과 같음(화면 자리 기준, 아래 표 참고) |
975
+ | `getFocusCell()` | `SelectedCell \| null` | 논리적 선택 끝점 — `selection.getFocusCell()`과 같음. [끝점·데이터 세션 상세](runtime-contracts.md) |
898
976
  | `getSelectedCellRowIds()` | `string[]` | 선택된 셀이 걸친 행 rowId 목록(중복 제거) |
899
977
  | `getSelectedCellColumnIds()` | `string[]` | 선택된 셀이 걸친 컬럼 columnId 목록(중복 제거) |
900
978
  | `getSelectedCellIndices()` | `number[]` | 선택된 셀이 걸친 행 sourceIndex 목록(중복 제거) |
@@ -907,10 +985,16 @@ rowSelection(선택된 행들)·cellSelection(선택된 셀 range)은 서로 다
907
985
 
908
986
  | 메서드 | 반환 | 설명 |
909
987
  |--------|------|------|
910
- | `selection.getSelectedRows()` | `RowHandle[]` | 현재 선택된 행들(viewIndex/sourceIndex 채워짐) |
911
- | `selection.getActiveRow()` | `RowHandle \| null` | 마지막 상호작용(행/셀 클릭) 행. 앵커 없으면 선택 행 마지막 폴백. traversal 앵커 |
988
+ | `selection.getSelectedRows()` | `RowHandle[]` | 현재 선택된 행들(viewIndex=화면 순번, sourceIndex=원본 데이터 위치 채워짐. 피벗 행·원본에 없는 행은 sourceIndex `undefined`) |
989
+ | `selection.getActiveRow()` | `RowHandle \| null` | 마지막 상호작용(행/셀 클릭) 행. 앵커 없으면 선택 행 마지막 폴백. traversal 앵커. **클릭한 행(rowId)을 계속 가리킨다** — 정렬·필터·접기 뒤에도 같은 행(`getActiveCell`과 다를 수 있음) |
912
990
  | `selection.getSelectedCells()` | `{ row: RowHandle; columnId }[]` | 현재 선택된 셀들 |
913
- | `selection.getActiveCell()` | `{ row: RowHandle; columnId } \| null` | 마지막 상호작용 셀. traversal 컬럼 유지용 |
991
+ | `selection.getActiveCell()` | `{ row: RowHandle; columnId } \| null` | 마지막 상호작용 셀. traversal 컬럼 유지용. 행은 **화면 자리 기준** — 셀 선택(`getSelectedCellIds`)·화면 강조·키보드 이동과 같은 행이다. 정렬·필터·그룹/트리 접기·디테일 펼침·드래그 재정렬로 그 자리에 다른 행이 오면 그 행, 자리가 그룹 배너·디테일 행이거나 사라지면 `null`. 행 선택 모드(셀 선택 없음)의 앵커와 고정(pinned) 행은 클릭한 행 그대로 |
992
+ | `selection.getFocusCell()` | `SelectedCell \| null` | 현재 유효한 논리적 선택 끝점 `{ row: RowHandle, columnId }`. traversal 앵커인 `getActiveCell()`과 별개. 전체 선택 열거·비동기 fetch 없이 조회하며 확인 불가능한 identity는 null. [상세 계약](runtime-contracts.md) |
993
+ | `selection.getSelectedGroups()` | `{ groupId: string; rowIds: RowId[] }[]` | 선택된 그룹 (`rowGroupConfig.selectable` — [그룹 배너 선택](#그룹-배너-선택-selectable)). `rowIds`는 리프 멤버 전체(접힘 무관, 중첩이면 하위 리프 전부). 옵션이 꺼져 있으면 `[]`. 행 선택 결과와 별개 |
994
+ | `selection.selectGroupsByIds(groupIds, { addToExisting? })` | `string[]` | 그룹을 코드로 선택 — 배너 본문 클릭과 같은 의미([그룹 배너 선택](#그룹-배너-선택-selectable)). 적용된 groupId(입력 순서, 중복 제거)를 반환. 화면에 없는 그룹·옵션 꺼짐은 무시 |
995
+ | `selection.selectGroupsByViewIndices(viewIndices, { addToExisting? })` | `string[]` | viewIndex로 그룹 선택. 그룹행이 아닌 위치는 무시. 나머지는 `selectGroupsByIds`와 같음 |
996
+
997
+ > **피벗 모드의 행(RowHandle)**: `row.id`는 피벗 행 nodeId(`'g:G1|i:I1'`, 소계 `'g:G1'`, 총합계 `'__grandtotal__'`), `row.data`는 **화면의 피벗 행** — `cell:click`/`cell:dblclick`의 `row`, `contextmenu`의 `rowData`와 같은 객체입니다. 내용은 `pivot.getAllRows()` 한 행의 `rowHeaders`와 `values`를 한 객체로 펼친 것에 메타 필드(`__pivotType: 'data' | 'subtotal' | 'grandtotal'`, `__nodeId`, `__parentNodeId`, `__level`, `__hasChildren`)를 더한 것입니다. 소계 행은 하위 행 필드에 `'Subtotal'`이, 총합계 행은 첫 행 필드에 `'Grand Total'`이 들어갑니다(화면 표시와 같음). 원본 행이 아닙니다. 접힘으로 화면에 없는 피벗 행은 `data`가 비어 있습니다. 예: 드래그 시작 셀의 행축 값 `const d = grid.selection.getActiveCell()?.row.data; d?.__pivotType === 'data' && d.g`.
914
998
 
915
999
 
916
1000
  #### 선택 일괄
@@ -921,18 +1005,23 @@ rowSelection(선택된 행들)·cellSelection(선택된 셀 range)은 서로 다
921
1005
  | `updateSelectedRows(field, value)` | `Promise<number>` | 선택 **행**들의 지정 필드 일괄 업데이트 |
922
1006
  | `discardSelectedRows()` | `Promise<number>` | 선택 행 변경 취소 |
923
1007
 
924
- #### 데이터 행 고정 (기존 데이터 행을 상단/하단에 고정)
1008
+ #### 데이터 행 고정 (기존 데이터 행을 상단/하단 고정 영역으로 옮기기)
925
1009
 
926
- > `pinTop`/`pinBottom`은 **데이터 행 객체**를 받아 그 행을 고정하는 메서드입니다 (`enableReferCache` 필요). 새로운 행/구성을 추가하려면 [구조적 행 고정](#구조적-행-고정) 섹션의 `addPinnedTop` / `addPinnedBottom`을 사용하세요.
1010
+ > `pinTop*`/`pinBottom*`은 본문 데이터 행을 고정 영역으로 **옮깁니다** — `rows.getById(id).setProps({ pinned: 'top' | 'bottom' })`와 같은 동작입니다. 호출 후 그 행은 본문에서 빠지고 고정 영역에 한 번만 보이며, 고정 영역에서의 편집은 원본 데이터에 반영됩니다(`changes` 포함). `unpin`/`unpinById`/`clearPinned`로 본문 원래 위치로 돌아옵니다. 같은 행을 다시 고정해도 중복되지 않고, `pinTop` ↔ `pinBottom`은 영역 이동입니다. 정렬·필터·그룹·트리 상태에서도 동작합니다.
1011
+ > `pinTop`/`pinBottom`은 **데이터 행 객체**를 받아 키(`keyFields`)로 본문 행을 찾습니다. 본문에 없는 객체/ID를 주면 기존처럼 화면 전용 고정 행으로 추가됩니다(`addPinnedTop`/`addPinnedBottom`과 동일). `Row` 인스턴스는 그대로 꽂힙니다. 새로운 행/구성을 추가하려면 [구조적 행 고정](#구조적-행-고정) 섹션의 `addPinnedTop` / `addPinnedBottom`을 사용하세요.
1012
+ > Worker 모드(`useWorker: true`)에서는 메인 스레드에 원본 데이터가 없어 `pinTop`/`pinTopById`/`pinBottom`/`pinBottomById`가 본문 행을 동기로 찾지 못합니다. 이때는 화면 전용 고정 행으로 추가되고 `PS503` 경고가 콘솔에 나옵니다(본문 행 이동·편집 반영·해제 시 본문 복귀 없음). `pin*ByIndex`는 Worker 모드에서 행을 찾지 못해 예외를 던집니다.
1013
+ > `enableReferCache: false`에서도 동작합니다(`*ByIndex`는 O(1), 객체·ID는 원본 데이터를 한 번 훑어 찾음). `setProps({ pinned })`로 직접 고정한 행도 같은 방식으로 원본 위치를 찾아 표시·클릭·우클릭·편집이 동작합니다.
927
1014
 
928
1015
  | 메서드 | 반환 | 설명 |
929
1016
  |--------|------|------|
930
- | `pinTop(row)` | `Row` | 데이터 행 객체로 상단 고정 (`enableReferCache` 필요) |
931
- | `pinTopById(rowId)` | `Row` | ID로 기존 데이터 행을 상단에 고정 |
932
- | `pinTopByIndex(sourceIndex)` | `Row` | sourceIndex로 기존 데이터 행을 상단에 고정 |
933
- | `pinBottom(row)` | `Row` | 데이터 행 객체로 하단 고정 (`enableReferCache` 필요) |
934
- | `pinBottomById(rowId)` | `Row` | ID로 기존 데이터 행을 하단에 고정 |
935
- | `pinBottomByIndex(sourceIndex)` | `Row` | sourceIndex로 기존 데이터 행을 하단에 고정 |
1017
+ | `pinTop(row)` | `Row` | 데이터 행 객체(키로 찾은 본문 행)를 상단 고정 영역으로 옮김 |
1018
+ | `pinTopById(rowId)` | `Row` | ID로 본문 행을 상단 고정 영역으로 옮김 |
1019
+ | `pinTopByIndex(sourceIndex)` | `Row` | sourceIndex로 본문 행을 상단 고정 영역으로 옮김 |
1020
+ | `pinBottom(row)` | `Row` | 데이터 행 객체(키로 찾은 본문 행)를 하단 고정 영역으로 옮김 |
1021
+ | `pinBottomById(rowId)` | `Row` | ID로 본문 행을 하단 고정 영역으로 옮김 |
1022
+ | `pinBottomByIndex(sourceIndex)` | `Row` | sourceIndex로 본문 행을 하단 고정 영역으로 옮김 |
1023
+
1024
+ 반환 `Row`는 화면에 그려지는 고정 행 인스턴스이며 데이터는 본문 행 데이터를 따릅니다(`rows.getPinned()`에도 포함).
936
1025
 
937
1026
  #### 구조적 행 고정
938
1027
 
@@ -943,14 +1032,18 @@ rowSelection(선택된 행들)·cellSelection(선택된 셀 range)은 서로 다
943
1032
  | `addGrandTotal(aggregates)` | `Row` | **(deprecated)** 전체 합계 행 추가 — `grid.setSummaryRow('pinnedBottom')` 를 사용할 것. 이 API 로 꽂은 행은 `getConfig()` 에 실리지 않아 레이아웃 프리셋으로 저장·복원되지 않는다 |
944
1033
  | `addFilter()` | `Row` | 필터 행 추가 |
945
1034
 
1035
+ > `{ id, data }`로 추가한 데이터 고정 행(본문 데이터에 없음)은 `enableReferCache` 없이도 클릭·더블클릭·우클릭(`contextmenu` 의 `rowData` 포함)이 동작합니다. 셀 편집은 그 고정 행 데이터(`row.getData()`)에 저장되고 화면에 표시되며 `cell:editCommitted`도 발행됩니다. 화면 전용 행이므로 변경 추적(`changes.getAll()`)에는 들어가지 않지만 편집은 Undo/Redo 됩니다(`enableReferCache:true` 에서 본문에 같은 id 행이 있으면 기존대로 본문 행이 편집됩니다). 고정 영역 셀을 선택하고 붙여넣기(Ctrl+V)해도 같은 규칙이며, 여러 셀은 Undo 한 번에 되돌아갑니다. 붙여넣기는 그 고정 영역의 행 순서로만 채우고 본문 행으로 넘치지 않습니다(본문 행을 고정한 행은 본문 데이터로 저장되어 변경 추적에 들어갑니다).
1036
+ > 화면 전용 고정 행(`addPinnedTop/Bottom`, 본문에 없는 행의 `pinTop*`/`pinBottom*` — `variant: 'custom'` 포함)의 **고정과 해제(`unpin`/`unpinById`/`clearPinned`)도 Undo/Redo** 됩니다. Undo 는 같은 `Row` 인스턴스(편집 값 포함)를 같은 영역·같은 순서로 되살리고, `clearPinned`로 여러 행을 해제했으면 Undo 한 번에 모두 복원됩니다. 본문 행의 고정·해제(`setProps({ pinned })`, 본문 행을 옮기는 `pin*`)와 `summaryRow` 총합계 행은 화면 상태라 기록하지 않습니다(컬럼 고정·정렬과 같은 기준). Undo/Redo 로 고정 상태가 바뀌면 `row:pinned`/`row:unpinned`가 `source: 'undo' | 'redo'`와 함께 다시 발행됩니다.
1037
+ > 고정 행(종류 무관 — 화면 전용·본문 행 고정 모두)의 키 컬럼(`keyFields`)은 편집할 수 없습니다.
1038
+
946
1039
  #### 고정 관리
947
1040
 
948
1041
  | 메서드 | 반환 | 설명 |
949
1042
  |--------|------|------|
950
1043
  | `unpin(row)` | `boolean` | `Row` 인스턴스로 고정 해제 |
951
- | `unpinById(rowId)` | `boolean` | ID로 행 고정 해제 |
952
- | `getPinned()` | `{ top: Row[]; bottom: Row[] }` | 고정된 행 조회 |
953
- | `clearPinned()` | `void` | 전체 고정 해제 |
1044
+ | `unpinById(rowId)` | `boolean` | ID로 행 고정 해제 (데이터 고정 행은 본문으로 복귀) |
1045
+ | `getPinned()` | `{ top: Row[]; bottom: Row[] }` | 고정된 행 조회 — 화면 순서의 구조 고정 행 + 데이터 고정 행 |
1046
+ | `clearPinned()` | `void` | 전체 고정 해제 (데이터 고정 행은 본문으로 복귀, core 소유 총합계 행은 유지) |
954
1047
  | `refreshPinned()` | `void` | 고정 행 표시 갱신 |
955
1048
 
956
1049
  ---
@@ -1093,7 +1186,7 @@ grid.columns.update('amount', { header: '판매금액' }, { updateSnapshot: true
1093
1186
 
1094
1187
  | 메서드 | 반환 | 설명 |
1095
1188
  |--------|------|------|
1096
- | `addRow(row, idx?)` | `Promise<string \| number>` | 행 추가 (dirty) |
1189
+ | `addRow(row, idx?)` | `Promise<string \| number>` | 행 추가 (dirty). idx는 기존 dirty 추가/삭제를 반영한 데이터 위치이며 정렬·그룹·고정·디테일을 포함한 화면 번호가 아닙니다. 생략 시 끝에 추가합니다. |
1097
1190
  | `updateCell(row, field, value)` | `Promise<void>` | 행 객체로 셀 수정 (`enableReferCache` 필요) |
1098
1191
  | `updateCellById(id, field, value, sourceIndex?)` | `Promise<void>` | ID로 셀 수정. 호출자가 sourceIndex를 알고 있으면 4번째 인자로 전달하여 idMap fallback을 회피할 수 있음 |
1099
1192
  | `updateCellByIndex(idx, field, value)` | `Promise<void>` | sourceIndex로 셀 수정 O(1) |
@@ -1248,8 +1341,98 @@ await grid.changes.commit({ allowInvalid: true });
1248
1341
 
1249
1342
  > **`getGroupRows(groupId)`** — 그룹 행에서 하위 멤버 전체를 일괄 조작할 때 사용합니다 (예: 그룹 단위 선택·체크박스 cascade). 멤버십은 그룹 컬럼 값 기준 매칭(`String(row[col]) === value`)이며, `groupId`의 각 세그먼트는 **첫 `:` 만** 구분자로 사용하므로 값에 `:`가 포함돼도 안전합니다(값에 `/` 포함은 경로 모호성 — 규약상 한계). `grid.rows.getByIds(getGroupRows(id))` 조합으로 일괄 수정하세요.
1250
1343
 
1344
+ #### 그룹 배너 선택 (`selectable`)
1345
+
1346
+ `rowGroupConfig.selectable: true`(기본 `false`)면 그룹 배너(그룹행)를 선택할 수 있습니다. 끄면 배너는 선택되지 않고 본문 클릭 = 접기/펼치기(현행 그대로)입니다.
1347
+
1348
+ | 조작 | 결과 |
1349
+ |------|------|
1350
+ | 데이터 셀에서 시작한 드래그 / Shift+클릭 범위가 배너를 지남 | 셀 범위 폭은 그대로(배너 폭으로 넓어지지 않음), 지나간 배너 = 선택된 그룹. 선택 사각형은 selectable 여부와 관계없이 배너(그룹행)에서 끊겨 데이터 행 구간만 묶고(배너에는 사각형·강조 없음), 셀 선택 결과(`getSelectedCellIds` 등)에는 데이터 셀만 들어갑니다. Ctrl+드래그/Ctrl+Shift는 기존 그룹에 더합니다 |
1351
+ | 배너 본문 클릭 | 그 그룹만 선택 (셀 범위 해제, 접기/펼치기 안 함) |
1352
+ | 배너에서 시작해 드래그 | 지나간 **그룹행만** 선택(데이터 행은 건너뜀, 셀 범위 없음) |
1353
+ | Ctrl/Cmd+배너 클릭 | 그룹 추가/제외 |
1354
+ | Shift+배너 클릭 | 마지막으로 클릭한 그룹 ~ 클릭한 그룹 사이의 그룹 전부 |
1355
+ | ▾ 버튼 클릭 / Ctrl+▾ | 접기/펼치기 / 같은 depth 일괄 접기/펼치기 (선택은 바뀌지 않음) |
1356
+
1357
+ - 결과: `grid.selection.getSelectedGroups()` → `[{ groupId, rowIds }]`, `selection:changed`의 `selectedGroups`. `rowIds`는 그룹의 리프 멤버 전체(접혀 있어도, 중첩 그룹이면 하위 리프 전부)이며 표기는 `getGroupRows`와 같습니다(단일 키 원시값).
1358
+ - 행 선택(`rowSelection`) 결과는 바뀌지 않습니다 — 그룹 멤버를 행 선택에 넣으려면 소비처가 `rowIds`를 사용합니다.
1359
+ - 배너에서 고른 그룹은 배너와 그룹행 셀을 행 선택 색으로 칠합니다(`.ps-group-banner-selected`, `.ps-group-row-selected`). 범위가 지나간 그룹은 결과에만 기록되고 화면 표시는 없습니다(사각형은 배너에서 분할).
1360
+ - 배너 커서: selectable이면 배너 본문·그룹행은 데이터 셀과 같은 커서이고 ▾ 버튼만 pointer입니다. 옵션(`selectable`/`defaultCollapsed`/`defaultExpandLevel`)은 그룹 컬럼을 모두 뺐다가(`removeColumn`/`groupBy([])`/`setConfig({ columns: [] })`, `getConfig()`는 `null`) 다시 넣어도 유지되며, 생략 시 이어받고 명시값(`false` 포함)이 이깁니다. `clear()`/`setConfig(null)`은 옵션까지 지웁니다.
1361
+ - 다른 셀/행 클릭·새 범위, `clearSelection`/`clearCellSelection`, `rowGroup.setConfig`(재그룹), `loadData` 시 그룹 선택이 비워집니다.
1362
+ - Ctrl+A·프로그래매틱 범위 API(`selectCellRangeBy*`)는 그룹을 고르지 않습니다.
1363
+ - 키보드(셀 범위 모드·행 선택 모드 공통, 펼침/접힘·다단 그룹 모두 같음):
1364
+
1365
+ | 조작 | 현재 선택 | 결과 |
1366
+ |------|-----------|------|
1367
+ | ↑/↓ 로 그룹행에 닿음 | 셀·행·그룹 | `selectable` 켬: 그 그룹을 선택(배너 본문 클릭과 같음 — 배너 강조, `selection:changed`의 `selectedGroups`, 셀 범위 해제). 다음 ↑/↓는 그 그룹행에서 이어지고, 데이터 행에 닿으면 그 셀(그룹에 들어가기 전 컬럼)/행을 선택합니다. `selectable` 끔: 그룹행을 건너뛰고 같은 방향의 다음 데이터 행으로 갑니다. 그 방향에 그룹행만 남았으면 움직이지 않습니다 |
1368
+ | Shift+↑/↓ | 셀·행 | 그룹행을 건너뛰며 범위를 넓힙니다. 범위에는 데이터 행만 들어가고 사각형은 배너에서 끊깁니다. 그룹은 고르지 않습니다 |
1369
+ | Shift+↑/↓ | 그룹(배너 선택) | 같은 방향의 다음 그룹행까지 그룹 선택을 넓힙니다(배너 Shift+클릭과 같음 — 기준 그룹 ~ 새 끝 사이 그룹 전부, 데이터 행은 건너뜀). 더 갈 그룹행이 없으면 그대로 |
1370
+ | ←/→ | 그룹(배너 선택) | 아무 일도 하지 않습니다 |
1371
+
1372
+ 선택 없이 처음 누른 ↑/↓는 0행이 그룹행이면 같은 규칙을 따릅니다(selectable이면 첫 그룹 선택, 아니면 첫 데이터 행). 그룹행의 셀에는 키보드로 셀 선택·강조가 생기지 않습니다. 행 선택 모드에서 그룹을 고르면 배너 클릭과 같이 기존 행 선택은 그대로 둡니다.
1373
+ - 코드로 고르기: `grid.selection.selectGroupsByIds(ids, { addToExisting? })` / `selectGroupsByViewIndices(vis, { addToExisting? })`. 배너 본문 클릭과 같습니다 — 셀 범위·이전 그룹을 비우고 그 그룹만 선택(`addToExisting: true`면 기존 셀·그룹에 추가, 토글하지 않음), 배너 강조, 이후 Shift+배너 클릭의 기준점, 변경이 있을 때 `selection:changed` 1회. 접힘 상태는 바꾸지 않습니다. 이미 같은 상태면 아무 일도 하지 않습니다. `selectable`이 꺼져 있거나 현재 화면에 없는 그룹(접힌 부모 아래, 없는 id, 데이터 행 위치)은 무시하고 반환에서 뺍니다(전부 무시되면 `[]`, 상태 변화 없음). `loadData`가 그룹 선택을 비우므로 로드가 끝난 뒤 호출합니다.
1374
+
1375
+ ```ts
1376
+ // 로드 후 첫(접힌) 그룹을 선택하고 상세를 조회
1377
+ // (selection:changed 는 집계 후속으로 같은 선택을 한 번 더 보낼 수 있으니 groupId 로 중복 조회를 거른다)
1378
+ grid.on('selection:changed', ({ selectedGroups }) => {
1379
+ const first = selectedGroups[0];
1380
+ if (first) loadDetail(first.groupId, first.rowIds);
1381
+ });
1382
+ await grid.loadData(rows);
1383
+ grid.selection.selectGroupsByViewIndices([0]); // 또는 selectGroupsByIds(['region:R1'])
1384
+ ```
1385
+
1251
1386
  ---
1252
1387
 
1388
+ #### 그룹 집계 표시 자리와 함수형 집계
1389
+
1390
+ 그룹 집계는 표시되는 자리가 둘이고 선언하는 곳도 다릅니다.
1391
+
1392
+ > **원칙(단일 규칙)** — 컬럼 정의 `aggregate`와 `rowGroupConfig.aggregates`는 서로 다른 자리입니다. 컬럼 정의 `aggregate`(문자열·함수)는 그룹행의 **그 컬럼 자기 칸**(고정 컬럼, center 양 끝 연속 `unmergeOnGroupRow`)에만 나오고, 자기 칸이 없는 컬럼이면 그룹행에 나오지 않습니다(배너 글자에도 안 나옴 — 고정 합계 행·선택 영역 집계·`ctx` 계산에는 그대로 쓰입니다). `rowGroupConfig.aggregates`(문자열·함수, `scope` 포함)는 **배너 글자에만** 나옵니다 — 그 컬럼에 자기 칸이 있어도, 같은 컬럼에 컬럼 정의 `aggregate`가 있어도 칸에 넣지 않고 배너에 냅니다. 예: `ratio`에 `aggregate: 'sum'` + `unmergeOnGroupRow`, `rowGroupConfig.aggregates: [{ column: 'ratio', func: 'max' }]` → 칸 `90`, 배너 `RATIO (Max): 50`. 내보내기(XLSX·CSV)도 화면과 같습니다(자기 칸 값 → 그 컬럼 칸, 배너 글자 → 그룹 라벨 뒤 ` · `).
1393
+
1394
+ | | `rowGroupConfig.aggregates` | 컬럼 정의 `aggregate` |
1395
+ |---|---|---|
1396
+ | 표시 자리 | 그룹 배너 글자 (`급여 (Sum): 90`)만 | 그룹행의 **자기 칸**(고정 컬럼·`unmergeOnGroupRow`)만 + 고정 합계 행(`summaryRow`) |
1397
+ | 개수 | 같은 컬럼에 여럿 가능 | 컬럼당 하나 |
1398
+ | 같은 컬럼에 둘 다 있을 때 | 배너 글자 | 자기 칸 (둘 다 각자 자리에 나옴, 함수끼리도 따로 계산) |
1399
+ | 자기 칸이 없는 컬럼 | 배너 글자 | 그룹행에 안 나옴 |
1400
+ | 함수형 | `func: (values, ctx) => 값` (배너 라벨은 `헤더: 값`) | `aggregate: (values, ctx) => 값` — 결과가 그 칸의 **진짜 셀 값**(mask/format/`cellRenderer`/XLSX 공통) |
1401
+ | 숨김 컬럼 | 명시했으면 배너에 표시 | **계산만** 하고 배너에 표시하지 않음 (Wijmo 동일) |
1402
+
1403
+ 함수 시그니처 (두 곳 같음, 기존 `(values) => 값` 함수도 그대로 동작):
1404
+
1405
+ ```ts
1406
+ type CustomAggregateFunction = (values: CellValue[], ctx: AggregateContext) => CellValue;
1407
+ interface AggregateContext {
1408
+ sum(col: string): CellValue; avg(col: string): CellValue; count(col: string): CellValue;
1409
+ min(col: string): CellValue; max(col: string): CellValue;
1410
+ aggregates: Readonly<Record<string, CellValue>>; // 이 그룹(또는 전체)의 문자열 집계 결과, 키 '함수_컬럼id'
1411
+ group?: { groupId: string; level: number; column: string; value: CellValue; itemCount: number }; // 고정 합계 행이면 없음
1412
+ }
1413
+ ```
1414
+
1415
+ - `ctx.sum('x')` 등은 엔진이 계산한 값을 읽으므로 **그 조합이 선언돼 있어야** 합니다 — 컬럼 정의 `aggregate: 'sum'`(숨김 컬럼 가능) 또는 `rowGroupConfig.aggregates`의 `{ column: 'x', func: 'sum' }`. 선언되지 않은 조합을 읽으면 `PS312` 오류입니다(그룹행: `refresh()`가 거절, 고정 합계 행: `console.error` 후 빈 값). 고정 합계 행은 컬럼 정의 `aggregate`만 선언으로 봅니다.
1416
+ - 다단 그룹은 단계마다 그 단계 집계로 다시 계산하고, 필터·편집·행 추가 후에도 다시 계산합니다.
1417
+ - 결과가 `null`/`undefined`/`NaN`이면 그룹행 칸은 빈 칸(고정 합계 행은 기존 null 표기 `-`). 0 나누기는 함수에서 처리합니다.
1418
+ - 일반 Main 모드는 기존 inline 함수를 그대로 사용합니다. Worker 모드는 `{ workerFunction: 'app:aggregate', params: ... }` 참조를 사용하며, 앱 Worker entry에 정적으로 등록된 함수가 실제 멤버 `values`와 `ctx`를 Worker 안에서 받습니다. 메인 계산 왕복·fallback은 없고 inline 함수·미등록 ID는 오류입니다. 함수의 마지막 인수는 descriptor의 `params`입니다.
1419
+ - 함수형 컬럼 aggregate가 하나라도 있으면 선언된 문자열 컬럼 aggregate를 모두(숨김·배너 아래 컬럼 포함) 계산만 합니다. 이 값들은 배너 글자에 나오지 않습니다.
1420
+ - 함수형 aggregate는 프리셋(JSON)에 저장되지 않고 코드가 소유합니다 — `applyConfig`가 지우거나 문자열로 바꾸지 않으며, wrapper 집계 탭은 "사용자 함수"로 표시하고 변경을 막습니다.
1421
+ - **선택 영역 집계**(`rows.selectionAggregates()`·`selection:changed`의 `aggregates`·컨텍스트 메뉴): 함수형 컬럼은 그 컬럼 셀이 선택된 행들을 **하나의 그룹처럼** 보고 함수를 적용합니다. `columns[]` 항목은 `aggregate: 'custom'`, `value` = 함수 값(`formattedValue`는 그 컬럼 mask 적용), `count` = 숫자 셀 수(기존 의미)입니다. `ctx.sum('x')` 등은 선택 범위에 `x` 셀이 없어도(숨김 컬럼 포함) 선택된 **행** 기준이고 `ctx.group`은 없습니다. 읽을 수 있는 조합은 고정 합계 행과 같이 컬럼 정의의 문자열 `aggregate`이며, 미선언 조합은 `console.error`(PS312) 후 빈 값입니다. 화면 밖 선택 행·그룹/트리 모드(데이터 행만)·Worker 모드 모두 같은 값이고, 컬럼 전체 선택(Ctrl+Space/Ctrl+A)은 표시 중인 전체 데이터 행으로 계산합니다(이때만 행을 한 번 받아 옵니다). `total`(합계·평균·개수·최소·최대)은 기존대로 **선택 셀 값** 기준입니다. wrapper 컨텍스트 메뉴는 `custom`에 함수 라벨 없이 `헤더 값`만 표시합니다.
1422
+ - 그룹 정렬 `basis: 'aggregate'`와 그룹 결과 필터(`basis: 'aggregate'`·수식 `GROUP(id, "aggregate", by)`)는 `rowGroupConfig.aggregates`의 **함수형 항목도** 공개 ID로 참조할 수 있습니다. 엔진은 함수를 실행하지 않으므로 메인 스레드가 그룹별 함수 값을 계산해 엔진에 다시 질의합니다(필터 참조 시 필터 전 snapshot A 값 1회 + 정렬 참조 시 1회 추가 질의 — 대용량에서 그만큼 느려집니다). 오름/내림·null 위치(오름차순 맨 앞)는 문자열 집계 정렬과 같고, Main/Worker·lazy/dense 결과가 같으며 접기/펼치기·필터·편집 뒤에도 유지됩니다. 컬럼 정의 `aggregate`(자기 칸 집계)는 정렬·필터 ID로 참조할 수 없습니다(문자열·함수 모두 기존과 같음).
1423
+ - `scope`가 붙은 `rowGroupConfig.aggregates` 항목이 있어도 그룹행 자기 칸 집계(컬럼 정의 `aggregate` — 문자열·함수)는 모든 레벨에 남습니다. scope는 배너 집계의 표시 레벨만 정합니다(자기 칸 컬럼의 `rowGroupConfig` 항목도 scope 단계 배너에만 나옵니다).
1424
+
1425
+ ```ts
1426
+ // 원본(Wijmo) 납기준수율: 그룹 합계끼리 나눈 가중 비율 (A그룹 (100,90),(300,150) → 60, 평균이면 70)
1427
+ fields: [
1428
+ { id: 'demandQty', dataType: 'number', aggregate: 'sum', unmergeOnGroupRow: true },
1429
+ { id: 'onTimeQty', dataType: 'number', aggregate: 'sum', hidden: true },
1430
+ { id: 'onTimeRatio', dataType: 'number', unmergeOnGroupRow: true,
1431
+ aggregate: (v, { sum }) => sum('demandQty') ? sum('onTimeQty') / sum('demandQty') * 100 : null },
1432
+ ],
1433
+ summaryRow: 'pinnedBottom', // 고정 합계 행도 전체 Σ/Σ
1434
+ ```
1435
+
1253
1436
  #### 그룹 집계 식별자
1254
1437
 
1255
1438
  `RowGroupConfig.aggregates`의 각 항목에는 선택적인 `id`를 지정할 수 있습니다.
@@ -1433,7 +1616,8 @@ wrapper 정렬 패널의 **그룹 정렬 → 정렬 기준**에서도
1433
1616
  | `getDataMatrix(options?)` | `Promise<DataMatrix \| null>` | 피벗 결과를 2D 표로 변환 |
1434
1617
  | `getPivotMeta()` | `PivotMeta \| null` | 읽기 전용 경량 메타 스냅샷 (필드/행수/열수) |
1435
1618
  | `getSeriesData(options?)` | `Promise<PivotSeriesData>` | 차트/대시보드용 그룹 시리즈 데이터 |
1436
- | `getRowHeaderPath(rowHeaders, categoryField?)` | `string` | 행 rowHeaders → 행 헤더 경로(= 차트 카테고리 라벨). `getSeriesData`와 동일 규칙(SSOT). leaf+다중 행필드면 "부모 › 잎" 경로, 그 외엔 해당 필드 값. (sync) |
1619
+ | `getRowHeaderPath(rowHeaders, categoryField?)` | `string` | 행 rowHeaders → 행 헤더 경로(= 차트 카테고리 표시 이름). `getSeriesData`와 동일 규칙(SSOT). 최상위 행 필드부터 범주 필드까지 " › " 로 이음(첫 단계 필드는 값 그대로, 중간 단계도 "서울 › 1공장" 처럼 부모 포함). (sync) |
1620
+ | `getRowHeaderKey(rowHeaders, categoryField?)` | `string` | 같은 경로의 식별 키(= `getSeriesData` `categories[].key`) — 경로 값을 `'|'` 로 잇고 값 안의 `'|'`·`'\'` 는 `'\'` 로 이스케이프(`'A\|B|1공장'`). (sync) |
1437
1621
 
1438
1622
  #### getFieldValues
1439
1623
 
@@ -1481,7 +1665,33 @@ await grid.pivot.getDataMatrix({ excludeStructuralColumns: true });
1481
1665
  | `subtotals` | `'all' \| 'visible' \| 'none'` | `'visible'` | 소계 행 노출 (all=숨김 포함 raw, visible=토글 ON만, none=제외) |
1482
1666
  | `grandTotals` | `'all' \| 'visible' \| 'none'` | `'visible'` | 총합계 행 노출 (값 의미는 `subtotals` 와 동일) |
1483
1667
  | `excludeStructuralColumns` | `boolean` | `false` | 열 소계/총합계 컬럼 제외 |
1484
- | `formatted` | `boolean` | `false` | mask 적용된 문자열로 반환 |
1668
+ | `formatted` | `boolean` | `false` | 화면 셀과 같은 표시 문자열로 반환 — 값 칸은 값 필드 mask/decimalFormat/showZeros(빈 칸은 `''`, showZeros 면 `'0'`), 행 헤더 칸은 화면 표시값(number 필드는 천 단위 등). `headers`/`columns`/`rowHeaderValues` 는 원본 그대로 |
1669
+
1670
+ **칸 정체 메타 `columns` / 행 필드 값 `rowHeaderValues`** — `headers` 는 표시 문자열뿐이라 행 필드 칸과 값 칸의 경계, 값 칸의 열 경로·값 필드를 알 수 없습니다. `columns` 는 `headers`·`rows[i]` 와 **같은 순서·길이**(옵션 적용 후)로 각 칸의 정체를 줍니다. 기존 `headers`/`rows`/`rowTypes` 는 그대로입니다.
1671
+
1672
+ ```typescript
1673
+ // 행 필드 region, item / 열 필드 year, quarter(열 소계·총합계 ON) / 값 qty 합계·최대(같은 field 두 번)
1674
+ const m = await grid.pivot.getDataMatrix();
1675
+ m.columns;
1676
+ // → [
1677
+ // { kind: 'row', fieldId: 'region' },
1678
+ // { kind: 'row', fieldId: 'item' },
1679
+ // { kind: 'value', path: { year: '2024', quarter: 'Q1' }, valueField: 'qtySum', structural: false },
1680
+ // { kind: 'value', path: { year: '2024', quarter: 'Q1' }, valueField: 'qtyMax', structural: false },
1681
+ // ...
1682
+ // { kind: 'value', path: { year: '2024' }, valueField: 'qtySum', structural: true }, // 2024 열 소계
1683
+ // ...
1684
+ // { kind: 'value', path: {}, valueField: 'qtySum', structural: true }, // 열 총합계
1685
+ // ]
1686
+ m.rowHeaderValues[0]; // → { region: '서울', item: '노트북' }
1687
+ ```
1688
+
1689
+ | 항목 | 설명 |
1690
+ |------|------|
1691
+ | `{ kind: 'row', fieldId }` | 행 필드 칸(tabular 레이아웃). `fieldId` = `getPivotMeta().rowFields[].id` |
1692
+ | `{ kind: 'outline', fieldIds }` | `rowDisplayMode: 'outline'` 의 합쳐진 행 헤더 칸 하나(모든 행 필드). `rows` 의 이 칸 값은 화면 outline 라벨(data 행 = 맨 하위 필드 값, 소계 = 그 레벨 필드 값, 총합계 = 총합계 라벨). 필드별 값은 `rowHeaderValues` 사용 |
1693
+ | `{ kind: 'value', path, valueField, structural }` | 값 칸. `path` = 열 필드 id → **열 헤더 키 문자열**(숫자 열 값도 `'2024'` 처럼 문자열), 열 필드가 없으면 `{}`. 열 소계는 덮는 상위 레벨까지만, 열 총합계는 `{}`. `valueField` = `getPivotMeta().valueFields[].id`(같은 field 중복 배치도 구분). `structural` = 열 소계/총합계 |
1694
+ | `rowHeaderValues[i]` | 행 i 의 행 필드 id → 값(레이아웃 무관). 소계 행은 자기 레벨까지 그룹 값, 바로 아래 필드는 소계 라벨(locale, 예 `'Subtotal'`/`'소계'`), 그 아래는 `''`. 총합계 행은 첫 필드가 총합계 라벨 |
1485
1695
 
1486
1696
  #### getPivotMeta / getSeriesData
1487
1697
 
@@ -1498,7 +1708,9 @@ const series = await grid.pivot.getSeriesData({
1498
1708
  subtotals: 'all', // 기본 'all' — 부모 카테고리 차트가 소계 토글 무관 동작
1499
1709
  grandTotals: 'none', // 기본 'none' — 총합계 카테고리는 'all'/'visible' 지정 시 추가
1500
1710
  });
1501
- // → { xLabels: ['Q1','Q2','Q3','Q4'], categories: [{ name: '서울', series: [{ name:'sales', data:[...] }, ...] }, ...] }
1711
+ // → { xLabels: ['Q1','Q2','Q3','Q4'], categories: [{ name: '서울', key: '서울', path: ['서울'], series: [{ name:'sales', data:[...] }, ...] }, ...] }
1712
+ // 범주: name = 최상위 행 필드부터 범주 필드까지 " › " (중간 단계 'plant' → '서울 › 1공장' / '부산 › 1공장' 별도 범주),
1713
+ // key = path 를 '|' 로 이음('|'·'\' 이스케이프, getRowHeaderKey), path = 경로 값 배열. 총합계는 key '__grandtotal__', path []
1502
1714
  ```
1503
1715
 
1504
1716
  ---
@@ -1655,12 +1867,12 @@ interface InvalidCell {
1655
1867
 
1656
1868
  | 필드 | 타입 | 기본값 | 설명 |
1657
1869
  |------|------|--------|------|
1658
- | `scope` | `'all' \| 'visible' \| 'selected'` | `'all'` | 데이터 범위 |
1870
+ | `scope` | `'all' \| 'visible' \| 'selected'` | `'all'` | 데이터 범위. 행 순서는 [화면 순서](#내보내기-행-순서) |
1659
1871
  | `columns` | `string[]` | 보이는 컬럼 전체 | 특정 컬럼 ID만 |
1660
1872
  | `includeHeaders` | `boolean` | `true` | 헤더 행 포함 |
1661
1873
  | `sheetName` | `string` | `'Sheet1'` | 시트 이름 |
1662
- | `preserveFormatting` | `boolean` | `false` | mask/decimalFormat → Excel numFmt 매핑 |
1663
- | `dateFormat` | `string` | `'yyyy-mm-dd'` | 날짜 서식 |
1874
+ | `preserveFormatting` | `boolean` | `false` | 숫자 decimalFormat 및 구조화된 날짜 mask → 컬럼별 Excel numFmt 매핑 |
1875
+ | `structuralPinnedRows` | `'exclude' \| 'include'` | `'exclude'` | flat XLSX의 구조적 고정 행 포함 여부. 범위/제외 규칙은 [아래 설명](#structural-pinned-행-xlsx-export) 참조 |
1664
1876
  | `chunkSize` | `number` | `10000` | 청크당 행 수 |
1665
1877
  | `compression` | `'deflate' \| 'store'` | `'deflate'` | 압축 방식 |
1666
1878
  | `freezeHeader` | `boolean` | `false` | 헤더 행 고정 (freeze pane) |
@@ -1671,6 +1883,43 @@ interface InvalidCell {
1671
1883
  | `signal` | `AbortSignal` | — | 취소 signal |
1672
1884
  | `onProgress` | `(ExportProgress) => void` | — | 진행률 콜백 |
1673
1885
 
1886
+
1887
+ **날짜 표시 서식:** 공개 export 옵션 `dateFormat`은 제거되었습니다(날짜 편집기의 `dateFormat`은 별개이며 유지). `preserveFormatting: true`이면 `dataType: 'date'` 컬럼의 `{ type: 'date', pattern: 'YYYY-MM-DD HH:mm:ss' }` mask를 Excel 서식으로 변환합니다. 미지정/`false`, 날짜 mask 없음 또는 지원 불가 패턴이면 `yyyy-mm-dd`를 사용합니다. 지원 불가 패턴은 기존 진단 경로의 `PS005` 경고를 냅니다.
1888
+
1889
+ - 지원 토큰: `YYYY`/`yyyy`, `YY`/`yy`, `MM`(월), `DD`/`dd`, `HH`(24시간), `hh` + `A`/`a`(12시간), `mm`(분), `ss`. 분은 시간 토큰 바로 뒤 또는 초 토큰 바로 앞의 Excel 시간 문맥이어야 합니다.
1890
+ - 예: `YYYY/MM/DD` → `yyyy/mm/dd`, `HH:mm:ss` → `hh:mm:ss`, `hh:mm:ss A` → `hh:mm:ss AM/PM`. 구분자, 한글 단위, 따옴표 자체는 리터럴로 보존·escape합니다. 따옴표/대괄호는 별도 토큰 인용 문법이 아닙니다.
1891
+ - 제한: 토큰 반복/별칭 혼합, 밀리초·요일·월 이름·경과 시간, 단독 분, `A` 없는 `hh`, `HH` + `A`, 임의 영문 리터럴, mask `prefix`/`suffix`는 지원하지 않습니다. 전체 패턴을 기본 날짜 서식으로 폴백하며 문자열 변환으로 가장하지 않습니다.
1892
+ - 값은 날짜 serial 또는 원본 숫자로 저장하고 표시만 바꿉니다. 날짜 전용 표시도 시간·밀리초를 자르지 않습니다. 기존 `timeZone` 및 import 계약을 유지하며 ISO 원문/오프셋 표기 보존을 뜻하지 않습니다. 숫자 반올림·정수 스타일 정책도 그대로입니다.
1893
+ - 일반/피벗 export는 실제 컬럼의 구조화된 날짜 mask를 사용합니다. 내장 피벗 date wrapper도 원본값 병기/파생 값 변환이 없는 경우만 변환합니다. 사용자 함수/보안 mask 정책은 변경하지 않습니다. mask가 없는 피벗 행 헤더에는 기본 서식을 사용합니다.
1894
+ - `exportSource`는 원본 `fields`의 날짜 mask가 존재할 때만 적용하며 원본 키 헤더·숨김 필드·중복 제거 정책을 유지합니다. 피벗 값 필드 mask를 원본 컬럼에 임의로 추론하지 않습니다. 날짜처럼 보이는 문자열 컬럼을 날짜로 승격하지 않습니다.
1895
+ - 변환한 서식은 직렬화 가능한 메타데이터이므로 메인/Worker XLSX 경로가 동일하게 처리합니다. 사용자 지정 날짜/시간 스타일은 import에서도 날짜로 인식합니다.
1896
+
1897
+ ##### 내보내기 행 순서
1898
+
1899
+ XLSX·CSV의 행 순서는 **화면에 보이는 순서**입니다(원본 Wijmo와 같음).
1900
+ - 정렬·필터 중에는 그 결과 순서입니다. 커밋 전 드래그 재정렬(`reorderableRows`)은 화면처럼 재정렬 순서로 나갑니다. 디테일을 펼친 상태도 같고, 디테일 행 자체는 내보내지 않습니다. 정렬·필터가 켜져 있으면 화면처럼 재정렬을 무시합니다.
1901
+ - 재정렬 상태의 병합(`preserveMerges`)은 화면 순서에서 이웃한, 같은 병합 블록의 행끼리만 다시 묶습니다. 예: `dept` 병합 A=[id1, id2], E=[id9, id10]에서 id10을 맨 위로 옮기면 A는 그대로 병합되고, E는 행이 떨어져 병합 없이 나갑니다. 서로 다른 병합 블록에서 온 행끼리는 같은 값이라도 합치지 않습니다. 화면의 병합 표시(병합 칸·선택·키보드·복사)도 커밋 전 재정렬 상태에서 같은 규칙을 따르므로 화면과 파일의 병합이 같습니다. 재정렬 상태에서는 엔진 가시 행 전체를 한 번 읽습니다(전체 복사 Ctrl+A와 같은 비용).
1902
+ - `scope: 'selected'`는 선택한 순서가 아니라 화면 순서입니다(상단 고정 → 본문 → 하단 고정, 그룹·트리·재정렬 반영). 예: id7을 선택한 뒤 id2를 Ctrl+클릭해도 [2, 7] 순서로 나갑니다. 화면에 없는 선택 행(접힌 그룹 안)은 뒤에 선택 순서대로 붙습니다.
1903
+ - 커밋 전에 추가하거나 수정한 행은 바뀐 값으로 나갑니다. 커밋 전에 삭제한 행은 화면에 삭제 표시로 남아 있으므로 파일에도 들어갑니다.
1904
+
1905
+ ##### 고정 행 XLSX·CSV export (`includePinnedRows`)
1906
+
1907
+ `includePinnedRows?: boolean`의 기본값은 `false`이며 기존 출력을 그대로 유지합니다. `true`면 일반 flat XLSX·CSV(`saveCsvToFile`)에 화면에 보이는 고정 행을 화면과 같은 순서로 추가합니다: 상단 [구조 고정..., 데이터 고정...] → 본문 → 하단 [데이터 고정..., 구조 고정...]. **래퍼 내보내기 메뉴(컨텍스트 메뉴 XLSX/CSV, Vue `runClientExport`)는 `true`를 넘깁니다**(화면에 보이는 그대로). 끄려면 `contextMenuConfig.clientExportOptions: { includePinnedRows: false }`. 구조 고정 행은 `addPinnedTop/Bottom`·`summaryRow` 등으로 추가한 행, 데이터 고정 행은 `setProps({ pinned })` 또는 `rows.pinTop*`/`pinBottom*`으로 본문에서 옮긴 행입니다(본문에는 없으므로 한 번만 나갑니다). `scope: 'selected'`에서는 선택된 고정 행만 포함하며 선택되지 않은 합계 행을 자동 추가하지 않습니다. 기본(`false`)의 selected 경로는 기존 raw pinned 데이터 fallback을 그대로 유지합니다.
1908
+ `setMergeConfig({ includePinned: true })`의 고정 행 병합은 `includePinnedRows: true`일 때 화면과 같은 범위로 엑셀 병합 셀(`mergeCells`)이 됩니다(`preserveMerges: false`면 제외). 데이터 고정 행에 걸친 범위도 그대로 나갑니다. `scope: 'selected'`로 빠진 행에 걸친 범위는 내보내는 연속 행으로 잘리고, 중간 행이 빠지거나 내보내는 열이 연속이 아니면(`columns` 옵션 등) 그 병합은 생략됩니다.
1909
+ 고정 행 데이터의 `__merge` 같은 사용자 필드는 `includePinned` 여부와 관계없이 병합으로 해석하지 않습니다(고정 행 병합은 위 설정에서만 나옴).
1910
+
1911
+ - `exportSource: true`는 항상 원본 데이터만 내보냅니다. 피벗 결과 export와 CSV는 이 옵션을 무시합니다.
1912
+ - 구조 고정 행의 집계는 현재 supplier 집계 요청 완료를 기다린 뒤 **숫자 등 원래 타입**으로 내보냅니다. 같은 `field`를 쓰는 서로 다른 컬럼의 sum/avg도 컬럼 ID별로 분리됩니다. 원본 row 데이터와 집계값을 단순 spread로 합치지 않습니다.
1913
+ - 구조 고정 행의 라벨은 pinned 행의 `data`에 **데이터 키(field)** 로 선언합니다. 예: `{ data: { name: '합계' }, aggregates: [{ columnId: 'total', func: 'sum' }] }`. 집계가 선언된 컬럼은 그 집계값이 우선합니다.
1914
+ - 데이터 고정 행은 본문 행과 같은 셀 값 변환(날짜·숫자, `preserveFormatting` 서식)으로 현재 데이터(편집 반영)를 씁니다.
1915
+ - CSV도 같은 행 순서로 나갑니다. 셀은 본문 CSV와 같은 화면 표시 서식(날짜·숫자·`mask`)이고, 구조 고정 행(합계·`summaryRow`)은 컬럼 ID별 집계값을 같은 서식으로 씁니다. CSV에는 병합이 없습니다.
1916
+ - `formatRow`의 DOM 텍스트, HTML, aggregate formatter 결과는 추출하지 않습니다. `AggregateConfig.label`에 새로운 의미를 부여하지 않습니다. 일반 `columns`, `includeHeaders`, `preserveFormatting` 옵션은 그대로 적용됩니다.
1917
+
1918
+ ```ts
1919
+ await grid.io.saveExcelToFile({ includePinnedRows: true });
1920
+ await grid.io.saveCsvToFile({ includePinnedRows: true });
1921
+ ```
1922
+
1674
1923
  ##### 원본 데이터 export 옵션
1675
1924
 
1676
1925
  | 필드 | 타입 | 기본값 | 설명 |
@@ -1696,7 +1945,7 @@ interface InvalidCell {
1696
1945
 
1697
1946
  | 필드 | 타입 | 기본값 | 설명 |
1698
1947
  |------|------|--------|------|
1699
- | `groupRowMode` | `'include' \| 'visible' \| 'dataOnly'` | `'include'` | 플랫 행 그룹화(`grid.rowGroup.setConfig`) 활성 시 export 동작. `'include'`: 접힘 상태 무시하고 모든 그룹 행 + data를 펼친 상태로 export. `'visible'`: 현재 접힘 상태 그대로 (collapsed 자식 제외). `'dataOnly'`: 그룹 행 제외하고 data 행만 |
1948
+ | `groupRowMode` | `'include' \| 'visible' \| 'dataOnly'` | `'include'` | 플랫 행 그룹화(`grid.rowGroup.setConfig`) 활성 시 export 동작. `'include'`: 접힘 상태 무시하고 모든 그룹 행 + data를 펼친 상태로 export. `'visible'`: 현재 접힘 상태 그대로 (collapsed 자식 제외). `'dataOnly'`: 그룹 행 제외하고 data 행만. 그룹 행·순서·개수·집계는 화면과 같다(행 필터·정렬, 그룹 정렬·그룹 결과 필터 반영). 내보내기는 화면 접힘 상태를 바꾸지 않는다. 그룹 행 집계도 화면 자리 그대로 나간다: 그룹 행에서 자기 칸을 갖는 컬럼(고정 컬럼, center 양 끝의 연속 `unmergeOnGroupRow`)의 컬럼 `aggregate` 값은 그 컬럼 칸에(숫자 셀, 서식·mask 는 본문 칸과 같다), 배너 글자(`rowGroupConfig.aggregates` — 자기 칸 컬럼 항목 포함)는 그룹 라벨 칸에 화면 배너와 같은 글자로 ` · ` 를 붙여 나간다(예: `A (3) · Qty (Sum): 6,000 · Amt (Max): 7`, `scope`·숨김 컬럼 규칙도 화면과 같다). 자기 칸이 없는 컬럼의 컬럼 `aggregate`(함수형 포함)는 화면처럼 나가지 않는다. GroupTemplate 으로 배너를 바꾼 화면도 파일은 기본 배너 글자를 쓴다 |
1700
1949
  | `groupOutline` | `boolean` | `true` | Excel 네이티브 outline (+/- 버튼) 생성. 그룹 행을 summary row로, 데이터 행에 `outlineLevel`을 부여. `groupRowMode: 'dataOnly'`이면 무시. 중첩 그룹(`columns: ['dept','role']`)은 depth별 outlineLevel 자동 계산 |
1701
1950
 
1702
1951
  ##### 트리 그리드 export 옵션
@@ -1905,6 +2154,7 @@ console.log(`변경 예상: +${summary.addedCount} ~${summary.updatedCount} -${s
1905
2154
  ```typescript
1906
2155
  // 초기 설정 (생성자)
1907
2156
  new PureSheet(container, {
2157
+ fields, enableReferCache: true,
1908
2158
  dragDropConfig: {
1909
2159
  rowOrderField: { field: 'displayOrder', strategy: 'dense' },
1910
2160
  mutateOnDirty: (row) => ({ updatedAt: new Date().toISOString() }),
@@ -1936,6 +2186,7 @@ if (grid.dragDrop.isActive()) {
1936
2186
 
1937
2187
  ```typescript
1938
2188
  new PureSheet(container, {
2189
+ fields, keyFields: ['id'],
1939
2190
  treeConfig: { idField: 'id', parentField: 'parentId', treeColumn: 'name' },
1940
2191
  dragDropConfig: {
1941
2192
  reorderableRows: true,
@@ -2187,7 +2438,7 @@ const unsub = row.subscribe(() => {
2187
2438
  | `visible` | `boolean` | 가시성 여부 |
2188
2439
  | `pinned` | `'top' \| 'bottom' \| null` | 고정 상태 |
2189
2440
  | `cssClass` | `string \| null` | CSS 클래스 |
2190
- | `height` | `number \| null` | 행 높이 (px) |
2441
+ | `height` | `number \| null` | 행별 높이(px). 본문·데이터 고정 행 및 가상 스크롤 좌표에 적용. `null`은 기본/자동 측정 높이로 복원 |
2191
2442
  | `changedFields` | `string[] \| null` | 변경된 필드 목록 |
2192
2443
 
2193
2444
  #### 쓰기 메서드
@@ -2249,7 +2500,8 @@ grid.off('cell:click', handler);
2249
2500
 
2250
2501
  | 이벤트 | 페이로드 | 설명 |
2251
2502
  |--------|---------|------|
2252
- | `row:click` | `{ row, rowIndex, event }` | 행 클릭 |
2503
+ | `tree:stateChanged` | `{ collapsedNodes: string[], source: 'user' \| 'api' }` | 네이티브 트리 접기·펼치기 완료 후 최종 접힘 상태. 단일/전체/깊이/경로 및 setState 복원을 포함하며 같은 상태는 통지하지 않음. 사용자 클릭·키보드는 user, 공개 API는 api. 구독 반환 함수로 해제. |
2504
+ | `row:click` | `{ row, rowId, viewIndex, sourceIndex, rowIndex, event }` | 행 클릭. 좌표는 아래 **셀·행 이벤트 좌표** 참조 (`rowIndex`는 하위호환 값 — 원본 위치 아님) |
2253
2505
  | `row:dblclick` | `{ row, rowIndex, event }` | (예약) 현재 코드 경로에서는 발행되지 않음 — `cell:dblclick`을 사용하세요 |
2254
2506
  | `row:pinned` | `{ row, position }` | 행 고정 |
2255
2507
  | `row:unpinned` | `{ rowId, position? }` | 행 고정 해제 |
@@ -2271,8 +2523,8 @@ grid.off('cell:click', handler);
2271
2523
 
2272
2524
  | 이벤트 | 페이로드 | 설명 |
2273
2525
  |--------|---------|------|
2274
- | `cell:click` | `{ row, columnId, value, event }` | 셀 클릭 |
2275
- | `cell:dblclick` | `{ row, columnId, value, event }` | 셀 더블클릭 |
2526
+ | `cell:click` | `{ row, rowId, viewIndex, sourceIndex, columnId, value, event }` | 셀 클릭. `row`는 클릭한 행 데이터(피벗은 화면의 피벗 행) |
2527
+ | `cell:dblclick` | `{ row, rowId, viewIndex, sourceIndex, columnId, value, event }` | 셀 더블클릭 |
2276
2528
  | `cell:change` | `{ rowIndex, rowId, columnId, oldValue, newValue }` | 에디터 편집에 따른 셀 값 변경. API·붙여넣기·undo/redo를 모두 관찰하는 이벤트가 아님. `rowId`는 정렬/필터 무관 안정 식별자 |
2277
2529
  | `data:changed` | `DataMutationEvent` | ChangeTracker에 반영된 셀 데이터 변경. 에디터 생명주기와 독립적인 추가 이벤트 (아래 계약 참조) |
2278
2530
  | `changes:changed` | `{ hasChanges, addedCount, deletedCount }` | 기존 dirty 상태 요약. 신규 행 셀 수정도 발행하며 상세 값은 `data:changed`로 구독 |
@@ -2281,6 +2533,17 @@ grid.off('cell:click', handler);
2281
2533
  | `cell:editCommitted` | `{ rowIndex, rowId, dataIndex, columnId, oldValue, newValue }` | 편집 커밋 완료. `rowId` 포함 (위와 동일) — boolean 셀 즉시 토글도 이 이벤트로 발화되어 체크박스 cascade 트리거로 쓰인다 |
2282
2534
  | `cell:tooltip` | `CellTooltipPayload` (아래 참조) | 셀·컬럼 헤더 호버 시 툴팁 표시 요청 (`tooltipMode` 로 발행 제어) |
2283
2535
 
2536
+ **셀·행 이벤트 좌표** (`cell:click`·`cell:dblclick`·`row:click`)
2537
+
2538
+ | 필드 | 의미 |
2539
+ |------|------|
2540
+ | `rowId` | 행 ID(keyFields 기준) — 행 식별은 이 값을 권장 |
2541
+ | `viewIndex` | 화면 순번. 그룹 배너·마스터-디테일 행 포함, 고정 행은 음수 |
2542
+ | `sourceIndex` | **원본 데이터 배열 위치** — `rows.getByIndex(sourceIndex)`·`changes.*ByIndex`와 같은 좌표. 정렬·필터·행 고정·그룹·트리와 무관. 원본에 없는 행(미저장 추가 행)·피벗 행·알 수 없을 때(Worker 캐시 미스 등)는 `undefined` |
2543
+ | `rowIndex` (`row:click`만) | 하위호환 값: 평면=필터·정렬 후 순번, 그룹·트리=flat 인덱스. 원본 위치가 아님 |
2544
+
2545
+ > 변경: 예전에는 `sourceIndex`에 평면=화면(필터·정렬 후) 순번, 그룹·트리=flat 인덱스를 그대로 담았습니다. 이 값을 `rows.getByIndex()` 등에 넘기던 코드는 정렬·필터·행 고정·그룹·트리에서 다른 행을 보고 있었으며, 이제 같은 코드가 올바른 행을 봅니다. 화면 순번이 필요하면 `viewIndex`를 쓰세요. `cell:beforeEdit`·`cell:editCommitting`·`cell:editCommitted`·`contextmenu`의 `dataIndex`는 바뀌지 않았고 원본 위치가 아닙니다(평면=필터·정렬 후 순번, 그룹·트리=flat 인덱스) — 원본 행이 필요하면 `rowId`로 찾으세요.
2546
+
2284
2547
  **`DataMutationEvent` — 셀 데이터 변경 계약**
2285
2548
 
2286
2549
  ```ts
@@ -2367,7 +2630,7 @@ grid.on('cell:tooltip', ({ element, cellRect, value, errors, hasError }) => {
2367
2630
 
2368
2631
  | 이벤트 | 페이로드 | 설명 |
2369
2632
  |--------|---------|------|
2370
- | `selection:changed` | `{ selectedRows, selectedCells }` | 선택 변경 |
2633
+ | `selection:changed` | `{ selectedRows, selectedCells, selectedGroups, aggregates }` | 선택 변경. `selectedGroups` = `grid.selection.getSelectedGroups()`와 같은 값(`rowGroupConfig.selectable`이 꺼져 있으면 `[]`) |
2371
2634
 
2372
2635
  #### 컬럼
2373
2636
 
@@ -2443,6 +2706,8 @@ grid.on('cell:tooltip', ({ element, cellRect, value, errors, hasError }) => {
2443
2706
  >
2444
2707
  > `describeChange`를 구현하지 않은 command는 `changes:undone`/`changes:redone`을 **발화하지 않습니다** (`change`가 non-null 계약).
2445
2708
  >
2709
+ > **화면 전용 고정 행 편집**(`rows.addPinnedTop/Bottom({ id, data })` 행)도 같은 UndoStack에 기록되며 `cell-edit`에 `screenOnly: true`가 붙어 옵니다(`rowId`는 그 고정 행 id, 변경 추적 제외 — 서버 동기화에서 거르세요). 그 행의 고정·해제도 같은 스택에 기록되어 `add-row`(고정) / `delete-row`(해제)에 `screenOnly: true`와 `pinned: 'top' | 'bottom'`이 붙어 옵니다(`index`는 그 고정 영역의 화면 전용 고정 행 순서, `clearPinned` 여러 행은 `batch`). 그래서 편집 → 해제 → Ctrl+Z(재고정) → Ctrl+Z(편집 취소) 순서가 성립합니다.
2710
+ >
2446
2711
  > **⚠️ 실질 변경(tree/flat reorder 등)은 거의 항상 `kind: 'batch'`로 래핑되어 옵니다.** `TreeReorderCoordinator`가 항상 batch로 감싸고 mutator가 추가 cell-edit(`updatedAt` 등)까지 넣기 때문입니다. `e.change.kind === 'tree-reorder'`처럼 **직접 비교하면 대부분 no-op**(조용한 실패)이 됩니다. 반드시 `@solasheet/core`의 `iterateLeafChanges(e.change)` / `findAllChangesByKind(e.change, kind)` / `findChangeByKind(e.change, kind)`로 batch를 평탄화한 뒤 분기하세요. 같은 kind가 여러 개 담길 수 있으므로(여러 tree-reorder + cell-edit) 하나만 필요한 게 아니면 `findAllChangesByKind`로 전부 수집해 유실을 막습니다.
2447
2712
  >
2448
2713
  > **⚠️ `batch.children`은 이미 direction-ordered입니다.** `BatchCommand.describeChange`가 undo 방향일 때 내부에서 이미 역순 정렬을 마칩니다. 소비처가 undo에서 다시 역순으로 순회하면 **이중 reverse**가 되어 순서가 깨집니다. 헬퍼가 방출한 순서를 **그대로 순차(pre-order) 적용**하세요.
@@ -2597,7 +2862,7 @@ interface MasterDetailConfig {
2597
2862
  minHeight?: number; // 최소 높이 (기본: 100)
2598
2863
  maxHeight?: number; // 최대 높이
2599
2864
  singleExpand?: boolean; // 단일 확장 모드 (기본: false)
2600
- keepExpandedOnDataChange?: boolean; // 데이터 변경 시 확장 유지 (기본: false)
2865
+ keepExpandedOnDataChange?: boolean; // 데이터 변경 시 펼친 디테일 유지 (기본: true)
2601
2866
  isExpandable?: (data: Row, dataIndex: number) => boolean; // 확장 가능 조건
2602
2867
  expandColumn?: string; // 확장 아이콘 컬럼 (기본: 첫 컬럼)
2603
2868
  keepMasterDetailRows?: boolean; // DOM 캐시 유지 (기본: true)
@@ -2605,6 +2870,20 @@ interface MasterDetailConfig {
2605
2870
  }
2606
2871
  ```
2607
2872
 
2873
+ **펼친 디테일 유지 (`keepExpandedOnDataChange`, 기본 `true`)** — 펼침 상태는 행 키(`keyFields`)로 저장됩니다.
2874
+
2875
+ | 동작 | 기본(`true`) | `false` (예전 기본) |
2876
+ |---|---|---|
2877
+ | 정렬·필터·드래그 재정렬·`changes.commit`·값 편집·`refresh()` | 같은 행은 펼친 채 유지 | 모두 접힘 |
2878
+ | 필터로 숨은 행 | 펼침 기억 → 필터를 풀면 펼친 채 다시 보임 | 접힘 |
2879
+ | `loadData` 재조회 | 같은 키의 행은 펼친 채 유지, 새 데이터에 없는 키는 목록에서 빠짐 | 모두 접힘 |
2880
+ | 커밋된 행 삭제 | 그 행의 키는 목록에서 빠짐 | 모두 접힘 |
2881
+
2882
+ - 유지할 때도 디테일 내용은 `renderer`로 **다시 그립니다**(이전 cleanup 실행 후 호출) — 바뀐 데이터를 반영하기 위해서입니다. 디테일 안의 입력값·스크롤 같은 DOM 상태는 남지 않습니다.
2883
+ - `keyFields` 없이 자동 행 ID(`enableReferCache: true`)를 쓰면 `loadData`마다 ID가 새로 붙으므로 재조회 뒤에는 접힙니다. 재조회 후에도 펼침을 유지하려면 `keyFields`를 지정하세요.
2884
+ - `visible:false`·Ctrl+H·데이터 행 고정·그룹/트리 접힘으로 안 보이는 동안 디테일은 표시하지 않습니다. 기본(`true`)은 행 키의 펼침 상태를 기억하여 다시 보일 때 복원하고, `false`는 기존처럼 접습니다.
2885
+ - `singleExpand: true`와 함께 쓰면 마지막으로 펼친 한 행만 유지됩니다.
2886
+
2608
2887
  ### GroupConfig
2609
2888
 
2610
2889
  초기 그리드 생성 시 `FlatModeConfig.rowGroup`으로 전달하는 가벼운 설정 (구 `group` 필드는 deprecated 별칭). *런타임*에는 `grid.rowGroup.setConfig()`로 더 풍부한 [GroupingConfig](#groupingconfig)를 사용합니다.
@@ -2659,6 +2938,8 @@ interface GroupingConfig {
2659
2938
  aggregates?: Record<string, AggregateFn>;
2660
2939
  /** 기본 접힘 상태 */
2661
2940
  defaultCollapsed?: boolean;
2941
+ /** 그룹 배너 선택 허용 (기본 false) — [그룹 배너 선택](#그룹-배너-선택-selectable) */
2942
+ selectable?: boolean;
2662
2943
  }
2663
2944
  ```
2664
2945
 
@@ -2667,8 +2948,12 @@ interface GroupingConfig {
2667
2948
  ```typescript
2668
2949
  interface EditorConfig {
2669
2950
  type: EditorType;
2670
- // 'text' | 'number' | 'date' | 'select' | 'checkbox'
2951
+ // 'text' | 'number' | 'date' | 'datetime' | 'select' | 'checkbox'
2671
2952
  // | 'dropdown' | 'datepicker' | 'multiselect' | 'custom'
2953
+ min?: number; // number 전용, 포함하는 최솟값
2954
+ max?: number; // number 전용, 포함하는 최댓값
2955
+ step?: number; // number 전용, 양수 간격 (기준: min 또는 0)
2956
+ inputMask?: string; // text 전용, 입력 마스크 (예: '99:99') — 표시 mask와 별개
2672
2957
  options?: EditorOption[]; // dropdown/select 옵션
2673
2958
  searchable?: boolean; // 검색 활성화 (dropdown, multiselect)
2674
2959
  placeholder?: string; // placeholder 텍스트
@@ -2680,6 +2965,68 @@ interface EditorConfig {
2680
2965
  }
2681
2966
  ```
2682
2967
 
2968
+ #### 숫자 입력 제약
2969
+
2970
+ `min`/`max`/`step`은 **`type: 'number'` 전용**입니다. 생략 시 범위 제한이 없고 소수 입력도 허용됩니다(`step` 생략 시 HTML input의 `step='any'`). `step`을 지정하면 `min ?? 0`을 기준으로 그 간격에 맞는 값만 확정할 수 있습니다. 소수 간격도 지원하며 값을 반올림하거나 범위에 맞춰 자동 보정하지 않습니다.
2971
+
2972
+ - 지정한 값은 유한수여야 하고 `min <= max`, `step > 0`이어야 합니다. 잘못된 설정은 **편집 시작 시** `RangeError`를 발생시킵니다.
2973
+ - 입력/붙여넣기 중 오류를 표시하고, 잘못된 값의 Enter/blur 확정을 차단하여 에디터와 기존 데이터를 유지합니다. Escape는 취소합니다.
2974
+ - 빈 입력은 `null`이며 필수 입력을 뜻하지 않습니다. 필수 규칙은 별도 `validation`으로 지정합니다. 브라우저가 `badInput`으로 보고하는 미완성 입력은 빈 값으로 확정하지 않습니다.
2975
+ - 이 옵션은 셀 에디터 제약이며 API 수정·클립보드 import·서버 데이터 전체에 대한 검증 규칙이 아닙니다. 그런 검증은 별도 `validation`/업무 로직에서 처리하세요. `editorResolver`가 반환하는 `EditorConfig`에도 같은 계약이 적용됩니다.
2976
+
2977
+ #### 텍스트 입력 마스크 (`inputMask`)
2978
+
2979
+ `inputMask`는 **`type: 'text'` 전용** 셀 에디터 입력 마스크입니다. 키 입력·에디터 안 붙여넣기·삭제·type-to-edit 첫 글자에 적용되고, 고정 문자를 자동으로 넣은 **문자열**을 확정합니다(`'1230'` 입력 → `'12:30'` 저장, Date 변환 없음). 다른 에디터 타입에서는 무시됩니다.
2980
+
2981
+ > 컬럼 **표시 mask**(`mask: { type: 'string', pattern }`, 토큰 `0`/`A`/`*`)와는 다른 기능·문법입니다. 표시 mask는 셀에 보이는 형식만 바꾸고 값을 바꾸지 않으며, `inputMask`는 편집 중 입력을 제한하고 확정 값에 고정 문자를 포함합니다. 두 옵션은 함께 쓸 수 있습니다.
2982
+
2983
+ | 토큰 | 의미 (Wijmo `InputMask`/`Column.mask`와 같음) |
2984
+ |------|------|
2985
+ | `0` | 필수 숫자 |
2986
+ | `9` | 선택 숫자 — 숫자 또는 공백 |
2987
+ | `L` | 필수 영문자 (ASCII a-z, A-Z) |
2988
+ | `\` | 다음 문자를 고정 문자로 (`\0`, `\A`) |
2989
+ | 그 외 | 고정 문자 — 입력 중 자동 삽입 |
2990
+
2991
+ - 허용되지 않는 문자와 마지막 자리 뒤 문자는 버립니다. 중간 삭제·붙여넣기 뒤에도 남은 문자를 앞에서부터 다시 맞춥니다(`'12:30'`에서 `2` 삭제 → `'13:0'`).
2992
+ - 필수 자리(`0`, `L`)가 빈 값(`'00:00'`에서 `'12:3'`)은 숫자 제약 위반과 같이 오류를 표시하고 Enter/blur 확정을 막습니다. Escape는 취소합니다. 선택 자리(`9`)는 비어도 확정합니다(`'99:99'`에서 `'12:3'` 그대로 확정 — Wijmo도 막지 않음). 빈 입력은 일반 text 에디터처럼 `''`로 확정하고, 편집 전 값을 그대로 닫는 것도 막지 않습니다.
2993
+ - 편집기를 열 때 기존 값도 마스크에 맞춰 보여 줍니다(`'0900'` → `'09:00'`). 그대로 확정하면 그 값으로 바뀝니다(Wijmo 동일).
2994
+ - Wijmo와 달리 빈 자리를 `_` 프롬프트로 채워 보여 주지 않습니다. 값은 앞에서부터 채운 문자열이며, 마지막으로 채운 자리 뒤에 자동 삽입한 고정 문자는 남기지 않습니다(`'12'`는 `'12'`, 직접 친 `'12:'`는 유지).
2995
+ - IME 조합 중에는 텍스트를 건드리지 않고, 조합이 끝나면 한 번 맞춥니다.
2996
+ - 지원하지 않는 Wijmo 토큰(`#`, `A`, `a`, `l`, `K`, `N`, `H`, `<`, `>`, `|`, 전각 토큰)이나 입력 자리가 없는 마스크는 **편집 시작 시** `RangeError`입니다. 고정 문자로 쓰려면 `\`로 이스케이프하세요. Wijmo의 `:`·`/`·`.`·`,`·`$` 지역화 문자 치환은 하지 않고 글자 그대로 씁니다.
2997
+ - validator가 아닙니다. 셀 에디터 입력에만 적용되며 클립보드 셀 붙여넣기·`bulkUpdate`·API 값 변경·서버 데이터는 검사하지 않습니다. 그런 검증은 `validation`/업무 로직에서 처리하세요. `editorResolver`가 반환한 설정에도 적용됩니다.
2998
+
2999
+ ```typescript
3000
+ { id: 'start_break_time', header: '시작 시간',
3001
+ editor: { type: 'text', inputMask: '99:99' } } // Wijmo mask="99:99" 대체
3002
+ ```
3003
+
3004
+ #### 날짜·시각 입력
3005
+
3006
+ `type: 'datetime'`은 초 단위 `datetime-local` 입력이며 **`Date | null`** 을 확정합니다. `date`/`datepicker`를 자동으로 대체하지 않으므로 날짜+시각 편집이 필요하면 명시하세요.
3007
+
3008
+ - 그리드 `timeZone`이 있으면 그 zone의 벽시계로 표시·해석하고, 생략하면 브라우저 로컬 zone을 사용합니다. offset 있는 문자열/`Date`의 절대 시각과 offset 없는 문자열의 벽시계 의미, DST `compatible` 해석은 [날짜 시간대 계약](#날짜-시간대-계약-timezone)을 따릅니다.
3009
+ - 빈 값은 `null`, 잘못되거나 미완성인 입력은 확정 차단입니다. 변경하지 않은 원본 `Date`를 확정할 때는 밀리초 및 DST 중복 시각의 원래 instant를 보존합니다. 문자열 입력은 확정 시 `Date`로 변환됩니다.
3010
+ - `min`/`max`/`step`은 datetime 범위/간격 옵션이 아닙니다. `editor.dateFormat`은 기존 datepicker 설정으로 유지되며, datetime 표시 mask나 XLSX export 옵션이 아닙니다.
3011
+
3012
+ ```typescript
3013
+ const editorFields: FieldDef[] = [
3014
+ { id: 'quantity', header: '수량', dataType: 'number',
3015
+ editor: { type: 'number', min: 0, max: 100, step: 0.5 } },
3016
+ { id: 'scheduledAt', header: '예정 시각', dataType: 'date',
3017
+ editor: { type: 'datetime' },
3018
+ mask: { type: 'date', pattern: 'YYYY-MM-DD HH:mm:ss' } },
3019
+ ];
3020
+ const editorGrid = new PureSheet(container, {
3021
+ enableReferCache: true, editable: true, timeZone: 'Asia/Seoul',
3022
+ fields: editorFields,
3023
+ data: [{ quantity: 1, scheduledAt: new Date('2026-08-31T15:12:34Z') }],
3024
+ });
3025
+ await editorGrid.waitForReady();
3026
+ // XLSX 날짜 표시에는 editor.dateFormat이 아니라 mask + preserveFormatting 사용
3027
+ await editorGrid.io.saveExcelToFile({ preserveFormatting: true });
3028
+ ```
3029
+
2683
3030
  ### MaskConfig
2684
3031
 
2685
3032
  5종 discriminated union. 공통 필드(`type`, `pattern`, `prefix?`, `suffix?`) + 타입별 추가 필드.
@@ -2908,7 +3255,7 @@ interface PivotValueField {
2908
3255
  | `PinPosition` | `'left' \| 'right' \| 'none'` |
2909
3256
  | `RowState` | `'pristine' \| 'added' \| 'modified' \| 'deleted'` |
2910
3257
  | `CellState` | `'pristine' \| 'modified'` |
2911
- | `EditorType` | `'text' \| 'number' \| 'date' \| 'select' \| 'checkbox' \| 'dropdown' \| 'datepicker' \| 'multiselect' \| 'custom'` |
3258
+ | `EditorType` | `'text' \| 'number' \| 'date' \| 'datetime' \| 'select' \| 'checkbox' \| 'dropdown' \| 'datepicker' \| 'multiselect' \| 'custom'` |
2912
3259
  | `EngineType` | `'native'` — **`'aq'`(Arquero)는 제거됨.** 단일 엔진화로 유니온에 값이 하나만 남았습니다(ITSM-2026-002190) |
2913
3260
  | `TotalPosition` | `'before' \| 'after'` |
2914
3261
 
@@ -3367,11 +3714,13 @@ interface PerformanceTiming {
3367
3714
 
3368
3715
  ### 데이터 로더 — LoaderConfig
3369
3716
 
3717
+ > 현재 Worker는 런타임 임의 URL import가 아니라 **앱 Worker entry의 정적 loader map**을 사용합니다. `module`은 map의 정확한 키입니다. [Worker 등록·데이터 세션 계약](runtime-contracts.md)을 먼저 확인하세요.
3718
+
3370
3719
  Web Worker에서 직접 데이터를 페칭하는 모듈을 지정합니다. 메인스레드 비경유로 UI 블로킹 없음.
3371
3720
 
3372
3721
  ```typescript
3373
3722
  interface LoaderConfig {
3374
- /** DataLoader를 default export하는 ES 모듈 URL */
3723
+ /** 앱 Worker entry의 loaders map에 등록한 키 (URL이면 문자열 표현과 일치해야 함) */
3375
3724
  module: URL | string;
3376
3725
  /** 초기 파라미터 (직렬화 가능한 plain object). 런타임에 grid.updateLoaderParams()로 갱신 */
3377
3726
  params?: Record<string, unknown>;
@@ -3390,8 +3739,11 @@ export default class MyLoader extends DataLoader {
3390
3739
 
3391
3740
  // 그리드 생성
3392
3741
  new PureSheet(container, {
3742
+ enableReferCache: true,
3743
+ useWorker: true,
3744
+ workerFactory: () => new Worker(new URL('./app-grid.worker.ts', import.meta.url), { type: 'module' }),
3393
3745
  loader: {
3394
- module: new URL('./my-loader.ts', import.meta.url),
3746
+ module: 'app:data', // worker entry: installProcessorWorker({ loaders: { 'app:data': MyLoader } })
3395
3747
  params: { authToken: 'abc' },
3396
3748
  },
3397
3749
  fields: [...],
@@ -3503,11 +3855,15 @@ interface BuiltInMergeConfig {
3503
3855
  strategy?: 'lazy' | 'eager';
3504
3856
  /** Lazy 모드 최대 탐색 깊이 @default 500 */
3505
3857
  maxSearchDepth?: number;
3858
+ /** 고정 행(pinnedTop/pinnedBottom)에도 적용 — 각 고정 영역 안에서만 병합 @default false(본문만) */
3859
+ includePinned?: boolean;
3506
3860
  }
3507
3861
 
3508
3862
  interface CustomMergeConfig {
3509
3863
  type: 'custom';
3510
3864
  columns: string[];
3865
+ /** 고정 행에도 콜백 호출(area로 구분) @default false(본문만 — 기존 동작) */
3866
+ includePinned?: boolean;
3511
3867
  /** Worker 모드에서 getRowData 행 데이터 버퍼 범위 @default 500 */
3512
3868
  maxSearchDepth?: number;
3513
3869
  /** 셀별 병합 범위 콜백 (null 반환 = 병합 없음) */
@@ -3521,8 +3877,11 @@ interface CustomMergeConfig {
3521
3877
  // 가로: colIndex / columnsInOrder / totalColumnCount
3522
3878
  // 2D 공용: getCellValue(rowIndex, columnId)
3523
3879
  interface MergeRangeContext {
3880
+ /** 행 영역 — 본문은 항상 'body'. 고정 영역은 includePinned:true일 때만 불린다 */
3881
+ area: 'body' | 'pinnedTop' | 'pinnedBottom';
3882
+ /** area 기준 행 번호 (고정 영역은 그 영역 안의 순번, 첫 행 0). getRowData/getCellValue/totalRowCount도 같은 기준 */
3524
3883
  rowIndex: number;
3525
- /** 현재 컬럼의 렌더(가시) 순서 인덱스 — `rowIndex`의 가로 대칭. `columnsInOrder` 기준 */
3884
+ /** 현재 컬럼의 `setMergeConfig` `columns` 배열 인덱스(화면 순서 아님) — `rowIndex`의 가로 대칭. `columnsInOrder` 기준 */
3526
3885
  colIndex: number;
3527
3886
  columnId: string;
3528
3887
  /** columns(설정) 배열 내 인덱스 (계층 레벨) */
@@ -3532,10 +3891,10 @@ interface MergeRangeContext {
3532
3891
  getRowData: (index: number) => Row | null;
3533
3892
  /** 임의 셀 값 조회 (2D 경계 탐색용) — `getRowData(rowIndex)?.[columnId]`와 동치 */
3534
3893
  getCellValue: (rowIndex: number, columnId: string) => CellValue;
3535
- /** 렌더(가시) 순서의 전체 컬럼 ID 목록 (가로 경계 탐색용) */
3894
+ /** 병합 대상 컬럼 ID 목록 — `setMergeConfig`의 `columns` 배열 그대로 (가로 경계 탐색용) */
3536
3895
  columnsInOrder: readonly string[];
3537
3896
  totalRowCount: number;
3538
- /** 전체(가시) 컬럼 수 — `totalRowCount`의 가로 대칭 (= columnsInOrder.length) */
3897
+ /** 병합 대상 컬럼 수 — `totalRowCount`의 가로 대칭 (= columnsInOrder.length = columns.length) */
3539
3898
  totalColumnCount: number;
3540
3899
  /** 이전 컬럼의 병합 범위 (계층적 커스텀용) */
3541
3900
  parentRange?: { startRow: number; endRow: number };
@@ -3545,12 +3904,25 @@ interface MergeRangeContext {
3545
3904
  interface MergeRangeResult {
3546
3905
  startRow: number; // inclusive
3547
3906
  endRow: number; // inclusive
3548
- startCol?: number; // inclusive, 렌더 컬럼 인덱스. 생략 시 현재 컬럼(세로 전용)
3549
- endCol?: number; // inclusive, 렌더 컬럼 인덱스. 생략 시 현재 컬럼
3907
+ startCol?: number; // inclusive, `columns` 배열 인덱스. 생략 시 현재 컬럼(세로 전용)
3908
+ endCol?: number; // inclusive, `columns` 배열 인덱스. 생략 시 현재 컬럼
3550
3909
  }
3551
3910
  ```
3552
3911
 
3553
- > **세로 / 가로 / 2D 병합** — `startCol`/`endCol`을 **생략하면 세로 병합**(현재 컬럼만, 기존 `{ startRow, endRow }` 계약 그대로), **지정하면 가로/2D 병합**입니다. 컬럼 인덱스는 `columnsInOrder`(렌더/가시 순서) 기준이며, 가로 병합은 **같은 파티션(center/pinned) 내 연속 컬럼**만 시각적으로 올바릅니다. 영역 내 아무 셀에서 같은 사각형을 반환해도 전략이 anchor=`(startRow, startCol)`를 잡고 나머지를 숨김 처리합니다. `null` 또는 단일 셀(`startRow===endRow && startCol===endCol`)이면 병합 없음.
3912
+ > **세로 / 가로 / 2D 병합** — `startCol`/`endCol`을 **생략하면 세로 병합**(현재 컬럼만, 기존 `{ startRow, endRow }` 계약 그대로), **지정하면 가로/2D 병합**입니다. 컬럼 인덱스(`colIndex`/`startCol`/`endCol`)는 **`setMergeConfig`의 `columns` 배열 순서**(= `columnsInOrder`) 기준이며 화면(가시) 컬럼 순서가 아닙니다 — 사용자가 컬럼을 숨기거나 옮겨도 인덱스는 변하지 않습니다. 단 병합 칸은 앵커 컬럼(`columns[startCol]`)부터 오른쪽으로 그려지므로, 가로 병합은 덮는 컬럼들이 **화면에서도 같은 순서로 붙어 있고 같은 파티션(center/pinned) 안에 있을 때만** 시각적으로 올바릅니다. 화면상 사이에 다른 컬럼이 끼거나 순서가 뒤바뀌면 끼어든 컬럼까지 덮어 보일 수 있으며, 이때 `console.warn`을 영역·컬럼 조합당 한 번(병합 설정이나 가시 컬럼 배치가 바뀌면 다시) 남깁니다. 숨긴 컬럼은 화면 폭이 없으므로 병합 컬럼 사이에 끼어 있어도 문제가 되지 않습니다 — 병합 칸은 덮는 병합 컬럼 중 보이는 것들의 폭 합으로 그려집니다(본문·고정 행 동일). 영역 내 아무 셀에서 같은 사각형을 반환해도 전략이 anchor=`(startRow, startCol)`를 잡고 나머지를 숨김 처리합니다. `null` 또는 단일 셀(`startRow===endRow && startCol===endCol`)이면 병합 없음.
3913
+
3914
+ > **고정 행 병합 (`includePinned`)** — 기본값(`false`)은 기존 동작 그대로 본문만 병합합니다. `true`면 같은 설정이 상단/하단 고정 행 영역에도 적용되며, 병합은 **각 영역 안에서만** 일어납니다(본문↔고정 영역을 넘는 병합 없음). 콜백은 `area`로 영역을 구분하고, 반환 `startRow`/`endRow`는 그 영역 안의 행 번호입니다. 좌우 고정 컬럼은 영역이 아니므로 같은 컬럼 구역 안에서만 가로 병합되며, 경계를 넘는 범위는 경고 후 무시됩니다. 병합 셀(본문·고정 행 모두)은 앵커(좌상단) 셀이 단독으로 그려질 때와 같게 — 앵커 컬럼의 `cellRenderer`로 그려지며(집계 고정 행은 원래처럼 포맷 텍스트), 데이터 고정 행의 클릭/선택은 앵커 셀로 전달되고 선택 강조는 본문 병합 셀과 같은 규칙(덮는 셀/행 중 하나라도 선택이면 강조)을 따릅니다(복사에는 병합이 반영되지 않음. 엑셀 내보내기는 `includePinnedRows: true`일 때 고정 행 병합을 반영).
3915
+ >
3916
+ > ```typescript
3917
+ > // 하단 집계 4행(sum/avg/min/max)의 앞 3열을 한 칸으로
3918
+ > grid.setMergeConfig({
3919
+ > type: 'custom',
3920
+ > columns: ['prod', 'site', 'grade'],
3921
+ > includePinned: true,
3922
+ > getMergedRange: ({ area }) =>
3923
+ > area === 'pinnedBottom' ? { startRow: 0, endRow: 3, startCol: 0, endCol: 2 } : null,
3924
+ > });
3925
+ > ```
3554
3926
 
3555
3927
  > **Worker 모드 제약**: `type: 'custom'`은 함수 직렬화 불가로 worker에서 사용 불가. content/hierarchical만 worker 호환.
3556
3928
 
@@ -3579,6 +3951,8 @@ interface StreamingOptions {
3579
3951
 
3580
3952
  `grid.applyTransaction(tx, options?)` / `applyTransactionAsync(tx, callback?)`에서 사용.
3581
3953
 
3954
+ `update`의 raw patch는 명시한 업무 키로 대상을 식별하는 경로입니다. 자동 ID 모드에서 `{ name: '수정' }` 같은 새 객체는 기존 occurrence를 식별하지 못합니다. ID와 값을 분리한 `grid.changes.updateCellById(rowId, field, value)`를 사용하세요. 아래 예제는 `keyFields: ['id']`로 생성한 그리드를 전제로 합니다.
3955
+
3582
3956
  ```typescript
3583
3957
  interface RowTransaction {
3584
3958
  add?: Row[];
@@ -3817,13 +4191,34 @@ interface CellRendererParams {
3817
4191
  value: CellValue;
3818
4192
  rowData: Record<string, unknown>;
3819
4193
  columnId: string;
4194
+ /**
4195
+ * 업무 행 ID — `grid.rows.getById(rowId)`로 해석되는 값이며 `cell:click` payload의 `rowId`·`data-row-id`와 같다.
4196
+ * 단일 keyFields는 `String(값)`(키 resolver 규칙), 복합 키는 인코딩된 문자열, keyFields 없음은 자동 ID(`__ps_auto_N`, loadData마다 바뀜).
4197
+ * 고정(pinned) 데이터행은 고정 영역 cell:click과 같은 문자열 ID, 피벗은 피벗 행 nodeId.
4198
+ * 그룹행·구조행처럼 행 ID가 없는 셀에서는 키 자체가 없다(내부 Row 인스턴스 id `row-N`은 노출하지 않음).
4199
+ */
3820
4200
  rowId?: string | number;
3821
4201
  /**
3822
4202
  * 행 선택 상태 — rowHeader 셀(`rowHeader.cellRenderer`) 렌더 시에만 채워짐. 일반 데이터 셀은 undefined.
3823
4203
  * 렌더 시점 스냅샷이라 선택 "변경"만으로는 재렌더되지 않는다 — CSS(`.ps-row.ps-selected`) /
3824
4204
  * `ps-row-header-checkbox-input` 클래스 / `repaintRows`로 반영.
4205
+ * 그룹행(`rowHeader.includeGroupRows`)에서는 그룹 배너 선택(`rowGroupConfig.selectable`) 상태.
3825
4206
  */
3826
4207
  selected?: boolean;
4208
+ /**
4209
+ * 그룹행 정보 — 그룹행 셀 렌더 시에만 채워짐(현재 `rowHeader.includeGroupRows: true`의 rowHeader 셀).
4210
+ * 데이터행에는 키가 없다. 멤버는 `grid.rowGroup.getGroupRows(group.groupId)`.
4211
+ */
4212
+ group?: CellRendererGroupInfo;
4213
+ }
4214
+
4215
+ interface CellRendererGroupInfo {
4216
+ groupId: string; // "col:val/col:val" 경로 — 같은 하위값이라도 부모가 다르면 다르다
4217
+ level: number; // 최상위 0
4218
+ column: string; // 이 레벨의 그룹 컬럼 id
4219
+ value: CellValue; // 그룹 값
4220
+ collapsed: boolean; // 렌더 시점 접힘 여부
4221
+ itemCount: number; // 하위 데이터행 수
3827
4222
  }
3828
4223
 
3829
4224
  /** Simple (arity 1) — vanilla JS 사용자 */
@@ -3840,7 +4235,7 @@ interface CellRendererInstance {
3840
4235
  * 셀이 재사용되며 데이터가 바뀔 때 호출.
3841
4236
  * - 반환 `false`: instance가 처리 못함 → 그리드가 destroy 후 re-mount
3842
4237
  * - 반환 `void`/`true`: instance가 자체 갱신함
3843
- * 미정의 시: 첫 mount만 실행, 이후 framework reactivity에 위임.
4238
+ * 미정의 시: 값/행이 바뀌면(값 변경, 스크롤 재활용) destroy 후 re-mount. 그대로면 유지.
3844
4239
  */
3845
4240
  update?: (params: CellRendererParams) => boolean | void;
3846
4241
  /** 셀이 사라질 때 호출 — pool release / 컬럼 제거 / grid destroy 시 */
@@ -3970,7 +4365,7 @@ interface GridConfigSnapshot {
3970
4365
  **현재 직렬화 안 되는 옵션 (향후 추가 여지)**:
3971
4366
  - `engine`, `useWorker`, `dynamicRowHeights`, `rowHeightChangeThreshold`
3972
4367
  - `enableReferCache`, `preventKeyEdit`
3973
- - `rowStyle`, `headerStyle` (CSS 문자열 — `rowHeight`/`headerHeight` 와 일부 중복)
4368
+ - `rowStyle`, `headerStyle` (`height:Npx` 추출 alias — 일반 CSS 적용 아님)
3974
4369
  - `PivotConfig.sorts` / `PivotConfig.filters` (피벗 전용 — `GridConfigSnapshot.sorts/filters` 와 별개)
3975
4370
 
3976
4371
  ---
@@ -3981,7 +4376,8 @@ interface GridConfigSnapshot {
3981
4376
 
3982
4377
  | Export | 값/시그니처 | 설명 |
3983
4378
  |---|---|---|
3984
- | `GRID_CONFIG_VERSION` | `'0.3'` | 현재 저장 스키마 버전. `getConfig()` 가 항상 이 값을 실어 내보냄 |
4379
+ | `GRID_CONFIG_VERSION` | `'0.5'` | 현재 저장 스키마 버전. `getConfig()` 가 항상 이 값을 실어 내보냄 |
4380
+ | `SNAPSHOT_CONFIG_VERSION` | `'0.5'` | 이 버전 이상 저장본은 전체 상태 — `applyConfig` 가 `missing` 미지정 시 `'reset'` 으로 해석하는 경계(원래 버전 기준) |
3985
4381
  | `LEGACY_CONFIG_VERSION` | `'0.1'` | 버전 필드가 없는 저장본을 읽을 때 가정하는 버전 |
3986
4382
  | `compareConfigVersion(a, b)` | `number` | 버전 비교 — **숫자 단위**로 봄(`a<b` 음수, 같으면 0, `a>b` 양수) |
3987
4383
  | `GridConfigMigrator` | `(config: GridConfigExport, fromVersion: string) => GridConfigExport` | 하위호환 변환기 타입. `PureSheetConfig.migrateConfig` 로 주입 |
@@ -3993,6 +4389,8 @@ interface GridConfigSnapshot {
3993
4389
  | `0.1` | 버전 필드가 없던 시절. `preFilters`/`preSorts` 사용, 수식은 `[라벨]` 참조 |
3994
4390
  | `0.2` | 수식 컬럼 참조가 `$id` 기준. `preFilters`/`preSorts` 폐지 |
3995
4391
  | `0.3` | 필터 소유권(`filterGroups`) 도입. 소유자 없는 옛 조건식을 고급 필터 수식으로 이관 |
4392
+ | `0.4` | 행 그룹 키 `rowGroupConfig`·집계 `aggregates`, 정렬 소유권(`sortGroups`) |
4393
+ | `0.5` | 구조 동일. **의미 변경**: 저장본이 전체 상태(snapshot) — 현재 값·명시적 "없음" 을 싣고, 빠진 항목은 기본 보기로 초기화(`missing: 'reset'`) |
3996
4394
 
3997
4395
  > **왜 키 존재 여부로는 부족한가** — 지금까지 하위호환은 키 존재 여부로 감지했습니다(`preFilters` 가 있으면 구버전). 그런데 **키는 그대로인데 값의 의미만 바뀌는** 변경에서는 통하지 않습니다:
3998
4396
  > ```
@@ -4005,11 +4403,12 @@ interface GridConfigSnapshot {
4005
4403
  >
4006
4404
  > **마이그레이션은 누적 적용입니다.** `0.1` 저장본이 들어오면 `0.1 → 0.2 → …` 를 **순서대로 전부** 거칩니다. 최신 변환만 돌리면 중간 단계를 건너뛰어 조용히 어긋납니다.
4007
4405
  >
4008
- > **변환 내용은 소비처(wrapper) 소유입니다.** core 는 호출 시점과 순서만 책임집니다 — core 가 변환까지 들고 있으면 소비처 고유 필드(`customCellConfig` 등)를 다룰 수 없고, 소비처마다 다른 진화 속도를 강제하게 됩니다. 변환기가 없거나 이미 최신이면 받은 것을 그대로 돌려줍니다(부작용 없음). 변환 중 예외가 나도 복원 자체는 막지 않고 경고(`PS901`)만 남깁니다.
4406
+ > **변환 내용은 소비처(wrapper) 소유입니다.** core 는 호출 시점과 순서만 책임집니다 — core 가 변환까지 들고 있으면 소비처 고유 필드(`customCellConfig` 등)를 다룰 수 없고, 소비처마다 다른 진화 속도를 강제하게 됩니다. 변환기가 없거나 이미 최신이면 받은 것을 그대로 돌려줍니다(부작용 없음). 변환 중 예외가 나면 **아무것도 적용하지 않고** `PS901` 을 던집니다(변환 전 원본으로 진행하면 옛 스키마 값이 오해석된 채 일부만 적용됨). 변환 결과도 적용 전에 형태 검증(`PS903`)을 거칩니다.
4009
4407
 
4010
4408
  ```typescript
4011
4409
  const grid = new PureSheet(el, {
4012
4410
  fields,
4411
+ enableReferCache: true,
4013
4412
  migrateConfig: (cfg, fromVersion) => {
4014
4413
  let out = cfg;
4015
4414
  if (compareConfigVersion(fromVersion, '0.2') < 0) out = to02(out);
@@ -4243,7 +4642,7 @@ interface GroupRowContext {
4243
4642
  itemCount: number;
4244
4643
  collapsed: boolean;
4245
4644
  aggregates: Record<string, CellValue>;
4246
- element: HTMLElement;
4645
+ element: HTMLElement; // 그룹행 요소. 예약 키 '_groupTemplate'(Vue GroupTemplate) 핸들러만 보이는 그룹 배너 안 템플릿 호스트를 받는다
4247
4646
  }
4248
4647
 
4249
4648
  interface SubtotalContext {
@@ -4515,6 +4914,13 @@ interface RowHeaderConfig {
4515
4914
  * checked를 자동 갱신한다. rowSelection.trigger 'checkbox' 활성 시 이 renderer가 built-in 체크박스를 대체한다.
4516
4915
  */
4517
4916
  cellRenderer?: CellRenderer;
4917
+ /**
4918
+ * 그룹행 rowHeader 셀에서도 cellRenderer를 호출 (opt-in). @default false
4919
+ * false: 기존대로 그룹행 rowHeader는 비운다. true: params.group(그룹 정보)과 함께 호출 —
4920
+ * rowData는 다른 그룹 셀 renderer와 같은 합성 객체(컬럼 id 키 집계값), rowId는 없음.
4921
+ * cellRenderer가 없으면 효과 없음.
4922
+ */
4923
+ includeGroupRows?: boolean;
4518
4924
  /** 코너(좌상단) 헤더 셀 렌더러. 반환은 HTMLElement 또는 문자열 */
4519
4925
  headerRenderer?: (params: { columnId: string; header: string }) => HTMLElement | string;
4520
4926
  }
@@ -4544,6 +4950,38 @@ grid.formatHeader.addHandler('corner', (ctx) => {
4544
4950
  });
4545
4951
  ```
4546
4952
 
4953
+ **그룹행에도 그리기 — `includeGroupRows`** (레거시 Wijmo 행 헤더의 업무 체크박스 `is_checked` 이식 예)
4954
+
4955
+ - 그룹행 rowHeader에서도 renderer가 호출되고 `params.group`이 채워집니다. 데이터행·그룹행을 `params.group` 유무로 구분하세요.
4956
+ - **클릭 동작은 바뀌지 않습니다.** 그룹행 rowHeader 칸 클릭은 기존대로 `rowGroupConfig.selectable`이면 그룹 선택, 아니면 접기/펼치기이고, 데이터행은 `rowSelection.trigger` 규칙을 따릅니다. renderer가 그린 요소에서 그리드 동작(접기/펼치기·선택·드래그 선택 시작)을 막으려면 그 요소의 `mousedown`과 `click`에서 `stopPropagation()`을 호출하세요(그리드는 viewport에서 버블링으로 받습니다).
4957
+ - 업무 체크는 선택과 독립입니다(선택 tri-state `rowSelection.groupRowCheckbox`는 `cellRenderer`가 있으면 원래 꺼집니다). 같은 하위값이 다른 부모 아래에 있어도 `group.groupId` 경로로 구분되므로 멤버는 반드시 `getGroupRows(groupId)`로 조회합니다.
4958
+ - 행 재사용(접기/스크롤)으로 같은 셀이 데이터↔그룹 행을 오가면 Lifecycle 인스턴스는 `destroy` 후 새 종류로 다시 mount됩니다.
4959
+
4960
+ ```ts
4961
+ const grid = new PureSheet(el, {
4962
+ fields, keyFields: ['id'], enableReferCache: true, // rows.getById 에 필요
4963
+ rowGroupConfig: { columns: ['parent', 'child'] },
4964
+ rowHeader: {
4965
+ includeGroupRows: true,
4966
+ cellRenderer: (p) => {
4967
+ const box = document.createElement('input');
4968
+ box.type = 'checkbox';
4969
+ const ids = p.group ? grid.rowGroup.getGroupRows(p.group.groupId) : [p.rowId!];
4970
+ box.checked = ids.length > 0 && ids.every(id => grid.rows.getById(id).data.is_checked === true);
4971
+ // 그리드 동작(접기/펼치기·그룹/행 선택·드래그 시작) 차단
4972
+ box.addEventListener('mousedown', e => e.stopPropagation());
4973
+ box.addEventListener('click', (e) => {
4974
+ e.stopPropagation();
4975
+ for (const id of ids) grid.rows.getById(id).setValue('is_checked', box.checked);
4976
+ });
4977
+ return box;
4978
+ },
4979
+ },
4980
+ });
4981
+ ```
4982
+
4983
+ > 그룹 체크 상태는 멤버 값에서 파생합니다. 멤버를 `setValue`로 바꾸면 그리드가 화면 행을 다시 그리면서 그룹행 renderer도 다시 호출되어 표시가 맞춰집니다(값을 그리드 밖에서 바꿨다면 `grid.refresh()`).
4984
+
4547
4985
  > 참고: `cellRenderer`를 주면 커스텀 렌더가 그려집니다(예: 체크박스 컬럼 대체). `rowHeader: true`(구 `showRowHeader: true`)는 **빈 셀**(너비 40, 체크박스 트리거 시 체크박스)만 그리는 축약형입니다 — 행 번호를 렌더하지 않습니다.
4548
4986
 
4549
4987
  ---
@@ -4586,7 +5024,7 @@ interface CellAttributeParams {
4586
5024
 
4587
5025
  ### DataLoader 클래스 — 워커 기반 커스텀 데이터 로딩
4588
5026
 
4589
- `LoaderConfig.module`이 default export하는 클래스의 베이스. 워커 안에서 실행되므로 DOM/localStorage 접근은 불가하고, `fetch`/`WebSocket`/`XMLHttpRequest` 등 네트워크 API는 사용 가능.
5027
+ 앱 Worker entry의 `installProcessorWorker({ loaders })` map에 등록하는 클래스의 베이스. 워커 안에서 실행되므로 DOM/localStorage 접근은 불가하고, `fetch`/`WebSocket`/`XMLHttpRequest` 등 네트워크 API는 사용 가능.
4590
5028
 
4591
5029
  ```typescript
4592
5030
  abstract class DataLoader {
@@ -4625,8 +5063,11 @@ export default class MyLoader extends DataLoader {
4625
5063
 
4626
5064
  // 그리드 생성
4627
5065
  new PureSheet(container, {
5066
+ enableReferCache: true,
5067
+ useWorker: true,
5068
+ workerFactory: () => new Worker(new URL('./app-grid.worker.ts', import.meta.url), { type: 'module' }),
4628
5069
  loader: {
4629
- module: new URL('./my-loader.ts', import.meta.url),
5070
+ module: 'app:data', // worker entry: installProcessorWorker({ loaders: { 'app:data': MyLoader } })
4630
5071
  params: { authToken: getToken() },
4631
5072
  },
4632
5073
  fields: [...],
@@ -4637,7 +5078,7 @@ await grid.updateLoaderParams({ authToken: newToken });
4637
5078
  await grid.refreshLoaderData();
4638
5079
  ```
4639
5080
 
4640
- > **`InlineDataLoader`**: 동일 모듈 내 인라인 클래스를 사용하고 싶을 때(URL이 아닌 클래스 자체 전달) 내부적으로 사용되는 변형. 일반 사용 시에는 `LoaderConfig.module` URL 패턴 권장.
5081
+ > **Worker loader 경계**: `LoaderConfig.module`에 클래스나 임의 URL을 전달해 동적 실행시키는 방식은 지원하지 않습니다. 앱에서 loader 클래스를 정적으로 import하고 등록하세요. `InlineDataLoader`라는 export의 존재가 런타임 클래스 전달 지원을 뜻하지 않습니다.
4641
5082
 
4642
5083
  ---
4643
5084
 
@@ -4684,8 +5125,8 @@ class UndoStack {
4684
5125
  const sharedUndo = new UndoStack({ maxSize: 200 });
4685
5126
  sharedUndo.attachKeyboard(document.getElementById('page'));
4686
5127
 
4687
- const grid1 = new PureSheet(el1, { undoStack: sharedUndo, fields, data });
4688
- const grid2 = new PureSheet(el2, { undoStack: sharedUndo, fields, data });
5128
+ const grid1 = new PureSheet(el1, { undoStack: sharedUndo, fields, data, enableReferCache: true });
5129
+ const grid2 = new PureSheet(el2, { undoStack: sharedUndo, fields, data, enableReferCache: true });
4689
5130
 
4690
5131
  // 한 곳에서 Ctrl+Z → 어느 그리드든 마지막 작업 복원
4691
5132
  // 두 그리드에서 일어난 변경이 동일 시간순으로 스택에 쌓임
@@ -4788,7 +5229,7 @@ import { PS, psError, psWarn, ErrorMessages } from '@vmscloud/moz-ui-grid-core';
4788
5229
  | `PS501` | `PS.WORKER_SYNC_API` | Worker에서 getAllDataSync 호출 |
4789
5230
  | `PS502` | `PS.WORKER_DATA_DIRECT` | Worker 모드에서 `data` 직접 전달 불가 — `loader`를 사용할 것 |
4790
5231
  | **행 ID / 캐시 (PS601~)** | | |
4791
- | `PS601` | `PS.ROW_ID_REQUIRED` | keyFields 미지정 + id 필드 없음 |
5232
+ | `PS601` | `PS.ROW_ID_REQUIRED` | 행 ID 추출 실패 진단. 키 생략 시 자동 모드는 `enableReferCache: true`가 필수이며, 빈 `keyFields`/캐시 미설정 초기화 오류는 별도 `Error`로 거부됨 (`id` 필드 자동 추론 없음) |
4792
5233
  | `PS602` | `PS.REFER_CACHE_REQUIRED` | 행 객체 API에 enableReferCache 필요 |
4793
5234
  | `PS603` | `PS.KEY_CONFLICT_COMMITTED` | 커밋된 데이터와 키 중복 |
4794
5235
  | `PS606` | `PS.KEY_LOAD_DUPLICATES` | loadData 중복 키 |