({
+ renderTime: 0,
+ componentCount: 0,
+ memoryUsage: 0,
+ updateCount: 0
+ })
+
+ const startTime = performance.now()
+ let updateCount = 0
+
+ const measureRenderTime = () => {
+ metrics.value.renderTime = performance.now() - startTime
+ }
+
+ const measureMemoryUsage = () => {
+ if ('memory' in performance) {
+ metrics.value.memoryUsage = (performance as any).memory.usedJSHeapSize
+ }
+ }
+
+ const incrementUpdateCount = () => {
+ updateCount++
+ metrics.value.updateCount = updateCount
+ }
+
+ // 개발 모드에서만 성능 측정
+ if (process.env.NODE_ENV === 'development') {
+ onMounted(() => {
+ measureRenderTime()
+ measureMemoryUsage()
+
+ console.group(`Performance Metrics - ${componentName}`)
+ console.log('Render Time:', metrics.value.renderTime.toFixed(2), 'ms')
+ console.log('Memory Usage:', (metrics.value.memoryUsage / 1024 / 1024).toFixed(2), 'MB')
+ console.groupEnd()
+ })
+
+ const observer = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (entry.name.includes(componentName)) {
+ console.log(`${componentName} Performance:`, entry)
+ }
+ }
+ })
+
+ observer.observe({ entryTypes: ['measure', 'navigation'] })
+
+ onUnmounted(() => {
+ observer.disconnect()
+ })
+ }
+
+ return {
+ metrics,
+ measureRenderTime,
+ measureMemoryUsage,
+ incrementUpdateCount
+ }
+}
+```
+
+---
+
+## 🚀 실무 적용 체크리스트
+
+### ✅ 기본 준비사항
+
+- [ ] TypeScript 5.0+ 설정 완료
+- [ ] Vue 3.3+ 사용 (최신 TypeScript 지원)
+- [ ] Vite/Webpack TypeScript 설정 최적화
+- [ ] ESLint + TypeScript 규칙 설정
+
+### ✅ 컴포넌트 설계
+
+- [ ] Props interface 명확히 정의
+- [ ] Emits 타입 안전하게 정의
+- [ ] Slots 타입 정의 (필요시)
+- [ ] 복잡한 상태는 composable로 분리
+
+### ✅ 타입 시스템
+
+- [ ] 전역 타입 정의 (types/ 폴더)
+- [ ] API 응답 타입 정의
+- [ ] 이벤트 핸들러 타입 정의
+- [ ] 유틸리티 타입 활용
+
+### ✅ 성능 최적화
+
+- [ ] 큰 객체는 shallowRef 사용
+- [ ] 무거운 컴포넌트는 defineAsyncComponent
+- [ ] 메모리 누수 방지 패턴 적용
+- [ ] 불필요한 re-render 방지
+
+### ✅ 에러 핸들링 & 테스트
+
+- [ ] 컴포넌트 레벨 에러 처리
+- [ ] 타입 가드로 런타임 검증
+- [ ] 테스트 가능한 구조 설계
+- [ ] 성능 모니터링 (개발 환경)
+
+---
+
+이 가이드는 **실무에서 바로 사용할 수 있는** 패턴들로 구성했습니다.
diff --git a/reference/2025-05-28-ts-migration-tutorial.md b/reference/2025-05-28-ts-migration-tutorial.md
new file mode 100644
index 0000000..781160c
--- /dev/null
+++ b/reference/2025-05-28-ts-migration-tutorial.md
@@ -0,0 +1,941 @@
+---
+title: VDropdown.vue TypeScript 변환 완전 가이드
+date: "2025-05-28"
+tags: [vue3, typescript]
+categories: TypeScript
+permalink: /blog/:year/:month/:day/:title/
+last_modified_at: "2025-05-28"
+---
+
+## 📋 왜 VDropdown부터 시작할까요?
+
+- ✅ **단순한 구조**: Props 2개, Event 1개
+- ✅ **명확한 역할**: 드롭다운 선택 UI
+- ✅ **타입 정의 연습하기 좋음**: 기본적인 패턴들 학습 가능
+- ✅ **의존성 적음**: 다른 복잡한 컴포저블에 의존하지 않음
+
+## 🔍 현재 VDropdown.vue 분석
+
+### 코드 구조 파악
+
+```vue
+
+
+
+ {{ text }}
+
+
+
+
+
+```
+
+### 🤔 분석 질문들
+
+#### 1. Props 타입: `options`는 정확히 어떤 타입의 배열일까요?
+
+
+💡 답변 보기
+
+
+**답변**: `string[]` 타입입니다.
+
+템플릿을 보면 `v-for="(text, key) in options"`에서 각 항목이 `text`로 사용되고, `option`의 `value`와 내용으로 직접 사용됩니다. 이는 `options` 배열의 각 요소가 문자열임을 의미합니다.
+
+```typescript
+// 올바른 타입 정의
+interface DropdownProps {
+ options: string[] // 문자열 배열
+ value: string
+}
+```
+
+
+
+
+#### 2. Value 타입: `value`는 항상 문자열일까요?
+
+
+💡 답변 보기
+
+
+**답변**: 네, 현재 구현에서는 항상 `string` 타입입니다.
+
+JavaScript 코드에서 `type: String`으로 정의되어 있고, HTML `select` 요소의 `value`는 항상 문자열로 처리됩니다. 따라서 TypeScript에서도 `string` 타입으로 정의하는 것이 맞습니다.
+
+만약 숫자나 다른 타입도 지원하려면 제네릭을 사용해야 합니다.
+
+
+
+
+#### 3. 이벤트 페이로드: `update:value`에는 어떤 타입이 전달될까요?
+
+
+💡 답변 보기
+
+
+**답변**: `string` 타입이 전달됩니다.
+
+`emit('update:value', newVal)`에서 `newVal`은 `valueModel`의 값이고, 이는 `select` 요소의 선택된 값이므로 문자열입니다.
+
+```typescript
+interface DropdownEmits {
+ 'update:value': [value: string] // 튜플 형태로 정의
+}
+```
+
+
+
+
+#### 4. 내부 상태: `valueModel`의 타입은 무엇일까요?
+
+
+💡 답변 보기
+
+
+**답변**: `Ref` 타입입니다.
+
+`ref(props.value || props.options[0])`에서 두 값 모두 문자열이므로, Vue의 타입 추론에 의해 `Ref`으로 추론됩니다.
+
+```typescript
+// 명시적 타입 지정
+const valueModel = ref(props.value || props.options[0] || '')
+```
+
+
+
+
+## 🛠️ TypeScript 변환 실습
+
+### 직접 해보기 (TODO 형태)
+
+```vue
+
+```
+
+## 🎯 도전 과제들
+
+TypeScript 변환에 필요한 핵심 패턴들을 단계별로 연습해봅시다!
+
+### 도전 1: Default Values 처리
+
+
+🤔 생각해보기
+
+
+**문제**: 기존 JavaScript에서는 `default: () => []`로 기본값을 설정했는데, TypeScript에서는 어떻게 처리할까요?
+
+```javascript
+// 기존 JavaScript 방식
+const props = defineProps({
+ options: {
+ type: Array,
+ default: () => [] // 이 부분을 TypeScript로?
+ },
+ value: {
+ type: String,
+ default: ''
+ }
+})
+```
+
+**힌트**: `withDefaults()` 함수를 사용해보세요.
+
+
+
+
+
+💡 해답
+
+
+**방법 1: withDefaults 사용**
+
+```typescript
+interface DropdownProps {
+ options: string[]
+ value: string
+}
+
+const props = withDefaults(defineProps(), {
+ options: () => [], // 배열은 함수 형태로
+ value: '' // 원시값은 직접
+})
+```
+
+**방법 2: 인터페이스에서 옵셔널로 정의**
+
+```typescript
+interface DropdownProps {
+ options?: string[] // 옵셔널로 정의
+ value?: string
+}
+
+const props = defineProps()
+// 사용시: props.options || []
+```
+
+**차이점**:
+
+- `withDefaults`: Vue가 기본값을 자동 할당
+- 옵셔널: 코드에서 직접 fallback 처리
+
+
+
+
+### 도전 2: 이벤트 타입 안전성
+
+
+🤔 생각해보기
+
+
+**문제**: emit 이벤트의 타입을 어떻게 정의해야 할까요?
+
+현재는 `defineEmits(['update:value'])`로 되어 있는데, 이것만으로는 타입 검증이 안됩니다.
+
+- 잘못된 페이로드 타입을 보내도 에러가 안남
+- 존재하지 않는 이벤트명을 사용해도 에러가 안남
+
+더 엄격한 타입 정의 방법이 있을까요?
+
+**힌트**:
+
+- 이벤트 이름과 페이로드 타입을 함께 정의할 수 있습니다
+- Props의 value 타입과 일치해야 합니다
+
+
+
+
+
+💡 해답
+
+
+**방법 1: 객체 형태로 정의 (좋음)**
+
+```typescript
+const emit = defineEmits<{
+ 'update:value': [value: string] // 튜플로 페이로드 타입 정의
+}>()
+```
+
+**방법 2: 인터페이스로 분리 (베스트)**
+
+```typescript
+interface DropdownEmits {
+ 'update:value': [value: string]
+}
+
+const emit = defineEmits()
+```
+
+**방법 3: Props와 연결된 타입 (고급)**
+
+```typescript
+interface DropdownProps {
+ options: string[]
+ value: string
+}
+
+interface DropdownEmits {
+ 'update:value': [value: DropdownProps['value']] // Props 타입 재사용
+}
+```
+
+**장점들**:
+
+- 잘못된 타입 전달시 컴파일 에러
+- 존재하지 않는 이벤트 사용시 컴파일 에러
+- IDE에서 자동완성 및 타입 힌트 제공
+
+
+
+
+### 도전 3: ref 타입 추론
+
+
+🤔 생각해보기
+
+
+**문제**: `ref`의 타입을 어떻게 정의해야 할까요?
+
+```typescript
+const props = defineProps<{ options: string[], value: string }>()
+
+// 방법 1: 자동 추론에 맡기기
+const valueModel = ref(props.value)
+
+// 방법 2: 명시적 타입 지정
+const valueModel = ref(props.value)
+
+// 방법 3: 초기값이 복잡한 경우
+const valueModel = ref(props.value || props.options[0])
+```
+
+**고민해볼 점들**:
+
+- 타입 추론이 항상 정확할까요?
+- 초기값이 `undefined`일 수 있다면?
+- 가독성 vs 간결성 중 무엇이 더 중요할까요?
+
+
+
+
+
+💡 해답
+
+
+**추천: 명시적 타입 지정**
+
+```typescript
+const valueModel = ref(props.value || props.options[0] || '')
+```
+
+**이유들**:
+
+**1. 타입 안전성**
+
+```typescript
+// 자동 추론의 문제점
+const valueModel = ref(props.value || props.options[0])
+// 만약 둘 다 undefined라면? ref가 될 수 있음
+
+// 명시적 지정으로 해결
+const valueModel = ref(props.value || props.options[0] || '')
+// 항상 string 타입 보장
+```
+
+**2. IDE 지원**
+
+```typescript
+// 명시적 타입 지정시
+valueModel.value. // ← string 메서드들 자동완성
+
+// 자동 추론시 불확실한 경우
+valueModel.value. // ← 타입이 애매하면 자동완성 부정확
+```
+
+**3. 복잡한 초기값 처리**
+
+```typescript
+// 안전한 초기값 설정 패턴
+const getInitialValue = (): string => {
+ if (props.value) return props.value
+ if (props.options.length > 0) return props.options[0]
+ return ''
+}
+
+const valueModel = ref(getInitialValue())
+```
+
+
+
+
+### 도전 4: 조건부 타입으로 옵션 검증
+
+
+🤔 생각해보기
+
+
+**문제**: `options` 배열이 비어있을 때만 `value`를 옵셔널로 만들고, 옵션이 있을 때는 필수로 만들려면?
+
+실제 사용 시나리오:
+
+```typescript
+// options가 비어있으면 value 불필요
+
+
+// options가 있으면 value 필수
+
+```
+
+**힌트**: 조건부 타입과 `keyof`를 활용해보세요.
+
+
+
+
+
+💡 해답
+
+
+```typescript
+type DropdownProps = T extends readonly []
+ ? {
+ options: T
+ value?: never // value 속성 자체를 금지
+ }
+ : {
+ options: T
+ value: T[number] // 배열 요소의 유니온 타입
+ }
+
+// 사용 예시
+const emptyProps: DropdownProps<[]> = {
+ options: []
+ // value: 'anything' // ← 에러! value 불가
+}
+
+const withOptionsProps: DropdownProps<['A', 'B']> = {
+ options: ['A', 'B'],
+ value: 'A' // ← 'A' | 'B'만 허용
+}
+```
+
+**더 실용적인 버전**:
+
+```typescript
+// 헬퍼 타입
+type NonEmptyArray = [T, ...T[]]
+
+interface EmptyDropdownProps {
+ options: []
+ value?: never
+}
+
+interface PopulatedDropdownProps {
+ options: NonEmptyArray
+ value: T[number]
+}
+
+type SafeDropdownProps =
+ T extends [] ? EmptyDropdownProps : PopulatedDropdownProps
+```
+
+
+
+
+### 도전 5: 브랜드 타입으로 선택값 보장
+
+
+🤔 생각해보기
+
+
+**문제**: `value`가 반드시 `options` 배열에 포함된 값이어야 한다는 것을 타입 레벨에서 보장하려면?
+
+현재 문제:
+
+```typescript
+const props = { options: ['A', 'B'], value: 'C' } // 'C'는 options에 없음!
+```
+
+런타임 검증은 가능하지만, 컴파일 타임에 잡을 수 있다면?
+
+**힌트**: 브랜드 타입과 타입 가드를 조합해보세요.
+
+
+
+
+
+💡 해답
+
+
+**🚨 중요한 오해 해결: 브랜드 타입의 실제 동작**
+
+```typescript
+// 1. 브랜드 타입 정의
+type ValidOption = T[number] & {
+ readonly __brand: unique symbol
+}
+
+// 2. 실제 테스트해보기
+const options = ['A', 'B', 'C'] as const
+const value = 'A' // 일반 문자열
+
+// 브랜드 타입으로 캐스팅
+const brandedValue = value as ValidOption
+
+console.log(value) // 결과: "A"
+console.log(brandedValue) // 결과: "A" (똑같음!)
+
+console.log('__brand' in brandedValue) // 결과: false (키가 없음!)
+console.log(Object.keys(brandedValue)) // 결과: [] (일반 문자열과 동일)
+
+// 🎯 핵심: __brand 속성은 실제로 존재하지 않습니다!
+```
+
+**❓ 그럼 `__brand`는 뭔가요?**
+
+**답**: `__brand`는 **TypeScript 컴파일러만 알고 있는 가상의 표시**입니다!
+
+```typescript
+type ValidOption = T[number] & { __brand: unique symbol }
+// ^^^^^^^^^^^^^^^^^^^^^^^^
+// 이 부분은 "타입 레벨"에서만 존재
+// 런타임에는 아무것도 없음!
+```
+
+**📊 타입 레벨 vs 런타임 레벨 비교**
+
+| 구분 | 타입 레벨 (컴파일 시간) | 런타임 레벨 (실행 시간) |
+|------|----------------------|----------------------|
+| **일반 문자열** | `string` | `"A"` |
+| **브랜드 타입** | `string & { __brand: symbol }` | `"A"` (똑같음!) |
+| **TypeScript가 보는 것** | 서로 다른 타입으로 인식 | 실제 값은 동일 |
+
+**🔍 더 명확한 예시**
+
+```typescript
+type UserId = string & { __brand: 'UserId' }
+type ProductId = string & { __brand: 'ProductId' }
+
+const userId: UserId = 'user123' as UserId
+const productId: ProductId = 'prod456' as ProductId
+
+// 런타임에서는...
+console.log(userId) // "user123" (일반 문자열)
+console.log(productId) // "prod456" (일반 문자열)
+
+// 하지만 TypeScript는 다르게 인식!
+function getUser(id: UserId) { ... }
+
+getUser(userId) // ✅ 정상 (UserId 타입)
+getUser(productId) // ❌ 에러! (ProductId ≠ UserId)
+getUser('user123') // ❌ 에러! (string ≠ UserId)
+
+// 런타임에서는 모두 동일한 문자열인데도 불구하고!
+```
+
+**🎭 브랜드 타입 = "가면"의 비유**
+
+```
+실제 값: "A"
+브랜드 타입: "A" + 가상의_라벨
+ ↑
+ TypeScript만 볼 수 있는 라벨
+ 실제로는 존재하지 않음
+```
+
+**⚙️ 그럼 어떻게 타입 안전성이 보장되나요?**
+
+```typescript
+// TypeScript 컴파일러의 타입 체킹
+function selectOption(option: ValidOption<['A', 'B']>) {
+ console.log(option) // 실제로는 그냥 문자열 받음
+}
+
+// 컴파일 시점에 타입 체크
+selectOption('A') // ❌ 컴파일 에러
+selectOption('A' as ValidOption) // ✅ 컴파일 통과
+
+// 컴파일 후 JavaScript 코드
+function selectOption(option) { // 타입 정보 모두 사라짐
+ console.log(option)
+}
+selectOption('A') // 실제로는 이렇게 실행됨
+```
+
+**📋 정리**
+
+1. `__brand: unique symbol` → **타입 정보일 뿐, 실제 속성 아님**
+2. `value as ValidOption` → **런타임에는 값 변화 없음, 컴파일러에게만 "이건 특별한 타입이야" 라고 알려줌**
+3. 브랜드 타입 = **컴파일 시간의 타입 안전성**, **런타임 성능 오버헤드 없음**
+
+따라서 `'A'`가 `ValidOption`로 캐스팅되어도 여전히 그냥 `'A'` 문자열입니다! 🎯
+
+**실용적인 Vue 컴포넌트 버전**:
+
+```typescript
+interface StrictDropdownProps {
+ options: T
+ value: T[number]
+}
+
+// Props 검증 함수
+function validateDropdownProps(
+ props: { options: T; value: string }
+): props is StrictDropdownProps {
+ return props.options.includes(props.value as T[number])
+}
+
+// 컴포넌트에서 사용
+const props = defineProps<{ options: string[]; value: string }>()
+
+// 검증 후 사용
+if (validateDropdownProps(props)) {
+ // 이 블록 안에서는 props.value가 안전하게 보장됨
+ console.log('Valid selection:', props.value)
+}
+```
+
+
+
+
+### 도전 6: 고차 컴포넌트 타입 래퍼
+
+
+🤔 생각해보기
+
+
+**문제**: 애플리케이션에서 여러 도메인의 드롭다운이 필요한데, 각각 다른 옵션 타입을 가지면서도 공통 인터페이스를 유지하려면?
+
+예시 시나리오:
+
+```typescript
+// 국가 선택 드롭다운
+const countryOptions = ['US', 'KR', 'JP'] as const
+
+// 언어 선택 드롭다운
+const languageOptions = ['en', 'ko', 'ja'] as const
+
+// 테마 선택 드롭다운
+const themeOptions = ['light', 'dark'] as const
+```
+
+각각의 타입 안전성을 보장하면서 공통 로직을 재사용하려면?
+
+**힌트**: 고차 타입과 팩토리 패턴을 활용해보세요.
+
+
+
+
+
+💡 해답
+
+
+**도메인별 타입 시스템**:
+
+```typescript
+// 1. 기본 드롭다운 인터페이스
+interface BaseDropdown
{
+ options: readonly T[]
+ value: T
+ onChange: (value: T) => void
+ placeholder?: string
+}
+
+// 2. 도메인 스키마 정의
+type AppDomains = {
+ country: ['US', 'KR', 'JP']
+ language: ['en', 'ko', 'ja']
+ theme: ['light', 'dark']
+ priority: ['low', 'medium', 'high']
+}
+
+// 3. 도메인별 드롭다운 타입 생성
+type DomainDropdowns = {
+ [K in keyof AppDomains]: BaseDropdown
+}
+
+// 4. 타입 안전한 팩토리 함수
+function createDomainDropdown(
+ domain: K,
+ options: AppDomains[K],
+ initialValue: AppDomains[K][number]
+): DomainDropdowns[K] {
+ return {
+ options,
+ value: initialValue,
+ onChange: (value) => {
+ console.log(`${domain} changed to:`, value)
+ // 도메인별 특별한 로직 처리 가능
+ }
+ }
+}
+
+// 5. 사용 예시
+const countryDropdown = createDomainDropdown(
+ 'country',
+ ['US', 'KR', 'JP'],
+ 'KR' // 타입 안전: 'US' | 'KR' | 'JP'만 허용
+)
+
+countryDropdown.onChange('JP') // ✅ 안전
+// countryDropdown.onChange('FR') // ❌ 타입 에러
+
+const themeDropdown = createDomainDropdown(
+ 'theme',
+ ['light', 'dark'],
+ 'dark' // 'light' | 'dark'만 허용
+)
+```
+
+**Vue 컴포넌트와 통합**:
+
+```typescript
+// 제네릭 Vue 컴포넌트
+interface GenericDropdownProps {
+ options: readonly T[]
+ modelValue: T
+ placeholder?: string
+}
+
+interface GenericDropdownEmits {
+ 'update:modelValue': [value: T]
+}
+
+// 도메인별 컴포넌트 생성
+type CountryDropdown = GenericDropdownProps
+type LanguageDropdown = GenericDropdownProps
+
+// 실제 컴포넌트에서 사용
+const countryProps = defineProps()
+const countryEmit = defineEmits>()
+```
+
+
+
+
+## 📚 단계별 완성 가이드
+
+### Step 1: 기본 변환
+
+```vue
+
+```
+
+### Step 2: 제네릭 사용 (고급)
+
+```vue
+
+```
+
+### Step 3: 타입 분리 (파일 구조화)
+
+**`src/types/components.ts`**
+
+```typescript
+export type DropdownOption = T
+
+export interface DropdownProps {
+ options: DropdownOption[]
+ value: T
+}
+
+export interface DropdownEmits {
+ 'update:value': [value: T]
+}
+```
+
+**`VDropdown.vue`**
+
+```vue
+
+```
+
+## ⚠️ 제네릭 사용 시 주의사항
+
+### generic 문법 호환성
+
+
+⚠️ 중요한 호환성 이슈
+
+
+**주의**: `generic="T extends string"` 문법은 Vue 3.4 이상에서만 지원됩니다.
+일부 환경(IDE, 테스트, ESLint 등)에서는 오류가 발생할 수 있습니다.
+
+
+
+
+### 안전한 대안들
+
+**방법 1: 안전한 초기값 분기 처리**
+
+```typescript
+const props = withDefaults(defineProps(), {
+ options: () => [] as T[],
+ value: undefined as unknown as T
+})
+
+const getDefaultValue = (): T => {
+ if (props.options.length > 0) return props.options[0]
+ return (typeof props.value === 'string' ? '' : 0) as T
+}
+
+const valueModel = ref(props.value ?? getDefaultValue())
+```
+
+**방법 2: value를 필수 prop으로 만들기**
+
+```typescript
+interface DropdownProps {
+ options: DropdownOption[]
+ value: T // 필수, 기본값 없음
+}
+
+const props = defineProps>() // withDefaults 제거
+```
+
+## 🧪 테스트 방법
+
+### 1. 타입 체크 테스트
+
+```bash
+npm run type-check
+# 또는
+npx vue-tsc --noEmit
+```
+
+### 2. 의도적 타입 에러 만들기 (학습용)
+
+```typescript
+// 이런 코드들을 시도해보세요
+const props = defineProps<{
+ options: string[]
+ value: number // 의도적으로 잘못된 타입
+}>()
+
+// 어떤 에러가 발생하나요?
+```
+
+### 3. IDE 타입 힌트 확인
+
+- `props.` 입력 시 자동완성이 나타나는지
+- `emit('update:value', )` 에서 타입 힌트가 나타나는지
+
+## 📝 변환 완료 체크리스트
+
+- [ ] `
diff --git a/src/components/pivottable-ui/VFilterBox.vue b/src/components/pivottable-ui/VFilterBox.vue
index 9ed7fb8..a42d3c5 100644
--- a/src/components/pivottable-ui/VFilterBox.vue
+++ b/src/components/pivottable-ui/VFilterBox.vue
@@ -62,85 +62,96 @@
-
diff --git a/src/components/pivottable/VPivottable.vue b/src/components/pivottable/VPivottable.vue
index 7f1c6fe..8370858 100644
--- a/src/components/pivottable/VPivottable.vue
+++ b/src/components/pivottable/VPivottable.vue
@@ -5,13 +5,12 @@
/>
-
diff --git a/src/components/pivottable/VPivottableHeaderColumns.vue b/src/components/pivottable/VPivottableHeaderColumns.vue
index cde5525..0e4de8b 100644
--- a/src/components/pivottable/VPivottableHeaderColumns.vue
+++ b/src/components/pivottable/VPivottableHeaderColumns.vue
@@ -14,27 +14,16 @@
-
diff --git a/src/components/pivottable/VPivottableHeaderRows.vue b/src/components/pivottable/VPivottableHeaderRows.vue
index 3a75650..d359ff7 100644
--- a/src/components/pivottable/VPivottableHeaderRows.vue
+++ b/src/components/pivottable/VPivottableHeaderRows.vue
@@ -9,30 +9,23 @@
- {{ colAttrsLength === 0 && rowTotal ? localeStrings.totals : null }}
+ {{ colAttrsLength === 0 && showRowTotal ? languagePack?.totals : null }}
-
diff --git a/src/components/pivottable/VPivottableHeaderRowsTotal.vue b/src/components/pivottable/VPivottableHeaderRowsTotal.vue
index cf11427..309b55a 100644
--- a/src/components/pivottable/VPivottableHeaderRowsTotal.vue
+++ b/src/components/pivottable/VPivottableHeaderRowsTotal.vue
@@ -3,23 +3,20 @@
class="pvtTotalLabel"
:rowSpan="colAttrsLength + (rowAttrsLength === 0 ? 0 : 1)"
>
- {{ localeStrings.totals }}
+ {{ languagePack?.totals }}
-
diff --git a/src/components/pivottable/renderer/TSVExportRenderers.vue b/src/components/pivottable/renderer/TSVExportRenderers.vue
index 90f876a..c6e66a4 100644
--- a/src/components/pivottable/renderer/TSVExportRenderers.vue
+++ b/src/components/pivottable/renderer/TSVExportRenderers.vue
@@ -6,22 +6,13 @@
/>
-
diff --git a/src/components/pivottable/renderer/index.ts b/src/components/pivottable/renderer/index.ts
new file mode 100644
index 0000000..3ab53e9
--- /dev/null
+++ b/src/components/pivottable/renderer/index.ts
@@ -0,0 +1,53 @@
+import { h, markRaw } from 'vue'
+import { RendererProps } from '@/types'
+import TableRenderer from './TableRenderer.vue'
+import TSVExportRenderers from './TSVExportRenderers.vue'
+
+const tableComponents = markRaw({
+ 'Table': {
+ name: 'VueTable',
+ setup (props: RendererProps) {
+ return () =>
+ h(TableRenderer, props)
+ }
+ },
+ 'Table Heatmap': {
+ name: 'VueTableHeatmap',
+ setup (props: RendererProps) {
+ return () =>
+ h(TableRenderer, {
+ ...props,
+ heatmapMode: 'full'
+ })
+ }
+ },
+ 'Table Col Heatmap': {
+ name: 'VueTableColHeatmap',
+ setup (props: RendererProps) {
+ return () =>
+ h(TableRenderer, {
+ ...props,
+ heatmapMode: 'col'
+ })
+ }
+ },
+ 'Table Row Heatmap': {
+ name: 'VueTableRowHeatmap',
+ setup (props: RendererProps) {
+ return () =>
+ h(TableRenderer, {
+ ...props,
+ heatmapMode: 'row'
+ })
+ }
+ },
+ 'Export Table TSV': {
+ name: 'TsvExportRenderers',
+ setup (props: RendererProps) {
+ return () =>
+ h(TSVExportRenderers, props)
+ }
+ }
+})
+
+export default tableComponents
diff --git a/src/composables/index.ts b/src/composables/index.ts
new file mode 100644
index 0000000..7232b5b
--- /dev/null
+++ b/src/composables/index.ts
@@ -0,0 +1,6 @@
+export { useProvidePivotData, providePivotData } from './useProvidePivotData'
+export { useProvideFilterBox, provideFilterBox } from './useProvideFilterbox'
+export { useMaterializeInput } from './useMaterializeInput'
+export { usePropsState } from './usePropsState'
+export { usePivotUiState } from './usePivotUiState'
+export { usePivotData } from './usePivotData'
\ No newline at end of file
diff --git a/src/composables/useMaterializeInput.ts b/src/composables/useMaterializeInput.ts
new file mode 100644
index 0000000..618f4e7
--- /dev/null
+++ b/src/composables/useMaterializeInput.ts
@@ -0,0 +1,83 @@
+import { Ref, ref, watch } from 'vue'
+import { PivotData } from '@/helper'
+
+export interface UseMaterializeInputOptions {
+ derivedAttributes: Ref) => any>>
+}
+
+export interface UseMaterializeInputReturn {
+ rawData: Ref
+ allFilters: Ref>>
+ materializedInput: Ref
+ processData: (data: any) => { AllFilters: Record>; materializedInput: any[] } | void
+}
+
+export function useMaterializeInput (
+ dataSource: Ref,
+ options: UseMaterializeInputOptions
+): UseMaterializeInputReturn {
+ const rawData = ref(null)
+ const allFilters = ref>>({})
+ const materializedInput = ref([])
+
+ function processData (data: any) {
+ if (!data || rawData.value === data) return
+
+ rawData.value = data
+ const newAllFilters: Record> = {}
+ const newMaterializedInput: any[] = []
+
+ let recordsProcessed = 0
+
+ PivotData.forEachRecord(
+ data,
+ options.derivedAttributes.value,
+ function (record: Record) {
+ newMaterializedInput.push(record)
+
+ for (const attr of Object.keys(record)) {
+ if (!(attr in newAllFilters)) {
+ newAllFilters[attr] = {}
+ if (recordsProcessed > 0) {
+ newAllFilters[attr].null = recordsProcessed
+ }
+ }
+ }
+
+ for (const attr in newAllFilters) {
+ const value = attr in record ? record[attr] : 'null'
+ if (!(value in newAllFilters[attr])) {
+ newAllFilters[attr][value] = 0
+ }
+ newAllFilters[attr][value]++
+ }
+
+ recordsProcessed++
+ }
+ )
+
+ allFilters.value = newAllFilters
+ materializedInput.value = newMaterializedInput
+
+ return {
+ AllFilters: newAllFilters,
+ materializedInput: newMaterializedInput
+ }
+ }
+
+ watch(() => dataSource.value, processData, { immediate: true })
+
+ watch(
+ () => options.derivedAttributes.value,
+ () => {
+ processData(dataSource.value)
+ }
+ )
+
+ return {
+ rawData,
+ allFilters,
+ materializedInput,
+ processData
+ }
+}
\ No newline at end of file
diff --git a/src/composables/usePivotData.ts b/src/composables/usePivotData.ts
new file mode 100644
index 0000000..114c7b9
--- /dev/null
+++ b/src/composables/usePivotData.ts
@@ -0,0 +1,18 @@
+import { computed, ref } from 'vue'
+import { PivotData } from '@/helper'
+
+export interface ProvidePivotDataProps { [key: string]: any }
+
+export function usePivotData (props: ProvidePivotDataProps) {
+ const error = ref(null)
+ const pivotData = computed(() => {
+ try {
+ return new PivotData(props)
+ } catch (err) {
+ console.error(err.stack)
+ error.value = 'An error occurred computing the PivotTable results.'
+ return null
+ }
+ })
+ return { pivotData, error }
+}
diff --git a/src/composables/usePivotUiState.ts b/src/composables/usePivotUiState.ts
new file mode 100644
index 0000000..03d4f2e
--- /dev/null
+++ b/src/composables/usePivotUiState.ts
@@ -0,0 +1,43 @@
+import { reactive } from 'vue'
+
+type PivotUiState = {
+ unusedOrder: string[]
+ zIndices: Record
+ maxZIndex: number
+ openStatus: Record
+}
+
+export function usePivotUiState () {
+ const pivotUiState = reactive({
+ unusedOrder: [],
+ zIndices: {},
+ maxZIndex: 1000,
+ openStatus: {}
+ })
+
+ const onMoveFilterBoxToTop = (attributeName: string) => {
+ pivotUiState.maxZIndex++
+ pivotUiState.zIndices[attributeName] = pivotUiState.maxZIndex
+ }
+
+ const onUpdateOpenStatus = ({
+ key,
+ value
+ }: {
+ key: string
+ value: boolean
+ }) => {
+ pivotUiState.openStatus[key] = value
+ }
+
+ const onUpdateUnusedOrder = (newOrder: string[]) => {
+ pivotUiState.unusedOrder = newOrder
+ }
+
+ return {
+ state: pivotUiState,
+ onMoveFilterBoxToTop,
+ onUpdateOpenStatus,
+ onUpdateUnusedOrder
+ }
+}
diff --git a/src/composables/usePropsState.ts b/src/composables/usePropsState.ts
new file mode 100644
index 0000000..f6bb10f
--- /dev/null
+++ b/src/composables/usePropsState.ts
@@ -0,0 +1,95 @@
+import { DefaultPropsType } from '@/types'
+import { computed, reactive, ComputedRef, UnwrapRef } from 'vue'
+import { locales, LocaleStrings } from '@/helper'
+export type UsePropsStateProps = Pick
+
+export interface UsePropsStateReturn {
+ state: UnwrapRef
+ localeStrings: ComputedRef | LocaleStrings>
+ updateState: (key: keyof T, value: any) => void
+ updateMultiple: (updates: Partial & { allFilters?: any, materializedInput?: any }) => void
+ onUpdateValueFilter: (payload: { key: string; value: any }) => void
+ onUpdateRendererName: (rendererName: string) => void
+ onUpdateAggregatorName: (aggregatorName: string) => void
+ onUpdateRowOrder: (rowOrder: string) => void
+ onUpdateColOrder: (colOrder: string) => void
+ onUpdateVals: (vals: any[]) => void
+ onDraggedAttribute: (payload: { key: keyof T; value: any }) => void
+}
+
+export function usePropsState (
+ initialProps: T
+): UsePropsStateReturn {
+ const state = reactive({
+ ...initialProps
+ }) as UnwrapRef
+
+ const localeStrings = computed(
+ () => initialProps?.languagePack?.[initialProps?.locale || 'en'].localeStrings ?? locales['en'].localeStrings
+ )
+
+ const updateState = (key: keyof T, value: any) => {
+ if (key in state) {
+ (state as any)[key] = value
+ }
+ }
+
+ const updateMultiple = (updates: Partial) => {
+ Object.entries(updates).forEach(([key, value]) => {
+ if (key in state) {
+ (state as any)[key] = value
+ }
+ })
+ }
+
+ const onUpdateValueFilter = ({ key, value }: { key: string; value: any }) => {
+ updateState('valueFilter' as keyof T, {
+ ...(state.valueFilter || {}),
+ [key]: value
+ })
+ }
+
+ const onUpdateRendererName = (rendererName: string) => {
+ updateState('rendererName' as keyof T, rendererName)
+ if (rendererName === 'Table Heatmap') {
+ updateState('heatmapMode' as keyof T, 'full')
+ } else if (rendererName === 'Table Row Heatmap') {
+ updateState('heatmapMode' as keyof T, 'row')
+ } else if (rendererName === 'Table Col Heatmap') {
+ updateState('heatmapMode' as keyof T, 'col')
+ } else {
+ updateState('heatmapMode' as keyof T, '')
+ }
+ }
+
+ const onUpdateAggregatorName = (aggregatorName: string) => {
+ updateState('aggregatorName' as keyof T, aggregatorName)
+ }
+ const onUpdateRowOrder = (rowOrder: string) => {
+ updateState('rowOrder' as keyof T, rowOrder)
+ }
+ const onUpdateColOrder = (colOrder: string) => {
+ updateState('colOrder' as keyof T, colOrder)
+ }
+ const onUpdateVals = (vals: any[]) => {
+ updateState('vals' as keyof T, vals)
+ }
+ const onDraggedAttribute = ({ key, value }: { key: keyof T; value: any }) => {
+ updateState(key, value)
+ }
+
+ return {
+ state,
+ localeStrings,
+ updateState,
+ updateMultiple,
+ onUpdateValueFilter,
+ onUpdateRendererName,
+ onUpdateAggregatorName,
+ onUpdateRowOrder,
+ onUpdateColOrder,
+ onUpdateVals,
+ onDraggedAttribute
+ }
+}
\ No newline at end of file
diff --git a/src/composables/useProvideFilterbox.ts b/src/composables/useProvideFilterbox.ts
new file mode 100644
index 0000000..812e1be
--- /dev/null
+++ b/src/composables/useProvideFilterbox.ts
@@ -0,0 +1,36 @@
+import { computed, ComputedRef, inject, provide, InjectionKey } from 'vue'
+import { getSort } from '@/helper'
+import { DefaultPropsType } from '@/types'
+import { Locale } from '@/helper'
+
+type ProvideFilterBoxProps = Pick & {
+ menuLimit: number
+ languagePack: Record
+ locale: string
+}
+
+interface FilterBoxContext {
+ localeStrings: ComputedRef
+ sorter: (x: string) => any
+ menuLimit: ComputedRef
+}
+
+const filterBoxKey = Symbol('filterBox') as InjectionKey
+
+export function provideFilterBox(props: ProvideFilterBoxProps) {
+ const localeStrings = computed(
+ () => props.languagePack[props.locale].localeStrings
+ )
+ const sorters = computed(() => props.sorters)
+ const sorter = (x: string) => getSort(sorters.value, x)
+ const menuLimit = computed(() => props.menuLimit)
+ provide(filterBoxKey, {
+ localeStrings,
+ sorter,
+ menuLimit
+ })
+}
+
+export function useProvideFilterBox() {
+ return inject(filterBoxKey)
+}
diff --git a/src/composables/useProvidePivotData.ts b/src/composables/useProvidePivotData.ts
new file mode 100644
index 0000000..80e30da
--- /dev/null
+++ b/src/composables/useProvidePivotData.ts
@@ -0,0 +1,147 @@
+import { Ref, provide, inject, computed, ComputedRef, InjectionKey } from 'vue'
+import { PivotData } from '@/helper'
+import { usePivotData } from './'
+import type { ProvidePivotDataProps } from './usePivotData'
+
+
+
+export interface PivotDataContext {
+ pivotData: ComputedRef
+ rowKeys: ComputedRef
+ colKeys: ComputedRef
+ colAttrs: ComputedRef
+ rowAttrs: ComputedRef
+ getAggregator: (rowKey: any[], colKey: any[]) => any
+ grandTotalAggregator: ComputedRef
+ spanSize: (arr: any[][], i: number, j: number) => number
+ valueCellColors: (rowKey: any[], colKey: any[], value: any) => any
+ rowTotalColors: (value: any) => any
+ colTotalColors: (value: any) => any
+ error: Ref
+}
+
+const PIVOT_DATA_KEY = Symbol('pivotData') as InjectionKey
+
+
+export function providePivotData (props: ProvidePivotDataProps): PivotDataContext {
+ const { pivotData, error } = usePivotData(props)
+ const rowKeys = computed(() => pivotData.value?.getRowKeys() || [])
+ const colKeys = computed(() => pivotData.value?.getColKeys() || [])
+ const colAttrs = computed(() => pivotData.value?.props.cols || [])
+ const rowAttrs = computed(() => pivotData.value?.props.rows || [])
+ const colorScaleGenerator = props.tableColorScaleGenerator
+ const getAggregator = (rowKey: any[], colKey: any[]) =>
+ pivotData.value?.getAggregator(rowKey, colKey) || {
+ value: () => null,
+ format: () => ''
+ }
+
+ const grandTotalAggregator = computed(() => {
+ return pivotData.value
+ ? getAggregator([], [])
+ : {
+ value: () => null,
+ format: () => ''
+ }
+ })
+
+ const allColorScales = computed(() => {
+ const values = rowKeys.value.reduce((acc: any[], r: any[]) => acc.concat(colKeys.value.map((c: any[]) => getAggregator(r, c).value())), [])
+ return colorScaleGenerator(values)
+ })
+ const rowColorScales = computed(() =>
+ rowKeys.value.reduce((scales: Record, r: any[]) => {
+ const key = JSON.stringify(r)
+ scales[key] = colorScaleGenerator(
+ colKeys.value.map((x: any[]) => getAggregator(r, x).value())
+ )
+ return scales
+ }, {} as Record)
+ )
+ const colColorScales = computed(() =>
+ colKeys.value.reduce((scales: Record, c: any[]) => {
+ const key = JSON.stringify(c)
+ scales[key] = colorScaleGenerator(
+ rowKeys.value.map((x: any[]) => getAggregator(x, c).value())
+ )
+ return scales
+ }, {} as Record)
+ )
+
+ const valueCellColors = (rowKey: any[], colKey: any[], value: any) => {
+ if (props.heatmapMode === 'full') {
+ return allColorScales.value(value)
+ } else if (props.heatmapMode === 'row') {
+ return rowColorScales.value[JSON.stringify(rowKey)]?.(value)
+ } else if (props.heatmapMode === 'col') {
+ return colColorScales.value[JSON.stringify(colKey)]?.(value)
+ }
+ return {}
+ }
+ const rowTotalValues = computed(() => colKeys.value.map((x: any[]) => getAggregator([], x).value()))
+ const rowTotalColors = (value: any) => {
+ if (!props.heatmapMode) return {}
+ return colorScaleGenerator(rowTotalValues.value)(value)
+ }
+ const colTotalValues = computed(() => rowKeys.value.map((x: any[]) => getAggregator(x, []).value()))
+ const colTotalColors = (value: any) => {
+ if (!props.heatmapMode) return {}
+ return colorScaleGenerator(colTotalValues.value)(value)
+ }
+
+ const spanSize = (arr: any[][], i: number, j: number): number => {
+ let x
+ if (i !== 0) {
+ let noDraw = true
+ for (x = 0; x <= j; x++) {
+ if (arr[i - 1][x] !== arr[i][x]) {
+ noDraw = false
+ }
+ }
+ if (noDraw) {
+ return -1
+ }
+ }
+
+ let len = 0
+ while (i + len < arr.length) {
+ let stop = false
+ for (x = 0; x <= j; x++) {
+ if (arr[i][x] !== arr[i + len][x]) {
+ stop = true
+ }
+ }
+ if (stop) {
+ break
+ }
+ len++
+ }
+ return len
+ }
+
+ const pivotDataContext: PivotDataContext = {
+ pivotData,
+ rowKeys,
+ colKeys,
+ colAttrs,
+ rowAttrs,
+ getAggregator,
+ grandTotalAggregator,
+ spanSize,
+ valueCellColors,
+ rowTotalColors,
+ colTotalColors,
+ error
+ }
+
+ provide(PIVOT_DATA_KEY, pivotDataContext)
+ return pivotDataContext
+}
+
+export function useProvidePivotData (): PivotDataContext {
+ const context = inject(PIVOT_DATA_KEY)
+ if (!context) {
+ throw new Error('useProvidePivotData must be used within a provider')
+ }
+ return context
+}
\ No newline at end of file
diff --git a/src/helper/defaultProps.ts b/src/helper/defaultProps.ts
new file mode 100644
index 0000000..e0be8fe
--- /dev/null
+++ b/src/helper/defaultProps.ts
@@ -0,0 +1,96 @@
+import { aggregators, locales, redColorScaleGenerator } from './'
+import type { AggregatorTemplate } from './'
+import type { PropType } from 'vue'
+
+export default {
+ data: {
+ type: [Array, Object, Function] as PropType,
+ required: true
+ },
+ aggregators: {
+ type: Object as PropType>,
+ default: () => aggregators
+ },
+ aggregatorName: {
+ type: String,
+ default: 'Count'
+ },
+ heatmapMode: String as PropType<'full' | 'col' | 'row' | ''>,
+ tableColorScaleGenerator: {
+ type: Function,
+ default: (value: number[]) => redColorScaleGenerator(value),
+ },
+ tableOptions: {
+ type: Object as PropType>,
+ default: () => ({})
+ },
+ renderers: {
+ type: Object as PropType>,
+ default: () => ({})
+ },
+ rendererName: {
+ type: String,
+ default: 'Table'
+ },
+ locale: {
+ type: String,
+ default: 'en'
+ },
+ languagePack: {
+ type: Object as PropType>,
+ default: () => locales
+ },
+ showRowTotal: {
+ type: Boolean as PropType,
+ default: true
+ },
+ showColTotal: {
+ type: Boolean as PropType,
+ default: true
+ },
+ cols: {
+ type: Array as PropType,
+ default: () => []
+ },
+ rows: {
+ type: Array as PropType,
+ default: () => []
+ },
+ vals: {
+ type: Array as PropType,
+ default: () => []
+ },
+ attributes: {
+ type: Array as PropType,
+ default: () => []
+ },
+ valueFilter: {
+ type: Object as PropType>,
+ default: () => ({})
+ },
+ sorters: {
+ type: [Function, Object] as PropType,
+ default: () => ({})
+ },
+ derivedAttributes: {
+ type: [Function, Object] as PropType,
+ default: () => ({})
+ },
+ rowOrder: {
+ type: String as PropType<'key_a_to_z' | 'value_a_to_z' | 'value_z_to_a'>,
+ default: 'key_a_to_z',
+ validator: (value: string) =>
+ ['key_a_to_z', 'value_a_to_z', 'value_z_to_a'].indexOf(value) !== -1
+ },
+ colOrder: {
+ type: String as PropType<'key_a_to_z' | 'value_a_to_z' | 'value_z_to_a'>,
+ default: 'key_a_to_z',
+ validator: (value: string) =>
+ ['key_a_to_z', 'value_a_to_z', 'value_z_to_a'].indexOf(value) !== -1
+ },
+ tableMaxWidth: {
+ type: Number,
+ default: 0,
+ validator: (value: number) => value >= 0
+ }
+}
\ No newline at end of file
diff --git a/src/helper/index.ts b/src/helper/index.ts
new file mode 100644
index 0000000..2521a50
--- /dev/null
+++ b/src/helper/index.ts
@@ -0,0 +1,3 @@
+export * from './utilities'
+export { default as defaultProps } from './defaultProps'
+export { redColorScaleGenerator } from './redColorScaleGenerator'
diff --git a/src/helper/redColorScaleGenerator.ts b/src/helper/redColorScaleGenerator.ts
new file mode 100644
index 0000000..9889547
--- /dev/null
+++ b/src/helper/redColorScaleGenerator.ts
@@ -0,0 +1,8 @@
+export function redColorScaleGenerator(values: number[]) {
+ const min = Math.min(...values)
+ const max = Math.max(...values)
+ return (x: number) => {
+ const nonRed = 255 - Math.round((255 * (x - min)) / (max - min))
+ return { backgroundColor: `rgb(255,${nonRed},${nonRed})` }
+ }
+}
diff --git a/src/helper/utilities.ts b/src/helper/utilities.ts
new file mode 100644
index 0000000..f0ebb9a
--- /dev/null
+++ b/src/helper/utilities.ts
@@ -0,0 +1,1001 @@
+// TypeScript 변환된 Utilities 모듈
+// 원본: utilities.js → TypeScript: utilities.ts
+
+// ==================== 브랜드 타입 정의 ====================
+type AttributeName = string & { readonly __brand: unique symbol }
+type FlatKey = string & { readonly __brand: unique symbol }
+type NumericValue = number & { readonly __brand: unique symbol }
+
+// ==================== 기본 타입 정의 ====================
+interface NumberFormatOptions {
+ digitsAfterDecimal?: number
+ scaler?: number
+ thousandsSep?: string
+ decimalSep?: string
+ prefix?: string
+ suffix?: string
+}
+
+type Formatter = (value: number) => string
+type SortFunction = (a: any, b: any) => number
+type RecordValue = string | number
+type DataRecord = Record
+
+// ==================== Aggregator 관련 타입 ====================
+interface AggregatorInstance {
+ count?: number
+ sum?: number
+ vals?: number[]
+ uniq?: any[]
+ val?: any
+ sorter?: SortFunction
+ n?: number
+ m?: number
+ s?: number
+ sumNum?: number
+ sumDenom?: number
+ selector?: [any[], any[]]
+ inner?: AggregatorInstance
+ push: (record: DataRecord) => void
+ value: () => any
+ format?: Formatter | ((x: any) => string)
+ numInputs?: number
+}
+interface PivotDataContext {
+ getAggregator: (rowKey: any[], colKey: any[]) => AggregatorInstance
+}
+type AggregatorFunction = (data?: PivotDataContext, rowKey?: any[], colKey?: any[]) => AggregatorInstance
+type AggregatorTemplate = (...args: any[]) => AggregatorFunction
+
+interface AggregatorTemplates {
+ count: (formatter?: Formatter) => AggregatorTemplate
+ uniques: (fn: (uniq: any[]) => any, formatter?: Formatter) => AggregatorTemplate
+ sum: (formatter?: Formatter) => AggregatorTemplate
+ extremes: (mode: 'min' | 'max' | 'first' | 'last', formatter?: Formatter) => AggregatorTemplate
+ quantile: (q: number, formatter?: Formatter) => AggregatorTemplate
+ runningStat: (mode: 'mean' | 'var' | 'stdev', ddof?: number, formatter?: Formatter) => AggregatorTemplate
+ sumOverSum: (formatter?: Formatter) => AggregatorTemplate
+ fractionOf: (wrapped: AggregatorTemplate, type?: 'total' | 'row' | 'col', formatter?: Formatter) => AggregatorTemplate
+ countUnique: (formatter?: Formatter) => AggregatorTemplate
+ listUnique: (separator: string) => AggregatorTemplate
+ max: (formatter?: Formatter) => AggregatorTemplate
+ min: (formatter?: Formatter) => AggregatorTemplate
+ first: (formatter?: Formatter) => AggregatorTemplate
+ last: (formatter?: Formatter) => AggregatorTemplate
+ median: (formatter?: Formatter) => AggregatorTemplate
+ average: (formatter?: Formatter) => AggregatorTemplate
+ var: (ddof: number, formatter?: Formatter) => AggregatorTemplate
+ stdev: (ddof: number, formatter?: Formatter) => AggregatorTemplate
+}
+
+// ==================== PivotData 관련 타입 ====================
+interface PivotDataProps {
+ data: DataRecord[] | DataRecord[][] | ((callback: (record: DataRecord) => void) => void)
+ aggregators?: Record
+ cols?: string[]
+ rows?: string[]
+ vals?: string[]
+ aggregatorName?: string
+ sorters?: Record | ((attr: string) => SortFunction)
+ valueFilter?: Record>
+ rowOrder?: 'key_a_to_z' | 'key_z_to_a' | 'value_a_to_z' | 'value_z_to_a'
+ colOrder?: 'key_a_to_z' | 'key_z_to_a' | 'value_a_to_z' | 'value_z_to_a'
+ derivedAttributes?: Record RecordValue>
+}
+
+// ==================== Locale 관련 타입 ====================
+interface LocaleStrings {
+ renderError: string
+ computeError: string
+ uiRenderError: string
+ selectAll: string
+ selectNone: string
+ tooMany: string
+ filterResults: string
+ totals: string
+ vs: string
+ by: string
+ cancel: string
+ only: string
+ apply?: string
+}
+
+interface Locale {
+ aggregators?: Record
+ frAggregators?: Record
+ localeStrings: LocaleStrings
+}
+
+// ==================== Derivers 타입 ====================
+interface Derivers {
+ bin: (col: string, binWidth: number) => (record: DataRecord) => number
+ dateFormat: (
+ col: string,
+ formatString: string,
+ utcOutput?: boolean,
+ mthNames?: string[],
+ dayNames?: string[]
+ ) => (record: DataRecord) => string
+}
+
+// ==================== 유틸리티 함수들 ====================
+
+const addSeparators = (nStr: string | number, thousandsSep: string, decimalSep: string): string => {
+ const x = String(nStr).split('.')
+ let x1 = x[0]
+ const x2 = x.length > 1 ? decimalSep + x[1] : ''
+ const rgx = /(\d+)(\d{3})/
+ while (rgx.test(x1)) {
+ x1 = x1.replace(rgx, `$1${thousandsSep}$2`)
+ }
+ return x1 + x2
+}
+
+const numberFormat = (optsIn?: NumberFormatOptions): Formatter => {
+ const defaults: Required = {
+ digitsAfterDecimal: 2,
+ scaler: 1,
+ thousandsSep: ',',
+ decimalSep: '.',
+ prefix: '',
+ suffix: ''
+ }
+ const opts = Object.assign({}, defaults, optsIn)
+
+ return (x: number): string => {
+ if (isNaN(x) || !isFinite(x)) {
+ return ''
+ }
+ const result = addSeparators(
+ (opts.scaler * x).toFixed(opts.digitsAfterDecimal),
+ opts.thousandsSep,
+ opts.decimalSep
+ )
+ return `${opts.prefix}${result}${opts.suffix}`
+ }
+}
+
+// 정규식 패턴들
+const rx = /(\d+)|(\D+)/g
+const rd = /\d/
+const rz = /^0/
+
+const naturalSort: SortFunction = (as: any, bs: any): number => {
+ // nulls first
+ if (bs !== null && as === null) {
+ return -1
+ }
+ if (as !== null && bs === null) {
+ return 1
+ }
+
+ // then raw NaNs
+ if (typeof as === 'number' && isNaN(as)) {
+ return -1
+ }
+ if (typeof bs === 'number' && isNaN(bs)) {
+ return 1
+ }
+
+ // numbers and numbery strings group together
+ const nas = Number(as)
+ const nbs = Number(bs)
+ if (nas < nbs) {
+ return -1
+ }
+ if (nas > nbs) {
+ return 1
+ }
+
+ // within that, true numbers before numbery strings
+ if (typeof as === 'number' && typeof bs !== 'number') {
+ return -1
+ }
+ if (typeof bs === 'number' && typeof as !== 'number') {
+ return 1
+ }
+ if (typeof as === 'number' && typeof bs === 'number') {
+ return 0
+ }
+
+ // 'Infinity' is a textual number, so less than 'A'
+ if (isNaN(nbs) && !isNaN(nas)) {
+ return -1
+ }
+ if (isNaN(nas) && !isNaN(nbs)) {
+ return 1
+ }
+
+ // finally, "smart" string sorting
+ const a = String(as)
+ const b = String(bs)
+ if (a === b) {
+ return 0
+ }
+ if (!rd.test(a) || !rd.test(b)) {
+ return a > b ? 1 : -1
+ }
+
+ // special treatment for strings containing digits
+ const aMatches = a.match(rx)
+ const bMatches = b.match(rx)
+
+ if (!aMatches || !bMatches) {
+ return a > b ? 1 : -1
+ }
+
+ while (aMatches.length && bMatches.length) {
+ const a1 = aMatches.shift()!
+ const b1 = bMatches.shift()!
+ if (a1 !== b1) {
+ if (rd.test(a1) && rd.test(b1)) {
+ const numDiff = parseFloat(a1.replace(rz, '.0')) - parseFloat(b1.replace(rz, '.0'))
+ return numDiff !== 0 ? numDiff : a1.length - b1.length
+ }
+ return a1 > b1 ? 1 : -1
+ }
+ }
+ return aMatches.length - bMatches.length
+}
+
+const sortAs = (order: any[]): SortFunction => {
+ const mapping: Record = {}
+ const lMapping: Record = {}
+
+ for (const i in order) {
+ const x = order[i]
+ mapping[x] = parseInt(i)
+ if (typeof x === 'string') {
+ lMapping[x.toLowerCase()] = parseInt(i)
+ }
+ }
+
+ return (a: any, b: any): number => {
+ if (a in mapping && b in mapping) {
+ return mapping[a] - mapping[b]
+ } else if (a in mapping) {
+ return -1
+ } else if (b in mapping) {
+ return 1
+ } else if (a in lMapping && b in lMapping) {
+ return lMapping[a] - lMapping[b]
+ } else if (a in lMapping) {
+ return -1
+ } else if (b in lMapping) {
+ return 1
+ }
+ return naturalSort(a, b)
+ }
+}
+
+const getSort = (sorters: Record | ((attr: string) => SortFunction) | null, attr: string): SortFunction => {
+ if (sorters) {
+ if (typeof sorters === 'function') {
+ const sort = sorters(attr)
+ if (typeof sort === 'function') {
+ return sort
+ }
+ } else if (attr in sorters) {
+ return sorters[attr]
+ }
+ }
+ return naturalSort
+}
+
+// 기본 포매터들
+const usFmt = numberFormat()
+const usFmtInt = numberFormat({ digitsAfterDecimal: 0 })
+const usFmtPct = numberFormat({
+ digitsAfterDecimal: 1,
+ scaler: 100,
+ suffix: '%'
+})
+
+// ==================== Aggregator Templates ====================
+
+const aggregatorTemplates: AggregatorTemplates = {
+ count (formatter: Formatter = usFmtInt): AggregatorTemplate {
+ return () => () => ({
+ count: 0,
+ push () {
+ this.count++
+ },
+ value () {
+ return this.count
+ },
+ format: formatter
+ })
+ },
+
+ uniques (fn: (uniq: any[]) => any, formatter: Formatter = usFmtInt): AggregatorTemplate {
+ return ([attr]: [string]) => () => ({
+ uniq: [] as any[],
+ push (record: DataRecord) {
+ const value = record?.[attr]
+ if (!this.uniq.includes(value)) {
+ this.uniq.push(value)
+ }
+ },
+ value () {
+ return fn(this.uniq)
+ },
+ format: formatter,
+ numInputs: typeof attr !== 'undefined' ? 0 : 1
+ })
+ },
+
+ sum (formatter: Formatter = usFmt): AggregatorTemplate {
+ return ([attr]: [string]) => () => ({
+ sum: 0,
+ push (record: DataRecord) {
+ const raw = record?.[attr]
+ const val = raw != null ? parseFloat(String(raw)) : NaN
+ if (!isNaN(val)) {
+ this.sum += val
+ }
+ },
+ value () {
+ return this.sum
+ },
+ format: formatter,
+ numInputs: typeof attr !== 'undefined' ? 0 : 1
+ })
+ },
+
+ extremes (mode: 'min' | 'max' | 'first' | 'last', formatter: Formatter = usFmt): AggregatorTemplate {
+ return ([attr]: [string]) => (data?: PivotDataContext) => ({
+ val: null as any,
+ sorter: getSort(
+ typeof data !== 'undefined' ? (data as any).sorters : null,
+ attr
+ ),
+ push (record: DataRecord) {
+ const raw = record?.[attr]
+ const x = raw
+ if (['min', 'max'].includes(mode)) {
+ const numX = x != null ? parseFloat(String(x)) : NaN
+ if (!isNaN(numX)) {
+ this.val = Math[mode as 'min' | 'max'](numX, this.val !== null ? this.val : numX)
+ }
+ }
+ if (
+ mode === 'first' &&
+ this.sorter(x, this.val !== null ? this.val : x) <= 0
+ ) {
+ this.val = x
+ }
+ if (
+ mode === 'last' &&
+ this.sorter(x, this.val !== null ? this.val : x) >= 0
+ ) {
+ this.val = x
+ }
+ },
+ value () {
+ return this.val
+ },
+ format (x: any) {
+ if (isNaN(x)) {
+ return x
+ }
+ return formatter(x)
+ },
+ numInputs: typeof attr !== 'undefined' ? 0 : 1
+ })
+ },
+
+ quantile (q: number, formatter: Formatter = usFmt): AggregatorTemplate {
+ return ([attr]: [string]) => () => ({
+ vals: [] as number[],
+ push (record: DataRecord) {
+ const raw = record?.[attr]
+ const x = raw != null ? parseFloat(String(raw)) : NaN
+ if (!isNaN(x)) {
+ this.vals.push(x)
+ }
+ },
+ value (): number | null {
+ if (this.vals.length === 0) {
+ return null
+ }
+ this.vals.sort((a: number, b: number) => a - b)
+ const i = (this.vals.length - 1) * q
+ return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0
+ },
+ format: formatter,
+ numInputs: typeof attr !== 'undefined' ? 0 : 1
+ })
+ },
+
+ runningStat (mode: 'mean' | 'var' | 'stdev' = 'mean', ddof: number = 1, formatter: Formatter = usFmt): AggregatorTemplate {
+ return ([attr]: [string]) => () => ({
+ n: 0.0,
+ m: 0.0,
+ s: 0.0,
+ push (record: DataRecord) {
+ const raw = record?.[attr]
+ const x = raw != null ? parseFloat(String(raw)) : NaN
+ if (isNaN(x)) {
+ return
+ }
+ this.n += 1.0
+ if (this.n === 1.0) {
+ this.m = x
+ }
+ const mNew = this.m + (x - this.m) / this.n
+ this.s = this.s + (x - this.m) * (x - mNew)
+ this.m = mNew
+ },
+ value () {
+ if (mode === 'mean') {
+ if (this.n === 0) {
+ return NaN
+ }
+ return this.m
+ }
+ if (this.n <= ddof) {
+ return 0
+ }
+ switch (mode) {
+ case 'var':
+ return this.s / (this.n - ddof)
+ case 'stdev':
+ return Math.sqrt(this.s / (this.n - ddof))
+ default:
+ throw new Error('unknown mode for runningStat')
+ }
+ },
+ format: formatter,
+ numInputs: typeof attr !== 'undefined' ? 0 : 1
+ })
+ },
+
+ sumOverSum (formatter: Formatter = usFmt): AggregatorTemplate {
+ return ([num, denom]: [string, string]) => () => ({
+ sumNum: 0,
+ sumDenom: 0,
+ push (record: DataRecord) {
+ const rawNum = record?.[num]
+ const rawDenom = record?.[denom]
+ const numVal = rawNum != null ? parseFloat(String(rawNum)) : NaN
+ const denomVal = rawDenom != null ? parseFloat(String(rawDenom)) : NaN
+ if (!isNaN(numVal)) {
+ this.sumNum += numVal
+ }
+ if (!isNaN(denomVal)) {
+ this.sumDenom += denomVal
+ }
+ },
+ value () {
+ return this.sumNum / this.sumDenom
+ },
+ format: formatter,
+ numInputs:
+ typeof num !== 'undefined' && typeof denom !== 'undefined' ? 0 : 2
+ })
+ },
+
+ fractionOf (wrapped: AggregatorTemplate, type: 'total' | 'row' | 'col' = 'total', formatter: Formatter = usFmtPct): AggregatorTemplate {
+ return (...x: any[]) =>
+ (data: PivotDataContext, rowKey: any[], colKey: any[]): AggregatorInstance => ({
+ selector: { total: [[], []], row: [rowKey, []], col: [[], colKey] }[type] as [any[], any[]],
+ inner: wrapped(...Array.from(x || []))(data, rowKey, colKey),
+ push (record: DataRecord) {
+ this.inner.push(record)
+ },
+ format: formatter,
+ value () {
+ return (
+ this.inner.value() /
+ (data as any)
+ .getAggregator(...Array.from(this.selector || []))
+ .inner.value()
+ )
+ },
+ numInputs: wrapped(...Array.from(x || []))().numInputs
+ })
+ },
+
+ // 편의 함수들
+ countUnique (f?: Formatter): AggregatorTemplate {
+ return this.uniques((x: any[]) => x.length, f)
+ },
+
+ listUnique (s: string): AggregatorTemplate {
+ return this.uniques(
+ (x: any[]) => x.join(s),
+ (x: any) => x
+ )
+ },
+
+ max (f?: Formatter): AggregatorTemplate {
+ return this.extremes('max', f)
+ },
+
+ min (f?: Formatter): AggregatorTemplate {
+ return this.extremes('min', f)
+ },
+
+ first (f?: Formatter): AggregatorTemplate {
+ return this.extremes('first', f)
+ },
+
+ last (f?: Formatter): AggregatorTemplate {
+ return this.extremes('last', f)
+ },
+
+ median (f?: Formatter): AggregatorTemplate {
+ return this.quantile(0.5, f)
+ },
+
+ average (f?: Formatter): AggregatorTemplate {
+ return this.runningStat('mean', 1, f)
+ },
+
+ var (ddof: number, f?: Formatter): AggregatorTemplate {
+ return this.runningStat('var', ddof, f)
+ },
+
+ stdev (ddof: number, f?: Formatter): AggregatorTemplate {
+ return this.runningStat('stdev', ddof, f)
+ }
+}
+
+// ==================== 기본 Aggregators ====================
+
+const aggregators: Record = {
+ 'Count': aggregatorTemplates.count(usFmtInt),
+ 'Count Unique Values': aggregatorTemplates.countUnique(usFmtInt),
+ 'List Unique Values': aggregatorTemplates.listUnique(', '),
+ 'Sum': aggregatorTemplates.sum(usFmt),
+ 'Integer Sum': aggregatorTemplates.sum(usFmtInt),
+ 'Average': aggregatorTemplates.average(usFmt),
+ 'Median': aggregatorTemplates.median(usFmt),
+ 'Sample Variance': aggregatorTemplates.var(1, usFmt),
+ 'Sample Standard Deviation': aggregatorTemplates.stdev(1, usFmt),
+ 'Minimum': aggregatorTemplates.min(usFmt),
+ 'Maximum': aggregatorTemplates.max(usFmt),
+ 'First': aggregatorTemplates.first(usFmt),
+ 'Last': aggregatorTemplates.last(usFmt),
+ 'Sum over Sum': aggregatorTemplates.sumOverSum(usFmt),
+ 'Sum as Fraction of Total': aggregatorTemplates.fractionOf(aggregatorTemplates.sum(), 'total', usFmtPct),
+ 'Sum as Fraction of Rows': aggregatorTemplates.fractionOf(aggregatorTemplates.sum(), 'row', usFmtPct),
+ 'Sum as Fraction of Columns': aggregatorTemplates.fractionOf(aggregatorTemplates.sum(), 'col', usFmtPct),
+ 'Count as Fraction of Total': aggregatorTemplates.fractionOf(aggregatorTemplates.count(), 'total', usFmtPct),
+ 'Count as Fraction of Rows': aggregatorTemplates.fractionOf(aggregatorTemplates.count(), 'row', usFmtPct),
+ 'Count as Fraction of Columns': aggregatorTemplates.fractionOf(aggregatorTemplates.count(), 'col', usFmtPct)
+}
+
+// ==================== 프랑스어 Aggregators ====================
+
+const frAggregators: Record = {
+ 'Compte': aggregatorTemplates.count(usFmtInt),
+ 'Compter les valeurs uniques': aggregatorTemplates.countUnique(usFmtInt),
+ 'Liste des valeurs uniques': aggregatorTemplates.listUnique(', '),
+ 'Somme': aggregatorTemplates.sum(usFmt),
+ 'Somme de nombres entiers': aggregatorTemplates.sum(usFmtInt),
+ 'Moyenne': aggregatorTemplates.average(usFmt),
+ 'Médiane': aggregatorTemplates.median(usFmt),
+ "Variance de l'échantillon": aggregatorTemplates.var(1, usFmt),
+ "Écart-type de l'échantillon": aggregatorTemplates.stdev(1, usFmt),
+ 'Minimum': aggregatorTemplates.min(usFmt),
+ 'Maximum': aggregatorTemplates.max(usFmt),
+ 'Premier': aggregatorTemplates.first(usFmt),
+ 'Dernier': aggregatorTemplates.last(usFmt),
+ 'Somme Total': aggregatorTemplates.sumOverSum(usFmt)
+}
+
+// ==================== Locales ====================
+
+const locales: Record = {
+ en: {
+ aggregators,
+ localeStrings: {
+ renderError: 'An error occurred rendering the PivotTable results.',
+ computeError: 'An error occurred computing the PivotTable results.',
+ uiRenderError: 'An error occurred rendering the PivotTable UI.',
+ selectAll: 'Select All',
+ selectNone: 'Select None',
+ tooMany: '(too many to list)',
+ filterResults: 'Filter values',
+ totals: 'Totals',
+ vs: 'vs',
+ by: 'by',
+ cancel: 'Cancel',
+ only: 'only'
+ }
+ },
+ fr: {
+ frAggregators,
+ localeStrings: {
+ renderError: 'Une erreur est survenue en dessinant le tableau croisé.',
+ computeError: 'Une erreur est survenue en calculant le tableau croisé.',
+ uiRenderError:
+ "Une erreur est survenue en dessinant l'interface du tableau croisé dynamique.",
+ selectAll: 'Sélectionner tout',
+ selectNone: 'Ne rien sélectionner',
+ tooMany: '(trop de valeurs à afficher)',
+ filterResults: 'Filtrer les valeurs',
+ totals: 'Totaux',
+ vs: 'sur',
+ by: 'par',
+ apply: 'Appliquer',
+ cancel: 'Annuler',
+ only: 'seul'
+ }
+ }
+}
+
+// ==================== Date 관련 상수들 ====================
+
+const mthNamesEn: readonly string[] = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
+] as const
+
+const dayNamesEn: readonly string[] = [
+ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
+] as const
+
+const zeroPad = (number: number): string => `0${number}`.substr(-2, 2)
+
+// ==================== Derivers ====================
+
+const derivers: Derivers = {
+ bin (col: string, binWidth: number) {
+ return (record: DataRecord): number => {
+ const value = Number(record[col])
+ return value - (value % binWidth)
+ }
+ },
+
+ dateFormat (
+ col: string,
+ formatString: string,
+ utcOutput: boolean = false,
+ mthNames: string[] = [...mthNamesEn],
+ dayNames: string[] = [...dayNamesEn]
+ ) {
+ const utc = utcOutput ? 'UTC' : ''
+
+ return (record: DataRecord): string => {
+ const date = new Date(Date.parse(String(record[col])))
+ if (isNaN(date.getTime())) {
+ return ''
+ }
+
+ return formatString.replace(/%(.)/g, (_: string, p: string): string => {
+ switch (p) {
+ case 'y':
+ return String((date as any)[`get${utc}FullYear`]())
+ case 'm':
+ return zeroPad((date as any)[`get${utc}Month`]() + 1)
+ case 'n':
+ return mthNames[(date as any)[`get${utc}Month`]()]
+ case 'd':
+ return zeroPad((date as any)[`get${utc}Date`]())
+ case 'w':
+ return dayNames[(date as any)[`get${utc}Day`]()]
+ case 'x':
+ return String((date as any)[`get${utc}Day`]())
+ case 'H':
+ return zeroPad((date as any)[`get${utc}Hours`]())
+ case 'M':
+ return zeroPad((date as any)[`get${utc}Minutes`]())
+ case 'S':
+ return zeroPad((date as any)[`get${utc}Seconds`]())
+ default:
+ return `%${p}`
+ }
+ })
+ }
+ }
+}
+
+// ==================== PivotData 클래스 ====================
+
+class PivotData {
+ public static defaultProps: Required = {
+ aggregators,
+ cols: [],
+ rows: [],
+ vals: [],
+ aggregatorName: 'Count',
+ sorters: {},
+ valueFilter: {},
+ rowOrder: 'key_a_to_z',
+ colOrder: 'key_a_to_z',
+ derivedAttributes: {},
+ data: []
+ }
+
+ public props: Required
+ public aggregator: AggregatorFunction
+ public tree: Record>
+ public rowKeys: any[][]
+ public colKeys: any[][]
+ public rowTotals: Record
+ public colTotals: Record
+ public allTotal: AggregatorInstance
+ public sorted: boolean
+ public filteredData: DataRecord[]
+
+ constructor(inputProps: Partial = {}) {
+ this.props = Object.assign({}, PivotData.defaultProps, inputProps)
+ this.aggregator = this.props.aggregators[this.props.aggregatorName]!(this.props.vals)
+ this.tree = {}
+ this.rowKeys = []
+ this.colKeys = []
+ this.rowTotals = {}
+ this.colTotals = {}
+ this.allTotal = this.aggregator(this, [], [])
+ this.sorted = false
+ this.filteredData = []
+
+ // 입력 데이터 순회하면서 셀 데이터 누적
+ PivotData.forEachRecord(
+ this.props.data,
+ this.props.derivedAttributes,
+ (record: DataRecord) => {
+ if (this.filter(record)) {
+ this.filteredData.push(record)
+ this.processRecord(record)
+ }
+ }
+ )
+ }
+
+ filter (record: DataRecord): boolean {
+ const allSelector = '*'
+ for (const k in this.props.valueFilter) {
+ if (k !== allSelector) {
+ const valueFilterItem = this.props.valueFilter[k]
+ if (valueFilterItem) {
+ if (record[k] in valueFilterItem) {
+ const existingKey = valueFilterItem[String(record[k])]
+ if (existingKey === true) {
+ return false
+ }
+ } else if (valueFilterItem[allSelector] === true) {
+ return false
+ }
+ }
+ }
+ }
+ return true
+ }
+
+ forEachMatchingRecord (criteria: Record, callback: (record: DataRecord) => void): void {
+ PivotData.forEachRecord(
+ this.props.data,
+ this.props.derivedAttributes,
+ (record: DataRecord) => {
+ if (!this.filter(record)) {
+ return
+ }
+ for (const k in criteria) {
+ const v = criteria[k]
+ if (v !== (k in record ? record[k] : 'null')) {
+ return
+ }
+ }
+ callback(record)
+ }
+ )
+ }
+
+ arrSort (attrs: string[]): SortFunction {
+ const sortersArr: SortFunction[] = attrs.map(a => getSort(this.props.sorters, a))
+
+ return (a: any[], b: any[]): number => {
+ for (const i of Object.keys(sortersArr)) {
+ const sorter = sortersArr[parseInt(i)]
+ const comparison = sorter(a[parseInt(i)], b[parseInt(i)])
+ if (comparison !== 0) {
+ return comparison
+ }
+ }
+ return 0
+ }
+ }
+
+ sortKeys (): void {
+ if (!this.sorted) {
+ this.sorted = true
+ const v = (r: any[], c: any[]) => this.getAggregator(r, c).value()
+
+ switch (this.props.rowOrder) {
+ case 'value_a_to_z':
+ this.rowKeys.sort((a, b) => naturalSort(v(a, []), v(b, [])))
+ break
+ case 'value_z_to_a':
+ this.rowKeys.sort((a, b) => -naturalSort(v(a, []), v(b, [])))
+ break
+ default:
+ this.rowKeys.sort(this.arrSort(this.props.rows))
+ }
+
+ switch (this.props.colOrder) {
+ case 'value_a_to_z':
+ this.colKeys.sort((a, b) => naturalSort(v([], a), v([], b)))
+ break
+ case 'value_z_to_a':
+ this.colKeys.sort((a, b) => -naturalSort(v([], a), v([], b)))
+ break
+ default:
+ this.colKeys.sort(this.arrSort(this.props.cols))
+ }
+ }
+ }
+
+ getFilteredData (): DataRecord[] {
+ return this.filteredData
+ }
+
+ getColKeys (): any[][] {
+ this.sortKeys()
+ return this.colKeys
+ }
+
+ getRowKeys (): any[][] {
+ this.sortKeys()
+ return this.rowKeys
+ }
+
+ processRecord (record: DataRecord): void {
+ // 이 코드는 타이트한 루프에서 호출됨
+ const colKey: any[] = []
+ const rowKey: any[] = []
+
+ for (const x of this.props.cols) {
+ colKey.push(x in record ? record[x] : 'null')
+ }
+ for (const x of this.props.rows) {
+ rowKey.push(x in record ? record[x] : 'null')
+ }
+
+ const flatRowKey = rowKey.join(String.fromCharCode(0)) as FlatKey
+ const flatColKey = colKey.join(String.fromCharCode(0)) as FlatKey
+
+ this.allTotal.push(record)
+
+ if (rowKey.length !== 0) {
+ if (!this.rowTotals[flatRowKey]) {
+ this.rowKeys.push(rowKey)
+ this.rowTotals[flatRowKey] = this.aggregator(this, rowKey, [])
+ }
+ this.rowTotals[flatRowKey].push(record)
+ }
+
+ if (colKey.length !== 0) {
+ if (!this.colTotals[flatColKey]) {
+ this.colKeys.push(colKey)
+ this.colTotals[flatColKey] = this.aggregator(this, [], colKey)
+ }
+ this.colTotals[flatColKey].push(record)
+ }
+
+ if (colKey.length !== 0 && rowKey.length !== 0) {
+ if (!this.tree[flatRowKey]) {
+ this.tree[flatRowKey] = {}
+ }
+ if (!this.tree[flatRowKey][flatColKey]) {
+ this.tree[flatRowKey][flatColKey] = this.aggregator(
+ this,
+ rowKey,
+ colKey
+ )
+ }
+ this.tree[flatRowKey][flatColKey].push(record)
+ }
+ }
+
+ getAggregator (rowKey: any[], colKey: any[]): AggregatorInstance {
+ const flatRowKey = rowKey.join(String.fromCharCode(0)) as FlatKey
+ const flatColKey = colKey.join(String.fromCharCode(0)) as FlatKey
+
+ let agg: AggregatorInstance | undefined
+
+ if (rowKey.length === 0 && colKey.length === 0) {
+ agg = this.allTotal
+ } else if (rowKey.length === 0) {
+ agg = this.colTotals[flatColKey]
+ } else if (colKey.length === 0) {
+ agg = this.rowTotals[flatRowKey]
+ } else {
+ agg = this.tree[flatRowKey]?.[flatColKey]
+ }
+
+ return agg || {
+ value: () => null,
+ format: () => '',
+ push: () => { }
+ }
+ }
+
+ // Static method for processing records
+ static forEachRecord (
+ input: DataRecord[] | DataRecord[][] | ((callback: (record: DataRecord) => void) => void),
+ derivedAttributes: Record RecordValue>,
+ f: (record: DataRecord) => void
+ ): void {
+ let addRecord: (record: DataRecord) => void
+
+ if (derivedAttributes && Object.getOwnPropertyNames(derivedAttributes).length === 0) {
+ addRecord = f
+ } else {
+ addRecord = (record: DataRecord) => {
+ for (const k in derivedAttributes) {
+ const derived = derivedAttributes[k](record)
+ if (derived !== null) {
+ record[k] = derived
+ }
+ }
+ return f(record)
+ }
+ }
+
+ // 함수인 경우, 콜백으로 호출
+ if (typeof input === 'function') {
+ return input(addRecord)
+ } else if (Array.isArray(input)) {
+ if (input.length > 0 && Array.isArray(input[0])) {
+ // 배열의 배열 - 첫 번째 행이 헤더인 경우
+ const firstRow = input[0] as unknown as any[]
+ for (let i = 1; i < input.length; i++) {
+ const compactRecord = input[i] as any[]
+ const record: DataRecord = {}
+ for (let j = 0; j < firstRow.length; j++) {
+ const k = String(firstRow[j] || `col_${j}`)
+ record[k] = compactRecord[j]
+ }
+ addRecord(record)
+ }
+ return
+ }
+
+ // 객체의 배열 - 타입 가드로 안전하게 처리
+ const dataArray = input as DataRecord[]
+ for (const record of dataArray) {
+ addRecord(record)
+ }
+ return
+ }
+
+ throw new Error('unknown input format')
+ }
+}
+
+// ==================== Export ====================
+
+export {
+ // 타입들
+ type NumberFormatOptions,
+ type Formatter,
+ type SortFunction,
+ type RecordValue,
+ type DataRecord,
+ type AggregatorInstance,
+ type AggregatorFunction,
+ type AggregatorTemplate,
+ type AggregatorTemplates,
+ type PivotDataProps,
+ type PivotDataContext,
+ type LocaleStrings,
+ type Locale,
+ type Derivers,
+ type AttributeName,
+ type FlatKey,
+ type NumericValue,
+
+ // 함수들과 객체들
+ aggregatorTemplates,
+ aggregators,
+ derivers,
+ locales,
+ naturalSort,
+ numberFormat,
+ getSort,
+ sortAs,
+ PivotData
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..98f226e
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,12 @@
+import { VuePivottable, VuePivottableUi } from './components'
+import * as PivotUtilities from './helper'
+import TableRenderer from './components/pivottable/renderer'
+import type { Component } from 'vue'
+export * from './composables'
+
+const Renderer: Record = {
+ ...TableRenderer
+}
+
+export { VuePivottable, VuePivottableUi, PivotUtilities, Renderer }
+export default { VuePivottable, VuePivottableUi, PivotUtilities, Renderer }
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..98e2da2
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,10 @@
+import { createApp } from 'vue'
+
+import App from './App.vue'
+// import VuePivottable from '@/'
+
+const app = createApp(App)
+
+// app.component('VuePivottableUi', VuePivottableUi)
+
+app.mount('#app')
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..c3c7bb3
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,36 @@
+import type { AggregatorTemplate } from '@/helper'
+import { VNode } from 'vue'
+import { Locale } from '@/helper'
+
+export interface DefaultPropsType {
+ data: any
+ aggregators?: Record
+ aggregatorName: string
+ heatmapMode?: 'full' | 'col' | 'row' | ''
+ tableColorScaleGenerator?: (...args: any[]) => any
+ tableOptions?: Record
+ renderers: Record
+ rendererName: string
+ locale?: string
+ languagePack?: Record
+ showRowTotal?: boolean
+ showColTotal?: boolean
+ cols: string[]
+ rows: string[]
+ vals?: string[]
+ attributes?: string[]
+ valueFilter?: Record
+ sorters?: any
+ derivedAttributes?: any
+ rowOrder?: 'key_a_to_z' | 'value_a_to_z' | 'value_z_to_a'
+ colOrder?: 'key_a_to_z' | 'value_a_to_z' | 'value_z_to_a'
+ tableMaxWidth?: number
+}
+
+export type RendererProps = DefaultPropsType & Record
+
+export interface RendererDefinition {
+ name: string
+ props?: Record
+ setup: (props: any) => () => VNode
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c856c15
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,43 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "preserve",
+
+ /* Linting */
+ "strict": false,
+ "strictNullChecks": true,
+ "strictBindCallApply": true,
+ "strictPropertyInitialization": true,
+ "noImplicitAny": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ /* Vue specific */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "types": ["vite/client"],
+ "allowJs": true,
+ "checkJs": false
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.d.ts",
+ "src/**/*.tsx",
+ "src/**/*.vue",
+ "src/**/*.js"
+ ],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..e2e4509
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,55 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+import dts from 'vite-plugin-dts'
+import { viteStaticCopy } from 'vite-plugin-static-copy'
+
+export default defineConfig({
+ plugins: [
+ vue(),
+ viteStaticCopy({
+ targets: [
+ {
+ src: 'src/assets/vue-pivottable.css',
+ dest: '.'
+ }
+ ]
+ }),
+ dts({
+ include: [
+ 'src',
+ ],
+ outDir: 'dist/types',
+ staticImport: true,
+ insertTypesEntry: true,
+ rollupTypes: true,
+ tsconfigPath: './tsconfig.json'
+ })
+ ],
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'src/index.ts'),
+ name: 'VuePivottable',
+ fileName: (format) => `vue-pivottable.${format}.js`,
+ formats: ['es', 'umd']
+ },
+ rollupOptions: {
+ external: ['vue', 'vue-draggable-next', 'papaparse'],
+ output: {
+ exports: 'named',
+ globals: {
+ 'vue': 'Vue',
+ 'vue-draggable-next': 'VueDraggableNext',
+ 'papaparse': 'Papa'
+ }
+ }
+ },
+ sourcemap: true,
+ target: 'es2015'
+ },
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src')
+ }
+ }
+})