diff --git a/.gitignore b/.gitignore index 37ec08b3..4c717a85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .DS_Store +__pycache__/ +*.py[cod] +.venv/ # Local scratch files created during setup. /android-mini-projects/ diff --git a/AGENTS.md b/AGENTS.md index 8ad8e95b..cf62cc5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,19 +50,26 @@ cd impl && python3 crawl.py cd keybuddy/frontend && npm run sync:data # output/keyboards.json -> 프론트 + Edge Function 사본 ``` -## 아키텍처: 두 개의 추천 경로 +## 아키텍처: 추천 경로 (현행 + 레거시) -keybuddy의 핵심은 **추천 엔진이 두 갈래로 존재**한다는 점이다. 혼동하지 말 것. +> **2026-06 갱신**: 결정론 의도 하네스가 UI에 배선됐다. 현행 프로덕션은 "Edge Function 태그추출(OpenAI) + 클라이언트 결정론 검색" 하이브리드이고, 과거의 "Edge Function 전체 추천 생성"과 "클라이언트 claude 태그추출"은 코드만 남은 레거시다. 혼동하지 말 것. -1. **현재 UI에 연결된 경로 (production)**: `App.tsx` → `lib/recommend.ts`(HTTP fetch) → Supabase Edge Function `recommend/index.ts`. Edge Function이 키워드 스코어링(`scoreKeyboard`)으로 후보를 40개 이하로 압축한 뒤 **OpenAI**(`gpt-5.4`)에 넘겨 추천 사유를 생성한다. `OPENAI_API_KEY`는 브라우저에 절대 내려가지 않고 Supabase secret에만 존재한다 (`recommend.ts`는 `apikey` 헤더만 보냄). +**현행 프로덕션 경로 (UI 연결)**: `App.tsx` → `lib/recommend.ts` -2. **결정론적 의도 하네스 (lib/, 테스트로만 검증됨 - 아직 UI 미연결)**: `extractRawTags.ts`(유일한 LLM 호출, `claude-sonnet-4-6`로 자연어→의도+명시제약 번역) → `intentProfile.ts`(의도를 차원별 태그로 정적 확장) → `intentSearch.ts`/`searchEngine.ts`(하드필터 + 소프트 스코어링 + 무결과 시 제약 완화). 설계 의도는 `docs/tag-extraction-flow.md` 참고. **"LLM은 번역만, 태그 확장·검색은 전부 결정론"**이 핵심 불변식이며, 같은 입력→같은 출력을 보장한다. +- **freeform(자연어)**: `recommend.ts`가 `{mode:'extract'}`로 Edge Function `recommend/index.ts`를 호출하면, Edge Function이 **OpenAI**(`gpt-5.4`, `OPENAI_MODEL`로 override)로 자연어를 `{intents, hardConstraints, softIntentTags}`로 **태그추출만** 한다. 이후 클라이언트가 `expandIntents`(intentProfile) → `searchWithProfile`(intentSearch)로 **결정론 검색**하고 점수순 상위 3개로 압축한다. +- **guided(단계선택)**: 네트워크 0회. `selectionOptionConverter`가 답변을 태그로 변환한 뒤 위와 동일한 결정론 검색을 탄다. +- `OPENAI_API_KEY`는 브라우저에 절대 내려가지 않고 Supabase secret에만 존재한다(`recommend.ts`는 `apikey` 헤더만 보냄). 양쪽 모두 **"LLM은 번역(또는 0회)만, 태그 확장·검색·랭킹은 전부 결정론"**이라는 불변식을 지켜 같은 입력→같은 출력을 보장한다. -`docs/tag-extraction-flow.md`는 경로 2를 "현재 흐름"으로 서술하지만 실제 `recommend.ts`는 경로 1(Edge Function)을 호출한다. 경로 2는 구축·테스트 완료됐으나 아직 `recommend.ts`에 배선되지 않았다 - 이 갭이 TODO의 "키보드 에이전트 직접 설계" 작업 대상이다. +**레거시 (코드만 존재, 현 UI 미사용)**: -### lib/ 파이프라인 핵심 모듈 (경로 2) +- Edge Function 전체 추천 생성 모드: `scoreKeyboard`로 후보를 압축한 뒤 `gpt-5.4`가 후보 중 직접 선택·사유 생성 → `composeRecommendations`. `index.ts`에 남아있으나 현재 UI는 호출하지 않는다(해당 분기 주석 "현재 UI는 미사용" 참조). +- 클라이언트 `extractRawTags.ts`의 LLM 호출(`claude-sonnet-4-6`, `dangerouslyAllowBrowser`). 프로덕션 추출은 Edge Function extract(OpenAI)가 담당하므로 이 함수들은 현재 직접 호출되지 않는다(타입·테스트용). -- `extractRawTags.ts` - 자연어 → `ExtractedTags { hardConstraints, softIntentTags }` (LLM 1회) +`docs/tag-extraction-flow.md`는 이 결정론 하네스의 설계를 서술한다(문서는 클라이언트 claude 추출 기준으로 작성됐고, 실제 프로덕션 추출은 Edge Function의 OpenAI extract가 대체한다). 과거 이 문서가 가리키던 "미연결" 갭은 해소됐다. + +### lib/ 결정론 파이프라인 핵심 모듈 (현행 검색 경로) + +- `extractRawTags.ts` - 태그 정제(`sanitizeTags`)와 타입 정의. 브라우저 LLM 추출 함수(`extractRawTags`/`extractIntentInput`, `claude-sonnet-4-6`)도 있으나 현행 프로덕션은 Edge Function extract를 쓰므로 미사용(테스트용) - `tagSchema.ts` - 소프트 의도 태그 어휘(controlled vocabulary)와 검증 - `softTagRules.ts` - 소프트 태그 → 키보드 매칭 술어(규칙 맵) - `intentProfile.ts` - 고수준 의도(사무용/게이밍/휴대용)를 차원별 요구로 확장. 강도는 `필수`(하드 승격)/`선호`(소프트 점수)/`상관없음`. 명시 제약이 의도보다 우선. @@ -70,19 +77,30 @@ keybuddy의 핵심은 **추천 엔진이 두 갈래로 존재**한다는 점이 - `softScorer.ts` - 소프트 태그 매칭 점수 → 랭킹 - `searchEngine.ts`/`intentSearch.ts` - 무결과 시 제약을 우선순위 역순으로 1개씩 완화 후 재검색(`HARD_CONSTRAINT_RELAXATION_ORDER`). `searchKeyboards`는 LEGACY, `searchWithProfile`이 신규 진입점. +### 이벤트 수집 (구매 클릭 / 추천 별점) + +추천 가설 검증용 데이터를 모으기 위해 두 가지 사용자 행동을 Supabase에 적재한다(커머스 아님, 실결제 없음). + +- `lib/session.ts` - 로그인 없는 익명 세션 식별자(localStorage UUID). 추천 -> 구매 클릭 -> 별점 흐름을 느슨하게 묶는다. PII 아님. +- `lib/events.ts` - `purchase_click`('구매하기' 버튼 클릭, payload `{product_code}`)과 `rating`(추천 전체 별점, payload `{rating}`)을 anon key로 PostgREST `/rest/v1/events`에 직접 insert. **best-effort**라 설정 누락/네트워크 실패는 삼키고 `false`만 반환해 UX를 막지 않는다. +- 스키마: `supabase/migrations/20260617000000_create_events.sql` - 단일 `events`(id, event_type, session_id, payload jsonb, created_at) + **insert-only RLS**(anon은 insert만, select/update/delete는 정책 부재로 기본 거부). +- 검증: `__tests__/events.test.ts`가 payload 형태와 PostgREST 요청/실패 동작을 단위검증. 실제 적재는 라이브 Supabase에서 수동 확인(아래 README 참고). + ## 데이터 파이프라인 `crawl.py`는 다나와 **목록 페이지만** 조회한다(상세 페이지 요청 안 함, `DELAY_SEC` 레이트리밋 준수). 한 상품에 스위치 옵션이 여럿이면 제품-스위치 조합별 레코드로 펼치고 최종 **600개**로 제한(`TARGET_RECORDS`). 스위치 이름은 `src/data/switch_aliases.json` 규칙으로만 매칭하고, 매칭 실패는 추론하지 않고 `output/unmatched_switches.json`에 격리한다. 카탈로그 `keyboards.json`은 **두 군데에 사본**으로 존재한다(프론트 표시용 + Edge Function 후보용). 반드시 `npm run sync:data`로 함께 갱신해야 둘이 엇갈리지 않는다. +YouTube 타건 영상 링크는 공식 YouTube Data API로만 수집한다(YouTube HTML 검색/스크래핑 우회 금지). `impl/output/youtube_media_cache.json`은 누적 캐시이며, 다음 크롤링 때 캐시를 먼저 입히고 캐시 미스만 새로 검색한다. `keyboards.json`에는 영상 메타데이터 전체를 넣지 않고 `media_url`, `media_url_is_placeholder`만 반영한다. 제목·점수·채널 등 감사 정보는 `youtube_media_cache.json`/`youtube_media_report.json`에 둔다. + ## 버전 관리 앱 버전의 단일 소스는 `frontend/package.json`의 `version`이다. `npm run build`/`deploy:function`이 `sync:function-version`으로 이 값을 Edge Function의 `version.ts`에 주입하며, Edge Function은 `GET /recommend`, 응답 `meta.version`, `X-Keybuddy-Version` 헤더로 노출한다. 배포 전 `npm version patch|minor|major --no-git-tag-version`으로 SemVer 증가. ## 테스트 규약 -`src/__tests__/`에 36개 vitest 스위트가 있다. 특징적인 패턴: +`src/__tests__/`에 40개 vitest 스위트가 있다. 특징적인 패턴: - **`*LlmNoCall.test.ts`** - 결정론 경로가 실제로 LLM을 호출하지 않음을 강제하는 불변식 테스트. lib/ 검색 로직 수정 시 이 보증을 깨지 말 것. - **`*.goldset.test.ts`** - 정답셋 기반 정확도 회귀 테스트(하드제약/소프트의도 정확도). diff --git a/docs/youtube-media-enrichment.md b/docs/youtube-media-enrichment.md new file mode 100644 index 00000000..f56dcfcd --- /dev/null +++ b/docs/youtube-media-enrichment.md @@ -0,0 +1,156 @@ +# YouTube 타건 영상 링크 보강 + +이 문서는 다나와 키보드 목록 크롤러가 수집한 제품-스위치 레코드에 YouTube 타건 영상 +링크를 보강하는 설계와 운영 규칙을 정리한다. + +## 원칙 + +- YouTube 링크는 공식 YouTube Data API로만 수집한다. +- YouTube 검색 HTML을 직접 파싱하거나 스크래핑으로 쿼터를 우회하지 않는다. +- 다나와 크롤링은 기존처럼 목록 페이지만 조회한다. YouTube 보강은 다나와 수집 이후의 + 별도 enrichment 단계다. +- `keyboards.json`에는 재생에 필요한 최소 필드만 넣는다. + +## 데이터 계약 + +`impl/output/keyboards.json`의 각 레코드에는 기존 UI 계약을 유지해 아래 필드만 반영한다. + +```json +{ + "media_url": "https://www.youtube.com/watch?v=VIDEO_ID", + "media_url_is_placeholder": false +} +``` + +링크를 찾지 못했거나 아직 검색하지 않은 경우: + +```json +{ + "media_url": null, + "media_url_is_placeholder": true +} +``` + +영상 제목, 채널명, 점수, 검색어, 실패 이유 같은 감사 정보는 `keyboards.json`에 넣지 +않고 별도 파일에 둔다. + +## 파일 역할 + +- `impl/output/youtube_media_cache.json`: 누적 캐시. 다음 실행에서 먼저 읽어 + `keyboards.json`에 다시 반영한다. +- `impl/output/youtube_media_report.json`: 최근 실행 리포트. 성공, 실패, 스킵, 쿼터 제한, + 오류를 확인한다. +- `impl/output/keyboards.json`: 앱이 사용할 원본 카탈로그. YouTube 보강 후 저장된다. +- `impl/keybuddy/frontend/src/data/keyboards.json`: 프론트 표시용 사본. +- `impl/keybuddy/supabase/functions/recommend/keyboards.json`: Edge Function 추천 후보용 사본. + +## 검색 키 + +검색어는 제품명과 스위치명을 조합한다. + +```text +상품명 + raw_switch_name +``` + +`raw_switch_name`이 없으면 `switch_name`을 사용한다. 둘 다 없으면 YouTube 검색을 건너뛰고 +placeholder 상태를 유지한다. + +캐시 키는 `product_code`가 있으면 우선 사용한다. + +```text +product_code:::switch: +``` + +`product_code`가 없을 때만 정규화된 `product_name + switch_label`로 fallback한다. + +## 실행 + +기본 크롤링은 YouTube API를 호출하지 않는다. + +```bash +cd impl +python3 crawl.py +``` + +YouTube 링크를 새로 수집하려면 API 키와 limit을 지정한다. + +```bash +cd impl +YOUTUBE_API_KEY= python3 crawl.py --with-youtube --youtube-limit 90 +``` + +새 검색 없이 기존 캐시만 현재 `keyboards.json`에 다시 입히려면: + +```bash +cd impl +python3 crawl.py --with-youtube --youtube-limit 0 +``` + +기존 캐시를 무시하고 다시 검색하려면: + +```bash +cd impl +YOUTUBE_API_KEY= python3 crawl.py --with-youtube --youtube-refresh --youtube-limit 90 +``` + +## 이어서 수집되는 방식 + +매 실행은 다음 순서로 동작한다. + +1. 다나와 목록에서 새 `collected` 레코드를 만든다. +2. `youtube_media_cache.json`을 읽는다. +3. 캐시에 있는 항목은 API 호출 없이 `media_url`을 바로 반영한다. +4. 캐시에 없는 항목만 `--youtube-limit` 범위 안에서 새로 검색한다. +5. 새 검색 결과를 캐시에 추가한다. +6. 캐시 반영분과 새 수집분이 합쳐진 상태로 `impl/output/keyboards.json`을 저장한다. + +즉 `keyboards.json`을 이어붙이는 방식이 아니라, 매번 새 다나와 결과에 누적 캐시를 다시 +입히는 방식이다. 다나와 상품명이나 옵션명이 바뀌어도 `product_code` 기반 캐시 키가 +있으면 기존 링크를 재사용할 수 있다. + +## 동기화 + +`impl/output/keyboards.json`을 갱신한 뒤에는 반드시 프론트와 Edge Function 사본을 함께 +동기화한다. + +```bash +cd impl/keybuddy/frontend +npm run sync:data +``` + +이 명령은 아래 두 파일을 함께 갱신한다. + +```text +src/data/keyboards.json +../supabase/functions/recommend/keyboards.json +``` + +둘 중 하나만 갱신하면 프론트 표시와 추천 후보 데이터가 어긋날 수 있다. + +## 검증 + +최소 검증: + +```bash +impl/.venv/bin/python -m unittest discover -s impl -p 'test*.py' +``` + +데이터 동기화 확인: + +```bash +cmp -s impl/output/keyboards.json impl/keybuddy/frontend/src/data/keyboards.json +cmp -s impl/output/keyboards.json impl/keybuddy/supabase/functions/recommend/keyboards.json +``` + +UI 검증: + +- `media_url`이 있는 추천 카드에서 영상 버튼이 표시되는지 확인한다. +- 버튼 클릭 시 새 탭에서 YouTube 영상 페이지가 열리는지 확인한다. +- `media_url`이 없는 항목은 준비 중 상태로 표시되는지 확인한다. + +## 주의 + +- API 키는 문서, 커밋, 프론트 `.env.local`에 남기지 않는다. +- `YOUTUBE_API_KEY`는 크롤러 실행 환경 변수로만 주입한다. +- YouTube API 쿼터를 고려해 기본 운영 limit은 하루 90건 정도로 둔다. +- 403 오류가 나면 쿼터 초과나 API key 제한 설정을 먼저 확인한다. diff --git a/impl/crawl.py b/impl/crawl.py index d5911765..3981d6fb 100644 --- a/impl/crawl.py +++ b/impl/crawl.py @@ -5,8 +5,10 @@ 제품-스위치 조합별 레코드로 펼쳐 JSON과 CSV에 저장한다. """ +import argparse import csv import json +import os import re import time from collections import defaultdict @@ -16,6 +18,11 @@ import requests from bs4 import BeautifulSoup +try: + from .youtube_media import enrich_items_with_youtube +except ImportError: + from youtube_media import enrich_items_with_youtube + LIST_URL = "https://prod.danawa.com/list/" LIST_AJAX_URL = "https://prod.danawa.com/list/ajax/getProductList.ajax.php" CATEGORY_CODE = "112782" @@ -28,6 +35,8 @@ SWITCH_DATA_DIR = BASE_DIR / "keybuddy" / "frontend" / "src" / "data" SWITCHES_PATH = SWITCH_DATA_DIR / "switches.json" SWITCH_ALIASES_PATH = SWITCH_DATA_DIR / "switch_aliases.json" +YOUTUBE_CACHE_PATH = OUTPUT_DIR / "youtube_media_cache.json" +YOUTUBE_REPORT_PATH = OUTPUT_DIR / "youtube_media_report.json" # 기존 데이터셋의 완전성 기준은 유지한다. 새 스위치 필드는 결측을 허용한다. REQUIRED_FIELDS = ( @@ -636,8 +645,47 @@ def save_results(collected: list[dict]) -> None: print(f"미매칭 스위치: {unmatched_path} ({len(unmatched)}종)") +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="다나와 키보드 목록 크롤러") + parser.add_argument( + "--with-youtube", + action="store_true", + help="공식 YouTube Data API로 타건 영상 링크를 보강합니다.", + ) + parser.add_argument( + "--youtube-limit", + type=int, + default=90, + help="이번 실행에서 새로 검색할 YouTube query 수입니다. 캐시 hit는 제외합니다.", + ) + parser.add_argument( + "--youtube-refresh", + action="store_true", + help="기존 YouTube cache를 무시하고 새로 검색합니다.", + ) + parser.add_argument( + "--youtube-cache-path", + type=Path, + default=YOUTUBE_CACHE_PATH, + help="YouTube 검색 결과 cache JSON 경로입니다.", + ) + parser.add_argument( + "--youtube-report-path", + type=Path, + default=YOUTUBE_REPORT_PATH, + help="YouTube 검색 리포트 JSON 경로입니다.", + ) + return parser.parse_args() + + def main(): + args = parse_args() OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + youtube_limit = max(0, args.youtube_limit) + youtube_api_key = os.environ.get("YOUTUBE_API_KEY", "").strip() + if args.with_youtube and youtube_limit > 0 and not youtube_api_key: + raise SystemExit("YOUTUBE_API_KEY 환경변수가 필요합니다.") + switches, aliases = load_switch_data() collected = [] @@ -696,6 +744,26 @@ def main(): page += 1 time.sleep(DELAY_SEC) + if args.with_youtube: + youtube_stats = enrich_items_with_youtube( + collected, + api_key=youtube_api_key, + cache_path=args.youtube_cache_path, + report_path=args.youtube_report_path, + search_limit=youtube_limit, + refresh=args.youtube_refresh, + ) + print( + "YouTube 보강: " + f"cache {youtube_stats['cache_hits']}, " + f"검색 {youtube_stats['api_searches']}, " + f"매칭 {youtube_stats['matched']}, " + f"미발견 {youtube_stats['not_found']}, " + f"스킵 {youtube_stats['skipped']}, " + f"제한 {youtube_stats['limited']}, " + f"오류 {youtube_stats['errors']}" + ) + save_results(collected) print(f"\n상품 {product_count}개에서 제품-스위치 조합 {len(collected)}개 수집 완료") diff --git a/impl/keybuddy/README.md b/impl/keybuddy/README.md index bfab29f1..7585ec42 100644 --- a/impl/keybuddy/README.md +++ b/impl/keybuddy/README.md @@ -34,6 +34,7 @@ keybuddy/ - `docs/tag-extraction-flow.md` - 자연어를 의도/제약 태그로 번역하고 결정론적으로 확장하는 흐름 - `docs/intent-harness-before-after.md` - 의도 하네스 적용 전/후 정성 비교 - `docs/deployment-version-management.md` - 배포 및 버전 관리 규칙 +- `docs/youtube-media-enrichment.md` - YouTube 타건 영상 링크 수집·캐시·동기화 운영 규칙 ## 크롤링 데이터와 스위치 매칭 @@ -58,6 +59,19 @@ keybuddy/ `null`로 저장합니다. `media_url`은 실제 자료를 확보하기 전까지 `null`로 저장하고 `media_url_is_placeholder`로 준비 중 상태를 표시합니다. +타건 영상 링크는 기본 크롤링에는 포함하지 않고, 공식 YouTube Data API 키가 있을 때만 +선택적으로 보강합니다. 검색어는 `상품명 + raw_switch_name`(없으면 `switch_name`)이며, +결과는 `../output/youtube_media_cache.json`에 누적합니다. 다음 실행에서는 캐시를 먼저 +입혀 이미 수집한 링크를 `keyboards.json`에 다시 반영하고, 캐시에 없는 항목만 새로 +검색합니다. + +```bash +cd impl +YOUTUBE_API_KEY= python3 crawl.py --with-youtube --youtube-limit 90 +``` + +`--youtube-limit 0`은 새 검색 없이 기존 캐시만 반영할 때 사용합니다. + ## 요청 흐름 ```text @@ -193,6 +207,33 @@ VITE_SUPABASE_URL=https://your-project-ref.supabase.co VITE_SUPABASE_ANON_KEY=your-supabase-publishable-key ``` +#### 이벤트 수집 테이블 적용 + +구매 클릭/추천 별점을 수집하려면 `events` 테이블 마이그레이션을 한 번 적용해야 합니다. +(같은 `VITE_SUPABASE_URL`/`VITE_SUPABASE_ANON_KEY`를 쓰는 프론트가 PostgREST로 직접 insert 합니다.) + +```bash +cd impl/keybuddy +supabase db push --project-ref your-project-ref +``` + +CLI를 쓰지 않는다면 Supabase 대시보드 SQL Editor에 +`supabase/migrations/20260617000000_create_events.sql` 내용을 붙여 실행해도 됩니다. +이 마이그레이션은 anon에게 **insert만** 허용하는 RLS를 걸어, 이벤트는 적재만 되고 +브라우저로 다시 조회되지 않습니다. +별점 이벤트는 한 추천 결과 화면에서 최초 1회만 적재하며, DB 정책에서도 `rating` +payload가 `0.5` 이상 `5.0` 이하인 숫자인 경우만 허용합니다. + +**수동 적재 확인**: 프론트에서 추천을 받은 뒤 ① '구매하기' 버튼 클릭 ② 별점 클릭을 하고, +대시보드 SQL Editor에서 다음으로 행이 쌓였는지 확인합니다. + +```sql +select event_type, session_id, payload, created_at +from public.events +order by created_at desc +limit 10; +``` + ## 로컬 실행 프론트: diff --git a/impl/keybuddy/frontend/src/App.tsx b/impl/keybuddy/frontend/src/App.tsx index e8dfadac..50e81fac 100644 --- a/impl/keybuddy/frontend/src/App.tsx +++ b/impl/keybuddy/frontend/src/App.tsx @@ -16,6 +16,8 @@ import { } from 'lucide-react'; import switchesData from './data/switches.json'; import { recommend } from './lib/recommend'; +import { logPurchaseClick, logRating } from './lib/events'; +import { getDisplayImageUrl } from './lib/imageUrl'; import { getBeginnerGuide, getProductTags } from './lib/productDisplay'; import { getSwitchDisplayData, type GraphLevel } from './lib/switchDisplay'; import type { @@ -26,6 +28,7 @@ import type { } from './types'; const switches = switchesData as SwitchDictionary; +const RATING_STAR_SIZE = 34; function useAutoDismiss(duration: number) { const [value, setValue] = useState(null); @@ -105,7 +108,8 @@ const questions = [ '숫자 패드가 있는 일반 키보드 (풀배열)', '숫자 패드가 있지만 콤팩트함 (1800배열)', '숫자 패드가 없음 (텐키리스)', - '숫자 패드도, 일부 특수키도 없음 (75%/65%)', + 'F열은 있고 숫자패드만 없는 콤팩트 (75%)', + 'F열 없이 방향키는 있는 콤팩트 (65%)', 'F1~F12키도 없는 미니 (60%)', ], }, @@ -127,6 +131,1687 @@ const questions = [ }, ]; +interface StepOptionGuide { + title: string; + description: string; + panelLabel: string; + insight: string; + badges: string[]; +} + +type KeyToken = string | { label: string; span?: number; rowSpan?: number }; +type KeyboardRow = KeyToken[]; +type KeyboardLayoutMode = 'full' | 'compact1800' | 'tkl' | 'compact75' | 'compact65' | 'sixty'; +type BacklightMode = 'rgb' | 'single' | 'off'; +type SizePreviewCluster = 'nav' | 'arrows' | 'numpad' | 'compactNav'; + +interface SizeLayoutBlueprint { + label: string; + description: string; + widthClass: string; + rows: KeyboardRow[]; + functionRow?: KeyboardRow; + clusters?: SizePreviewCluster[]; + activeKeys?: string[]; + labelMode?: 'all' | 'essential'; + visibleLabels?: string[]; +} + +const STEP_OPTION_GUIDES: Record> = { + 용도: { + 사무용: { + title: '사무용', + description: '문서 입력과 회의가 많은 환경에서 안정적으로 쓰기 좋은 기준입니다.', + panelLabel: 'Work Focus', + insight: '소음, 장시간 입력 피로, 숫자 입력 빈도를 함께 보면 실패 확률이 낮아집니다.', + badges: ['조용함', '정확한 입력', '피로도'], + }, + 게임용: { + title: '게임용', + description: 'WASD 접근성, 빠른 입력, 책상 위 마우스 공간을 우선합니다.', + panelLabel: 'Game Focus', + insight: '마우스 이동 폭을 확보하려면 텐키리스 이하 배열과 안정적인 유선/동글 연결이 유리합니다.', + badges: ['WASD', '응답성', '마우스 공간'], + }, + 상관없음: { + title: '상관없음', + description: '용도를 강하게 제한하지 않고 추천 후보를 넓게 탐색합니다.', + panelLabel: 'Balanced', + insight: '용도가 정해지지 않았을 때는 소리, 키감, 예산처럼 체감 차이가 큰 조건을 더 크게 반영합니다.', + badges: ['넓은 탐색', '균형', '취향 우선'], + }, + }, + 휴대성: { + '책상에 놓고 쓸 거예요': { + title: '책상 고정', + description: '무게와 크기보다 안정적인 타건감과 기능키 구성을 우선합니다.', + panelLabel: 'Desk Setup', + insight: '고정 사용이면 숫자패드, 팜레스트, 묵직한 하우징까지 후보에 포함할 수 있습니다.', + badges: ['안정감', '기능키', '책상용'], + }, + '자주 가지고 다닐래요': { + title: '휴대용', + description: '가방에 넣기 쉬운 폭, 낮은 무게, 무선 연결을 우선합니다.', + panelLabel: 'Portable Setup', + insight: '휴대성이 중요하면 75% 이하 배열과 블루투스, 낮은 무게가 실제 사용 만족도를 크게 좌우합니다.', + badges: ['가벼움', '무선', '작은 폭'], + }, + 상관없음: { + title: '상관없음', + description: '고정 사용과 휴대 사용을 모두 열어두고 추천합니다.', + panelLabel: 'Flexible', + insight: '사용 장소가 섞이면 무게보다 연결 방식과 배열 호환성을 먼저 확인하는 편이 안전합니다.', + badges: ['유연함', '범용', '후보 확대'], + }, + }, + 소리: { + '조용해야 해요 (매우 낮음)': { + title: '매우 조용', + description: '공유 사무실, 도서관처럼 주변 소음 민감도가 높은 환경에 맞춥니다.', + panelLabel: 'Noise Level 1', + insight: '저소음 스위치, 펜타그래프, 무접점 계열을 우선하면 주변 사람에게 들리는 소리가 줄어듭니다.', + badges: ['저소음', '공유 공간', '흡음'], + }, + '조금 소리가 났으면 해요 (낮음)': { + title: '낮은 소리', + description: '약간의 타건음은 허용하지만 거슬리는 클릭음은 줄이는 선택입니다.', + panelLabel: 'Noise Level 2', + insight: '리니어나 저소음 택타일처럼 소리가 낮은 축을 고르면 사무실에서도 무난합니다.', + badges: ['낮은 타건음', '무난함', '업무용'], + }, + '적당한 소리 (보통)': { + title: '보통', + description: '기계식 키보드의 기본적인 타건음을 허용하는 균형형 선택입니다.', + panelLabel: 'Noise Level 3', + insight: '소리 제한이 강하지 않으면 키감과 가격 선택지가 넓어집니다.', + badges: ['균형', '선택지 넓음', '일반'], + }, + '경쾌한 소리 (조금 큼)': { + title: '경쾌한 소리', + description: '타건음이 분명하게 들리는 쪽을 선호하는 선택입니다.', + panelLabel: 'Noise Level 4', + insight: '클릭감이나 하우징 울림이 있는 제품까지 후보에 넣되, 공용 공간 사용 여부를 꼭 확인해야 합니다.', + badges: ['분명한 소리', '타건 재미', '개인 공간'], + }, + '타건감 위주 (시끄러워도 됨)': { + title: '타건감 우선', + description: '소음보다 손끝 피드백과 재미를 우선합니다.', + panelLabel: 'Noise Level 5', + insight: '청축류나 강한 택타일도 후보가 되지만, 주변 환경과 야간 사용에는 불리할 수 있습니다.', + badges: ['클릭감', '강한 피드백', '개인용'], + }, + }, + 키감: { + '또각또각 (걸림이 있는 느낌)': { + title: '또각또각', + description: '눌리는 중간에 걸림이 있어 입력 지점이 손끝에 분명히 느껴집니다.', + panelLabel: 'Tactile Feel', + insight: '오타를 줄이고 입력감을 느끼고 싶다면 택타일 계열이 잘 맞습니다.', + badges: ['택타일', '걸림', '입력감'], + }, + '서걱서걱 (부드럽게 들어가는 느낌)': { + title: '서걱서걱', + description: '중간 걸림 없이 부드럽게 내려가는 리니어 느낌입니다.', + panelLabel: 'Linear Feel', + insight: '빠른 반복 입력과 게임 용도에는 걸림이 적은 리니어 축을 선호하는 경우가 많습니다.', + badges: ['리니어', '부드러움', '반복 입력'], + }, + '보글보글 (독특한 무접점 느낌)': { + title: '보글보글', + description: '러버돔과 정전용량 방식 특유의 둥글고 탄성 있는 느낌입니다.', + panelLabel: 'Capacitive Feel', + insight: '무접점은 소리와 키감이 독특하므로, 가격대와 배열 선택지를 함께 확인하는 것이 좋습니다.', + badges: ['무접점', '탄성', '독특함'], + }, + '잘 모르겠어요': { + title: '잘 모르겠어요', + description: '키감 조건을 강하게 제한하지 않고 다른 취향 조건을 먼저 반영합니다.', + panelLabel: 'Open Feel', + insight: '초보자라면 소리와 키압을 먼저 고르고, 키감은 추천 결과에서 비교해도 충분합니다.', + badges: ['후보 확대', '초보자', '비교 추천'], + }, + }, + 키압: { + '가볍게 눌렸으면 좋겠어요 (35~45g)': { + title: '가벼운 키압', + description: '오래 입력해도 손가락 부담이 적은 35~45g 중심의 선택입니다.', + panelLabel: 'Light Force', + insight: '장시간 문서 작업에는 가벼운 키압이 편하지만, 손을 올려두는 습관이 있으면 오입력이 늘 수 있습니다.', + badges: ['35~45g', '피로 감소', '가벼움'], + }, + '보편적인게 좋아요 (45~55g)': { + title: '보편적인 키압', + description: '대부분의 사용자가 적응하기 쉬운 45~55g 범위입니다.', + panelLabel: 'Medium Force', + insight: '처음 구매라면 중간 키압이 실패 가능성이 가장 낮습니다.', + badges: ['45~55g', '무난함', '입문'], + }, + '묵직한게 좋아요 (60g 이상)': { + title: '묵직한 키압', + description: '눌림이 확실하고 반발력이 강한 60g 이상 중심의 선택입니다.', + panelLabel: 'Heavy Force', + insight: '강한 피드백을 좋아하면 만족도가 높지만 장시간 사용 피로는 커질 수 있습니다.', + badges: ['60g+', '확실한 반발', '묵직함'], + }, + '잘 모르겠어요': { + title: '잘 모르겠어요', + description: '키압을 제한하지 않고 보편적인 범위까지 열어둡니다.', + panelLabel: 'Open Force', + insight: '키압이 낯설다면 45~55g 전후의 추천을 기준으로 비교하는 편이 안전합니다.', + badges: ['열린 조건', '입문', '균형'], + }, + }, + 연결방식: { + 유선: { + title: '유선', + description: '케이블 연결로 지연과 배터리 걱정을 줄입니다.', + panelLabel: 'USB Wired', + insight: '게임이나 고정 책상 사용에서는 유선이 가장 단순하고 안정적인 선택입니다.', + badges: ['안정성', '무충전', '낮은 지연'], + }, + '무선 USB 동글': { + title: '무선 USB 동글', + description: '2.4GHz 리시버로 무선 편의성과 빠른 반응을 함께 노립니다.', + panelLabel: '2.4GHz Dongle', + insight: '게이밍 무선은 블루투스보다 동글 연결을 쓰는 제품이 많습니다.', + badges: ['2.4GHz', '낮은 지연', '무선'], + }, + 블루투스: { + title: '블루투스', + description: '노트북, 태블릿, 휴대폰 등 여러 기기 전환에 유리합니다.', + panelLabel: 'Bluetooth', + insight: '휴대성과 멀티페어링이 중요하면 블루투스 지원 여부가 핵심입니다.', + badges: ['멀티페어링', '태블릿', '휴대'], + }, + '유/무선 모두': { + title: '유/무선 모두', + description: '책상에서는 유선, 이동 중에는 무선으로 상황에 맞게 씁니다.', + panelLabel: 'Hybrid', + insight: '사용 장소가 자주 바뀌면 연결 방식을 모두 지원하는 모델이 오래 쓰기 좋습니다.', + badges: ['하이브리드', '전환', '범용'], + }, + 상관없음: { + title: '상관없음', + description: '연결 조건을 제한하지 않고 다른 취향을 우선합니다.', + panelLabel: 'Any Connection', + insight: '연결 방식이 중요하지 않다면 같은 예산에서 키감이나 배열 선택지를 넓힐 수 있습니다.', + badges: ['후보 확대', '가격 우선', '취향 우선'], + }, + }, + 크기: { + '숫자 패드가 있는 일반 키보드 (풀배열)': { + title: '풀배열', + description: 'F열, 방향키, 숫자패드를 모두 갖춘 가장 익숙한 구성입니다.', + panelLabel: '100% Full Size', + insight: '숫자 입력이 많고 책상 공간이 충분하면 가장 안정적인 선택입니다.', + badges: ['숫자패드', 'F열', '넓은 폭'], + }, + '숫자 패드가 있지만 콤팩트함 (1800배열)': { + title: '1800배열', + description: '숫자패드를 유지하면서 전체 폭을 줄인 압축 배열입니다.', + panelLabel: '1800 Compact', + insight: '엑셀 작업은 많지만 마우스 공간도 필요할 때 풀배열보다 효율적입니다.', + badges: ['숫자패드', '콤팩트', '업무'], + }, + '숫자 패드가 없음 (텐키리스)': { + title: '텐키리스', + description: '숫자패드를 덜어내 마우스 공간을 확보한 대표적인 축소 배열입니다.', + panelLabel: 'TKL', + insight: '게임과 문서 작업을 함께 한다면 배열 적응 부담이 적고 공간 효율도 좋습니다.', + badges: ['마우스 공간', '방향키', '입문'], + }, + 'F열은 있고 숫자패드만 없는 콤팩트 (75%)': { + title: '75%', + description: 'F열과 방향키는 남기고 숫자패드와 여백을 줄인 콤팩트 배열입니다.', + panelLabel: '75% Compact', + insight: 'F열을 자주 쓰면서도 마우스 공간을 확보하고 싶을 때 좋지만, 우측 Shift와 방향키 위치 적응이 필요할 수 있습니다.', + badges: ['F열 유지', '방향키', '콤팩트'], + }, + 'F열 없이 방향키는 있는 콤팩트 (65%)': { + title: '65%', + description: 'F열은 덜어내고 숫자열, 문자열, 방향키 중심으로 폭을 줄입니다.', + panelLabel: '65% Compact', + insight: '방향키와 기본 입력 키는 남기면서 휴대성을 높이고 싶을 때 적합하며, F키는 조합키 사용에 익숙해져야 합니다.', + badges: ['F열 없음', '방향키', '휴대성'], + }, + 'F1~F12키도 없는 미니 (60%)': { + title: '60%', + description: '문자 입력 핵심 키만 남겨 가장 작게 줄인 미니 배열입니다.', + panelLabel: '60% Compact', + insight: '마우스 공간은 가장 넓지만 방향키와 F열을 자주 쓰면 조합키 학습이 필요합니다.', + badges: ['최소 폭', '미니', '조합키'], + }, + }, + 각인: { + '한국어, 영어가 모두 필요해요': { + title: '한/영 각인', + description: '한국어와 영어 표기가 모두 있어 처음 쓰는 사람도 찾기 쉽습니다.', + panelLabel: 'Korean + English', + insight: '가족이나 공용 PC에서 함께 쓰면 한/영 각인이 가장 안전합니다.', + badges: ['한글', '영문', '공용'], + }, + '영어만 적혀있길 바라요': { + title: '영문 각인', + description: '키캡 표기가 단정하고 디자인 선택지가 넓습니다.', + panelLabel: 'English Only', + insight: '키 위치가 익숙하다면 영문 각인이 더 깔끔하게 느껴질 수 있습니다.', + badges: ['깔끔함', '디자인', '숙련자'], + }, + '한국어만 적혀있길 바라요': { + title: '한글 각인', + description: '한국어 입력 위치 확인을 가장 우선합니다.', + panelLabel: 'Korean Only', + insight: '한글 단독 각인은 제품 선택지가 제한될 수 있어 후보 수를 함께 확인해야 합니다.', + badges: ['한글', '가독성', '선택지 확인'], + }, + 상관없음: { + title: '상관없음', + description: '각인 조건을 제한하지 않고 가격과 성능 후보를 넓힙니다.', + panelLabel: 'Any Legends', + insight: '각인이 중요하지 않다면 같은 조건에서 더 다양한 브랜드와 키캡 옵션을 볼 수 있습니다.', + badges: ['후보 확대', '가격', '디자인'], + }, + }, + 백라이트: { + '화려한 RGB가 좋아요': { + title: 'RGB 백라이트', + description: '여러 색상 효과와 게이밍 감성을 중시합니다.', + panelLabel: 'RGB Lighting', + insight: 'RGB는 시각적 만족도가 높지만 무선 사용 시 배터리 소모가 커질 수 있습니다.', + badges: ['RGB', '효과', '게이밍'], + }, + '은은한 단색 조명이 좋아요': { + title: '단색 백라이트', + description: '어두운 환경에서 키 위치를 확인하기 쉬운 절제된 조명입니다.', + panelLabel: 'Single Color', + insight: '단색 조명은 실용성과 배터리 부담 사이의 균형이 좋습니다.', + badges: ['가독성', '절제', '야간'], + }, + '없어도 돼요 (배터리 절약)': { + title: '백라이트 없음', + description: '조명보다 배터리 지속 시간과 단순한 디자인을 우선합니다.', + panelLabel: 'No Lighting', + insight: '무선 휴대용 키보드에서는 백라이트 없음이 배터리와 무게 측면에서 유리할 수 있습니다.', + badges: ['배터리', '단순함', '실용'], + }, + }, +}; + +const BUDGET_GUIDE: StepOptionGuide = { + title: '예산 범위', + description: '최소와 최대 금액을 정하면 추천 후보의 가격대를 안정적으로 좁힙니다.', + panelLabel: 'Budget Range', + insight: '기계식, 무접점, 무선 기능은 가격 차이가 커서 예산 범위가 추천 정확도에 직접 영향을 줍니다.', + badges: ['가격대', '후보 압축', '가성비'], +}; + +const DEFAULT_GUIDE: StepOptionGuide = { + title: '선택 기준', + description: '선택한 조건을 기준으로 추천 후보를 좁힙니다.', + panelLabel: 'Guide', + insight: '조건을 명확히 고를수록 추천 이유와 후보 정렬이 더 구체적입니다.', + badges: ['조건', '추천', '비교'], +}; + +const SIZE_REFERENCE_VISIBLE_LABELS = [ + 'Esc', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', + '`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'Del', 'Bksp', + 'Tab', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '[', ']', '\\', + 'Caps', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', "'", 'Enter', + 'Shift', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', 'Ctrl', 'Win', 'Alt', 'Fn', 'Menu', 'Space', + 'Ins', 'Home', 'PgU', 'End', 'PgD', '↑', '←', '↓', '→', + 'Clr', 'Num', '+', '*', 'Ent', 'Prt', 'Scr', 'Pause', +]; + +const SIZE_COMPACT_VISIBLE_LABELS = [ + 'Esc', 'Tab', 'Caps', 'Shift', 'Ctrl', 'Alt', 'Fn', 'Space', 'Del', 'Bksp', 'Enter', + '`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', + 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '[', ']', + 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', "'", + 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', '↑', '←', '↓', '→', +]; + +const NUMPAD_KEYS: KeyToken[] = [ + 'Clr', + '=', + '/', + '*', + '7', + '8', + '9', + '-', + '4', + '5', + '6', + '+', + '1', + '2', + '3', + { label: 'Ent', rowSpan: 2 }, + { label: '0', span: 2 }, + '.', +]; + +const NUMPAD_1800_KEYS: KeyToken[] = [ + 'Clr', + '=', + '/', + '*', + '7', + '8', + '9', + '-', + '4', + '5', + '6', + { label: '+', rowSpan: 2 }, + '1', + '2', + '3', + { label: '0', span: 2 }, + '.', + { label: 'Ent', rowSpan: 2 }, +]; + +const SYSTEM_1800_KEYS = ['Ins', 'Home', 'PgU', 'Prt', 'Del', 'End', 'PgD', 'Scr', 'Pause']; +const ESSENTIAL_KEY_LABELS = new Set([ + 'Esc', + 'Tab', + 'Caps', + 'Shift', + 'Ctrl', + 'Win', + 'Alt', + 'Space', + 'Fn', + 'Menu', + 'Enter', + 'Bksp', + 'Del', + '↑', + '←', + '↓', + '→', +]); + +function getOptionGuide(questionId: string, option?: string) { + if (questionId === '예산') return BUDGET_GUIDE; + const guides = STEP_OPTION_GUIDES[questionId]; + if (!guides) return DEFAULT_GUIDE; + if (option && guides[option]) return guides[option]; + return Object.values(guides)[0] ?? DEFAULT_GUIDE; +} + +function formatBudget(value: number) { + return value >= 1000000 ? '100만원+' : `${value.toLocaleString()}원`; +} + +function getBudgetSearchLabel(budget: { min: number; max: number }) { + const hasMin = budget.min > 0; + const hasMax = budget.max < 1000000; + if (!hasMin && !hasMax) return null; + if (hasMin && hasMax) return `${formatBudget(budget.min)}~${formatBudget(budget.max)}`; + if (hasMin) return `${formatBudget(budget.min)} 이상`; + return `${formatBudget(budget.max)} 이하`; +} + +function isMeaningfulSearchAnswer(option: string) { + return option !== '상관없음' && option !== '잘 모르겠어요'; +} + +function getGuidedSearchTitle(answers: Record, budget: { min: number; max: number }) { + const summaryOrder = ['용도', '크기', '소리', '키감', '키압', '연결방식', '각인', '백라이트', '휴대성']; + const labels = summaryOrder + .map((questionId) => { + const option = answers[questionId]; + if (!option || !isMeaningfulSearchAnswer(option)) return null; + return getOptionGuide(questionId, option).title; + }) + .filter((label): label is string => Boolean(label)); + const budgetLabel = getBudgetSearchLabel(budget); + if (budgetLabel) labels.push(budgetLabel); + if (labels.length === 0) return '조건을 넓게 둔 키보드'; + return labels.join(' · '); +} + +function getSearchContextTitle(input: RecommendInput) { + if (input.mode === 'freeform') return `"${input.query.trim().replace(/\s+/g, ' ')}"`; + return getGuidedSearchTitle(input.answers, input.budget); +} + +function getKeyboardLayoutMode(option?: string): KeyboardLayoutMode { + if (!option) return 'full'; + if (option.includes('1800')) return 'compact1800'; + if (option.includes('텐키리스')) return 'tkl'; + if (option.includes('75%')) return 'compact75'; + if (option.includes('65%')) return 'compact65'; + if (option.includes('60%')) return 'sixty'; + return 'full'; +} + +function getSoundLevel(option?: string) { + if (!option) return 3; + if (option.includes('매우 낮음')) return 1; + if (option.includes('낮음')) return 2; + if (option.includes('보통')) return 3; + if (option.includes('조금 큼')) return 4; + return 5; +} + +function getPressureTarget(option?: string) { + if (!option) return 'medium'; + if (option.includes('35~45g')) return 'light'; + if (option.includes('60g')) return 'heavy'; + return 'medium'; +} + +function getBacklightMode(option?: string): BacklightMode { + if (!option) return 'single'; + if (option.includes('RGB')) return 'rgb'; + if (option.includes('단색')) return 'single'; + return 'off'; +} + +function keyTokenLabel(token: KeyToken) { + return typeof token === 'string' ? token : token.label; +} + +function keyTokenSpan(token: KeyToken, defaultSpan = 1) { + return typeof token === 'string' ? defaultSpan : token.span ?? defaultSpan; +} + +function keyTokenRowSpan(token: KeyToken) { + return typeof token === 'string' ? 1 : token.rowSpan ?? 1; +} + +const SIZE_FUNCTION_ROW: KeyboardRow = [ + { label: 'Esc', span: 6 }, + { label: 'F1', span: 4 }, + { label: 'F2', span: 4 }, + { label: 'F3', span: 4 }, + { label: 'F4', span: 4 }, + { label: 'F5', span: 4 }, + { label: 'F6', span: 4 }, + { label: 'F7', span: 4 }, + { label: 'F8', span: 4 }, + { label: 'F9', span: 4 }, + { label: 'F10', span: 4 }, + { label: 'F11', span: 4 }, + { label: 'F12', span: 4 }, + { label: 'Del', span: 6 }, +]; + +const SIZE_1800_FUNCTION_ROW: KeyboardRow = [ + { label: 'Esc', span: 6 }, + { label: 'F1', span: 4 }, + { label: 'F2', span: 4 }, + { label: 'F3', span: 4 }, + { label: 'F4', span: 4 }, + { label: 'F5', span: 4 }, + { label: 'F6', span: 4 }, + { label: 'F7', span: 4 }, + { label: 'F8', span: 4 }, + { label: 'F9', span: 4 }, + { label: 'F10', span: 4 }, + { label: 'F11', span: 4 }, + { label: 'F12', span: 4 }, +]; + +const SIZE_FULL_ROWS: KeyboardRow[] = [ + [ + { label: '`', span: 4 }, + { label: '1', span: 4 }, + { label: '2', span: 4 }, + { label: '3', span: 4 }, + { label: '4', span: 4 }, + { label: '5', span: 4 }, + { label: '6', span: 4 }, + { label: '7', span: 4 }, + { label: '8', span: 4 }, + { label: '9', span: 4 }, + { label: '0', span: 4 }, + { label: '-', span: 4 }, + { label: '=', span: 4 }, + { label: 'Bksp', span: 8 }, + ], + [ + { label: 'Tab', span: 7 }, + { label: 'Q', span: 4 }, + { label: 'W', span: 4 }, + { label: 'E', span: 4 }, + { label: 'R', span: 4 }, + { label: 'T', span: 4 }, + { label: 'Y', span: 4 }, + { label: 'U', span: 4 }, + { label: 'I', span: 4 }, + { label: 'O', span: 4 }, + { label: 'P', span: 4 }, + { label: '[', span: 3 }, + { label: ']', span: 3 }, + { label: '\\', span: 3 }, + ], + [ + { label: 'Caps', span: 8 }, + { label: 'A', span: 4 }, + { label: 'S', span: 4 }, + { label: 'D', span: 4 }, + { label: 'F', span: 4 }, + { label: 'G', span: 4 }, + { label: 'H', span: 4 }, + { label: 'J', span: 4 }, + { label: 'K', span: 4 }, + { label: 'L', span: 4 }, + { label: ';', span: 4 }, + { label: "'", span: 4 }, + { label: 'Enter', span: 8 }, + ], + [ + { label: 'Shift', span: 10 }, + { label: 'Z', span: 4 }, + { label: 'X', span: 4 }, + { label: 'C', span: 4 }, + { label: 'V', span: 4 }, + { label: 'B', span: 4 }, + { label: 'N', span: 4 }, + { label: 'M', span: 4 }, + { label: ',', span: 4 }, + { label: '.', span: 4 }, + { label: '/', span: 4 }, + { label: 'Shift', span: 10 }, + ], + [ + { label: 'Ctrl', span: 5 }, + { label: 'Win', span: 5 }, + { label: 'Alt', span: 5 }, + { label: 'Space', span: 25 }, + { label: 'Alt', span: 5 }, + { label: 'Fn', span: 5 }, + { label: 'Menu', span: 5 }, + { label: 'Ctrl', span: 5 }, + ], +]; + +const SIZE_1800_ROWS: KeyboardRow[] = [ + [ + { label: '`', span: 4 }, + { label: '1', span: 4 }, + { label: '2', span: 4 }, + { label: '3', span: 4 }, + { label: '4', span: 4 }, + { label: '5', span: 4 }, + { label: '6', span: 4 }, + { label: '7', span: 4 }, + { label: '8', span: 4 }, + { label: '9', span: 4 }, + { label: '0', span: 4 }, + { label: '-', span: 4 }, + { label: '=', span: 4 }, + { label: 'Bksp', span: 8 }, + ], + [ + { label: 'Tab', span: 7 }, + { label: 'Q', span: 4 }, + { label: 'W', span: 4 }, + { label: 'E', span: 4 }, + { label: 'R', span: 4 }, + { label: 'T', span: 4 }, + { label: 'Y', span: 4 }, + { label: 'U', span: 4 }, + { label: 'I', span: 4 }, + { label: 'O', span: 4 }, + { label: 'P', span: 4 }, + { label: '[', span: 3 }, + { label: ']', span: 3 }, + { label: '\\', span: 7 }, + ], + [ + { label: 'Caps', span: 8 }, + { label: 'A', span: 4 }, + { label: 'S', span: 4 }, + { label: 'D', span: 4 }, + { label: 'F', span: 4 }, + { label: 'G', span: 4 }, + { label: 'H', span: 4 }, + { label: 'J', span: 4 }, + { label: 'K', span: 4 }, + { label: 'L', span: 4 }, + { label: ';', span: 4 }, + { label: "'", span: 4 }, + { label: 'Enter', span: 8 }, + ], + [ + { label: 'Shift', span: 10 }, + { label: 'Z', span: 4 }, + { label: 'X', span: 4 }, + { label: 'C', span: 4 }, + { label: 'V', span: 4 }, + { label: 'B', span: 4 }, + { label: 'N', span: 4 }, + { label: 'M', span: 4 }, + { label: ',', span: 4 }, + { label: '.', span: 4 }, + { label: '/', span: 4 }, + { label: 'Shift', span: 10 }, + ], + [ + { label: 'Ctrl', span: 5 }, + { label: 'Win', span: 5 }, + { label: 'Alt', span: 5 }, + { label: 'Space', span: 25 }, + { label: 'Alt', span: 5 }, + { label: 'Fn', span: 5 }, + { label: 'Menu', span: 5 }, + { label: 'Ctrl', span: 5 }, + ], +]; + +const SIZE_COMPACT_ROWS: KeyboardRow[] = [ + [ + { label: 'Esc', span: 5 }, + { label: '1', span: 4 }, + { label: '2', span: 4 }, + { label: '3', span: 4 }, + { label: '4', span: 4 }, + { label: '5', span: 4 }, + { label: '6', span: 4 }, + { label: '7', span: 4 }, + { label: '8', span: 4 }, + { label: '9', span: 4 }, + { label: '0', span: 4 }, + { label: '-', span: 4 }, + { label: '=', span: 4 }, + { label: 'Bksp', span: 7 }, + ], + [ + { label: 'Tab', span: 7 }, + { label: 'Q', span: 4 }, + { label: 'W', span: 4 }, + { label: 'E', span: 4 }, + { label: 'R', span: 4 }, + { label: 'T', span: 4 }, + { label: 'Y', span: 4 }, + { label: 'U', span: 4 }, + { label: 'I', span: 4 }, + { label: 'O', span: 4 }, + { label: 'P', span: 4 }, + { label: '[', span: 3 }, + { label: ']', span: 3 }, + { label: 'Del', span: 7 }, + ], + [ + { label: 'Caps', span: 8 }, + { label: 'A', span: 4 }, + { label: 'S', span: 4 }, + { label: 'D', span: 4 }, + { label: 'F', span: 4 }, + { label: 'G', span: 4 }, + { label: 'H', span: 4 }, + { label: 'J', span: 4 }, + { label: 'K', span: 4 }, + { label: 'L', span: 4 }, + { label: ';', span: 4 }, + { label: "'", span: 4 }, + { label: 'Enter', span: 8 }, + ], + [ + { label: 'Shift', span: 9 }, + { label: 'Z', span: 4 }, + { label: 'X', span: 4 }, + { label: 'C', span: 4 }, + { label: 'V', span: 4 }, + { label: 'B', span: 4 }, + { label: 'N', span: 4 }, + { label: 'M', span: 4 }, + { label: ',', span: 4 }, + { label: '.', span: 4 }, + { label: '/', span: 4 }, + { label: 'Shift', span: 7 }, + { label: '↑', span: 4 }, + ], + [ + { label: 'Ctrl', span: 6 }, + { label: 'Win', span: 6 }, + { label: 'Alt', span: 6 }, + { label: 'Space', span: 24 }, + { label: 'Fn', span: 6 }, + { label: '←', span: 4 }, + { label: '↓', span: 4 }, + { label: '→', span: 4 }, + ], +]; + +const SIZE_75_ROWS: KeyboardRow[] = [ + [ + { label: '`', span: 4 }, + { label: '1', span: 4 }, + { label: '2', span: 4 }, + { label: '3', span: 4 }, + { label: '4', span: 4 }, + { label: '5', span: 4 }, + { label: '6', span: 4 }, + { label: '7', span: 4 }, + { label: '8', span: 4 }, + { label: '9', span: 4 }, + { label: '0', span: 4 }, + { label: '-', span: 4 }, + { label: '=', span: 4 }, + { label: 'Bksp', span: 8 }, + ], + [ + { label: 'Tab', span: 7 }, + { label: 'Q', span: 4 }, + { label: 'W', span: 4 }, + { label: 'E', span: 4 }, + { label: 'R', span: 4 }, + { label: 'T', span: 4 }, + { label: 'Y', span: 4 }, + { label: 'U', span: 4 }, + { label: 'I', span: 4 }, + { label: 'O', span: 4 }, + { label: 'P', span: 4 }, + { label: '[', span: 3 }, + { label: ']', span: 3 }, + { label: '\\', span: 7 }, + ], + [ + { label: 'Caps', span: 8 }, + { label: 'A', span: 4 }, + { label: 'S', span: 4 }, + { label: 'D', span: 4 }, + { label: 'F', span: 4 }, + { label: 'G', span: 4 }, + { label: 'H', span: 4 }, + { label: 'J', span: 4 }, + { label: 'K', span: 4 }, + { label: 'L', span: 4 }, + { label: ';', span: 4 }, + { label: "'", span: 4 }, + { label: 'Enter', span: 8 }, + ], + [ + { label: 'Shift', span: 9 }, + { label: 'Z', span: 4 }, + { label: 'X', span: 4 }, + { label: 'C', span: 4 }, + { label: 'V', span: 4 }, + { label: 'B', span: 4 }, + { label: 'N', span: 4 }, + { label: 'M', span: 4 }, + { label: ',', span: 4 }, + { label: '.', span: 4 }, + { label: '/', span: 4 }, + { label: 'Shift', span: 7 }, + { label: '↑', span: 4 }, + ], + [ + { label: 'Ctrl', span: 6 }, + { label: 'Win', span: 6 }, + { label: 'Alt', span: 6 }, + { label: 'Space', span: 24 }, + { label: 'Fn', span: 6 }, + { label: '←', span: 4 }, + { label: '↓', span: 4 }, + { label: '→', span: 4 }, + ], +]; + +const SIZE_SIXTY_ROWS: KeyboardRow[] = [ + [ + { label: 'Esc', span: 6 }, + { label: '1', span: 4 }, + { label: '2', span: 4 }, + { label: '3', span: 4 }, + { label: '4', span: 4 }, + { label: '5', span: 4 }, + { label: '6', span: 4 }, + { label: '7', span: 4 }, + { label: '8', span: 4 }, + { label: '9', span: 4 }, + { label: '0', span: 4 }, + { label: '-', span: 4 }, + { label: '=', span: 4 }, + { label: 'Bksp', span: 6 }, + ], + [ + { label: 'Tab', span: 7 }, + { label: 'Q', span: 4 }, + { label: 'W', span: 4 }, + { label: 'E', span: 4 }, + { label: 'R', span: 4 }, + { label: 'T', span: 4 }, + { label: 'Y', span: 4 }, + { label: 'U', span: 4 }, + { label: 'I', span: 4 }, + { label: 'O', span: 4 }, + { label: 'P', span: 4 }, + { label: '[', span: 4 }, + { label: ']', span: 4 }, + { label: '\\', span: 5 }, + ], + [ + { label: 'Caps', span: 9 }, + { label: 'A', span: 4 }, + { label: 'S', span: 4 }, + { label: 'D', span: 4 }, + { label: 'F', span: 4 }, + { label: 'G', span: 4 }, + { label: 'H', span: 4 }, + { label: 'J', span: 4 }, + { label: 'K', span: 4 }, + { label: 'L', span: 4 }, + { label: ';', span: 4 }, + { label: "'", span: 4 }, + { label: 'Enter', span: 7 }, + ], + [ + { label: 'Shift', span: 10 }, + { label: 'Z', span: 4 }, + { label: 'X', span: 4 }, + { label: 'C', span: 4 }, + { label: 'V', span: 4 }, + { label: 'B', span: 4 }, + { label: 'N', span: 4 }, + { label: 'M', span: 4 }, + { label: ',', span: 4 }, + { label: '.', span: 4 }, + { label: '/', span: 4 }, + { label: 'Shift', span: 10 }, + ], + [ + { label: 'Ctrl', span: 7 }, + { label: 'Win', span: 7 }, + { label: 'Alt', span: 7 }, + { label: 'Space', span: 25 }, + { label: 'Fn', span: 7 }, + { label: 'Ctrl', span: 7 }, + ], +]; + +const SIZE_LAYOUT_BLUEPRINTS: Record = { + full: { + label: '100%', + description: 'F열 + 방향키 + 숫자패드', + widthClass: 'w-full', + functionRow: SIZE_FUNCTION_ROW, + rows: SIZE_FULL_ROWS, + clusters: ['nav', 'arrows', 'numpad'], + labelMode: 'essential', + visibleLabels: SIZE_REFERENCE_VISIBLE_LABELS, + }, + compact1800: { + label: '1800', + description: '숫자패드는 유지하고 폭은 압축', + widthClass: 'w-full', + functionRow: SIZE_1800_FUNCTION_ROW, + rows: SIZE_1800_ROWS, + clusters: ['compactNav', 'numpad'], + labelMode: 'essential', + visibleLabels: SIZE_REFERENCE_VISIBLE_LABELS, + }, + tkl: { + label: 'TKL', + description: '숫자패드 제외, 방향키 유지', + widthClass: 'w-[88%]', + functionRow: SIZE_FUNCTION_ROW, + rows: SIZE_FULL_ROWS, + clusters: ['nav', 'arrows'], + labelMode: 'essential', + visibleLabels: SIZE_REFERENCE_VISIBLE_LABELS, + }, + compact75: { + label: '75%', + description: 'F열 유지, 숫자패드 제외', + widthClass: 'w-[80%]', + functionRow: SIZE_FUNCTION_ROW, + rows: SIZE_75_ROWS, + activeKeys: ['↑', '←', '↓', '→'], + labelMode: 'essential', + visibleLabels: SIZE_REFERENCE_VISIBLE_LABELS, + }, + compact65: { + label: '65%', + description: 'F열 제외, 방향키 유지', + widthClass: 'w-[74%]', + rows: SIZE_COMPACT_ROWS, + activeKeys: ['↑', '←', '↓', '→'], + labelMode: 'essential', + visibleLabels: SIZE_COMPACT_VISIBLE_LABELS, + }, + sixty: { + label: '60%', + description: 'F열·방향키·숫자패드 없음', + widthClass: 'w-[68%]', + rows: SIZE_SIXTY_ROWS, + activeKeys: ['A', 'S', 'D', 'Space'], + labelMode: 'essential', + visibleLabels: SIZE_COMPACT_VISIBLE_LABELS, + }, +}; + +function SizePreviewKey({ + token, + active = false, + showLabel = true, + lighting = 'off', + colorIndex = 0, +}: { + token: KeyToken; + active?: boolean; + showLabel?: boolean; + lighting?: BacklightMode; + colorIndex?: number; +}) { + const label = keyTokenLabel(token); + const span = keyTokenSpan(token); + const rowSpan = keyTokenRowSpan(token); + const rgbClasses = [ + 'border-rose-300/70 bg-rose-400/70 text-white', + 'border-orange-300/70 bg-orange-400/70 text-slate-950', + 'border-amber-200/80 bg-amber-300/80 text-slate-950', + 'border-emerald-200/70 bg-emerald-400/70 text-slate-950', + 'border-sky-200/70 bg-sky-400/70 text-slate-950', + 'border-violet-300/70 bg-violet-500/70 text-white', + ]; + const inactiveClass = + lighting === 'rgb' + ? rgbClasses[colorIndex % rgbClasses.length] + : lighting === 'single' + ? 'border-cyan-100/90 bg-cyan-200 text-slate-950 shadow-[0_0_10px_rgba(103,232,249,0.45)]' + : 'border-slate-400/60 bg-slate-100 text-slate-600 shadow-[inset_0_-1px_0_rgba(15,23,42,0.14)]'; + + return ( +
1 ? 'h-full min-h-4 sm:min-h-6' : 'h-4 sm:h-6' + } ${ + active + ? 'border-blue-500 bg-blue-500 text-white' + : inactiveClass + } ${lighting === 'rgb' && !active ? 'keybuddy-rgb-key' : ''}`} + style={{ + gridColumn: `span ${span} / span ${span}`, + gridRow: rowSpan > 1 ? `span ${rowSpan} / span ${rowSpan}` : undefined, + animationDelay: lighting === 'rgb' && !active ? `${(colorIndex % 12) * -0.09}s` : undefined, + }} + > + {showLabel ? label : ''} +
+ ); +} + +function KeyboardSizePreview({ + blueprint, + lighting = 'off', +}: { + blueprint: SizeLayoutBlueprint; + lighting?: BacklightMode; +}) { + const activeSet = new Set(blueprint.activeKeys ?? []); + const visibleLabelSet = new Set(blueprint.visibleLabels ?? []); + const visibleLabel = (label: string) => + blueprint.labelMode === 'all' || activeSet.has(label) || ESSENTIAL_KEY_LABELS.has(label) || visibleLabelSet.has(label); + let colorIndex = 0; + + const renderRow = (row: KeyboardRow) => ( +
+ {row.map((token, index) => { + const label = keyTokenLabel(token); + return ( + + ); + })} +
+ ); + + const renderNavigationCluster = () => ( +
+ {['Ins', 'Home', 'PgU', 'Del', 'End', 'PgD'].map((label) => ( + + ))} +
+ ); + + const renderArrowCluster = () => ( +
+
+ +
+ {['←', '↓', '→'].map((label) => ( + + ))} +
+ ); + + const render1800StatusDots = () => ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ); + + const render1800SystemCluster = () => ( +
+ {SYSTEM_1800_KEYS.map((label) => ( + + ))} +
+ ); + + const renderNumpadCluster = (keys: KeyToken[] = NUMPAD_KEYS) => ( +
+ {keys.map((token, index) => { + const label = keyTokenLabel(token); + return ( + + ); + })} +
+ ); + + const renderReference1800Preview = () => ( +
+
+
+
+
+ {blueprint.functionRow &&
{renderRow(blueprint.functionRow)}
} + {blueprint.rows.map((row) => renderRow(row))} +
+ +
+
+ {render1800StatusDots()} + {render1800SystemCluster()} +
+
+ {renderArrowCluster()} +
+
+ +
+ {renderNumpadCluster(NUMPAD_1800_KEYS)} +
+
+
+
+
+ ); + + if (blueprint.clusters?.includes('compactNav')) { + return renderReference1800Preview(); + } + + return ( +
+
+
+
+ {blueprint.functionRow &&
{renderRow(blueprint.functionRow)}
} + {blueprint.rows.map((row) => renderRow(row))} +
+ + {(blueprint.clusters?.includes('nav') || blueprint.clusters?.includes('arrows')) && ( +
+ {blueprint.clusters?.includes('nav') ? renderNavigationCluster() :
} + {blueprint.clusters?.includes('arrows') && renderArrowCluster()} +
+ )} + + {blueprint.clusters?.includes('numpad') && renderNumpadCluster(NUMPAD_KEYS)} +
+
+
+ ); +} + +function UnifiedKeyboardPreview({ + mode = 'tkl', + activeKeys = [], + lighting = 'off', +}: { + mode?: KeyboardLayoutMode; + activeKeys?: string[]; + lighting?: BacklightMode; +}) { + return ( + + ); +} + +function KeyboardSizeArtwork({ option }: { option?: string }) { + const mode = getKeyboardLayoutMode(option); + const selected = SIZE_LAYOUT_BLUEPRINTS[mode]; + + return ( +
+
+
+

Selected Layout

+

{selected.label}

+
+

+ {selected.description} +

+
+ +
+ ); +} + +function PurposeArtwork({ option }: { option?: string }) { + const isGame = option === '게임용'; + const isWork = option === '사무용'; + const workMetrics = [ + { label: '장시간 입력', value: '높음' }, + { label: '소음 민감도', value: '높음' }, + { label: '숫자 입력', value: '상황별' }, + ]; + const gameMetrics = [ + { label: '반응 속도', value: '높음' }, + { label: '마우스 공간', value: '중요' }, + { label: '반복 입력', value: '높음' }, + ]; + const activeMetrics = isGame ? gameMetrics : workMetrics; + + return ( +
+
+
+

문서 입력

+
+ {workMetrics.map((metric) => ( +
+ {metric.label} + {metric.value} +
+ ))} +
+
+
+

게임 조작

+
+ {gameMetrics.map((metric) => ( +
+ {metric.label} + {metric.value} +
+ ))} +
+
+
+
+
+

+ {isGame ? '게임 환경 우선순위' : isWork ? '업무 환경 우선순위' : '균형 탐색 기준'} +

+ + {isGame ? '공간/반응' : isWork ? '소음/피로' : '조건 완화'} + +
+
+ {activeMetrics.map((metric, index) => ( +
+ {metric.label} + + + + {metric.value} +
+ ))} +
+
+
+ ); +} + +function PortabilityArtwork({ option }: { option?: string }) { + const portable = option?.includes('가지고'); + const desk = option?.includes('책상'); + + return ( +
+
+
+
+
+
+
+

고정된 책상 환경

+
+
+
+
+
+
+
+
+

가방에 넣고 이동

+
+
+ ); +} + +function SoundArtwork({ option }: { option?: string }) { + const level = getSoundLevel(option); + const labels = ['매우 낮음', '낮음', '보통', '조금 큼', '큼']; + + return ( +
+
+ {[1, 2, 3, 4, 5].map((value) => ( +
+
+ + {labels[value - 1]} + +
+ ))} +
+
+ 공유 공간 + 개인 공간 +
+
+ ); +} + +const YOUTUBE_FEEL_VIDEOS = { + tactile: { + title: '택타일 스위치 13종 타건 모음', + query: '택타일 키보드 축 소리 비교 또각또각', + videoId: 'StsG-ihgrZo', + }, + linear: { + title: '서걱서걱 리니어 스위치 비교', + query: '리니어 키보드 축 소리 비교 서걱서걱', + videoId: '_Xs70BUDYrk', + }, + capacitive: { + title: '보글보글 무접점 키보드 타건 소리 모음', + query: '무접점 키보드 보글보글 타건음', + videoId: 'Ye19rE8f-tA', + }, +} as const; + +function getYouTubeEmbedUrl(videoId: string) { + const params = new URLSearchParams({ + rel: '0', + modestbranding: '1', + playsinline: '1', + }); + return `https://www.youtube.com/embed/${videoId}?${params.toString()}`; +} + +function getYouTubeSearchUrl(query: string) { + const params = new URLSearchParams({ search_query: query }); + return `https://www.youtube.com/results?${params.toString()}`; +} + +function FeelArtwork({ option }: { option?: string }) { + const isLinear = option?.includes('서걱'); + const isCapacitive = option?.includes('보글'); + const isUnknown = !option || option.includes('잘 모르겠'); + const video = isLinear + ? YOUTUBE_FEEL_VIDEOS.linear + : isCapacitive + ? YOUTUBE_FEEL_VIDEOS.capacitive + : YOUTUBE_FEEL_VIDEOS.tactile; + + if (isUnknown) { + return ( +
+
+ {[ + { label: '또각또각', detail: '걸림 있음' }, + { label: '서걱서걱', detail: '부드러움' }, + { label: '보글보글', detail: '무접점' }, + ].map((feel) => ( +
+

{feel.label}

+

{feel.detail}

+
+ ))} +
+

+ 키감은 제한하지 않고, 소리와 키압 같은 다른 조건을 먼저 반영합니다. +

+
+ ); + } + + return ( +
+
+