676 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5.5 bcb1c725c4 refactor(M02): 항목 카드 넷 공용 틀 CardShell (PLAN 31-7)
- 공용 틀 — 제목 줄(제목 · 저장 안 한 점 ● · 오른쪽 단추 자리) + 한 줄 부제(번호 · 이름 · 말줄임) · 상태(불러오는 중 · 실패 + 다시 불러오기 · 비었음) · [저장](늘 보임 · 고쳐야 켜짐 · 카드 안 Ctrl+S · 알림 글 한 벌) · ?(단축키 상자)
- 도면 정보 · 면적산출 · 재료산출(ItemCards) · 선택 입력(관리 화면 · 고르기 모달 값 카드)이 틀에 내용만 넣음
- 「〇〇 수정」 빈 단추 한 모양 · 엑셀 카드에도 ? · 불러오는 중 네 카드 · 실패는 카드 안 글(토스트 안 씀) · 물음 한 번(어느 카드든)
- 저장 길(saveTemplate · 도면 write · sheetEdit.save · 위 [저장] saveInputs) · 단축키는 그대로 · 고르기 모달은 보기만
- 시험 test_m02_card_shell 새로 · 옛 모양 시험 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:24:49 +09:00
eomsangdonandClaude Opus 5.5 2f7acff79f fix(M02): 카드 넷 높이 = 화면에 맞춰 크게 · 엑셀 격자가 카드를 채움 (PLAN 31-6)
- 카드 높이 360px → min(640px, 70vh) · 도면 CAD 칸은 카드를 채움
- 엑셀 카드(면적 · 재료) 격자 = 카드 몫 최소 높이(카드 - 8rem) · 판이 없으면 채움 · 자재 조합 판은 그 아래 카드 안 스크롤(격자 높이 0 이던 흠 함께)
- 시험 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:58:16 +09:00
eomsangdonandClaude Opus 5.5 8206bf6bb4 fix(M02): 자동 커밋 fedc0b5b 에 남은 충돌 표시 풂 — 63c6fa36 합친 모양으로
- main_laptop_1 의 8b2473af(같은 충돌을 먼저 푼 것)를 다시 합칠 때 충돌 · autopush 가 표시째 올림
- 두 풀이를 견줘 더 넓은 쪽(63c6fa36)을 씀 — 위 [저장]이 도면 카드도 먼저 저장 · 다른 항목 고름 물음 한 번(discard) · 나머지는 같음
- 대상: ItemCards · Structure · UI_Main · test_m02_drawing_card(DrawingCard 는 이미 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-30 20:40:00 +09:00
eomsangdon fedc0b5b15 auto: 2026-09-30 20:38 (ESD_LAPTOP) 2026-09-30 20:38:54 +09:00
eomsangdonandClaude Opus 5.5 63c6fa360c fix(M02): 31-3 합치기 충돌 표시 풀기 — 도면 카드(31-4) · 엑셀 카드(31-3) 둘 다 살림
- main_laptop_1 의 auto 커밋(99a514d9)이 ItemCards · Structure · UI_Main 에 충돌 표시를 그대로 올림 — 군데마다 양쪽을 합침
- ItemCards: 글(도면 버림 물음 + 저장 글) · 옵션(onDirty + sheetEdit) · 손잡이 dirty = 도면 또는 엑셀 · saveAll = 도면 먼저 · 엑셀 · discard 새로 · 옛 hideToolbar 는 DrawingCard 몫이라 뺌
- DrawingCard: save() 드러냄(위 [저장]이 먼저 부름 · 못 하면 던짐)
- Structure: 다른 항목 고름 물음은 한 번(카드 셋 모두) · 확인이면 discard 뒤 읽음 · 손잡이 dirty 한 벌 · saveCards
- UI_Main: 떠나기 · 창 닫기 막기 = busy()(이 화면 + 카드 둘)
- 시험 test_m02_drawing_card 물음 판정을 합친 모양으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-30 20:38:52 +09:00
eomsangdonandClaude Opus 5.5 8b2473af6e fix(M02): 자동 커밋에 남은 합치기 충돌 표시 풂 — 도면 카드(31-4) · 엑셀 카드(31-3) 둘 다 살림
- ItemCards: 글 · 옵션 · 손잡이 합침 · 떠날 때 물음은 도면 + 엑셀 카드 · saveAll · 안 쓰는 hideToolbar 뺌
- Structure: saveCards · dirty 한 벌
- UI_Main: busy() 로 물음
- 시험 test_m02_drawing_card 모양 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-30 20:34:55 +09:00
eomsangdon 99a514d9b8 auto: 2026-09-30 20:29 (ESD_LAPTOP) 2026-09-30 20:29:01 +09:00
eomsangdon 7147ca1a40 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-30 20:28:11 +09:00
eomsangdon 88d0b8c5ca Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-09-30 20:27:09 +09:00
eomsangdonandClaude Opus 5.5 13c6b581ca feat(M02): 면적산출 · 재료산출 카드 미리보기에서 기본 수정(31-3)
- 스프레드시트 옵션 preview 한 곳 — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 · 알약 줄(다른 화면 그대로)
- 항목 카드 sheetEdit(있을 때만 고침 · 고르기 모달은 보기만 그대로) · 카드 [저장] = 읽은 문서에 시트를 덮어 saveTemplate(전체 편집기와 같은 길 · 번호 칸 유지)
- 알약 · 자재 조합 판은 전체 편집기와 같은 부품 — 면적 = pillsFor · 재료 = 담은 자재 + 선택 입력 알약 · 「+ 자재 추가」 · 아래 자재 조합 판(추가 · 빼기 · 적용)
- 저장 안 한 카드 고침 — 다른 항목 고르면 물음(머물면 고른 열 되돌림) · 떠나기 · 나무 고침 · 새로고침 막기도 물음 · 위 [저장]도 카드를 먼저 저장
- 카드 안 [자재 추가] 창 목록 높이를 줄여 안 잘리게
- 시험 test_m02_card_sheet_edit 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-30 20:27:08 +09:00
eomsangdonandClaude Opus 5.5 fb56d7c294 fix(M02): 좌측 두 컨테이너 펼친 채 진입 · 끌기 뒤 놓을 자리 강조 지움 (PLAN 31-5)
- 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 들어옴
- 끌어 놓은 뒤 나무 칸 자신(맨 위 자리) 강조가 남던 흠 — 놓음 · 끝 · 칸 밖 나감에서 모두 지움(칸 안 자식 위로는 그대로)
- 끌기 코드를 TreePanel_Drag 로 나눔(700줄)
- 시험 한 건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:27:02 +09:00
eomsangdon f1b464e64b Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-09-30 20:25:12 +09:00
eomsangdonandClaude Opus 5.5 6ad43ed795 feat(M02): 도면 카드 미리보기에서 단축키로 그리기 · 고치기 · 카드 [저장] · ? 도움말(PLAN 31-4)
- 새 M02_MasterTemplete_DrawingCard — 고칠 권한 + 저장 길이면 CAD 를 고치기로(보기만 끔) · 제목줄 · 리본 · 패널 · 상태줄 숨김(같은 출처 스타일 · CAD 앱 안 고침) · 전체 편집기 [도면 수정]과 같은 단축키
- 카드 [저장] · Ctrl+S = 전체 편집기와 같은 저장 길(saveTemplate · 읽은 문서에 도면 칸만)
- ItemSource write(M02 화면 자료 자리만 · 모달 복사본은 보기만)
- 저장 안 한 카드 고침 — 다른 항목 열 때 · 페이지 떠날 때 물음(ItemCards · Structure dirty · UI_Main 두 줄)
- 시험 test_m02_drawing_card(새) · structure_panel · item_picker_detail 모양 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 20:25:11 +09:00
eomsangdonandClaude Opus 5.5 b981911755 feat(M02): 항목 추가 창 = 종류 + 이름 + 일위대가 · 카드 넷 2×2 · 표 접기 (PLAN 31-2)
- 항목 추가(나무 「항목 추가」 · 「새 항목」) — 구조물 종류(있는 것 고름 · 새 이름 · 폴더를 골라 두었으면 그 안) + 이름 + 일위대가(세 층 조합 고르기 창 · 찾기 AND · 비워도 됨) · 단위 칸 없앰 · 빈 칸 안내 · 서버 add-item 이 일위대가 받음
- 일위대가 고르기 창을 pickCombo 로 내보내 표 칸 고르기와 같이 씀
- 카드 넷(도면 정보 · 면적산출 · 재료산출 · 선택 입력) 2행 2열 · 높이 360px · 넘치면 카드 안 스크롤 · 상세 칸 전체 스크롤(좁으면 1열)
- 「구조물 조합」 제목을 누르면 표 접힘 · 펴짐
- 시험 — 항목 추가 서버 · 창 · 격자 · 접기 · 옛 단위 필수 시험을 31 정함으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:19:11 +09:00
eomsangdonandClaude Opus 5.5 f7c91f2f12 feat(M02): 구조물 조합 표 — 머리 이름 고침 = 나무 이름 바꾸기 · 단위 줄 숨김 · 열 폭 자동 · 마지막 분류 칸 강조 · 안내글 없앰 (PLAN 31-1)
- 표 부품 combo 선택(구조물 조합 표만): 단위 줄 · 옛 안내글 없음 · 열 폭 = 머리 이름 · 일위대가 키 · 도번 등 번호가 한 줄에 들게(저장 폭 안 씀 · 번호 줄 줄바꿈 없음) · 고른 열의 가장 깊은 이름 칸 강조(나무에서 고를 때도) · 방향키가 숨은 단위 줄을 안 밟음
- 머리 이름 칸 두 번 누르기 · F2 · Enter → headRenameEdit(가장 깊은 칸 = 항목 · 위 칸 = 폴더) → 나무 editFromTable(메뉴 이름 바꾸기와 같은 길 — 저장 물음 · 같은 이름 막음 안내 · 다시 그림) · 쓸 수 있는 층만
- 시험 test_m02_combo_table 3 · test_m02_combo_screen 머리 잠금 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 20:13:18 +09:00
eomsangdon 3913258df9 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-30 20:03:35 +09:00
eomsangdonandClaude Opus 5.5 25cf716a0d feat(M02): 컨테이너 안 「불러오는 중」 — 0.3초 넘을 때만 · 실패 = 까닭 + 다시 불러오기 (PLAN 30-6)
- 공용 부품 ui_template_loading(showLoading · loadingError · delayedShow) — 칸 안 작은 회전 표시 + 「불러오는 중...」 · 화면 전체 잠금 아님 · 다른 페이지도 씀
- 붙인 자리 — 좌측 나무(구조물 · 일위대가 · 사람 나무) · 상세 칸(구조물 표 · 도면 · 면적 · 재료) · 조합 목록 · 조합 상세 · 항목 카드
- 공용 글 Common_Load_Failed · Common_Reload
- 시험 test_ui_loading(0.3초 규칙 가짜 시계 · 실패 글 · 붙은 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:02:36 +09:00
eomsangdon c56d4254f5 auto: 2026-09-30 19:58 (ESD_LAPTOP) 2026-09-30 19:58:30 +09:00
eomsangdonandClaude Opus 5.5 1b908d3c7e feat(M02): 구조물 조합 표에서 NO · 측점 열도 숨김 · 틀고정 열 수 맞춤 (PLAN 30-7)
- comboView 숨긴 열에 no · sta 더함(비고와 같은 자리 — 보일 문서에서만 빼고 [저장] 때 저장본 것 되끼움)
- 틀고정 열 수는 숨긴 열만큼 줄여 보이고(2 → 0) [저장] 때 저장본 값 되돌림 · 열너비 고침은 받음
- 양식 파일 · M02_Table_Fill · 프로젝트 · B08 안 건드림
- 시험 test_m02_combo_screen 기대값(NO · 측점 · 비고 숨김 · 틀고정 · 저장본 차례)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 19:57:18 +09:00
eomsangdonandClaude Opus 5.5 71666a8633 test(M01): 조합 시험 사본이 정본 마스터 머리 「폴더」를 안 물려받음 · 정본 폴더 이름에 안 기댐
- test_m01_combo · test_m02_combo_branch · test_m02_combo_layers(master_copy) — 사본 조합 파일에서 「폴더」 뺌(사용자 화면 편집 9f69ebe2 뒤 빨강)
- 마스터 나무 시험은 사본 첫 폴더 이름을 읽어 씀(「실무 일위대가」 박지 않음)
- 정본 마스터 파일은 안 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 19:44:48 +09:00
eomsangdon 74ae298b9c Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-09-30 19:27:33 +09:00
eomsangdonandClaude Opus 5.5 3ec501ab47 feat(M02): 일위대가 「새 조합」 단추를 제목 줄 오른쪽 끝으로 · 조합 목록 공용 표 모양(PLAN 30-5 일위대가)
- UI_Main showView 에 tools(빈 slot · 다음 열기 때 drop 이 비움) · .m02-main__tools 오른쪽 끝
- 읽기 층은 단추 없음 · 목록 안 옛 단추 뺌
- 조합 목록 = ui-general-block__table(줄 선 · 머리 줄 고정) · 줄 누름 모양
- 시험 test_m02_labor_tree 한 건 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 19:25:53 +09:00
eomsangdonandClaude Opus 5.5 dc725012ed feat(M02): 「구조물 조합」 제목 줄 오른쪽 끝 「새 항목」 (PLAN 30-5 구조물 쪽)
- 쓸 수 있는 층만 · 구조물 조합일 때만 보임 · 나무 「항목 추가」와 같은 창 · 고른 폴더 안(없으면 맨 위)
- 공용 모양 newItemButton · m02-main__new(일위대가 「새 조합」이 같은 자리 · 모양을 씀)
- 시험 한 건 · 머리 줄 시험 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:23:39 +09:00
eomsangdonandClaude Opus 5.5 4fa49e307a feat(M02): 일위대가 나무에 여러 개 고르기 · 복사 · 붙여넣기 · 〇〇로 복사 · 여러 개 옮기기 · 지우기 연결(PLAN 30-3)
- Labor 나무 api.many → /combos/tree-many · api.paste → /combos/paste(대상 층 = 부품이 준 층 · 다른 층이면 이 나무 그대로) · clipKind 「일위대가」
- 「〇〇로 복사」 목록은 페이지가 구조물 나무와 같은 것을 줌(UI_Page 한 줄)
- 시험 test_m02_labor_tree 한 건 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 19:22:02 +09:00
eomsangdon c53eabf517 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_6 2026-09-30 19:14:01 +09:00
eomsangdon 14068c0931 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-09-30 19:13:48 +09:00
eomsangdon 62073aac1b Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-30 19:13:35 +09:00
eomsangdonandClaude Opus 5.5 d1675e6218 feat(M02): 「〇〇로 복사」 · 끌어다 옮기기 · 켜진 층 단추 = 목록으로 (PLAN 30-2 더함 · 30-4)
- 오른쪽 클릭 「마스터 · 회사 · 개인으로 복사」 — 쓸 수 있는 자기 층(지금 층 뺌) 맨 위에 바로 · 새 키 · 같은 이름 「(2)」
- 끌어다 옮기기 — 같은 층 · 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 고른 것 전부 · 제 안으로 막음 · 읽기 층은 끌기 없음
- 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 · 저장 안 한 고침은 물음)
- 시험 맞춤 · 30-4 시험 한 건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:12:25 +09:00
eomsangdonandClaude Opus 5.5 0f510eb53b feat(M02): 공용 나무 여러 개 고르기 · 복사 · 붙여넣기 · 여러 개 옮기기 · 지우기 + 층 사이 붙여넣기 서버 · 「+」 오른쪽 맞춤 (PLAN 30-2)
- 나무 부품 — Ctrl · Shift 클릭 고름(TreePanel_Pick) · 고른 줄 배경 · 층 단추 줄 「N개 고름」 · 오른쪽 클릭 메뉴가 고른 것 전부(복사 · 옮기기 · 지우기) · Ctrl+C · Ctrl+V · Delete · 복사는 읽기 층에서도 · 붙여넣기는 쓸 수 있는 층만 · 콜백 api.many · api.paste · clipKind(일위대가 쪽이 붙일 자리)
- 서버 — POST /layers/{층}/items/paste(여러 항목 · 폴더 · 새 SC/SP 키 · 본뜬 = 원본 · 같은 이름 「(2)」 · 세 파일 · 입력 연결 · 자재 조합 · 빈 폴더까지 · M02_Layer_Access 쓰기 층만) · 나무 고침 대상 여러 개(옮기기 · 지우기 한 번 저장 · 막히면 안 씀)
- 층 단추 줄 「N개 고름」 · 「+」 오른쪽 끝
- 시험 test_m02_tree_paste 새로 · 고르기 규칙 Node 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:05:01 +09:00
eomsangdonandClaude Opus 5.5 9f69ebe225 chore(M02): 마스터 일위대가 견본 조합 맨 위로 · 폴더 목록(사용자 화면 편집)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-30 19:02:06 +09:00
eomsangdonandClaude Opus 5.5 0541830750 fix(공용): 모달 위 고정 · 보이는 글 「더하기」 → 「추가」(30-1)
- 공용 .ui-modal: 세로 가운데 대신 최대 높이 기준 가운데 자리에 윗변 고정 · 아래로만 늘고 줆(찾기 칸이 안 움직임) · 큰 창은 --modal-max(고르기 모달 94vh · 노선 추천 92vh · M01 복사 시트 90vh) · M02 단추 아래 작은 창은 margin-top 0
- 따로 둔 모달도 같게: B01 대시보드 모달 · M01 로직 고르기(m01-logic) · M01 조달 찾기
- 가운데 그대로: 불러오는 중 가림 · 공용 확인 창 · B03 확인 창 · B05 노선 편집(고정 높이) · 스프레드시트 메모 편집
- 「더하기」 → 「추가」 25곳(스프레드시트 · M01 · M02 · 공용 표 · locale) · 안쪽 명령 이름 시트더하기 · 주석은 그대로
- 시험 test_modal_top_and_add_word 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-30 18:56:43 +09:00
eomsangdonandClaude Opus 5.5 c0a76b844d feat(M02): 일위대가 조합 층 사이 복사 · 여러 개 옮기기 · 지우기 서버(PLAN 30-3)
- POST /api/m02/layers/{layer}/combos/paste — 원본 층(읽기) 조합 · 폴더 → 이 층(쓰기) 경로 폴더에 새 키(UA · UC · UP) · 본뜬키 = 원본(마스터는 UA 만) · 같은 이름 「이름 (2)」 · 폴더는 안 조합 · 빈 폴더까지 · 깊이 2 넘으면 422
- POST /api/m02/layers/{layer}/combos/tree-many — 나무 고침 여럿 차례대로 · 저장 한 번 · 하나라도 막히면 안 씀
- 새 M02_MasterTemplete_Combo_Copy · Combo_Store edit_tree 를 edit_tree_many · commit 한 길로
- Labor_Api 에 pasteCombos · editComboTreeMany
- 시험 test_m02_combo_layers 네 건 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 18:53:57 +09:00
eomsangdon 7ea8b75162 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-30 18:28:13 +09:00
eomsangdonandClaude Opus 5.5 4a1fb2128b feat(M02): 「사람 고르기」 없앰 · 시스템 관리자는 고른 회사를 회사 관리자처럼 — 「개인」 = 그 회사 사람 나무 (PLAN 29-6)
- 남의 회사 「개인」 단추(읽기) = 사람 줄 → 고른 사람 라이브러리(구조물 조합 · 일위대가 두 나무) · 안 골랐으면 첫 사람 · 사람 없으면 마스터
- 시스템 관리자 자기 개인 층은 자기 회사를 고를 때만 · 회사 관리자 화면 그대로
- 시험 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:24:40 +09:00
eomsangdon 7459f31170 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_3 2026-09-30 18:18:47 +09:00
eomsangdon f685b4e3b3 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-30 18:16:41 +09:00
eomsangdonandClaude Opus 5.5 c1ad11e869 feat(M02): 좌측 같은 이름 단추 · 나무 뿌리 줄 없앰 · 「+」 더하기 · 찾기 하나(AND) (PLAN 29-6)
- 「일위대가 조합」 · 「구조물 조합」 컨테이너 안 같은 이름 줄 단추 없앰 — 제목을 펼치면 그 화면을 엶
- 나무 맨 위 뿌리 줄 없앰 — 폴더 · 항목부터 · 맨 위 더하기는 빈 자리 오른쪽 클릭 · 층 단추 옆 「+」(읽기만 층 = 숨김) · 빈 나무 안내글 맞춤
- 찾기 칸 하나 — 띄어쓰기 낱말 모두(AND · 대소문자 무시) · 이름 · 키 · 경로(구분 · 상세구분) · 단위 · 공용 matchText 를 나무 · 일위대가 조합 목록이 함께 씀(목록의 따로 찾기 칸 없앰)
- 시험 — 찾기 AND · 새 배치 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:15:43 +09:00
eomsangdonandClaude Opus 5.5 9ed36efe08 fix(B01): 회사 알약 제목 바로 옆 · 누르면 조회 후 고르기 창(29-5)
- 알약을 컨테이너 제목 바로 옆 왼쪽 맞춤(다섯 컨테이너 같게 · 제목과 16px)
- 드롭다운 메뉴 대신 회사 고르기 창: 찾기 칸(이름 · 사업자번호 · 띄어쓰기 단어 모두) → 거른 목록(최근 등록 순 · 50줄까지 · 넘으면 좁히라는 안내) → 줄 누름 또는 Enter 로 고름 · Esc · 바깥 누름 닫기
- 공용 item_picker 는 구조물 항목 전용이라 안 씀 · B01 모달 꼴 새 파일 B01_Dashboard_UI_CompanyPick
- 시험 test_b01_settings_cards 맞춤 · 거르기 시험 보탬

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-30 18:10:21 +09:00
eomsangdonandClaude Fable 5.1 95849aefbd chore(M01): 결정 목록 보탬 — 산림 1~3장 · 못 고친 흠 · 가정 값 · 화면 · 정리(물음 51)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-30 17:59:33 +09:00
eomsangdonandClaude Opus 5.5 d17ede799d fix(M02): 「사람 고르기」는 시스템 관리자에게만 — 회사 관리자에게는 숨김 (PLAN 29-2 뒷손질)
- 회사 관리자의 남의 개인 층 읽기 길(해시 owner · 서버 권한)은 그대로
- 시험 한 줄 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:47:30 +09:00
eomsangdon b315181246 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-09-30 17:43:33 +09:00
eomsangdon 04b9524b54 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-30 17:43:31 +09:00
eomsangdon 61caa8e27d Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-30 17:39:54 +09:00
eomsangdonandClaude Fable 5.1 6a5c5382b3 chore(M01): 산림 1~3장 표 99개 대조 기록(PLAN 29-4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-30 17:39:17 +09:00
eomsangdon 647b474446 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-09-30 17:38:34 +09:00
eomsangdon 5a206fc2fe Revert "auto: 2026-09-30 17:37 (ESD_LAPTOP)"
This reverts commit 5d97102f35.
2026-09-30 17:38:33 +09:00
eomsangdonandClaude Opus 5.5 cd2ee5664d feat(M02): 좌측 층 단추 셋 · 나무를 좌측 패널로 · 탐색기 모양 나무 · 사람 고르기 · 해시 company (PLAN 29-2)
- 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 층 단추 셋(마스터 · 회사 · 개인 · 권한 없으면 「읽기」) → 고른 층의 나무를 좌측 패널에 · 상세는 표 · 편집만
- 찾기 칸은 상세 위쪽(구조물 조합일 때 · 일위대가 화면 맨 위)
- 회사 → 사람 펼침 없앰 · 「사람 고르기」 select(같은 줄 · 회사 관리자 · 시스템 관리자) → 해시 owner
- 시스템 관리자 볼 회사 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사(viewCompany)
- 공용 나무(ui_template_tree) — 들여쓰기 안내선 · 폴더 · 항목 아이콘 · 폴더 굵게(M01 · 고르기 모달도 같이)
- 시험 — combo_screen · labor_tree · layer_screen 새 배치 · viewCompany Node 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:37:46 +09:00
eomsangdon 5d97102f35 auto: 2026-09-30 17:37 (ESD_LAPTOP) 2026-09-30 17:37:02 +09:00
eomsangdonandClaude Opus 5.5 e7d8ea8002 feat(B01): 회사 고르기를 알약으로 — 회사 따라 바뀌는 컨테이너마다(29-1 고침)
- 고르기 select 뺌 · 「시스템 설정」 · 프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리 제목 오른쪽에 고른 회사 알약(공용 ui-tag 모양)
- 누르면 등록된 회사 작은 메뉴(공용 createMapContextMenu) · 어디서 골라도 함께 바뀜 · 알약 누름은 접힘으로 안 샘
- 회사 관리자 · 개인 화면은 알약 없음 · 임시 보관함 그대로
- 시험 test_b01_settings_cards 알약 판정으로 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-30 17:35:59 +09:00
eomsangdonandClaude Opus 5.5 f7f4572f3c fix(M01): 자재표에 없는 품목 34건 — 재료 줄 검색어 비움(40줄) · 빈 검색어 = 후보 없음 · 결정 목록 절(PLAN 29-3)
- 로직 14 파일 재료 줄 40 검색어를 빈 글로(억지 연결 뗌) · 그 줄만 고침(다른 줄 · 모양 그대로)
- GC000432 메탈리밍쉘 {규격} 은 그대로 — BX 는 품목 있음 · NX 는 이미 후보 없음
- 엔진: 빈 검색어 = 자재표에 없는 품목 — candidates 빈 목록 · 계산 단가 없음 + 까닭 · 고름은 그대로 · 검사 틀림 안 셈(master_pick · master_material) · _틀.md 한 줄
- 결정 목록 맨 아래 「자재표에 없는 품목 — 사용자 결정」 34줄
- 시험 한 건 더함(test_masterdata_check)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 17:31:31 +09:00
eomsangdonandClaude Opus 5.5 59a4dd4eba feat(B01): 대시보드 회사 고르기 — 시스템 관리자가 고른 회사로 보고 고침(29-1)
- 시스템 관리자 화면: 「시스템 설정」을 리소스 현황 아래로 · 제목 줄 오른쪽에 회사 고르기(기본 자기 회사)
- 고른 회사 = sessionStorage aislo.adminCompany · 프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리 · 팀원 추가가 그 회사 것
- 카드 해시에 &company=<id> · 회사 관리자 · 개인 화면은 그대로 · 임시 보관함은 사람 것이라 그대로
- 서버: /admin/projects · /admin/join-requests · /admin/members(POST · invite)에 company_id(_scope_company 한 곳 · 시스템 관리자만 남의 회사) · /admin/users 에 회사 거르기
- 라우터 700줄 넘어 나눔: 권한 도우미 B01_Dashboard_Scope · 회사 자산 B01_Dashboard_Router_Assets(본체가 include)
- 시험 test_b01_settings_cards 보탬

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-30 17:31:17 +09:00
eomsangdonandClaude Fable 5.1 a01cf96769 chore(M01): 고침 창별 목록 · 못 고침 · 입력빠짐 기록(PLAN 28-2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-30 15:29:13 +09:00
eomsangdon e55494b379 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-09-30 15:21:48 +09:00
eomsangdonandClaude Opus 5.5 4a5b00d72d fix(M02): 일위대가 라이브러리에서 뿌리를 바꿔도 일위대가 화면 유지 · 좌측으로 열면 해시 open=일위대가(PLAN 27-2b 흠)
- 원인: 뿌리 줄이 두 벌(구조물 조합 쪽은 숨김) — 숨은 줄이 눌리면 go 기본값 open=구조물조합 · 좌측 열기는 해시를 안 바꿈
- 고침: 페이지가 지금 화면(view)을 쥐고 go 기본값을 그것으로 · 일위대가를 열면 history.replaceState 로 해시만 open=일위대가 · 일위대가에서 양식으로 가면 구조물조합
- 시험: test_m02_labor_tree 한 건 더함 · test_m02_structure_panel 콜백 모양 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 15:17:56 +09:00
eomsangdon 657d82b9ce Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1
# Conflicts:
#	resources/master_data/로직_건설품셈_08장_건설기계.json
2026-09-30 15:14:31 +09:00
eomsangdon 6096e065af Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1
# Conflicts:
#	resources/master_data/로직_건설품셈_01장_도로포장공사.json
#	resources/master_data/로직_건설품셈_13장_플랜트설비공사.json
2026-09-30 14:46:06 +09:00
eomsangdonandClaude Opus 5.5 4accd1e641 fix(M01): 28-2a 새 로직 기계 고르기 밖 막음 (GC001105 · GF000278 · GC000273)
- GC001105 굴착기+압쇄기 — 집게 전용 GC000270 대신 굴착기 경비(GC000267) + 압쇄기 손료(GC000273 · 0250-0100) 두 줄
- GC000273 기계 고르기에 이제 쓰는 0250-0100 · 5210-0010 · 5210-0019 더함(GF000278 소형브레이커 네 규격 모두 받음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 14:35:38 +09:00
eomsangdon 9c3528f063 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-30 14:16:36 +09:00
eomsangdonandClaude Opus 5.5 f9fd05809b fix(M01): 28-2a 새 로직 자동값 멈춤 3건 막음 (GF000294 · GF000297 · GF000277)
- GF000294 · GF000297 크레인 요소 — 크레인 칸 없는 작은 지름(근원 20㎝ · 흉고 18㎝ 미만)은 기계 고르기 전에 비켜 감(수량 0 과 같은 문턱)
- GF000277 살수 — 자연함수비 ≥ 최적함수비(W ≤ 0)면 Q5 · 살수차 시간 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 14:15:56 +09:00
eomsangdon 08f1fc56a6 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-09-30 14:15:32 +09:00
eomsangdonandClaude Opus 5.5 05a4943bcc test(M02): 27-2 새 규칙에 맞춤 — 회사 층 갈라 내기 권한 · 조합 이름 읽기 · 좌측 컨테이너 차례(PLAN 27-2)
- test_m02_layers_store: 갈라 내기 = 회사 층 쓰기 권한(일반 403 · 회사 관리자 UC7-000001) · 조합 이름은 Combo_Store.find
- test_m02_structure_options: 좌측 차례 일위대가 조합 → 구조물 조합 → 옵션 → 도면 · onOpen 이 일위대가 고름도 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 14:13:38 +09:00
eomsangdon b2b6435778 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-09-30 14:09:25 +09:00
eomsangdon 5f2b8ae906 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-09-30 14:08:43 +09:00
eomsangdonandClaude Opus 5.5 2847fec327 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
28-2d 합치기 충돌 풀기 — 13장 로직 · 소요량 두 파일을 줄(키)마다 세 갈래(바탕 · sub4 · main) 대조로 합침
- 양쪽이 고친 13줄은 칸이 안 겹침 — sub4 = 인력 중간 ×1.1 · 비고 · Tainter 코크스 줄 · Bolt Beating 값 · 음극 Plate 구분 / main = 재료 호표(아세틸렌 병→ℓ 환산 · 검색어 「아세틸렌 98% (1kg=853ℓ)」 · 광명단 · 페인트 · 신너 · 소창직 검색어) · 검사및교정 줄 단위 칸 — 둘 다 살림
- 한쪽만 고친 줄은 그쪽 것(sub4 19 · main 49)
- check_master 틀 · 본문 · 로직 0 · 계산전부 1469 통과 · test_masterdata_check + test_m01_api 98 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:53:49 +09:00
eomsangdon 5b92de9510 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-09-30 13:51:05 +09:00
eomsangdonandClaude Opus 5.5 6d4b309e0d fix(M02): 일위대가 라이브러리 — 나무에서 고른 조합 표시 · 층 조합 검사 글에서 아직 없는 키 뗌(PLAN 27-2b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 13:49:56 +09:00
eomsangdonandClaude Sonnet 5 6e108f010a fix(M01): 기계설비 13장·측량 9장 로직 일부 구현 — 소모자재 보강·막힘 문서화
GC000992 산소·용접봉·Grease 자재줄 보강 · GC000515·520 축척 보간 막힘 · GC000914·916·917·920·921·922 검사및교정 막힘 문서화

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 13:49:51 +09:00
eomsangdon 5f14c4b89a Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-30 13:45:02 +09:00
eomsangdonandClaude Opus 5.5 9785556ec0 fix(M01): 28-2d 주 글에서 원문에 없는 칸 번호 뺌 · Bolt Beating 원문 줄바꿈 주 (PLAN 28-2d)
- check_master 본문 대조 0건(CC000064 · CC000072 · QC001934 · QC001628 주 글 · QC001684 주)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:41:52 +09:00
eomsangdonandClaude Opus 5.5 4d128335cf fix(M01): 공통 7장 석재판 붙임 로직 없음(절 누락) 2절에 새 로직 (PLAN 28-2a)
- 7-4-2 앵커 긴결공법 · 7-4-3 강제트러스 지지공법 — GC001138 · GC001139
- QC000495 · QC000496 범위규칙 앞에 엔진 이름 「초과·이하」 · 용도.로직키 연결

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 13:40:36 +09:00
eomsangdonandClaude Opus 5.5 58aaaf5bcf fix(M01): 유지관리 1-2-9 살수차 3,800ℓ · 2-1-11 맨홀 인상 5이하 (PLAN 28-2d)
- QC001878 1,800ℓ · 3,800ℓ 병합 0.07h/100㎡ 폄
- GC001043 인상높이 5이하(원문 「-」 할증 없음) 고르기 · 중간 인상할증 · QC001934 주

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:40:32 +09:00
eomsangdonandClaude Opus 5.5 8c9e51936d fix(M01): 유지관리 3장 건축 · 4장 기계설비 로직 없음(절 누락) 34절에 새 로직 (PLAN 28-2a)
- 새 로직 파일 로직_건설품셈_03장_건축.json(25) · 로직_건설품셈_04장_기계설비.json(9) · 부문 유지관리 · 차례 55 · 56
- GC001104~1137 · 표 용도.로직키 연결

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 13:39:08 +09:00
eomsangdonandClaude Opus 5.5 8e0aeccd8b fix(M01): 기계설비 12-1-1 · 13-1-8 · 13-7-15 · 13-7-21 표 · 검사 10% 원문대로 (PLAN 28-2d)
- QC001603 25% 가산 기준 「12-1-2 플랜트 계기 설치 품」
- QC001628 부설장비 규격 표를 원문 열대로(관경 → 크레인 규격)
- GC000965 검사시험및교정 10% — 기술관리 뺀 세 직종 ×1.1
- QC001775 음극 Plate 4줄을 「6. 음극 Plate 조립 설치」 독립 구분으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:38:40 +09:00
eomsangdonandClaude Sonnet 5 7cc8428aa5 fix(M01): 기계설비 13장 로직 일부 구현 — GC000943 Tar Epoxy 전기 혼입 분리
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 13:38:01 +09:00
eomsangdon 242db61ee4 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-09-30 13:36:43 +09:00
eomsangdon ccd738fff8 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_6 2026-09-30 13:36:25 +09:00
eomsangdon 316bf7a958 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_6 2026-09-30 13:36:24 +09:00
eomsangdon 1ff64f4d92 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-09-30 13:36:23 +09:00
eomsangdonandClaude Opus 5.5 cb280c1de9 fix(M01): 기계설비 13-1 재질할증 대상 · 13-10 · 13-11 검사시험 10% 제외 품 (PLAN 28-2d)
- GC000871~875 재질 할증 — 용접식은 플랜트용접공 · 나사식은 플랜트배관공(원문 주⑧)
- GC000986 · GC000987 기계산업기사(기술관리)에서 ×1.1 뺌
- QC001813 검사시험 줄 비고 · GC000991 ×1.0982 → ×1.1(기술관리 · 뒷정리 제외) · 비고 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:36:14 +09:00
eomsangdon 8fa828f038 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-30 13:36:08 +09:00
eomsangdonandClaude Sonnet 5.5 13dd39325d fix(M01): 공통 3장 토공사 암발파·암파쇄 소모자재 덧줄 7건 + 시험 기대값 갱신
3-3-1~3-3-6 크롤러드릴 24%(3-3-1·2는 대형브레이커 5%) · 3-3-8 대형브레이커 2% 덧줄. 시험: 호표를 줄여도 소모자재 덧줄이 남는 새 규칙에 맞춤 (28-2c).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 13:35:53 +09:00
eomsangdonandClaude Opus 5.5 c8686ee3ad feat(M02): 일위대가 라이브러리 화면 — 좌측 「일위대가 조합」 컨테이너 · 뿌리 셋 나무 · 조합 편집 · M01 컨테이너 뺌(PLAN 27-2b · 27-2c 화면)
- 좌측 「일위대가 조합」(구조물 조합 위) → 메인 칸 왼쪽 뿌리 셋 + 조합 나무(폴더 [구분, 상세구분] 깊이 2 · TreePanel 공용) · 오른쪽 목록 · 상세(옛 M01 조합 화면 옮김 · Labor_Edit)
- 나무 오른쪽 클릭: 폴더 더하기(2 단까지) · 조합 더하기(그 폴더 채운 빈 조합) · 이름 · 옮기기 · 지우기 · 조합 이름 바꾸기 서버 길 더함
- 해시 open=일위대가 로 진입 · 다른 뿌리를 누르면 그 뿌리 일위대가로
- 읽기만 뿌리는 저장 · 지우기 · 로직 더하기 없음
- TreePanel 에 maxDepth · item(부르는 말 · 새 항목 화면) 선택지 더함(구조물 라이브러리는 그대로)
- 조합 고르기(면적산출 옆 목록 · 일위대가 고르기)가 이 층이 쓸 수 있는 세 층 조합 · 알약은 /api/m02/combos/key/{키}/inputs
- M01 좌측 · 페이지에서 일위대가 조합 컨테이너 뺌
- 시험 test_m02_labor_tree(새) · test_m02_combo_layers 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 13:35:38 +09:00
eomsangdon ec613b8dc6 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-30 13:35:35 +09:00
eomsangdon aa1edd65dc auto: 2026-09-30 13:34 (ESD_LAPTOP) 2026-09-30 13:34:20 +09:00
eomsangdon 2afb8879a0 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-09-30 13:33:12 +09:00
eomsangdon c8144a97b1 auto: 2026-09-30 13:32 (ESD_LAPTOP) 2026-09-30 13:32:53 +09:00
eomsangdonandClaude Opus 5.5 0b0106f527 fix(M01): 기계설비 13장 표 섞인 글 · 병합 칸 · Bolt Beating 값 · 펌프 이름 (PLAN 28-2d)
- QC001717 Roller Gate 직종별 표를 한 줄로(Tainter 와 같은 꼴)
- QC001642 · QC001667 · QC001694 · QC001697 주 끝에 붙은 다음 표 · 소제목 글 뺌
- QC001693 · GC000926 이름 원문대로(Rotary Pump, Centrifugal pump(3,4 stage))
- QC001684 Bolt Beating 「0.097 5」 → 0.0975 · GC000920 100~350 계수 0 → 0.0975
- QC001655 구경별 K 병합 칸 폄 · 주⑨ 91㎜ 이상 공식

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:32:51 +09:00
eomsangdonandClaude Opus 5.5 dc1399244d test(M02): 합친 뒤 빨강 둘 — 옛 규칙을 재던 시험을 26-7e · 27-1d 규칙으로
- test_m02_structure_combo: 화면 표 [저장]은 있던 항목 머리(경로)를 저장본 그대로(나무가 정본 · 26-7e) — 분류1 · 분류3 은 저장 전 값 · 단위 · 일위대가는 표대로
- test_m02_structure_options: 좌측 차례를 새 모양(extras 옵션 → 도면 · children 차례)으로 잼(27-1d reveal)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:32:15 +09:00
eomsangdonandClaude Opus 5.5 e63cadfde4 fix(M01): 산림 13장 로직 없음(절 누락) 5절에 새 로직 (PLAN 28-2a)
- 13-4-6 밑돌·천단 돌쌓기 · 13-10-2 나무 말뚝박기(작은 말뚝 · 기초말뚝 · 윈치말뚝) · 13-11-1 원형 · 13-11-2 타원형 돌망태 · 13-15-1 목재 껍질벗기기
- GF000324~330 · 표 용도.로직키 연결

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 13:32:08 +09:00
eomsangdonandClaude Opus 5.5 f7743c95e8 fix(M01): 기계설비 13-6 수력발전 시험 · 검사 10% 는 원문 제외 품 빼고 · Tainter 코크스 (PLAN 28-2d)
- GC000931 · 933 · 934 · 937 도장공 · GC000935 · 936 · 938 플랜트전공(전원배선) · GC000932 기계기사 · 포장해체 목공 · 특별인부 — ×1.1 뺌(원문 「… 을 제외한 품의 10%」)
- QC001720 문종(Tainter · Roller) 칸 · 코크스는 Roller 만 · GC000935(Tainter) 코크스 줄 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:29:42 +09:00
eomsangdonandClaude Sonnet 5 31fd33f12d fix(M01): 건축 9장 로직 일부 구현 — 모르타르 타설 기계 3종 더함
- 건축 미장공사(9-1-3 GC000679): 모르타르 펌프 37㎾·양수기 1.49㎾를 GC000273(손료계수만 있는 기종)으로 연결, 모르타르 믹서 0.3㎥도 같은 방식 + 휘발유 연료(1.3ℓ/hr) 재료 줄 더함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 13:29:26 +09:00
eomsangdon 341ae31877 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-09-30 13:29:16 +09:00
eomsangdonandClaude Sonnet 5.5 296337327e fix(M01): 유지관리 2장 토목 — 포장절단 분무기 단위 · 표층재생 팁(날) 재료 줄
2-1-2 동력분무기 단위 수→대 · 2-1-19 주⑤ 팁(날) 100㎡당 0.7개 재료 줄 (28-2c).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 13:26:52 +09:00
eomsangdonandClaude Sonnet 5.5 8f1dfa0605 fix(M01): 기계설비 2장 덕트 · 9장 기타 — 흡입구 투영면적 입력 · 시운전 단위 공란
2-2-2 후드 투영면적 ㎡ 입력 더함 · 9-5-1 온풍난방 비고 줄 단위 비움 (28-2c).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 13:26:51 +09:00
eomsangdonandClaude Sonnet 5.5 22bf0ad76f fix(M01): 기계설비 13장 플랜트 — 아세틸렌 병→ℓ 환산 · 검사및교정 10% · 표 단위 공란 · 주 보충
아세틸렌 병×ℓ입 21건(ℓ 품목) · 13-6-7 기타 10% · 13-7-16~21 · 13-8-1~7 · 13-11-2 ×1.1 · 원문 공란 단위 · Socket 40% 감 · 원문 주 보충 · 드럼 입력 합침 (28-2c).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 13:26:51 +09:00
eomsangdonandClaude Opus 5.5 1428a25919 fix(M01): 토목 9-7 · 9-10 · 9-11 지적측량 주 · 필계점 「30.28)」 값 (PLAN 28-2d)
- QC001104 · QC001107 · QC001132 · QC001135 성과작성품 목록 주 · QC001107 체감계수 「다만 …」 문장
- QC001125 · QC001129 축척변경 적용 설명 주(9-10-1 ⑥㉮ · 9-10-2 ⑤㉮)
- QC001132 필계점 측량 지적산업기사 「30.28)」 → 수 30.28 · GC000543 상수 대신 표 찾기(값 같음)
- 9-7~9-10 계수 합성 · 9-9-1 면적계수 올림은 원문 안 갈림 — 사람 확인으로 남김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:26:21 +09:00
eomsangdonandClaude Opus 5.5 bf5f4001c3 fix(M01): 산림 8장 로직 없음(절 누락) 7절에 새 로직 (PLAN 28-2a)
- GF000317~323: 나무주사 약제주입기·약제주입병 · 끈끈이 롤 트랩 · 드론방제 · 지상방제 차량살포 · 그물망 피복 · 이동식 임목 파쇄
- 쓰는 표의 용도.로직키 · 범위규칙 세 표에 엔진 이름을 앞에 붙임

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 13:26:11 +09:00
eomsangdonandClaude Sonnet 5 ae1b3682e8 fix(M01): 측량 9장·유지관리 2장 로직 일부 구현 — 보간·교통통제·소형브레이커 등
- 측량(9-5-4-1 GC000490): 수정도화 사진판독 가산시간(주④)을 인·일로 바꿔 총인에 더함
- 측량(9-5-6-2나 GC000518): 착묵 사진분석 가산(초급기술자·중급기능사 각 9인, 주⑤)을 도엽수만큼 더함
- 유지관리(2-1-17 GC001053): 교통통제 및 안전처리 보통인부 8명(주⑥) 더함
- 유지관리(2-1-20 GC001056): 산소·아세틸렌 재료를 횡재부에만 적용(원문 표가 횡재부 전용)
- 유지관리(2-1-11 GC001043, 2-1-12 GC001044): 소형브레이커(전기식) 1.5㎾를 GC000273(손료계수만 있는 기종)으로 연결 · 2-1-11엔 모르타르 믹서 0.3㎥ 경비·휘발유 연료도 더함
- 건설기계(GC000273 고르기): 5220-0015(소형브레이커 전기식) 추가

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 13:26:09 +09:00
eomsangdonandClaude Opus 5.5 ca946ebbf1 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_6
- 충돌 Router_Items 갈라 내기 권한 — sub6 쪽(층 쓰기 권한 · 시스템 관리자 한정 뺌 · PLAN 27-2c)
- Router_Combos 가 M02_Layer_Access(own_owner) 로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 13:25:26 +09:00
eomsangdonandClaude Sonnet 5.5 7ffafabb77 fix(M01): 재료 검색어 잘못 연결 고침 — 건설품셈 기계설비 로직(배관 · 덕트 · 플랜트) (PLAN 28-2b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-30 13:25:04 +09:00
eomsangdonandClaude Sonnet 5.5 5ada61bf88 fix(M01): 재료 검색어 잘못 연결 고침 — 건설품셈 건축 로직(철골 · 조적 · 수장 · 방수 · 지붕 · 금속 · 미장) (PLAN 28-2b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-30 13:25:01 +09:00
eomsangdonandClaude Sonnet 5.5 07b01f963f fix(M01): 재료 검색어 잘못 연결 고침 — 건설품셈 공통 로직(가설 · 기초 · 철근콘크리트) (PLAN 28-2b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-30 13:24:59 +09:00
eomsangdonandClaude Sonnet 5.5 8ba38e47b6 fix(M01): 재료 검색어 잘못 연결 고침 — 건설품셈 토목 로직(도로포장 · 유지관리 토목 · 궤도 · 강구조 · 관부설 · 지반조사) (PLAN 28-2b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-30 13:24:56 +09:00
eomsangdonandClaude Sonnet 5.5 a9a4631160 fix(M01): 재료 검색어 잘못 연결 고침 — 산림품셈 로직 4장 파일 · 맞는 품목이 걸리게 (PLAN 28-2b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-30 13:24:54 +09:00
eomsangdonandClaude Opus 5.5 0c5d42fb7b fix(M01): 토목 9-5-6 · 9-5-9 · 9-5-10 계수 적용 공정 · 도화 열 · 병합 칸 원문대로 (PLAN 28-2d)
- GC000516 1:1,000 수정제작 지리조사 = 신규제작 품(원문 주)
- GC000521 차로폭 계수 = MMS 자료수집 · 표준자료 제작 · 이미지처리 · 객체추출 · 구조화편집 · 도로복잡도 = 객체추출 · 구조화편집
- QC001082 수치도화 도화 열 원문대로 · GC000522 도화고급기능사 줄 · GSD 계수는 원문 주⑨㉯ 공정만 · QC001084 작업계획및준비 0.5~4㎢ 줄(1㎢ 멈춤 풀림) · 0.25㎢ 5cm 원문 합계와 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:24:06 +09:00
eomsangdon 3f0c1d774f Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-30 13:23:58 +09:00
eomsangdonandClaude Opus 5.5 b20c702c54 fix(M01): 산림 7장 로직 없음(절 누락) 10절에 새 로직 (PLAN 28-2a)
- GF000307~316: 수라집재 · 트랙터부착형·HAM200 숲가꾸기 · 타워야더 가선설치·해체 · 부착형 타워 수확·숲가꾸기 · 굴착기 우드그랩 산지집재 · 동력상하차기 집적 · 검척
- 쓰는 표의 용도.로직키 · 범위규칙 열한 표에 엔진 이름을 앞에 붙임
- 7-1-1 은 표 QF000178 에 천연림·인공림이 섞여 못 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 13:23:17 +09:00
eomsangdonandClaude Opus 5.5 f8302149a9 refactor(M02): 일위대가 조합 화면 부품 M01 → M02 옮김 — Labor_Api(층 · 키 하나 · 세 층 · 나무 · 담을 로직) · Pick · Preview · Text · css(PLAN 27-2b 앞)
- 조합 Api 가 층 길(/api/m02/layers/{layer}/combos) · 키 하나는 /api/m02/combos/key 로 — 회사 · 개인 조합도 읽음
- 로직 고르기 · 미리 보기는 /api/m02/logic-lib(로그인) 로 읽음
- M01 조합 화면 · M02 구조물 조합 쪽 부름을 새 자리로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 13:22:13 +09:00
eomsangdonandClaude Opus 5.5 ac0b55e819 fix(M01): 산림 6장 로직 없음(절 누락) 3절에 새 로직 (PLAN 28-2a)
- GF000304~306: 칡덩굴 소금처리 · 칡덩굴 뿌리제거 · 교목 시비
- 쓰는 표의 용도.로직키 · 범위규칙 세 표에 엔진 이름(이상·미만)을 앞에 붙임

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 13:22:03 +09:00
eomsangdonandClaude Opus 5.5 6c0210ebc5 feat(M02): 조합 키 하나 읽기 /api/m02/combos/key/{키} — 어느 층이든 · 그 층 읽기 권한(PLAN 27-2c 서버)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 13:20:19 +09:00
eomsangdon 209f05f802 auto: 2026-09-30 13:20 (ESD_LAPTOP) 2026-09-30 13:20:06 +09:00
eomsangdonandClaude Opus 5.5 203179f93e fix(M01): 토목 9-5-4 정사영상 정리점검 직종 · GSD 병합 칸 · 설계예 장비 · 도화 열 (PLAN 28-2d)
- QC001032 정리점검 직종 원문대로(고급기술자 0.60 · 중급기술자 0.10) · GC000510 정리점검 몫을 고급 · 중급기술자로 옮김(도엽당 합계 원문과 같음)
- QC001037 25cm≤GSD 병합 1.00 을 12 공정에 폄 · GC000510 상수 1 대신 표 찾기
- QC001040 장비 칸 — 표정~영상융합 수치사진측량장비 · 레이어추출 · 영상지도 편집 컴퓨터
- QC001054 설계예1 도화 세 직종 열(0.46 · 2.28 · 1.83)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:19:59 +09:00
eomsangdonandClaude Opus 5.5 59cb28ce58 feat(M02): 구조물 라이브러리 한 화면 · 뿌리 셋 · 층 권한 한 벌 · 남의 층 목록 · 해시 진입 (PLAN 27-1)
- 층 권한 한 벌 M02_Layer_Access — 나무 · 항목(Router_Items) · 양식(Router_Layers) 같은 규칙 · owner 인자 · 남의 층은 읽기만 · 읽기로 남의 층에 뼈대 안 씀
- 남의 층 목록 GET /api/m02/layers/{company|personal}/owners — 시스템 관리자 회사 목록 · 회사 관리자 같은 회사 사람
- ?layer= 페이지 분리 폐기 → 한 화면 뿌리 셋(마스터 · 회사 · 개인) · 뿌리 부품 TreePanel_Roots(펼칠 때 불러옴) · TreePanel 은 owner · api 를 받는 공용 부품
- 진입 해시 ?open=구조물조합|일위대가|옵션|도면&root=&owner= · 항목 고르기 [관리 화면에서 고치기]도 이 해시
- 「항목 나무」 → 「구조물 라이브러리」
- 시험 test_m02_layer_access 새로 · layer_screen · template_layers 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:19:29 +09:00
eomsangdonandClaude Opus 5.5 fd3800857d fix(M01): 산림 5장 로직 없음(절 누락) 14절에 새 로직 (PLAN 28-2a)
- GF000290~303: 관목굴취 · 교목굴취(나무높이 · 근원직경) · 뿌리돌림 · 관목식재(단식 · 군식) · 교목식재(나무높이 · 흉고직경) · 단끊기 · 표토절취 · 표토펴기 · 막갈이 · 흙부수기 · 매트 포장
- 쓰는 표의 용도.로직키 · 범위규칙 열 표에 엔진 이름(이상·이하 · 이하)을 앞에 붙임
- 5-5 · 5-26-3 은 표 조건 칸이 없어 못 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 13:18:31 +09:00
eomsangdonandClaude Opus 5.5 ae51bed23c feat(M02): 담을 로직 읽기 길 /api/m02/logic-lib — 로그인이면 로직 목록 · 갈래 · 하나 · 견본 입력(PLAN 27-2b 서버)
- 회사 · 개인 조합 편집이 M01 관리자 전용 길 없이 로직을 고르고 미리 보기
- 시험 test_m02_combo_layers 한 벌 더함(9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 13:17:57 +09:00
eomsangdonandClaude Opus 5.5 d8e5723c78 fix(M01): 산림 4·9·10장 로직 없음(절 누락) 38절에 새 로직 (PLAN 28-2a)
- 9장 27절(GF000252~278): 암파쇄 · 발파(10%) · 깎기(90%) · 기계소할 · 콘크리트·아스팔트 깨기 · 포장절단 · 측구·구조물 터파기 암 14절 · 살수 · 발파암 비탈면 고르기
- 10장 7절(GF000279~285): 인력운반 3절 · 짐내리기 · 덤프 자재운반 · 덤프운반(굴착기 적재+15ton) · 묘목 인력운반
- 4장 4절(GF000286~289): 수확베기 · 단목베기 100본·1,000㎡ · 위험목 베기
- 쓰는 표의 용도.로직키 · 4장 범위규칙 네 표에 엔진 이름(이상·이하 · 이상·미만)을 앞에 붙임(원문 설명은 뒤에 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 13:17:10 +09:00
eomsangdonandClaude Opus 5.5 16babf0515 fix(M01): 토목 9-5-4 지하시설물 공동구축 절감은 위치측량만 · 3차원 국토공간정보 공정별 계수 원문대로 (PLAN 28-2d)
- GC000502 환산연장(탐사길이)과 공동구축환산연장을 가름 — 위치측량만 절감 · 설계예 다섯 직종 계와 같음
- GC000511 P · K 는 원문 주⑦ 공정에만 · 기초데이터편집 = 면적 ÷ (0.0214 × K) ÷ 8 × 제작방법 × 세밀도 · 가시화 자료취득 = P × K × T × 제작방법 · 점군데이터 제작 계수 없음(점밀도는 9-5-4-9 공정 — 입력 점밀도 뺌) · 설계예1(건물만) 아홉 직종 계와 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:17:01 +09:00
eomsangdonandClaude Opus 5.5 354e6cd029 feat(M02): 일위대가 조합 층 저장소 · 층 길 — 마스터 · 회사 · 개인(PLAN 27-2a · 27-2c 서버)
- 새 M02_MasterTemplete_Combo_Store · Router_Combos — /api/m02/layers/{layer}/combos 목록 · 하나 · 새로 · 고침 · 지움 · 나무 · 알약 · 미리 보기 · /api/m02/combos/all(세 층)
- 키 장부 UC{회사}-###### · UP{사용자}-######(파일 머리 다음 · 지운 번호 안 씀) · 마스터 UA 는 M01 저장 길 그대로
- 나무 = 폴더 [구분, 상세구분] 깊이 2 · 빈 폴더는 머리 폴더 · 쓰는 조합 든 폴더 지우기 409
- 옛 /api/m01/combo* 길 뺌(logic/combos 만 남김) · 조합 화면 Api 는 M02 마스터 층 길로
- use-combo · 갈라 내기 · 항목 이름 · 면적 알약 · 스냅샷 원본이 세 층 조합을 읽음 · 남의 층 조합 403 · 갈라 낸 조합은 그 층에(시스템 관리자 한정 뺌)
- 시험 옮김(test_m01_combo · test_m01_combo_inputs) · 새 test_m02_combo_layers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 13:15:10 +09:00
eomsangdonandClaude Sonnet 5 effde345f7 fix(M01): 토목 1·3장 로직 일부 구현 — 트럭·소형브레이커·동력분무기 줄 더함
- 도로포장공사(1-8-9·10·11·13·14 GC000316·317·319·320·321·322·323·324·326·327): 덤프트럭(옛이름 「트럭·화물트럭」)을 지역 입력과 함께 연결
- 도로포장공사(1-9-4 GC000336): 소형브레이커(공압식) 2.7㎥/min을 GC000273(연료·조종원 없는 기종)으로 연결
- 터널공사(3-4-3 GC000350): 동력분무기 4.85㎾를 GC000272(조종원 없는 기종)로 연결

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 13:15:09 +09:00
eomsangdonandClaude Opus 5.5 161d0f214b fix(M01): 토목 9-5-1 · 9-5-4 표 주 · 비고 원문대로 (PLAN 28-2d)
- QC000918 주① 평탄지역 수직항공 사진촬영 기준 · QC000927 설계예 재료비(계획용지도) 줄
- QC000960 비고 산식 ÷ 기호 · QC000985 주① 「데이터간의 상호 상관관계를 유지하기 위하여」
- 9-5-4-5 기계비 여덟 표 비고 「S/W포함」 · QC000972 원문 두 값 어긋남 주에 적음
- QC000973 주⑤~⑦ · QC000990 주⑦⑧ · QC000976 주① 원문 문장

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:12:29 +09:00
eomsangdonandClaude Fable 5.1 7502315026 chore(M01): 의심 · 사람 확인 사용자 결정 목록(PLAN 28-3)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-30 13:11:53 +09:00
eomsangdonandClaude Sonnet 5.5 21184fa104 fix(M01): 토목 6장 관부설 주 빠짐 5건 — 공구손료 및 잡재료 인력품 요율 덧줄
6-7-1 · 6-7-3 (1%) · 6-8-1 · 6-8-2 · 6-8-4 (2%) + 원문 주 보충 (28-2c).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 13:10:41 +09:00
eomsangdonandClaude Sonnet 5.5 417fcb2b29 fix(M01): 토목 5장 강구조 희석재 사용량 칸 — 도료 사용량 25% 로 고침
5-2-3 희석재 단위 ℓ 유지, 값을 비율 식으로 (28-2c).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 13:10:40 +09:00
eomsangdonandClaude Sonnet 5.5 c105d7ff2e fix(M01): 토목 1장 도로포장 주 빠짐 8건 — 공구손료·잡재료 요율 덧줄과 원문 주 보충
1-6-2 · 1-7-1 · 1-7-2 · 1-8-2 · 1-8-9 · 1-8-13 · 1-8-14 · 1-9-2. 견본 계산으로 요율 곱 확인 (28-2c).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 13:10:40 +09:00
eomsangdon f4d383369a auto: 2026-09-30 13:08 (ESD_LAPTOP) 2026-09-30 13:08:52 +09:00
eomsangdonandClaude Opus 5.5 515e09631a fix(M01): 토목 9-4-3 디지털 도로대장 계 줄 원문대로 (PLAN 28-2d)
- QC000902 계 — 특급 (1.7) · 고급 1.0(7.3) · 중급 6.0(13.8) · 초급 3.0(12.4)(빈 특급 외업 칸이 빠져 밀렸던 것)
- QC000916 설계예 계 초급 2.4 → 4.4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:08:49 +09:00
eomsangdonandClaude Opus 5.5 af1e694c83 fix(M01): 토목 9-3 기준점 배점 비고 · 하천 50m 내업 · 구획정리 · 용지측량 주 원문대로 (PLAN 28-2d)
- QC000858 비고 — 1·2급 「연결을 위한 측량」 · 3·4급 「연결 및 현황측량에 필요한 골격측량」
- QC000871 50m 초급기능사(측량) 내업 20(원문 50(20))
- QC000879 주① · 「정리」 글 칸 · QC000879 · QC000886 주⑧ 성과 작성품 목록
- QC000889 주⑬ 성과작성품 12항목

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:07:36 +09:00
eomsangdonandClaude Opus 5.5 7716f4dd5b fix(M01): 토목 9-2 수준측량 P 위 끝 1 · GNSS 높이측량 답사선점 고급 줄 (PLAN 28-2d)
- GC000458~461 작업량 계수 P = 최소(1, 최대(0.9, 0.8+3/거리)) — 원문 15이내 1 · 견본 10km 1.10→1.00
- QC000846 · QC000849 답사선점 고급기술자 1.2 · 1.6 줄 더함 · GC000462 · GC000463 고급 식에 더함 — 원문 계와 같아짐(10.6 · 7.3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:05:15 +09:00
eomsangdonandClaude Sonnet 5 de9b3958f3 fix(M01): 산림 10·12장 로직 일부 구현 — 케이블 크레인 운전·유로폼
- GF000144(10-8-3 케이블 크레인 운전): 구분·운반기구·비고 입력을 더해 짐내리기 품(10-8-1)의 보통인부를 찾기로 연결
- GF000210(12-38 유로폼): 패널·내부패널 재료 줄과 부자재·소모자재 요율 덧줄을 더함(패널은 자재품목 「유로폼패널 600×1200㎜」 연결)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 13:04:35 +09:00
eomsangdonandClaude Fable 5.1 8bed79a277 chore(M01): 로직 1,354줄 입력 목록 · 요약(PLAN 28-1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-30 13:02:48 +09:00
eomsangdonandClaude Opus 5.5 be0317ef74 fix(M01): 토목 8-4-2 케이싱 설치 등 인력품 주② 원문 문장으로 (PLAN 28-2d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:01:47 +09:00
eomsangdonandClaude Opus 5.5 be37e29093 fix(M01): 토목 7장 준설선 부속선단 · 준설여굴 병합 칸 폄 (PLAN 28-2d)
- QC000749 14,920 줄 양묘선 「186.5 이상」(원문 4열 11~12행 병합)
- QC000771 그래브 보통토사 5.5∼9.0m 미만 0.5 · 암반 5.5∼9.0m 미만 · 9.0m 이상 0.5(원문 병합 0.5m)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:01:09 +09:00
eomsangdonandClaude Opus 5.5 434e65ad94 fix(M01): 토목 5-1 강교 제작 용접공에서 대형단품계수 뺌 · 계수 표 주 정리 (PLAN 28-2d)
- GC000373 용접공 수량 · 공장제작 제경비 식 — 원문 5-1-2 주② 대형단품계수는 철판공 · 가조립 철공에만 · 견본 용접공 1.7801 → 1.8351
- CC000119 주⑤ 끝에 붙은 「여기서, b : 부품 평균중량(㎏)」 을 CC000120 머리로
- CC000120 주③ 뒤 붙은 가조립 식 · 본체 보정 식 · ※ 문장(CC000122~127 에 이미 있음) 뺌
- CC000120 b=150 경계는 엔진 범위규칙 한계라 남김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 13:00:08 +09:00
eomsangdonandClaude Opus 5.5 5b7bf3d789 fix(M01): 공통 8장 롤러 작업효율 · 경운기 주행속도 병합 칸 폄 (PLAN 28-2d)
- CC000064 노체·축제·노상 타이어롤러 · 진동롤러 · 양족식롤러(자주식) 0.80/0.60/0.40 (원문 3~5열 8~11행 병합)
- CC000072 석재류 적재 83/57/35 · 적하 117/83/57 (원문 3~8열 3~4행 병합)
- 8-1-3 이동속도 표는 새 키가 필요해 남김(sub1 몫)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 12:57:39 +09:00
eomsangdonandClaude Opus 5.5 b18ac9a581 fix(M01): 공통 5-3-1 전용장비 규격 표 병합 칸 폄 (PLAN 28-2d)
- QC000363 둘째 줄(500미만 · 20이상)에 파일천공전용장비 「100이하」 — 원문 3열 1~2행 병합

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 12:56:31 +09:00
eomsangdonandClaude Opus 5.5 158d978434 fix(M01): 산림 10-4 중기운반 표에 빠진 자주식 「크레인(트럭 10TON)」 줄 더함 (PLAN 28-2d)
- QF000307 원문 16줄 = 자료 16줄 · 값은 이름의 규격(10 TON) · 콘크리트 믹서 줄과 같은 꼴
- GF000137 은 자주식 줄을 안 씀 · 견본 계산(t2 30분 · 트레일러) 됨

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 12:55:12 +09:00
eomsangdonandClaude Opus 5.5 50abcb894d feat(B01): 대시보드 시스템 설정 컨테이너 재구성(27-3)
- 「구조물 관리」 컨테이너 삭제 · 「시스템 설정」을 모든 역할 맨 위 한 줄 · 통째로 접힘(attachCollapsible)
- 안은 작은 카드(제목 + 한 줄 설명) 공용 양식 하나 · 역할로 숨김
  - 시스템 관리자: 마스터 데이터 · 일위대가 조합 · 구조물 조합 · 구조물 옵션 · 도면 양식
  - 회사 관리자 · 개인: 일위대가 조합 · 구조물 조합(마스터 읽기 · 회사 수정|읽기 · 개인 수정)
- 카드 누르면 #/m02-master-template?open=…&root=… · 마스터 데이터는 M01
- 시험 test_b01_settings_cards 새로 · test_m02_layer_screen 옛 대시보드 카드 시험 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-30 12:53:21 +09:00
eomsangdon 6c09be87f8 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-09-30 12:19:17 +09:00
eomsangdon dffc42381a Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-30 12:17:47 +09:00
eomsangdon a3407f56b1 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-09-30 12:17:46 +09:00
eomsangdon 3cd3ef8869 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-30 12:14:10 +09:00
eomsangdonandClaude Fable 5.1 9a43a924a7 chore(M01): 품셈 원문 대조 기록 모음 — 코덱스 · 데스크탑 둘 · 랩탑 셋 · 종합 셋(흠 799 · 표 3,033)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-30 12:11:47 +09:00
eomsangdonandClaude Sonnet 5 6f778cd900 chore(M01): 산림 대조 기록 dmain · dsub
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 11:50:57 +09:00
eomsangdonandClaude Sonnet 5.5 36f57cf722 chore(M01): sub3 대조 기록 docs/ 사본 제거 — 기록은 tmp/M01_점검/ 로 이동(git 밖)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 11:14:26 +09:00
eomsangdonandClaude Sonnet 5.5 689f232ab9 chore(M01): 건설 공통 2장 가설공사 170표 원문 대조 — 끝남 170 · 로직 흠 2
- 표 대조: 2-1 한도 ~ 2-12 공통장비 전 절 칸 단위 펼침 후 짝지음(세 단 머리 · 좌우 병합 · 칸 안 표 · 〃 단위)
- 새 흠: 강관 동바리 잡재료 5% 로직 미적용(GC000015 · GC000017, 의심)
- 점검만 · 코드 · 로직 · 자료 무변경

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 11:12:57 +09:00
eomsangdonandClaude Sonnet 5.5 01785fbcd9 chore(M01): 건설 공통 3장 토공사 100표 원문 대조 — 끝남 99 · 의심 1 · 로직 흠 16
- 표 대조: 3-2 굴착 ~ 3-11 스마트 토공 전 절 칸 단위 펼침 후 짝지음(칸 안 두 줄 · 좌우 피벗 · 병합 시공량 풀이)
- 새 흠: 암발파 소모자재 요율 미적용(GC000091∼096 · GC000100 확실) · 수중발파 잠수부 줄 빠짐(확실) · 치즐 요율 기준 · 유항 단위 · 재료량 미사용 · MG 굴착기 0.6㎥ 단위 ㎡ 오기(의심)
- 점검만 · 코드 · 로직 · 자료 무변경

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 11:06:52 +09:00
eomsangdonandClaude Sonnet 5 6406927f09 chore(M01): 산림 5장 식재 및 녹화 표 101개 원문 대조 기록 (dmain)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 11:06:50 +09:00
eomsangdonandClaude Sonnet 5.5 e31baa7eea chore(M01): sub2 점검 기록을 docs 에서 빼고 tmp/M01_점검 으로 옮김 (docs 는 여러 창이 쓰는 junction)
- docs/raw/M01_점검/sub2 삭제 (기록은 워크트리 tmp/M01_점검/sub2 에 보관)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-30 11:06:35 +09:00
eomsangdon 2e921ee1e7 auto: 2026-09-30 11:05 (EOMSANGDON-HOME) 2026-09-30 11:05:51 +09:00
eomsangdonandClaude Sonnet 5 733f70cfa3 chore(M01): 산림 12장(철근콘크리트) 원문↔자료 표 대조 완료
소요량 71표 전부 원문 12-1~12-38과 칸 단위 대조(흠 0) · 로직(GF) 56개
전부 재료 검색어·식 대조. 확실 연결 흠 11곳(문양스티로폴→단열재 ·
핀폴→영구거푸집 · 잡석→모래 · 철근 3곳→에폭시가공비 · 원형거푸집PE→
폼타이 · 강관비계철물→안전발판 · JOINT FILLER·BOX접합 접착제/시너→
석고본드·오일스테인 · 다웰바 강봉→트러스 · 탄성고무받침/다웰바캡→
무관 자재 · 고철대(공제항목)→봉땜납 구입 오인) · 의심 3곳. 다음은 13장
구조물.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-09-30 11:05:45 +09:00
eomsangdonandClaude Sonnet 5.5 1b5a6ab79a chore(M01): sub1 대조 기록을 docs 에서 제거 (tmp/M01_점검/ 로 이동)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 11:04:27 +09:00
eomsangdonandClaude Sonnet 5 9463338bdd chore(M01): 산림 4장 나무베기 표 17개 원문 대조 기록 (dmain)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Niyrmyr3LQFRxC4DNEynf5
2026-09-30 11:01:52 +09:00
eomsangdonandClaude Sonnet 5.5 5d0f0fd1d7 chore(M01): 건설 공통 5장 기초공사 101표 원문 대조 — 끝남 99 · 확실 1 · 의심 1 · 로직 흠 12
- 표 대조: 5-1 ~ 5-4 전 절(공구손료 요율표 포함) 칸 단위 펼침 후 짝지음
- 새 흠: QC000363 병합 펼침 누락(확실) · 로직 리더 비고 stale · 전용장비 선택지 160ton · 공기압축기 25 ↔ 25.5 · 요율 열 짝짓기 등(의심)
- 점검만 · 코드 · 로직 · 자료 무변경

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 11:01:51 +09:00
eomsangdon dc59f8afd8 auto: 2026-09-30 10:59 (EOMSANGDON-HOME) 2026-09-30 10:59:41 +09:00
eomsangdonandClaude Sonnet 5 e916c81e38 chore(M01): 산림 10장(자재·장비 운반) 원문↔자료 표 대조 · dsub 인계 기록
산림품셈 10장 소요량 42표 전 항목 원문과 칸 단위 대조(코덱스 완료 7표 제외) ·
로직(GF) 17개 전부 대조. 확실 흠 2(케이블 크레인 운전 보통인부 누락 ·
10-12 덤프운반 Q1/Q 계산 로직 전체 누락) · 의심 1(모노레일 기타비용 20%
상한 대 정액 적용). 나머지 로직 미구현 항목은 값 정확·참고로 구분.
다음은 12장 철근콘크리트.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-09-30 10:59:35 +09:00
eomsangdon 8b82df0d54 auto: 2026-09-30 10:56 (ESD_LAPTOP) 2026-09-30 10:56:58 +09:00
eomsangdonandClaude Sonnet 5.5 d1a565b8f1 chore(M01): 토목 4~9장 표 605개 원문 대조 점검 결과 (sub2 · 읽기 전용)
- 끝남 361 · 확실 111 · 의심 114 · 사람 확인 19 · 못 봄 0
- 흠 180줄 (확실 77 · 의심 98 · 사람 확인 5)
- 진행 · 보고 · 보고_줄글 + 구간별 원자료(_부분)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-30 10:56:57 +09:00
eomsangdon 38a7e95992 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-09-30 10:56:17 +09:00
eomsangdon 61d82cc060 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-09-30 10:56:15 +09:00
eomsangdonandClaude Sonnet 5.5 2c87f91f36 fix(B05·B06): 고르기 모달 [해제] 가 [저장] 에서 사라지던 흠 · 항목 더하기 창 빈 칸 안내 (PLAN 26장)
- 구조물 저장은 세션 초안을 보냄(횡단 보기에서 한 지정 · 해제가 종단 보기 메모리 때문에 사라지던 것)
- 관 시설 보관소는 서버 응답을 정본으로(집계 항목 칸이 없으면 미지정 · 옛 지정이 되살아나던 것)
- 항목 더하기 · 새 항목 창: 빈 이름 · 빈 단위에 칸 빨간 테두리 + 한 줄

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 10:56:13 +09:00
eomsangdon 238ec02859 auto: 2026-09-30 10:53 (ESD_LAPTOP) 2026-09-30 10:53:15 +09:00
eomsangdonandClaude Sonnet 5.5 1982821e56 chore(M01): 건설 공통 6장 · 7장 표 대조 96표 — 끝남 94 · 의심 2 · 로직 흠 4
- 6장 철근콘크리트공사 86표 · 7장 돌공사 10표를 원문과 칸 단위로 대조(sub3) — 값 · 단위 · 배수 · 비목
- 새 흠(의심 4): 전석깔기 밑수 ㎡당↔일당 · 솔 플레이트 시공량 개↔m · 기계적 이음 절단공 매핑 · 유로폼 자재표 로직 미사용
- 7-4-2 · 7-4-3 로직 없음은 코덱스 기보고

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 10:53:11 +09:00
eomsangdon cee8893e64 Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1 2026-09-30 10:51:51 +09:00
eomsangdonandClaude Sonnet 5.5 7a776cb028 chore(M01): 토목 1~3장 표 160개 원문 대조 기록 (sub1)
표 값 끝남 152 · 확실(흠) 7 · 의심 1 · 로직 흠 20줄 · 의심 1줄 — 기록만, 코드·로직·자료 변경 없음.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-09-30 10:50:48 +09:00
eomsangdon 6af6e87560 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-30 10:49:03 +09:00
eomsangdon 544d012808 auto: 2026-09-30 10:48 (EOMSANGDON-HOME) 2026-09-30 10:48:59 +09:00
eomsangdon 32cafa6d2f Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into main_desktop_1 2026-09-30 10:48:47 +09:00
eomsangdon 41a30e2b89 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-30 10:48:43 +09:00
eomsangdon ce4ea7069e auto: 2026-09-30 10:48 (EOMSANGDON-HOME) 2026-09-30 10:48:39 +09:00
eomsangdonandClaude Opus 5.5 b4100acc6d feat(M02): 관리 화면 「구조물 조합」 — 나무가 주 · 표는 참조표 · 비고 · 전구간 · 합계 숨김(PLAN 26-7a~e)
- 화면 이름 「구조물집계표」 · 「구조물 집계 양식」 → 「구조물 조합」(좌측 줄 · 상세 제목 · 안내글) · 파일 이름 · 양식 키 · API 길 그대로
- 좌측 패널 「항목 나무」 컨테이너 없앰 — 페이지가 나무를 만들어 편집 칸 왼쪽(aside)에 붙임 · 「구조물 조합」일 때만 보임 · 오른쪽 클릭 · 찾기 · 표와 서로 고름 · 고치기 전 저장 물음 그대로
- 표 부품 lockHead — 열 더하기 · 분류N 더하기 · 열 지우기 단추 없음 · 머리 · 층 이름 고치기 없음(단위 · 일위대가 · 조회 후 선택은 그대로)
- 비고 열 · 전구간 줄 · 합계 줄은 보일 문서에서만 뺌(comboView) · [저장] 때 저장본 것을 다시 끼움(comboMerge) · 양식 파일 · M02_Table_Fill 그대로
- 서버 — 화면 표 [저장](store.write screen=True)은 있던 항목의 경로 · 층 이름 · 빈 폴더를 저장본 그대로(tree.keep_paths · 브라우저 머리 안 받음) · 머리는 경로에서 다시 만듦 · 나무 고침 줄기 그대로
- 나무 같은 이름 409 는 「이미 있는 항목」 까닭 그대로 보임(판 다름 안내와 가름)
- 시험 test_m02_combo_screen 3(숨김 · 되끼움 Node · 잠금 · 나무 자리) · test_m02_tree 표 저장 약속 새로 · 이름 · 잠금 기대값 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 10:39:59 +09:00
eomsangdon 92e2784038 docs: M01 품셈 대조 중간 결과와 인계 방법 보존
사용자 지시에 따라 대조를 중단하고 기존 보고, 줄글 목록, 진행 상태와 대조 방법을 보존한다.
2026-09-30 10:38:16 +09:00
eomsangdon 551593d040 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-09-30 09:41:51 +09:00
eomsangdonandClaude Opus 5.5 2a3051fbca feat(M02): 마스터 구조물집계표 초기화 · 시험 견본화(PLAN 26-5a)
- 마스터 층 구조물 항목 51개 지움 — 나무 지우기(tree.apply delete) → 집계표 저장(store.write) 한 줄기 · 집계표 구조물 열 · SS 장부 줄 · structure/area/material 파일 153개 · 남김 = no · sta · 비고 열 · 변수 · 쪽줄 · 합계줄 · 바인딩규칙 · 자재조합 · 구조물옵션표시 · drawing · M01 · 회사 · 개인 층 · 프로젝트 복사본 · 지운 열의 열너비만 뺌
- 키 · 번호 다시 안 씀 — 장부 다음 52(새 항목 SS000052) · 지운번호 51개 기록(새 번호는 SD-000001 모양이라 옛 8자리와 안 겹침)
- 지우기 전 사본 tmp/마스터_집계표_초기화전_0930/(git 밖 · 156 파일 = 지우기 전과 같음)
- 시험 안 견본 fixtures/m02_master_sample(옛 집계표 · 장부 · 자재조합 · rv · c4 · pp_len · ms 세 파일 · 도면 줄임) + helper_m02_master_sample — 실제 마스터를 읽던 시험 일곱(table_fill · template_layers · structures_summary_snapshot · item_copy_complete · m02_tree · m02_item_inputs · spreadsheet_saved_values)을 견본으로 · M01 쓰는 곳 찾기(users_of) 시험은 이미 임시 표(combo_inputs)라 그대로
- 새 시험 test_m02_empty_master — 빈 마스터에서 항목 0 · 빈 나무 · 첫 화면 길 · 새 폴더 · 새 항목(SS000052 · SD-000001) · 지우기 · 고르기 모달 마스터 0 · 프로젝트 복사본 calc 그대로 · 원본없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 09:41:04 +09:00
eomsangdonandClaude Sonnet 5.5 58ba4545f2 feat(B05): 노선 추천 알약 여덟(영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소) · 영선 목표 기울기 칸 · 비교값 두 묶음 (PLAN 23-5b)
- 알약: 예상노선 추적 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중 계산 — 짧은 노선 · 흙 균형 뺌 · 네 칸 두 줄(좁으면 두 칸 네 줄)
- 영선 노선일 때만 「목표 기울기」 칸(criteria.target_grade_pct · 비우면 상한) · 응답에서 쓴 값을 기준값 줄에 보임
- 비교값 두 묶음: 노선 · 기울기(목표 기울기 편차 · 옆경사) / 흙 · 안전 · 비용(위험 구간 통과 · 계류 횡단 · 이격 · 공사비 어림 상대값)
- 옛 저장값 short · balance 는 예상노선 추적으로 읽음 · 시험: 새 이름 · 비교값 · 목표 기울기 · 옛 값 · 왕복에 새 넷

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 09:24:38 +09:00
eomsangdon 4d7a978d0b Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7 2026-09-30 09:14:23 +09:00
eomsangdonandClaude Opus 5.5 a718c2d67c feat(B05): 추천 갈래 다시 — 영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소(PLAN 23-5a)
- 갈래 short · balance 뺌(요청 422) · gradeline · safe · crossings · cost 더함 — 각 목표로 제약 탐색한 후보를 묶음에 넣고 제 비교값으로 고름
- 탐색 목표 셋 더함: 목표 기울기 편차(영선 · 등경사선) · 위험(옆경사 35° 이상 · 계류 버퍼 안 통과 길이) · 계류 횡단 링크 · 공사비는 상대 단가 그대로
- 비교값 더함: side_slope_avg_pct · side_slope_max_pct · hazard_m · stream_crossings · stream_offset_avg_m · grade_dev_pct · cost_index
- 공사비 상대 단가(절성토 1 ㎥ = 1 · 연장 1 m = 2 · 계류 횡단 1 곳 = 400)는 임시 상대값 · 브레인 임의 판정
- 요청 criteria.target_grade_pct(영선 목표 기울기 · 없으면 상한) · 응답 criteria.target_grade_pct
- 시험 test_route_initial 31 · 왕복 시험은 화면이 아는 갈래만(새 넷은 23-5b 뒤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 09:12:05 +09:00
eomsangdonandClaude Opus 5.5 760e43c8df feat(M02): 항목 키 체계 · 품번 새 모양(PLAN 26-4a)
- 항목 키 = 마스터 SS + 6자리 · 회사 SC{회사}- + 6자리 · 개인 SP{사용자}- + 6자리(장부 key_head · 층 폴더로 가름) · 한 번 주면 안 바뀜 · 지운 키 다시 안 씀 · 층 사이 복사 · 개인에 저장 · 새 항목은 그 층 새 키 + 본뜬 곳
- 회사 · 개인 층의 옛 SS 키는 장부를 맞출 때(처음 읽을 때 한 번) 새 모양으로 옮김(숫자 여섯 그대로 · 겹치면 다음 번호) · 옛 키는 줄 옛키 에 남김 · 옛 키로도 찾음(store.find_combo · 나무 고침 · 입력 목록 · 항목 복사 · M02_Item_Source.row_of)
- summary_item 키 검사 — SC · SP 는 그 층 · 머리 번호 = owner 일 때만 · 옛 SS 는 어느 층이든 받음
- 품번(도번 SD · 면적번 SA · 재료번 SM) 새 일련 = 붙임표 + 6자리(SD-000001) — 옛 8자리(제목행 자리) · 12장 옛 6자리(SD010101)와 모양이 달라 안 겹침 · 옛 번호는 그대로 유효 · 새 모양끼리 셈 · 지운 번호 안 씀
- 시험 test_m02_item_keys 7(층마다 키 모양 · 안 바뀜 · 다시 안 씀 · 복사 본뜬 곳 · 옛 SS 옮김 · 옛 키로 찾음 · 겹친 숫자 · 참조 키 모양 · 120 형제 깊이 6 번호 · 층마다 번호 따로 · 키대장 안 겹침) · 번호 · 키 시험 기대값을 새 모양으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 09:04:39 +09:00
eomsangdon d9ae3a1a7a Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_6' into main_laptop_1 2026-09-30 08:32:05 +09:00
eomsangdonandClaude Sonnet 5.5 b67a3be753 fix(M02): 재적용 뒤 복사본 「고침」 을 끔 — 살린 입력값 · 고른 자재는 「값있음」 으로 따로 알림 · 목록 줄에도 반영 (PLAN 26-2b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 08:31:31 +09:00
eomsangdonandClaude Sonnet 5.5 8f55366949 feat(M02): 관리 화면 항목 나무 — 좌측 폴더 나무 · 표 ↔ 나무 고름 · 오른쪽 클릭 더하기 · 이름 바꾸기 · 옮기기 · 지우기 (PLAN 26-3a)
- 좌측 「구조물 집계 양식」 아래 「항목 나무」 카드(세 층 공용 · 깊이 자유 · 검색 · 키보드 위아래 · 펼침 · F2 · Delete)
- 항목 누름 → 표 그 열 고름 + 카드 넷 · 표에서 열 고름 → 나무 표시
- 나무 고침 POST /api/m02/layers/{층}/tree · 판 409 다시 읽고 알림 · 저장 안 한 표 고침은 저장/버림/취소 물음
- 읽기만 층은 메뉴 · 키 없음 · 나무 메뉴 자리는 sub5 의 ui_template_tree 그대로
- 시험 test_m02_treepanel 8 · test_m02_layer_screen 문구 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-30 08:28:42 +09:00
eomsangdonandClaude Sonnet 5.5 542c5cc260 test(spreadsheet): 늦게 불러오는 곳에 고르기 모달 편집 화면을 더함 (PLAN 26-3b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 08:26:01 +09:00
eomsangdon 397f5d51a6 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-09-30 08:24:53 +09:00
eomsangdon fcd7191a93 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_5 2026-09-30 08:24:52 +09:00
eomsangdon 0c79b4e209 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7 2026-09-30 08:16:41 +09:00
eomsangdonandClaude Sonnet 5.5 26eac086a8 feat(ui_template): 고르기 모달 — 구조물 복사본 기준 · [재적용] · [개인에 저장] · 나무 오른쪽 클릭 새 항목 · 경로 이름 (PLAN 26-3b)
- 카드 · 편집이 그 구조물의 복사본(폴더 = 구조물 id · 관 = pipe@측점)을 읽고 고침 · 같은 항목을 고른 구조물끼리 값이 따로
- [재적용](덮이는 것을 확인 창으로 알리고 덮임 부분을 알림) · [개인에 저장](경로 · 이름 물음) · 상태 줄에 고침 · 마스터가 바뀜 · 원본 없음 표시
- 나무 개인 그룹 · 폴더 오른쪽 클릭 「새 항목」 → 돌려받은 summary_item 을 폼에 지정 · [적용] 뒤 [저장] 안내 한 줄
- B05 구조물 폼이 구조물 id 를 모달에 넘김

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 08:15:55 +09:00
eomsangdonandClaude Sonnet 5.5 2deef5a5da feat(B05): 노선 추천 — 알약 여섯(짧은 노선 · 흙 균형) · 남는 흙 비교값 · 같은 노선 알림 줄 · 비중 고정 시험 (PLAN 23-4c 화면 · 26-2c)
- 알약: 예상노선 추적 · 토공 최소 · 기울기 순한 · 짧은 노선(short) · 흙 균형(balance) · 비중 계산 — 짧은 노선 · 흙 균형은 비중 없음
- 비교값에 「남는 흙」(surplus_m3 · + 남음 / − 모자람)
- 응답 similar · band_width_m → 비교값 아래 「이 지형은 제약 안에서 갈 길이 폭 …」 한 줄 + 흐린 안내 · 없으면 줄 숨김
- 시험: 가짜 응답 · 여섯 이름 · 남는 흙 · 알림 줄 · 비중 계산 사유 · 왕복 시험에 short · balance 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 08:10:09 +09:00
eomsangdon a66df6d887 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7 2026-09-30 08:07:03 +09:00
eomsangdon 53d08087c9 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-30 08:05:41 +09:00
eomsangdonandClaude Sonnet 5.5 d6c85a5f28 feat(ui_template): 나무 오른쪽 클릭 메뉴 자리(menu · openTreeMenu) · 항목 경로 나무(폴더 깊이 자유) (PLAN 26-3b)
- TreeNode.menu = 메뉴 항목을 밖에서 주는 자리 · openTreeMenu 공용 작은 메뉴
- PickerRow.경로 · pathOf · buildTree 가 경로 앞부분을 폴더로 · PickNode.folder{layer,path}

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 08:05:12 +09:00
eomsangdon 312f4ab8b6 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-30 08:00:51 +09:00
eomsangdonandClaude Opus 5.5 c14717d0c2 feat(B05): 비중 = 탐색 비용의 가중합 · 짧은 노선 · 흙 균형 갈래 · 같은 노선 알림(PLAN 23-4)
- 비중 계산을 후보 고르기에서 탐색 한 번으로 — 몫(이격 · 토공 · 기울기)을 끝 노선 셋(추적 · 토공 최소 · 기울기 순한) 사이 범위로 크기 맞춤 · 제약은 (1 − 예상 몫)만큼 벌점(막지 않음) · 넘은 곳은 「비중 계산」 사유로 목록
- 끝값 100/0/0 · 0/100/0 · 0/0/100 = 추적 · 토공 최소 · 기울기 순한 그 선 · 예상 99 → 추적에서 5.0 m
- 새 갈래 short(연장 최소) · balance(|절토 − 성토| 최소) · 비교값 surplus_m3(흙 남음)
- 응답 similar(10 m 안인 다른 갈래 · 가장 먼 거리) · band_width_m(제약 안 띠 폭 어림)
- 후보 묶음을 새 모듈 _Pool 로 · 토공 ↔ 기울기 쓸기 11 벌을 묶음에 넣어 갈래가 사이 비중보다 제 목표에서 좋게
- 같은 상한 탐색은 간선 틀을 다시 씀 · 같은 노드는 다시 안 잼(첫 요청 엔진 7.1 → 2.2 초)
- 시험 test_route_initial 27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 08:00:42 +09:00
eomsangdon 015921c906 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-09-30 08:00:38 +09:00
eomsangdonandClaude Opus 5.5 b3b4363120 feat(M02): 프로젝트에서 만든 개인 층 항목도 경로 그대로(PLAN 26-2a · 26-2b 이음)
- M02_Item_Personal.create_item — 경로를 제목행 넷에 접지 않고 그대로(머리 줄 수는 집계표 저장이 맞춤) · 같은 경로면 「이름 (2)」 · 같은 앞마디 형제 곁에 끼움
- 시험 test_m02_tree 6(깊이 5 새 항목 · 같은 경로 이름 (2))

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 07:57:41 +09:00
eomsangdon dd2f51b521 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-09-30 07:50:27 +09:00
eomsangdonandClaude Opus 5.5 b5114ec17f feat(M02): 집계 항목 폴더 구조 서버 — 경로 · 일련번호 · 나무 고침 길(PLAN 26-2a)
- 항목 경로 = 폴더 이름 배열(깊이 자유) · 집계표 열 머리(빈 칸 건너뜀)가 곧 경로 · 열에 경로를 같이 적음 · 머리 줄 수(층)는 가장 깊은 경로에 맞춤(넷 밑으로는 안 줄임) · 빈 폴더는 집계표 폴더 칸(M02_MasterTemplete_Tree)
- 표 저장과 나무 고침 한 줄기 — 나무 고침은 집계표 문서를 고쳐 같은 집계표 저장(store.write)을 탐(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기가 함께)
- 나무 고침 길 POST /api/m02/layers/{layer}/tree(add-folder · add-item · rename · move · delete · 판 409 · 422 · 404 · 층 권한) · 읽기 GET 같은 길 {판, 폴더, items}
- 번호 = 새 항목은 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가장 큰 수 + 1) · 있던 번호 그대로
- SS 장부 = M02_MasterTemplete_Book 로 떼어 냄(Store 543 → 478) · 줄에 경로 · 이름 · 옛 종류 · 분류1~3 = 경로 앞 넷 · 경로 없는 옛 줄은 읽을 때 채움 · 옮기는 함수 migrate_paths
- 목록 길 /api/m02/items 줄에 경로 · 이름(경로 이음) · 자재 조합 · 갈라 내기 이름도 경로 이음
- 마스터 집계표 · 장부를 경로 모양으로 옮김(51 항목 · 키 · 번호 그대로 · 빈 글 머리는 null)
- 시험 test_m02_tree 5(옛 51 항목 · 옮기기 두 번 같음 · 깊이 1 · 2 · 5 · 이름 · 옮기기 · 지우기 · 마지막 항목 · 409 · 403 · 표 저장 한 줄기) · 번호 시험을 일련번호 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 07:50:20 +09:00
eomsangdon 41cf1cd195 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5 2026-09-30 07:47:31 +09:00
eomsangdonandClaude Opus 5.5 fbb214cd94 feat(M02): 집계 항목 복사본을 구조물마다 — templates/structures/{구조물 id} · 옛 항목 폴더 옮겨 받기 · 재적용(덮임 알림) · 개인에 저장 · 프로젝트에서 새 항목 · 지정 바꿈 · 삭제 때 고친 것은 _지난 (PLAN 26-2b)
- 같은 항목을 고른 구조물 둘도 도면 · 면적 · 재료 · 자재조합 · 일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 따로 가짐 · 폴더 = structure_id 또는 pipe@측점
- 옛 templates/items/{층}_{주인}_{키} 는 읽을 때 그 항목을 쓰는 구조물마다 복사(고친 값 유지) · 안 쓰이고 고친 것은 structures/_지난
- 길: 미리 가져오기 {structure, layer, owner, key} · POST …/{폴더}/reapply(refresh 옛 이름) · …/save-as {to, 경로, 이름} · …/items/new {structure, 경로, 이름, 단위} · 회사 없음 409
- manifest 원본에 경로(26-1 · 경로 칸 우선 · 옛 종류 · 분류는 건너뛰며 받음) · 원본이 바뀌거나 지워져도 복사본 · 계산 그대로(목록 마스터바뀜 · 원본없음)
- 채움(M02_Table_Fill)은 구조물 복사본의 열 정의를 읽음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 07:45:20 +09:00
eomsangdon 559d445e4e auto: 2026-09-30 07:36 (ESD_LAPTOP) 2026-09-30 07:36:32 +09:00
eomsangdon bf21676ef5 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_5' into main_laptop_1 2026-09-30 00:15:48 +09:00
eomsangdonandClaude Sonnet 5.5 818470e25f fix(M02): 카드 안내 글(도면 없음 · 선택 입력 없음 …) 글자 대비 4.5 이상 (PLAN 25-2d)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 00:14:58 +09:00
eomsangdonandClaude Sonnet 5.5 3a59f715f6 feat(ui_template): 항목 고르기 모달을 실서버에 이음 · 선택 입력 카드는 sub2 파일 한 벌 (PLAN 25-2d)
- 복사본 창구를 sub6 길 모양({항목} · {종류, 판, 내용} · PUT {판, 내용} · 409 message · refresh · calc)으로 — 가짜 응답 지움
- 입력 목록 · 항목 연결은 sub4 길(fetchItemInputs · readInputLinks) · 알약은 pillsOf
- 값 받기 카드를 M02_MasterTemplete_Inputs.ts 에 모드 view · value 로 더함(InputsCard 지움 · 두 벌 금지) · 셈은 _Inputs_Model
- 상태 줄에 복사본 계산 한 줄(계 · 빠진 입력) · 목록은 모달에서 한 번만 받아 들고 있음
- Structure.ts: sub2 의 선택 입력 카드를 ItemCards 의 extraCards 로 다시 얹음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 00:06:37 +09:00
eomsangdon 4a1a71fd3a Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5 2026-09-30 00:00:54 +09:00
eomsangdon 7c62d90d9a Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_5 (선택 입력 카드 sub2 판 · Structure.ts 는 ItemCards 위에 다시 얹음) 2026-09-30 00:00:53 +09:00
eomsangdonandClaude Opus 5.5 767adc4a42 fix(B06): 초안 되얹기는 온 칸만 · 조정값 쓰기는 저장값에서 출발(PLAN 24-4a)
- sub8 24-7a 되얹기(일부 칸)와 폼 「칸 전부」 규칙을 가름 — 되얹기는 안 온 칸 · 기슭막이 높이 · 형태를 건드리지 않음
- 기슭막이 조작값 쓰기가 세션이 없을 때 저장된 좌우 · 상하에서 출발 — 새 탭에서 폼 칸 하나만 고쳐도 저장값이 지워지던 것
- 시험 test_b06_adjust_single_form 7건(되얹기 1 추가)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-30 00:00:21 +09:00
eomsangdon 365ff5a13d Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_3 2026-09-29 23:54:12 +09:00
eomsangdonandClaude Opus 5.5 7f04bf961c feat(B06): 관 · 옹벽 칸 한 곳 — 조정 창은 손잡이 · 읽기만(PLAN 24-4a)
- 옹벽 · 세월교 · BOX 조정 창의 값 칸(셀렉트 · −/+) 걷음 — 읽기 · 십자 · 그림 끌기 손잡이만(input 0)
- 그림 끌기 손잡이: 고른 구조물을 끌면 십자와 같은 걸음 · 같은 동작 · 끄는 동안 미리 보기
- 좌측 [횡단 조정] 사본 상자 없앰 — 폼에 없던 줄(읽기 · 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이 · 다단 벽)만 폼 칸에 옮겨 붙음
- 집수정 기준측점 전 · 후를 폼 칸으로(옵션 보이기 따름) · 옮겨 붙은 줄도 옵션 보이기 규칙
- 폼 값 = 칸 전부: 비운 칸은 그림 자동 · 가게(pipes)에서도 지움 · 조정 예약(culvertopt)에 따로 담지 않음
- 시험 test_b06_adjust_single_form 6건 · 거울 시험(집수정 전 · 후 폼 칸) 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-29 23:54:12 +09:00
eomsangdonandClaude Sonnet 5.5 d94f3f78c1 feat(ui_template): 항목 고르기 모달 — 시험 · 글자 대비 · 새로 가져올 때 넣은 값 유지 · 편집 화면 높이 (PLAN 25-2d)
- test_item_picker_detail: 복사본 창구(가짜 · 약속 길 · 403 까닭) · 입력 목록 · 카드 순수 함수 · 한 벌 지킴 · 원본 층에 안 씀
- 자동값 · 확인 표시 글자 대비 4.5 이상 · 고르기 값은 원래 모양(숫자)으로 저장
- 새로 가져오기 = 원본에서 온 문서만 덮고 입력값은 남김
- 모달 폭 두 클래스로(공용 모달 폭에 안 밀림) · CAD · 시트 편집 높이 채움

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 23:49:43 +09:00
eomsangdonandClaude Sonnet 5.5 45a22c5d72 fix(B05·B06): 저장 전 관 옵션 초안이 새로고침 뒤 폼 · 카드에 살아남음(PLAN 24-7a)
원인 — 횡단 캐시 상세(section-detail)는 저장분이라 새로고침 뒤 월류 폭 · 관경 같은 스펙 값이
저장값으로 돌아오고, 폼(withSpecDefaults)은 스펙을 초안 옵션보다 앞세워 초안 3.5 대신 10 을 보임.
또 횡단 보기가 먼저 선 뒤 종단 보기가 서면 관 목록을 못 받은 채 구조물 로딩이 관을 지워 초안 옵션이 사라짐.

- 횡단 보기가 서며 관 초안 옵션을 캐시 스펙에 다시 얹음(조정창 정본인 기슭막이 높이 · 형태는 제외)
- 배수유역도가 마지막으로 알린 관 목록을 한 벌 자리에 기억해 뒤에 서는 종단 보기가 먼저 실음
- 시험 test_b06_draft_pipe_replay 1건

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-29 23:33:57 +09:00
eomsangdonandClaude Opus 5.5 b038e99d8d feat(M02): 복사본 길 모양 맞춤 — 미리 가져오기 몸 {layer, owner, key}(이름은 층 SS 줄) · 목록에 원본판 · 입력연결은 sub4 층 읽기(_Inputs.read_file)로 (PLAN 25-2b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 23:31:44 +09:00
eomsangdonandClaude Sonnet 5.5 083c12ad3a feat(ui_template): 항목 고르기 모달 상세 — 카드 넷 · 프로젝트 복사본 편집 · 뒤로 (PLAN 25-2d)
- 오른쪽 상세를 카드 넷(도면 · 면적산출 · 재료산출 · 선택 입력)으로 — M02 와 같은 카드 한 벌
- 카드 [수정] = 모달 안 편집 화면(CAD · 스프레드시트 · 선택 입력 값 넣기) · [뒤로] = 선택지
- 고침은 프로젝트 복사본만 · 안 가져온 항목은 「프로젝트로 가져와 고침」 · 마스터가 바뀜 표시 + [새로 가져오기]
- 서버 길(sub6 · sub4)이 없으면 약속 모양의 가짜 응답(이음 자리 FAKE)
- B05 구조물 폼이 projectId 를 모달에 넘김 · 모달 넓힘

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 23:31:05 +09:00
eomsangdon 98b1adac34 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_6 2026-09-29 23:27:22 +09:00
eomsangdonandClaude Opus 5.5 0d8f967262 feat(M02): 집계 항목 복사본 완전체 — 입력연결 · 입력값 · 고른자재 · 단가(닫힌 묶음) · 가져온날 · 원본판 · 복사본 길 · 새로 가져오기 · 미리 가져오기 · 복사본만으로 계산 (PLAN 25-2b · 25-2e)
- 스냅샷 나눔: 원본 모으기(M02_Item_Source) · 맞추기(M02_Item_Snapshot) · 복사본 읽기 · 쓰기(M02_Item_Copy) · 단가 묶음 · 계산(M02_Item_Prices)
- 단가.json = 담은 로직 · 하위 로직 · 요소 · 준용 · 찾기 표 · 재료 고르기 후보 · 지역 · 계약종별 후보(마스터 파일 차례 그대로)
- 길 /api/projects/{id}/items(목록 · 미리 가져오기) · /{폴더}/{종류}(GET · PUT 판 409 · 면적 · 재료 서버 계산값) · /refresh · /calc
- 고친 복사본은 구조물에서 빠져도 안 지움 · 옛 복사본은 읽을 때 채움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 23:26:23 +09:00
eomsangdonandClaude Sonnet 5.5 470d00a18c refactor(M02): 항목 카드를 공용 한 벌로 — 자료 자리 창구 · 카드 셋 옮김 · 선택 입력 카드 뼈대 (PLAN 25-2d)
- ItemSource: 문서를 읽는 자리를 인자로(M02 = 지금 층 · 고르기 모달 = 프로젝트 복사본)
- ItemCards: Structure.ts 의 도면 · 면적산출 · 재료산출 카드 그리기를 옮김(DOM · 동작 그대로) + 덧붙는 카드 자리
- InputsCard: 선택 입력 카드 뼈대(로직마다 묶음 · view · value 모드 · link 는 25-2c)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 23:18:47 +09:00
eomsangdonandClaude Sonnet 5.5 db1bb9aa87 feat(M02): 구조물집계표 넷째 카드 「선택 입력」 — 로직마다 입력 목록 · 자리 고르기(면적 · 재료 · 선택 · 안 씀) · 알약 줄에 뜸 (PLAN 25-2c)
- 카드 = 로직 이름 · 결과단위 묶음 아래 입력 줄(이름 · 설명 · 단위 · 고르기/범위 · 자동값 흐리게) + 자리 상자 · 처음은 모두 안 씀
- 저장 = 집계표 [저장]에 이어 PUT input-links(자동저장 없음) · 읽기만 층은 상자 잠금
- 면적 · 재료로 고른 입력은 그 시트 알약 줄에 「로직 이름 · 입력 이름」 · 자재 모드에서도 [목록에서 빼기] 막음(고정)
- 알약 줄 시험을 합계 줄 아래 자리에 맞춤(25-0 뒤 어긋난 것)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-29 23:18:13 +09:00
eomsangdonandClaude Opus 5.5 057d3976f9 feat(M02): 항목 선택 입력 층 자료 틀 — 입력 목록 · 항목 연결 파일 · 알약(PLAN 25-2a)
- GET /api/m02/layers/{layer}/items/{key}/inputs — 항목 일위대가가 담은 로직마다 입력 전부(합치지 않음) · 자동값(master_auto) · 빠진로직 · 일위대가 없으면 빈 목록 · 층 읽기 권한(시스템 층 = 로그인 누구나)
- GET|PUT …/items/{key}/input-links — 층 폴더 inputs/<열id>.json {판, 줄: [{로직, 입력, 자리, 알약?}]} · 없으면 {판: null, 줄: []} · 판 다르면 409 · 없는 로직 · 입력 · 모르는 자리 · 두 번은 422 · 알약 키 입력|로직|입력 은 서버가 붙임 · 일위대가가 바뀌어 사라진 연결은 읽을 때 사라짐 표시
- GET …/items/{key}/pills?place=면적|재료 — 조합 물량 알약(면적만) + 그 시트로 이은 입력 알약(이름 = 입력 · 로직 이름)
- 열 지우기 · 분류 더하기 복사 · 항목 개인 복사가 연결 파일을 같이 다룸(판 1 부터) · 스냅샷용 읽기 함수 read_file · copy_between
- M01 은 읽기만(Store · Store_Combo · master_auto)
- 시험 test_m02_item_inputs 4건(실제 마스터 항목 셋 로직 · 입력 · 자동값 = M01 · 연결 저장 · 409 · 422 · 사라짐 · 알약 · 권한 · 복사 · 지우기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-29 23:17:33 +09:00
eomsangdonandClaude Sonnet 5.5 fe30cdbbd8 feat(spreadsheet): 알약 줄을 합계 줄 아래 맨 끝으로 (PLAN 25-0)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-29 22:45:07 +09:00
eomsangdon 978ccde8b1 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-09-29 22:39:21 +09:00
eomsangdonandClaude Opus 5.5 473d959d60 refactor(B05·B06): 진짜 한 벌 · 가운데 보기 전환(PLAN 24-3b)
- 좌측 패널 · 아래 단추 넷 · 종단도 · 구조물 배치 폼을 페이지에 한 번만 만듦(design_shared) — 두 보기가 같은 노드
- 보기는 가운데 위 칸만: 종단 보기 = 3D(design_view_profile) · 횡단 보기 = 횡단 카드(design_view_cross · 처음 보일 때 그림)
- 옛 B05_Profile_UI_Page · B06_Section_UI_Page 삭제 · 한 벌이 받은 조작은 지금 보기로, 관 목록 · 횡단 재계산 알림은 두 보기 모두로
- 진행 단계 종단설계 · 횡단설계 두 칸이 보기를 따라 켜짐 · 누르면 주소만 바꿈 · 제목도 따라감(setTitle · setActiveStep)
- [저장] · [확정] 한 벌 — 두 보기 몫을 모아 saveDesign 한 번 · 종단 보기 [확정] 켬(횡단 보기를 숨긴 채 세워 횡단 자료를 얻음)
- [초기화] · 계획노선 편집은 같은 페이지라도 다시 세움(remountRoute)
- 덧붙임: 방향키는 초점 있는 쪽만(조정창 ↔ 종단도 계획고) · 횡단 보기 계획고 편집에도 [3D 업데이트] 대기 표시 · 종단 보기 폼 관 옵션을 횡단 캐시에도 얹음 · 종단 보기에서 횡단 조정 칸 가림
- 나눔: Cross_View 756→658(_Cross_View_Frame) · Page_Structures_Panel 700→547(_Structures_Spec) · 옛 B05 Page 786 → design_view_profile 638(_Page_Latest · _Page_Stations)
- 시험: test_design_view_parity 한 벌 기준(한 패널 · 보기 오가도 같은 노드) · page_single · profile_single · save_channels · save_recompute 등 옮긴 자리로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-29 22:38:35 +09:00
eomsangdon 7ad36c122f Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-09-29 21:59:11 +09:00
eomsangdonandClaude Opus 5.5 d74eecc9f6 fix(B06): 숨은 횡단 보기의 조정창이 다른 보기의 방향키를 먹지 않음(24-3a)
- 조정창 방향키 듣기가 창이 숨은 보기에 있으면 넘김(Cross_Panel_Base · Cross_Structure_Panel 한 줄씩)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 21:58:17 +09:00
eomsangdon 19056e63a9 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-29 21:55:36 +09:00
eomsangdonandClaude Sonnet 5.5 a2340c4d6d fix(B05): 3D 코리도 구조물 — ringsAt 를 pushSwept 앞에 선언해 ReferenceError(TDZ)를 없앰 (PLAN 24-6a)
- 원인: e371c275(2026-08-28)부터 기슭막이 단 반복이 ringsAt 선언 전에 pushSwept 를 불러 기슭막이 있는 프로젝트의 3D 코리도가 서버 사전 생성 · 브라우저 폴백 모두 안 만들어짐
- 시험: 선언 차례(ringsAt · pushSwept · 기슭막이 단 반복)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
2026-09-29 21:55:29 +09:00
eomsangdonandClaude Opus 5.5 90a2cb9a11 refactor(B05·B06): 한 페이지 뼈대 — 주소 둘이 같은 페이지(design_page) · 같은 페이지 안 이동은 보기만 바꿈 · 떠날 때 3D · 종단도 · 횡단 정리 · 숨긴 3D 그리기 멈춤 · 창 키 듣기 뗌 · 뷰어 나눔(771→541)
- 라우터: 여러 주소 페이지(MountedPage) 길 · 같은 페이지면 셸도 다시 안 그림 · 떠나면 dispose · 늦게 끝난 옛 렌더 버림
- design_page: 보기 약속 DesignPageView(mount 중간 단계 · mountCenter 끝 모습 + shared 한 벌 자리) · host.onShow · onHide · onDispose
- 3D 뷰어: setActive(루프 멈춤 · 창 포인터 뗌) · dispose 가 WebGL 문맥까지 놓음 · 보관 지형은 남김 · 코리도 조각을 Viewer_Corridor 로
- 종단도: setActive(창 키 듣기 뗌 · 보일 때 초안 · 판 다시 읽음)
- 겹친 알약: 같은 측점이면 누른 알약을 그대로(24-6b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 21:39:31 +09:00
eomsangdon 76676b78fc Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_8 2026-09-29 21:17:20 +09:00
eomsangdon 5167c0ae6d Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-29 21:17:17 +09:00
eomsangdonandClaude Sonnet 5.5 8a9456579a refactor(B05·B06): 측점 · 구조물 고름을 selection 한 곳으로 · 관 찾는 거리 PIPE_MATCH_M 한 값 · 횡단 카드 누름이 옹벽 · 세월교 · BOX · 기슭막이 폼을 엶 (24-2d)
- 목록 · 종단 알약 · 3D · 횡단 카드 · 벽 어디서 골라도 selectDesign 한 길로 세션에 적음(구조물 id 포함) — B05 목록 구조물 고름도 B06 으로 이어짐
- 폼이 열 항목 풀이 resolveSelection 한 곳(관 먼저 · 구조물 다음 · 가장 가까운 것) — 공용 폼의 0.05 없앰
- B06 벽 · 구체 누름은 showAtChainage 하나를 탐(옛 관 전용 showPipeAt 없앰) · 벽 누름 중 폼이 카드를 먼저 고르는 이중 토글 막음
- B06 진입 때 구조물 id 로 카드를 찾고, 못 찾으면 다시 부르게 함 · B05 진입 때 구조물 id 가 있을 때만 구조물로 연다
- 시험 test_b05_b06_selection_single 21

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-29 21:02:55 +09:00
eomsangdonandClaude Opus 5.5 dd80662750 refactor(B05·B06): 옛 B06 계획선 편집 껍데기 정리 · 유토 뱃지 한 계산(PLAN 24-2a 덧붙임)
- B06_Section_UI_Page_Grade_Edit.ts 삭제 · 페이지 배선 · Section_View setGradeEdit · LegacyGradeEdit 제거
  (▲▼ 는 종단도 한 벌 · 계획고 가게는 노선마다 한 벌 — useProfileEditStore 도 걷음)
- 구조물 폐회로 면적을 재계산 창구(refreshCrossDesigns) 한 자리에서 얹음 — B05 · B06 유토 뱃지 같은 값
  (실측 전 B05 −14,144.5 · B06 −12,533.2 → 후 두 보기 −12,993.7 · B06 편집 뒤 −12,952.9)
- 시험: test_masshaul_single_source 에 한 자리 잠금 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-29 21:02:02 +09:00
eomsangdon ac660e12c7 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-09-29 20:53:10 +09:00
eomsangdonandClaude Opus 5.5 acd30ba32c refactor(B05·B06): 종단도 한 벌 마무리 — 배수유역도 판(createDrainageSide)을 두 보기에 끼움 · 켬끔 단추 · 흙덮기 강제 값 한 칸 · 계획고 초안 키를 등록부(alignment-draft)로
- 두 보기 같은 오른쪽 판 · 같은 켬끔 값(drainage-visible) · 단추는 도구줄 오른쪽(Profile_SideTool)
- B06 배수유역도 노선 평면선 = 최신 노선 캐시(없으면 한 번 읽기)
- 최소 흙덮기 강제 여부를 두 보기가 한 칸(profile-enforce-min-cover)에서 봄 — B06 막기 동작
- 계획고 초안 b05-profile-alignment-draft:{노선} → aislo:draft:alignment-draft:{프로젝트}:{노선}(옛 값 옮겨 받음) · 되돌리기 사진 · [초기화] 쓸기 포함
- 세로 창 고정을 _Profile_Drag 로(design_profile 684줄) · 시험 test_design_profile_single 6건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 20:47:40 +09:00
eomsangdonandClaude Sonnet 5.5 308c20fd9c refactor(B05): [초기화] 본문을 design_reset 한 함수로(sub4 판 위에서 · 24-2b)
- resetDesignAction 은 design_reset.resetDesign 을 부름 · B06 [초기화]와 같은 함수

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 20:45:32 +09:00
eomsangdon 507a216373 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-09-29 20:41:56 +09:00
eomsangdonandClaude Sonnet 5.5 a8ab28ca17 refactor(B05): Page_Actions 의 [초기화] 본문을 옛 자리로 되돌림(sub4 저장 한 줄기와 충돌 피함)
- resetDesignAction 은 sub4 판 위에서 design_reset 호출로 다시 바꿈(다음 커밋)
- B06 [초기화]는 design_reset 을 그대로 부름

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 20:41:51 +09:00
eomsangdon 6ad33cd105 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5 2026-09-29 20:41:15 +09:00
eomsangdon 692a2903ef Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-09-29 20:41:12 +09:00
eomsangdon dbcc19da51 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_5 2026-09-29 20:41:11 +09:00
eomsangdonandClaude Sonnet 5.5 7d92298b03 test(B05·B06): 좌측 패널 같음 지킴 시험 · 아래 단추 한 줄 배치(24-2f)
- test_design_view_parity.py 10건 — 두 보기 공용 칸 DOM · 단추 넷 차례 · 보기 전환 글자 · 페이지 파일이 좌측 칸 · 단추를 직접 만들면 빨강
- 아래 단추 글자가 두 줄로 꺾이지 않게 design_dock.css

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 20:41:02 +09:00
eomsangdon adb822e0b2 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-29 20:28:28 +09:00
eomsangdonandClaude Opus 5.5 38aae89b2d refactor(B05·B06): 종단도 한 벌 — B05 종단 패널 본체를 design_profile(createDesignProfile)로 옮김 · B06 위 종단을 같은 종단도(가운데 아래 · 카드 위 칸 스크롤)로 바꿈
- A00_Common/design/design_profile.ts 본체 · design_profile_slots.ts 판 자리 약속 · Profile_Panel 827 → 감싸개 37줄(Guard · Drag 로 나눔)
- B06: 옛 붙박이 종단(Section_View_Draw · Chart · Panel) 걷음 · Section_View_Profile 이음(선택 · 구조물 편집 · 카드 갱신 · 최소 흙덮기 관 목록)
- 크기 · 접기 키 하나(profile-height · profile-collapsed) · 옛 section-panel-* 값 옮겨 받음
- 소스 문자열 시험 넷을 옮긴 자리로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 20:28:15 +09:00
eomsangdon c471133cd0 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-09-29 20:24:05 +09:00
eomsangdon a8bae4e693 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-09-29 20:24:03 +09:00
eomsangdonandClaude Sonnet 5.5 8d3050c5ac feat(B05): 노선 추천 모달 다듬기 — 라벨 패널 위로 · 미리 보기 지도를 편집 창 확대·끌기·회전 코드로 · 비중 세로 막대 · 제약 4행 · 글자 키움 · 도구 막대 구분선 자리(PLAN 23-3a~d)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-29 20:22:49 +09:00
eomsangdonandClaude Opus 5.5 17212254b9 fix(B05): 비중 끝값을 그 갈래와 같은 노선으로 — 예상 100 = 예상노선 추적 · 기울기 비교 순서를 기울기 순한과 같게(PLAN 23-3e)
- 비중 예상 100 · 토공 0 · 기울기 0 이면 예상노선 추적 선 그대로(전에는 후보 중 이격 최소 1153.26 m · 추적과 41.4 m 떨어짐)
- 사이 비중은 제약을 지킨 후보에서만 가중합으로 고름(추적 선은 끝값에서만 — 평지에서 사이 비중이 제약 넘는 추적 선을 고르던 것 막음)
- 비중의 기울기 몫 = 최대 먼저 · 평균은 같을 때만(기울기 순한과 같은 순서)
- 시험: 끝값 셋이 그 갈래와 거리 0.01 m 미만 · 노드 같음 · 비중을 옮기면 그 목표 값 단조(합성 언덕 · 랩탑_보조)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-29 20:21:32 +09:00
eomsangdon 0a04660932 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_7 2026-09-29 20:19:23 +09:00
eomsangdonandClaude Sonnet 5.5 789eddf460 Merge origin/sub_laptop_6 into sub_laptop_7 — _Drainage_Side 충돌은 sub6 의 DesignSideFactory 모양에 맞추고 켜고 끄기를 살림
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
2026-09-29 20:18:43 +09:00
eomsangdonandClaude Sonnet 5.5 df0240eb2f refactor(B05·B06): 측점 · 구조물 고름 한 곳 뼈대 — design_selection(PIPE_MATCH_M · selection 읽기 · 쓰기 · 구독) (24-2d)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-29 20:17:39 +09:00
eomsangdonandClaude Sonnet 5.5 2c520537b6 refactor(B05·B06): 좌측 칸을 등록부로 · 아래 단추 넷 · B05 Panel 나눔(24-2b)
- B05 좌측 패널(714줄)을 Fields · Settings · Route · View · Sections 로 나누고 createRoutePanel 은 등록부로 세움
- B06 좌측 칸(횡단 보기 설정 · 표준 횡단면 설정)을 등록부로 · 페이지는 createDesignPanel 만 부름
- 「구조물 배치」는 design_sections 한 정의로 두 보기
- 아래 단추 [초기화][저장][확정][보기 전환] — B05 [확정]은 저장 흐름 이음 전까지 꺼 둠
- [초기화] 는 design_reset 한 함수를 두 보기가 부름
- 칸 DOM 은 고치기 전과 같음(전후 DOM 대조)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 20:17:35 +09:00
eomsangdon 3b674dc1b2 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7 2026-09-29 20:17:02 +09:00
eomsangdonandClaude Opus 5.5 7760dcd777 refactor(B05·B06): 설계 가게 한 곳 · 저장 한 줄기(PLAN 24-2a)
- A00_Common/design/design_store.ts — useDesignStore(projectId, routeId) get · set · subscribe
  (structures · pipes · alignmentDraft · sectionDetail · selection) · 밑은 b_page_state 등록 키
  (selection 등록 · alignmentDraft 는 옛 키 감쌈 — 브레인 판정 B)
- A00_Common/design/design_save.ts — saveDesign(ctx, {confirm}) 한 줄기:
  관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · B05 [임시저장] · B06 [저장] · [확정]이 같은 차례
- 계획고 초안 가게 노선마다 한 벌(B05 Profile_Edit 주인 · B06 Grade_Edit 는 useProfileEditStore)
- 관 목록 주인 = 가게 pipes(Pipes_Draft 창구)
- 시험: test_design_store_save 추가 · save_channels · save_recompute · save_roundtrip 새 차례로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-29 20:15:28 +09:00
eomsangdon 40e70c38e4 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-29 20:15:26 +09:00
eomsangdonandClaude Sonnet 5.5 79f1a52a3e refactor(B05·B06): 배수유역도 판 창구가 자기 모양(CSS)을 들고 옴 — 어느 보기에 끼워도 같은 판 (PLAN 24-2e 1단계)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
2026-09-29 20:15:13 +09:00
eomsangdon 01062fcdb6 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_3 2026-09-29 20:08:49 +09:00
eomsangdon 5717d3cabd Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_2 2026-09-29 20:08:46 +09:00
eomsangdonandClaude Opus 5.5 3068e03dc1 refactor(B05): 배수유역도 오른쪽 판 첫 판 — createDrainageSide(지금 동작 그대로 · 판 자리 약속 모양)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 20:08:29 +09:00
eomsangdon 7beeb089d1 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7 2026-09-29 20:06:15 +09:00
eomsangdonandClaude Opus 5.5 0ff37c9b97 refactor(B05·B06): 종단도 한 벌 판 자리 약속 — A00_Common/design/design_profile.ts 뼈대(DesignView · side 판 · overlay 판 · createDesignProfile 모양)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 20:06:12 +09:00
eomsangdonandClaude Sonnet 5.5 34b0ec6e2a refactor(B05·B06): 배수유역도 판 홀로 서기 뼈대 — _Drainage_Side(끼우는 창구 · 켜고 끄기) · _Drainage_Report(관 보고) 떼기 · Panel 700 → 660줄 (PLAN 24-2e 1단계)
- createDrainageSide(host) — 종단쪽 이음은 DrainagePanelCallbacks 약속으로만 · setVisible · 켜고 끄는 단추
- 관 보고 만들기를 _Report 로 · DrainagePipeReport 형을 _Panel_Types 에 이름 붙임 · 관 요청 몸통 pipeRequest 하나로
- 시험: 종단 · B06 를 안 가져옴 · 700줄 · 관 보고 셈 · 켜고 끄는 창구

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
2026-09-29 20:05:59 +09:00
eomsangdonandClaude Sonnet 5.5 439f605ba6 refactor(A00_Common): 좌측 칸 등록부 · 아래 단추 뼈대(design_panel · design_dock · 24-2b 1차)
- registerPanelSection · createDesignPanel · createDesignDock · DesignView(24-1 약속 그대로)
- 아래 단추 넷 [초기화][저장][확정][보기 전환] · 글자 키 Design_Btn_ToCross · ToProfile
- 페이지 연결은 다음 커밋

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 20:05:49 +09:00
eomsangdon cd26699d21 Merge remote-tracking branches 'origin/sub_laptop_5', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_3 2026-09-29 19:53:12 +09:00
eomsangdon 812d969999 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-29 19:53:10 +09:00
eomsangdonandClaude Sonnet 5.5 b492a9195a feat(B05): 계획노선 편집 창 — 측점 라벨 글자 · 호를 눌러도 고름(22-2 · 22-3)
- 누르기(Pointer)에 라벨 상자를 넘기고 눈금 다음에 라벨 · 호 갈래를 더함
- 호를 누르면 교각점을 누른 것과 같은 곡선 · 같은 패널(끌기 없음)
- 우선순위 점 · 손잡이 > 눈금 > 라벨 > 호 > 등고선

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 19:53:00 +09:00
eomsangdonandClaude Opus 5.5 77d3ffeee6 fix(B05): 초기노선 추천 갈래가 한 노선으로 모이던 흠 — 갈래마다 제 목표 비교값으로 고름 · 예상노선 추적 = 전처리 초기값(PLAN 23-1)
- 원인: 가파른 지형(옆경사 평균 67 %)에서 4 m 링크의 14 %뿐이 상한 안 · 반지름 규칙까지 지키는 길이 없어 풂(상한 29 %) · 풂 탐색 벌점(1 m 당 500 × 넘은 비율)이 목표(1 m 당 평균 1)를 덮어 네 갈래가 「넘은 기울기 합 최소」 한 길로 · 탐색 목표(4 m 링크)와 비교값(측점 20 m · 절성토)이 어긋나 벌점만 낮춰도 순서가 안 맞음 · follow 는 이격 최소 탐색이라 초기값과 다름
- 후보 묶음(목표 비중 셋 × 벌점 50 · 5 + 센 벌점 기준 길 · 엄격하면 비중마다 상한 단계)을 한 번 짜고 갈래는 비교값으로 고름 — 토공 최소 = 절성토 합 최소 · 기울기 순한 = 최대(같으면 평균) 최소 · 비중 = 이격 · 절성토 · 기울기를 후보 범위로 정규화한 가중합 · 기준 길보다 못 지킨 곳이 많은 후보는 뺌
- follow = build_planned_polyline(예상노선) — planned_route_initial.csv 와 같은 선(1078.01 m) · 넘은 곳은 「예상노선 추적」 사유로 목록
- 탐색을 B05_Profile_Engine_RouteInitial_Search.py 로 나눔 · 상태 그래프 한 번만 짜고 탐색마다 간선만 거름 · 막는 기울기 이분 탐색은 BFS · 후보 묶음은 같은 입력이면 프로세스 메모리에서 다시 씀
- initial_route 를 B05_Profile_Router_RouteInitial.py 로 나눔(Router_Replan 712 → 630줄, 같은 router 에 등록) · 없는 프로젝트 500 → 404
- 시험 test_route_initial 20(추종 = 초기값 · 갈래별 목적 순서 · 랩탑_보조 실데이터 갈래 차이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-09-29 19:51:50 +09:00
eomsangdonandClaude Opus 5.5 ea4cb043cd feat(B05): 계획노선 편집 창 고정 표시 · 시점 · 종점 고정(PLAN 22-4 · 22-5)
- 고정 한 벌 — 시점 · 종점(기본 고정) · 교각점을 한 칸 줄로 봄 · 저장 · 읽기는 _RouteEdit_Lock.ts(readRouteLocks = 초기노선 계산에 넘길 한 곳)
- 곧은 선은 양 끝 · 호는 교각점과 앞뒤 점이 모두 고정일 때 고정 색(--map-route-locked · 밝음 #db2777 · 어두움 #f472b6) · 고정 점도 같은 색
- 시점 · 종점을 누르면 고정 단추만 뜸 · 풀면 끌어 옮김 · 고정이면 끌기 · 지우기 막힘
- 되돌리기 · [취소] · 다시 열기 · [확인] 뒤에도 고정 유지(pref 통 · 좌표로 되짚음) · [확인] 몸통은 그대로
- 시험 test_b05_routeedit_lock 5건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 19:46:46 +09:00
eomsangdonandClaude Sonnet 5.5 be93410299 feat(B05): 노선 추천 — 되돌리기 · 다시하기가 「현재 추천」 글을 함께 되살림(걸음마다 추천을 실음) (PLAN 23-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
2026-09-29 19:45:22 +09:00
eomsangdon 77754ad5f0 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7 2026-09-29 19:40:53 +09:00
eomsangdon 3b00f19dc3 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-29 19:40:50 +09:00
eomsangdonandClaude Sonnet 5.5 399c3a11ea fix(B05): 노선 추천 — 알약 이름 「기울기 순한」 · 적용 알림 글 다듬음 · 도구 막대 구성 시험 추가 (PLAN 23-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
2026-09-29 19:40:43 +09:00
eomsangdonandClaude Sonnet 5.5 22eaed6509 fix(B05): 계획노선 편집 창 파란 반폭 선 길이를 「횡단 반폭」 적용값 양쪽으로 (PLAN 22-1)
- 횡단 미리보기 요청에 적용 반폭을 실어 보내고 서버가 덮은 반폭을 되돌려 줌
- 파란 선 좌·우 길이 = 그 반폭 (원지반 선 범위와 같은 값) · 적용한 적 없으면 서버 기본값
- 시험 test_b05_routeedit_cross_width.py 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-29 19:35:02 +09:00
eomsangdon 998660c342 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-09-29 19:33:10 +09:00
eomsangdonandClaude Sonnet 5.5 90194b2849 feat(B05): 계획노선 편집 창 — 측점 라벨 · 호 고르기 판정 함수(22-2 · 22-3 1차)
- 측점 라벨 글자 상자를 그릴 때 기록(drawStationTicks 의 onLabel · B04 지도 · 배수유역도 동작 그대로)
- stationAtScreen 이 눈금 다음 라벨 상자를 봄 · 솎여 안 보이는 눈금은 안 잡음
- 곡선 호 점열(curveArcVertices)과 점-호 판정(arcAtScreen) 더함
- 시험 resources/tester/test_b05_routeedit_pick.py 8건
- RouteEdit.ts 배선은 sub6 나누기 뒤 새 파일에 잇는 다음 커밋

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-29 19:32:57 +09:00
eomsangdon e7ee049548 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-29 19:28:34 +09:00
eomsangdonandClaude Sonnet 5.5 074d9aec13 feat(B05): 계획노선 편집 창 — [노선 추천] 모달(알약 넷 · 미리 보기 지도 · 비중 · 제약 칸) · 초기화 = 전처리 초기값 노선 · 연 상태로 단추 뺌 (PLAN 23-2)
- 도구 막대: 추천 묶음을 빼고 「1m 등고선」 왼쪽에 구분선 + [노선 추천] 하나 · 「연 상태로」 뺌 · 「현재 추천: ○○」 표시
- 모달(_Recommend): 알약 넷(예상노선 추적 기본) · 비중 슬라이더 · 제약 칸 · 미리 보기 지도 · 비교값 · 못 지킨 구간 · [적용] · [닫기]
- [초기화]: 재계산 없이 GET /route/plan 의 initial_route(전처리 초기값 노선)를 놓음
- 서버: GET /route/plan 에 initial_route(노드 · 곡선) 추가
- 시험: 헬퍼 · 왕복 시험 갱신(추적 표기 · initial_route · 지도 선)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
2026-09-29 19:28:20 +09:00
eomsangdonandClaude Opus 5.5 5b41cfaf01 refactor(B05): 계획노선 편집 창 누르기 · 끌기 · 놓기를 _RouteEdit_Pointer.ts 로 떼어냄(697 → 529줄 · 동작 그대로)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 19:20:52 +09:00
eomsangdonandClaude Sonnet 5.5 eb387d5f84 feat(B05·B06): 「구조물 옵션 보이기」에서 끈 옵션을 배수관 · BOX암거 · 세월교 · 기슭막이 하위 폼에서도 가림(21-10 흠 8)
- 시설 폼 칸에 옵션 키 표식(data-option-keys) · 끈 키의 칸은 가림 · 칸이 다 가려진 줄은 줄째
- 키가 여럿인 칸(본체 규격 · 유입구 구조)은 키가 다 꺼졌을 때만 가림 · 독립 기슭막이는 유입 · 유출 채널 키로 넓힘
- 숨긴 칸은 칸 값(제안값 채우기 포함)이 아니라 폼이 받은 옛 값을 저장에 그대로 실음 · 이 폼에는 필수 검사가 없음
- 가리기 코드는 새 파일(_Facility_Hidden)로 분리해 Facility.ts 700줄 아래 유지
- 시험 3개 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 14:20:06 +09:00
eomsangdon 8cd4a78efc Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-09-29 13:45:24 +09:00
eomsangdonandClaude Sonnet 5.5 5f43ef8541 fix(B05·B06): 집계 항목 지정이 폼 검증에 막혀 초안에 안 들어가던 흠 · 저장 전 B05→B06→B05 에서 관 지정이 옛값으로 돌아가던 흠(21-10 흠 2·3)
- 흠 2 고치는 구조물의 집계 항목 지정 · 해제는 필수 옵션 · 측점 검증을 거치지 않고 기록에 바로 반영(commitSummaryOnly) · 관 · 새 항목은 종전 흐름
- 흠 3 배수유역 화면이 들어올 때 세션 초안 관 목록이 있으면 그것으로 세움(초안이 없으면 종전대로 정본)
- 시험 2개 추가(집계 항목 전용 반영 · 초안 우선 진입)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 13:44:39 +09:00
eomsangdonandClaude Sonnet 5.5 5b04f453a6 fix(ui_template): 나무 화살표 · 줄 수 글자 대비 · 항목 고르기 툴팁 · 자리표 이름 (PLAN 21-10 흠 5~7)
- 나무 화살표 · 줄 수와 항목 고르기의 흐린 글을 var(--color-text-secondary) 로 — 밝은 테마 대비 3.08 → 6.3 이상
- M01 왼쪽 목록 줄 수도 같은 공용 클래스(ui-tree__count)로 — 밝은 테마 2.64 → 5.41
- 항목 고르기: 잎 툴팁 = 층 이름 + 키 · 「새 분류N」 자리표는 이름 · 나무 이름에서 뺌 · 관리 화면 단추는 개인 항목만(확인)
- ghost 단추는 두 테마 모두 6.85 이상이라 색 변경 없음 — 다크 1.06 은 ORCA 탭이 색 전환을 멈춰 생긴 측정값
- 자료 시험에 자리표 · 툴팁 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-29 13:36:36 +09:00
eomsangdonandClaude Sonnet 5.5 22f3df9476 fix(M02): 빈 층에서 첫 열 만들기 · 읽기만 표의 편집 단추 모두 숨김 (PLAN 21-10 흠 1 · 4)
- 표 부품 — 열 0 이면 [열 더하기] 가 켜져 첫 열을 만들고 머리 글 고치기를 바로 엶(종류/분류 는 / 로 이음)
- 표 부품 `readOnly` — 단추 줄 · 안내 글 숨김 · 칸 · 머리 · 층 이름 고치기 · 열 너비 끌기 · Delete 막음 · M02 가 읽기만일 때 넘김
- 시험 test_m02_layer_screen.py 7(첫 열 Node 실행 · 읽기만 자리)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-29 13:36:35 +09:00
eomsangdonandClaude Sonnet 5.5 90e478fe0b fix(M02): 자재 조합 이름 오류 글 — 파일명 · 키 없이 쉬운 말 한 줄 (PLAN 21-10 흠 7 서버)
- 같은 이름 · 60자 넘는 이름을 틀 검사 앞에서 「이름」 한 줄로 막음
- 오류 글에 이름은 30자에서 자름(…)
- 시험: 겹침 글 · 긴 이름 자름 · 60자 넘음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-29 13:31:59 +09:00
eomsangdon 5cb65dff16 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1
충돌 셋(UI_Side · UI_Style · locale_m2)은 자리만 다른 같은 내용 — 이미 받은 배치를 취해 한 벌로 풂(중복 없음).
2026-09-29 12:39:21 +09:00
eomsangdon 64e0759a25 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1
충돌 셋(UI_Side · UI_Style · locale_m2)은 같은 내용의 자리만 다름 — sub_laptop_2 의 배치를 취해 한 벌로 풂(중복 없음 · 항목 고르기 문구 그대로).
2026-09-29 12:38:51 +09:00
eomsangdon 9f22a6b467 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_1
충돌 하나(UI_Side)는 sub_laptop_4 의 층별 구성(옵션 보이기 · 도면 양식은 시스템 층에만)을 취해 풂.
2026-09-29 12:34:44 +09:00
eomsangdon 78dc1075b6 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1 2026-09-29 12:34:06 +09:00
eomsangdonandClaude Sonnet 5.5 10cc5dd373 feat(ui_template): 항목 고르기를 실서버에 이음 — 가짜 응답 지움 (PLAN 21-9)
- GET /api/m02/items?all=1 = [{layer, owner, items}] 를 줄마다 layer · owner 를 붙여 폄 · 404 갈래와 ui_template_item_picker_fake.ts 지움
- 보이는 항목 이름 = 종류 + 분류1~3 (열은 집계표 열 id) · summary_item.name 도 같은 이름
- 폴더 줄은 서버 names{일위대가 · 자재조합 · 도번 · 면적번 · 재료번} 이름을 보임(키는 툴팁) · 이름이 없으면 키를 작은 글로
- 복사 응답 {key} 의 개인 항목을 바로 고름
- 자료 시험을 서버 줄 모양으로 다시 씀

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-29 12:33:55 +09:00
eomsangdonandClaude Opus 5.5 e65592a76d feat(M02): 항목 목록 줄마다 names(일위대가 · 자재조합 · 도번 · 면적번 · 재료번 화면 이름) (PLAN 21-9)
- GET /api/m02/items 줄에 names — 키 대신 보일 이름 · 없으면 null
- 개인 복사 응답 {key} = 새 SS 키 확인 시험
- 결정 기록: 일위대가 갈라 내기 SYSTEM_ADMIN 만 · 첫 진입 뒤 같은 이름 409 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-29 12:32:37 +09:00
eomsangdonandClaude Sonnet 5.5 1973eba8b2 feat(B05): 숨긴 옵션은 필수 아님 — 저장 검증이 M02 옵션 보이기 표를 봄 · 시험 한 건
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-29 12:32:24 +09:00
eomsangdonandClaude Sonnet 5.5 4e0fd3106d feat(B05·B06): 집계 항목 고르기를 공용 모달(21-5)로 이음 · 임시 모달 삭제(21-9)
- 임시 Summary_Picker 삭제 · Panel 은 @ui/ui_template_item_picker 의 openItemPicker({current, canCopy}) 결과(apply · clear · cancel)로 처리
- 임시 모달 전용 CSS 삭제
- origin/dev 되받기 충돌 둘(List · Panel_Types) 양쪽 살려 풂 · 쓰지 않는 import 하나 정리

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 12:31:31 +09:00
eomsangdon eb3ede5350 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1
충돌 둘(Structures_List · Panel_Types)은 양쪽을 모두 살려 풂 — 측점 글은 공용 stationLabel · 집계 항목 읽기 · isB05Option · SummaryItem 을 한 벌로, 쓰지 않게 된 isB05Option 가져오기는 뺌.
2026-09-29 12:29:53 +09:00
eomsangdon f94d7c13a4 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7
# Conflicts:
#	B05_Profile/B05_Profile_UI_Structures_List.ts
#	B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts
2026-09-29 12:29:41 +09:00
eomsangdon 35dbc4254f Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_4
# Conflicts:
#	B05_Profile/B05_Profile_UI_Structures_List.ts
#	B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts
2026-09-29 12:29:37 +09:00
eomsangdonandClaude Sonnet 5.5 9aa07fda75 Merge remote-tracking branch 'origin/dev' into sub_laptop_2
충돌 풂 — 양쪽 다 살림: M02 좌측(층 인식 도면 양식 + 구조물 옵션 보이기는 시스템 층만) · 스타일 · 사전(층 화면 + 항목 고르기) · B05 목록·패널 타입(공용 측점 표기 + 집계 항목 import)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-29 12:29:29 +09:00
eomsangdon 6c4b059837 Merge remote-tracking branch 'origin/dev' into sub_laptop_3
# Conflicts:
#	B05_Profile/B05_Profile_UI_Structures_List.ts
#	B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts
2026-09-29 12:29:02 +09:00
eomsangdon 2be64ab732 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_3 2026-09-29 12:28:30 +09:00
eomsangdon 03b8ef42ce Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_3 2026-09-29 12:28:28 +09:00
eomsangdon 6837feaa2a Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3 2026-09-29 12:28:24 +09:00
eomsangdon a1bae17e4d Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1
충돌 셋(UI_Side · UI_Style · locale_m2)은 양쪽을 모두 살려 풂 — 옵션 보이기 · 항목 고르기 문구 · 층 화면 문구를 한 벌로, 도면 양식 줄은 시스템 층에서만.
2026-09-29 12:28:14 +09:00
eomsangdon 1a0ba6c6dc Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts
#	M02_MasterTemplete/M02_MasterTemplete_UI_Style.css
#	ui_template/ui_template_locale_m2.ts
2026-09-29 12:28:06 +09:00
eomsangdon cc6889e4f3 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_7
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts
#	M02_MasterTemplete/M02_MasterTemplete_UI_Style.css
#	ui_template/ui_template_locale_m2.ts
2026-09-29 12:27:37 +09:00
eomsangdon 4a25b99f88 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-29 12:27:01 +09:00
eomsangdon 0941a02eb4 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-09-29 12:26:59 +09:00
eomsangdonandClaude Sonnet 5.5 71fac35dab feat(B05·B06): 구조물 좌측 패널 「집계 항목」 줄 · 옵션 보이기 · [수정] 옵션 보존 · 목록 지정 색(21-4)
- 폼 옵션 아래 「집계 항목」 줄(이름 · 미지정 · [고르기] · [해제]) — 구조물 · 관 계열 하위 폼 · B05 · B06 한 코드
- 옵션 `visible:false` 는 폼에서 숨김(정의 · 옛 값 그대로) · [수정]은 폼이 그린 칸만 갈아 끼우고 안 그린 칸(상세 · 숨긴 칸) 보존 — 옵션 덮어쓰기 흠 고침
- 목록 줄 `.is-assigned` 색 · 툴팁 항목 이름 · 캐시는 지금 초안 기록 칸(structures · pipes) 안 summary_item
- 미지정이면 칸을 안 실음 · 관은 서버 응답에 칸이 없으면 같은 측점에서 화면이 이어받음
- 항목 고르기 모달은 21-5 가 오기 전까지 같은 이름의 임시 함수 · Structures_Panel 700줄 넘어 옵션 그리기를 Panel_Options 로 나눔

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 12:19:31 +09:00
eomsangdonandClaude Sonnet 5.5 154d693599 feat(M02): 구조물 관리 화면 — 층 인자(개인 · 회사) · 층별 권한 · 대시보드 카드
- 주소 `?layer=personal|company`(없으면 시스템) — 라우터가 인자를 떼고 `currentQuery` 로 읽음 · 층 고정 풂
- 층 길 접두어 `/layers/{층}` — 구조물집계표 · 조합 · 복사 호출이 화면 층을 따름(시스템은 옛 길 그대로)
- 제목 셋(마스터 템플릿 · 구조물 관리(회사) · 구조물 관리(개인)) · 돌아가기 층 무관 · 도면 양식은 시스템 층만
- 편집 권한 — 시스템 SYSTEM_ADMIN · 회사 ADMIN 또는 is_master · 개인 본인 · 읽기만이면 단추 숨김 + [개인용으로 복사]
- 회사 없음 → 「회사부터 등록」 안내 · 대시보드 카드 「구조물 관리」(USER 개인용 · ADMIN · SYSTEM_ADMIN 회사용 + 개인용)
- 시험 test_m02_layer_screen.py 5 · test_m02_structure_panel.py 갱신

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-29 12:09:31 +09:00
eomsangdon 2d1719039e Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_4 2026-09-29 12:01:36 +09:00
eomsangdon b5eea31357 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-09-29 12:01:34 +09:00
eomsangdonandClaude Opus 5.5 b8195aff2e feat(M02): 층 인식 집계표 저장소 — 회사 · 개인 층 · 항목 목록 · 항목 복사 (PLAN 21-2)
- Store · Matset · Branch 가 지금 층 폴더(store.at · 기본 시스템 층)를 봄 — 시스템 층 동작 · 옛 길 그대로
- 회사 · 개인 층 첫 진입 때 빈 뼈대(집계표 열 0 · 조합 장부 다음 1 · 자재 조합 빈 표)
- /api/m02/layers/{layer}/… 집계표 길(번호 · 조합 장부 · 구조물 새로 · 복사 · 조합 갈라 내기 · 자재 조합) · 권한 = 시스템 SYSTEM_ADMIN · 회사 관리자 · 개인 본인
- 층 양식 저장(시스템 · 회사 · 개인)은 집계표 규칙(새 열 파일 · 번호 · SS) 거침 · 시스템 층 쓰기는 SYSTEM_ADMIN · 쓰는 양식 지우기 409
- GET /api/m02/items(한 층 · ?all=1 세 층) · POST /api/m02/layers/personal/items/copy · 회사 없음 409 「회사부터 등록」
- 시험 test_m02_layers_store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-29 12:01:08 +09:00
eomsangdonandClaude Opus 5.5 5892232890 feat(B05·M02): 구조물 집계 항목 칸 · 저장 스냅샷 · 지정 항목 채움 (PLAN 21-3)
- summary_item 칸: StructureInstance · pipe_points 점 기록(options 밖) — 모양 검사 한 벌 common_util_summary_item(층 system·company·personal · owner null·{회사}·{회사}/{사용자} · key SS+6자리 · name) · 없으면 null(옛 파일 호환) · 관 요청 모양 틀림 400
- 저장 스냅샷 M02_Item_Snapshot: PUT /route/structures · PUT /drainage/pipe-points 뒤 지정 항목마다 templates/items/{layer}_{owner}_{key}/ 에 집계(SS 줄 + 열 정의) · 도면 · 면적 · 재료 · 자재조합 · 일위대가(UA 줄 + 담은 로직 줄) · manifest(원본 · 판 · 적용일 · 쓰는 구조물) — 있는 폴더는 다시 안 씀(원본이 바뀌어도 프로젝트 값 그대로) · 안 쓰는 폴더 지움 · 실패해도 저장 유지
- 채움 fill_table = 지정 항목 모드: 구조물이 고른 열(묶음)에만 · 미지정 · 복사본 없음은 안 셈 + 알림 · 스냅샷 열 정의 사용 · 회사 · 개인 항목은 열id@폴더 새 열 · 자동 맞춤은 fill_document 기본으로만 남음
- 관 시설 되붙이기가 summary_item 도 옮김
- [초기화]는 templates/ 를 안 건드림 · 복제 도구는 폴더 통째 복사 — 손댈 것 없음(시험으로 지킴)
- 시험: test_structures_summary_snapshot 17 · 옛 채움 시험은 자동 맞춤 길(fill_document)로 · 층 라우트 채움 시험은 새 규칙(미지정 관 = 줄 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-29 12:00:44 +09:00
eomsangdonandClaude Sonnet 5.5 927e85d110 feat(ui_template): 항목 고르기 모달 · 공용 나무 보기 — PLAN 21-5
- ui_template_tree: M01 renderTree 를 공용으로 올림(M01 은 같은 이름으로 그대로 부름) · 기본 줄 createTreeRow · 클래스 ui-tree__*
- ui_template_item_picker: openItemPicker({current, canCopy}) → apply(summary_item) | clear | cancel · 왼쪽 마스터 · 회사 · 개인 → 종류 → 분류1 → 항목 나무 + 찾기 · 오른쪽 폴더 모양(키는 툴팁) · 개인용으로 복사 · 관리 화면 이동
- 목록 GET /api/m02/items?all=1 · 복사 POST /api/m02/layers/personal/items/copy — 서버가 없으면(404) 21-1 모양 가짜 응답
- 자료 쪽(나무 · 찾기 · 폴더 줄)은 화면 없는 모듈로 나눠 시험

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-29 12:00:13 +09:00
eomsangdonandClaude Sonnet 5.5 51b380b64d feat(M02): 구조물 옵션 보이기 — 종류마다 B05/B06 폼에 보일 옵션 체크 · 표 · 서버 길 · 좌측 줄 · 화면
- 표 resources/master_template/구조물옵션표시.json(숨김만 저장 · 없으면 모두 보임)
- GET /api/projects/structure-types 옵션마다 visible · GET/PUT /api/m02/structure-options
- M02 좌측 「구조물 옵션 보이기」 줄 → 메인 종류(군별) → 옵션 체크 · [저장] · [모두 보임]

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-29 11:57:27 +09:00
eomsangdon b3764299a3 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-29 11:54:58 +09:00
eomsangdonandClaude Opus 5.5 08e493f132 feat(B05·B06·B07): 소수 측점 횡단 — 관 · 구조물 자리마다 따로 · 이름 소수 2자리 · 화면 표기만 정수
PLAN 20장 소수 측점 횡단(sub6). 사용자 확정: 관 · 구조물이 놓인 소수 측점(예 119.8)에 그 자리 횡단을
따로 둠 · 백엔드 · 파일 · 저장값은 소수 그대로 · 화면 표기만 「측점 + 거리」의 거리를 정수로(5+20).
- 이름 규칙 한 벌(B05_Profile_Engine_Sections): cross_key · cross_filename · parse_cross_key ·
  CROSS_KEY_PATTERN — 정수 m 는 옛 이름 그대로(cross_00120m) · 소수 측점은 cross_00119.80m.
  find_cross_file = 읽기용(새 이름이 없으면 옛 정수 m 이름) · prune_stale_cross_files 도 이것으로
  가려 옛 프로젝트의 정수 이름 파일을 지우지 않음
- generate_sections: 정수 m 격자 붙임을 풂 — 같은 이름이 되는 0.005m 안 겹침만 하나로
- B06: 찾기 · 패치 입력(Router_Design 둘) · 샘플 넓힘(SampleExtend)이 find_cross_file ·
  DB 설계 찾기 · 병합(get_cross_section_design · merge_…_by_round)은 ROUND(chainage_m)=정수 →
  0.005m 안 매칭
- B07: 도면 id · 장 id(cross_{key}m · cross_s{key}m) 정규식 · 측점 키(cross_station — 정수는 int
  그대로라 옛 id · 수량표 키 안 바뀜) · 측점은 파일 속 값으로(옛 정수 이름 파일도) · 횡단 파일 찾기
- 화면 표기 한 벌 common_util/common_util_station.ts(stationLabel · stationPartsLabel · 순수 함수) —
  common_util_svg 가 다시 내보내고 B05 formatStation 을 대신함(B05 목록 · 표시 · 폼 범위 · 계획노선
  편집 · 비정규 측점 라벨 · B06 카드 · 종단 · 유토) · 입력 칸 · 저장값은 소수 그대로
- B05_Profile_UI_Structures_Panel 700줄 → 690(순수 도우미 셋을 Panel_Types 로)
- M02_Table_Fill 관연장 찾기는 이미 측점 값(정확 먼저 · 0.5m 안)이라 그대로
시험: test_b05_station_roundtrip(표기 5+20 · 옛 정수 이름 찾기 · 안 지움 · 119.8 과 120 따로) ·
test_b05_extra_stations_supplier(새 규칙) · test_b06_patch_station(119.8 한 건) ·
test_b05_b06_save_roundtrip(보낸 관 측점마다 소수 이름 횡단 파일 있음) 등 고친 대상 108 통과 ·
test_b05_station_merge 통과 · 타입검사 통과.
ORCA(IP 5181 · 사본 프로젝트 sub6_검증 — 끝나고 DB 행 · 폴더 지움 · 원본 안 씀): 노출형 횡단수로를
5+19.8 에 놓고 저장 → structures.json 119.8 · 종단 측점 119.8(irregular) + 120 · 파일
cross_00119.80m.json 과 cross_00120m.json · B05 목록 「5+20」 · B06 카드 「5+20 · 119.8m」 열림.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-29 11:54:51 +09:00
eomsangdon b7449f857c Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-29 11:51:31 +09:00
eomsangdonandClaude Sonnet 5.5 41c975c7bf chore(plan): 계획서 이관 문서를 이 브랜치에도 추적 — dev 와 같은 내용(받기 막힘 해소)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-29 11:51:25 +09:00
eomsangdon 9d85ce8b4a Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-29 11:50:59 +09:00
eomsangdonandClaude Sonnet 5.5 76c450b948 chore(plan): 계획서 이관 문서를 이 브랜치에도 추적 — dev 와 같은 내용(받기 막힘 해소)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 11:50:52 +09:00
eomsangdon 38b3459efb Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1 2026-09-29 11:50:16 +09:00
eomsangdon 4417628a1c Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_5 2026-09-29 11:49:41 +09:00
eomsangdon 6ad10d92d3 Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_7 2026-09-29 11:49:41 +09:00
eomsangdon ef31f057f8 auto: 2026-09-29 11:41 (EOMSANGDON-HOME) 2026-09-29 11:41:41 +09:00
eomsangdonandClaude Sonnet 5 4e7e9a6ac2 feat(B05): 초기노선 갈래 공통 제약 칸 — 종단기울기 · 최소반지름 · 계류이격 · 지반고름 화면에서 변경
- 공통 제약 기준값 확정(9-29 사용자 결정) — 후보 표 임시값 그대로 기본값, 화면에서 프로젝트마다 바꿀 수 있음
- 로직 단추 옆 제약 네 칸(비우면 placeholder 로 기본값) · 채운 칸만 POST /route/initial 몸통 criteria 로 실어 보냄
- [확인]의 initial.criteria 에도 실어 성공하면 initial_choice.json 에 겹친 칸만 남김(빈 칸은 기본값이라 안 담김)
- 다시 열 때 current_initial.criteria 로 그 칸만 복원 · 캐시가 서버 갈래·비중·제약과 다르면 버림(sameBase)
- 서버: generate_initial_route/_SearchGrid 가 stream_offset_m·grade_smooth_m 겹침을 받아 탐색·비교값에 반영
- 응답 criteria.pending 은 이제 늘 빈 목록 — 화면의 「후보 · 사용자 확정 대기」 글 삭제

시험: test_route_initial(+2) · test_b05_routeedit_initial(criteria 겹침·확정 표시 삭제) ·
test_b05_routeedit_initial_roundtrip(+1, 사본에서 계류 이격 30m 겹침 왕복)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 11:41:35 +09:00
eomsangdonandClaude Fable 5.1 2cbfc1f53f chore(plan): 계획서 요약 — 끝난 10~19장을 docs/raw/plans 로 이관 · PLAN.md 는 1장 · 20장만
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LayTXxoGnEer4vYpTGmYk2
2026-09-29 11:18:20 +09:00
eomsangdonandClaude Opus 5.5 1758ba0fb2 feat(B05): 초기노선 갈래 — 제약 못 지킨 구간 한 줄 · 상한 풂 안내 · 기울기 잴 지반 고름 칸
- 비교값 줄 아래 「제약 못 지킨 구간 N곳: No.9 16.4%(지형상 불가능) · …」(없으면 「모두 지킴」 · 5곳 넘으면 「외 N곳」 · 풍선에 전부와 사유)
- relaxed 면 「지형상 14.0% 로 못 이어져 상한을 29.1% 로 풂」(경고 색)
- 공통 제약 줄에 서버 grade_smooth_m 을 「기울기 잴 지반 5m 고름」으로
- 시험 — 글 만들기(test_b05_routeedit_initial) · 사본 왕복에서 목록 · 풂 · 고름 칸이 화면 줄에(test_b05_routeedit_initial_roundtrip)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 10:42:14 +09:00
eomsangdonandClaude Opus 5.5 258a3512a4 fix(B05): 초기노선 공통 제약을 넘지 못하게 — 지형상 불가능한 곳만 목록(측점 · 값 · 사유)
- 종단기울기 상한 넘는 링크 · 계류 버퍼 안 나란히 달리기는 탐색에서 뺌(추종 갈래 포함)
- 최소 곡선반지름: 이웃 방향으로만 꺾고 접선 길이만큼 곧게 간 뒤 다시 꺾음 · 노드마다 원호
- 길이 없을 때만 막은 기울기(규칙 지키는 어떤 길도 넘어야 하는 값)를 이분 탐색해 그 위는 막고 넘긴 곳을 metrics.violations 에
- over_limit 수 = 목록 수 · 종단기울기는 5 m 로 고른 지반의 측점 20 m 기울기(탐색 · 비교값 같은 잣대)
- 비교값 · 목록은 새 파일 B05_Profile_Engine_RouteInitial_Metrics.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 10:35:44 +09:00
eomsangdon 927b7aa727 Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/sub_laptop_6' into sub_laptop_5 2026-09-29 10:15:36 +09:00
eomsangdonandClaude Opus 5.5 548daa6f4f fix(B06): 옮긴 관 측점의 횡단 수정을 저장 때 버리지 않음 — 행 없으면 측점부터 세움
- 원인: B06 [저장]은 관 측점 비정규 측점을 종단에 싣지 않아(확정은 상단측만) 옮긴 관 자리에 DB 행이 없고, merge_cross_section_designs 가 행 없는 수정을 버림
- 고침(서버 한 곳): 저장 · 확정이 행 없는 측점의 수정을 받으면 ensure_structure_stations — [측점 만들기]와 같은 조건(샘플링 기록)으로 구조물 측점을 세움 → 기존 _rowless_station_chainages 가 기본 설계 행을 만들고 수정이 그 위에 얹힘
- 판정: 화면이 행을 만들려면 지표 샘플링 · 횡단 파일이 필요해 서버만 할 수 있음 — 서버 한 곳으로 충분
- B06_Section_Router_Stations: 측점 다시 뜨기를 _regenerate_structure_stations 로 떼어 단추 · 저장이 같이 씀
- 시험: test_b06_patch_station(부르는 조건) · sub8 왕복 시험 [B06] 통과(프로젝트 사본 · DB 읽기만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-29 10:15:27 +09:00
eomsangdonandClaude Opus 5.5 f25cd17ab5 test(B05): 초기노선 갈래 저장 왕복 — [확인]에 실은 초기값이 다시 열기 뒤 그대로인지 사본에서 대조
- 데스크탑_메인 storage 사본 · DB 읽기 전용 · 재계산 체인은 성공만 돌려주는 가짜
- 추종 · 토공 최소 · 기울기 순한 · 비중 50/25/25 마다 /route/initial → 화면 코드로 [확인] 몸통 → /route/replan → /route/plan
- 현재 초기값 = 보낸 것 · 다시 연 노드 = 보낸 노드 · [초기화] = 마지막 초기값(캐시 없으면 서버가 같은 노드) · 비교값 다 있음
- 화면 셈은 helper_b05_routeedit_initial_apply.cjs 가 화면 코드 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 10:15:15 +09:00
eomsangdonandClaude Opus 5.5 867c4650c7 fix(B05): 관 · 구조물 자체 측점이 종단 측점에서 정수 m 격자로 붙던 것 — 파일 이름에만 정수 m
PLAN 19장 남은 차이(sub8 왕복 시험): 관 440.24 · 619.71 · 720.44 · 899.91 이 저장 뒤 종단 측점에서
440 · 620 · 720 · 900 으로 붙음(generate_sections 의 정수 m 격자 붙이기). 판정 (다) 유지 — 횡단 파일
이름(cross_{정수m}m)은 그대로 한 정수 m 에 한 장, 측점 값은 관 · 구조물 자체 측점 그대로:
- 같은 정수 m 에 격자 측점이 있으면 격자 대신 관 · 구조물 측점을 씀(kind irregular · 라벨 그대로)
- 시 · 종점 격자는 그대로 두고 라벨만 얹음(노선 끝 측점이 사라지지 않게)
- 비정규끼리 같은 정수 m 면 앞선 것만(종전과 같음 · 파일 한 장)
시험 — test_b05_b06_save_roundtrip[B05] 통과(관 측점 차이 0 · 사본 storage · 읽기 전용 DB) ·
test_b05_extra_stations_supplier(옛 「격자 우선」 기대를 새 규칙으로 · 시 · 종점 하나 더함) ·
test_b05_station_roundtrip · test_regeneration_extra_stations 통과. [B06] 경우의 「횡단 patch 263.47 —
받을 행이 없어 DB 가 버림」은 고치기 전 코드에서도 같게 실패(이번 변경과 무관 · 보고함).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-29 10:12:59 +09:00
eomsangdonandClaude Opus 5.5 43572ccccc feat(B05): 계획노선 편집 창 — 열 때 서버가 적어 둔 현재 초기값(current_initial)을 「현재 초기값」에
- /route/plan 의 current_initial {logic, weights} 를 읽어 이름 · 비중 슬라이더에 보임(없으면 예상노선 추종)
- 캐시가 서버와 다른 갈래 · 비중이면 캐시를 버림(서버가 정본 — [확인] 때만 적힘)
- 노드 없이 이름만 아는 초기값의 [초기화]는 그 갈래를 서버에 물어 노드를 실음
- 시험 test_b05_routeedit_initial.py — 서버 초기값 펴기 · 캐시 견주기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 10:08:44 +09:00
eomsangdon 2615bf78d2 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-29 10:04:56 +09:00
eomsangdon aafd38d0c7 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into sub_laptop_6 2026-09-29 10:03:45 +09:00
eomsangdon 73c5733e22 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-29 10:03:44 +09:00
eomsangdonandClaude Opus 5.5 aecf04acad test(B05): 사용자가 놓은 구조물 119.8 저장 · 다시 읽기 · 다시 열기 표시 그대로 확인
PLAN 19장 sub6 ② — 브레인 판정(다): 횡단 측점 · 파일 이름의 정수 m 격자 붙이기는 그대로 두고
①(옵션 · 메모만 고칠 때 측점 그대로)로 마감. 구조물 정본 측점이 소수 그대로 남는지만 시험으로 굳힘.
- 서버(임시 storage): manual 구조물 119.8 을 save_structures → load_structures 119.8 ·
  resolve_extra_stations 119.8 · generate_sections 횡단 측점은 120(cross_00120m 한 장) ·
  그 뒤 다시 읽어도 정본 119.8 · 메모 그대로
- 화면(측점 칸): 119.8 을 실으면 5 + 19.8 로 보이고 칸을 안 고치면 119.8 로 읽힘
공유 storage 배정 프로젝트는 이번에 쓰지 않음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-29 10:03:42 +09:00
eomsangdonandClaude Opus 5.5 4366cd3f03 fix(B05): 초기노선 갈래 화면을 서버 길 모양(PLAN 20장 API 줄)에 맞춤
- 요청 { mode, weights?{expected, earthwork, grade} } — logic · slope 가정을 버림
- 비교값 avg_grade_pct · over_limit.total · offset_avg_m, 기준값 pending(비면 확정) 읽기
- [확인] 몸통 initial 도 { mode, weights }
- 시험 test_b05_routeedit_initial.py 를 서버 답 모양으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 10:03:38 +09:00
eomsangdonandClaude Opus 5.5 3b0c5480f2 feat(B05): 초기노선 갈래 logic 이름 받음 · 현재 초기값을 [확인] 때 프로젝트에 적음
- POST /route/initial 이 logic(또는 mode) 을 받음 · 응답에 logic 칸
- POST /route/replan 의 initial {logic, weights} 을 성공 때만 initial_choice.json 에 적음(자동저장 없음)
- GET /route/plan · POST /route/initial · POST /route/replan 응답에 current_initial

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 10:02:14 +09:00
eomsangdonandClaude Opus 5.5 7dbae140c1 test(B05·B06): 저장 왕복 — 지도에서 끈 관은 화면처럼 속성을 새 측점으로 옮긴 뒤 되붙임
- sub5 고침(끌기 onMove → 시설 보관소 move)을 받은 뒤 본문 만들기도 같은 차례로
- sub5 · sub6 받은 뒤 남은 차이: 관 측점 정수 m 격자 붙이기 4 곳(440.24 · 619.71 · 720.44 ·
  899.91) · B06 저장 차례에서 옮긴 관 측점 횡단 patch 가 받을 행이 없어 버려짐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-29 10:02:00 +09:00
eomsangdon 0494079111 Merge remote-tracking branches 'origin/main_desktop_1', 'origin/sub_laptop_5', 'origin/sub_laptop_6' and 'origin/sub_laptop_8' into sub_laptop_3 2026-09-29 10:00:26 +09:00
eomsangdon b777b36a1a Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-29 10:00:24 +09:00
eomsangdon 751a5da02c Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8 2026-09-29 10:00:11 +09:00
eomsangdon a24dd43eec Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8 2026-09-29 10:00:10 +09:00
eomsangdonandClaude Opus 5.5 af1b419d5d test(B05·B06): 저장 왕복 시험 — 고른 값이 저장 · 다시 읽기 뒤 그대로인지 0 차이 대조
- test_b05_b06_save_roundtrip.py: 배정 storage 프로젝트(랩탑_보조) 사본에서 B05 [임시저장] ·
  B06 [저장] 차례대로 서버 API 호출(관 → 계획선 → 구조물 → 확정 → 구간값 → 횡단) ·
  pipe_points.json · structures.json · 종단 파일 · 응답 · DB 로 가는 횡단 patch 를 고른 값과 대조 ·
  다시 열어 그대로 저장해도 안 바뀌는지
- DB 는 읽기 전용 세션으로 읽기만 · 쓰는 함수는 받은 값을 적는 가짜 · 원본 · DB 안 바뀜
- helper_b05_save_payload.mjs: 저장 본문을 화면 코드 그대로(시설 되붙이기 · 구조물 측점 칸 ·
  구간값 · 이동 예약 흘려보내기) node 로 만듦
- 지금 코드에서 잡힌 것: 옛 관 이동 예약 적용 · 측점 0.1m 재읽기(관 · 구조물) · 새 관이 이웃
  관 속성을 가져감 · 관 측점 격자 붙이기 · B06 저장에서 옮긴 관 측점 횡단 patch 버려짐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-29 10:00:03 +09:00
eomsangdonandClaude Opus 5.5 55f1eeeb7d feat(B05): 초기 계획노선 갈래 — 추종 · 토공 최소 · 기울기 순한 · 비중(POST /route/initial)
- 갈래 인자 하나로 초기노선 생성 · follow 는 지금 로직 그대로(예상노선 폴리라인화)
- 탐색 = 예상노선 둘레 100 m 복도 · 4 m 격자 · 16 방향 + 진행 방향 기억 Dijkstra(꺾을 때 R·θ 벌점)
- 비중 = 예상 이격 · 토공 · 기울기 세 목표를 복도 평균으로 정규화해 가중합(합 100 · 기본 50/25/25)
- 공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격) — 탐색 벌점 · 원호 끼우기 · 초과 구간 수
- 비교값: 연장 · 최대/평균 종단기울기 · 절성토량 · 기준 초과 구간 수 · 예상노선 이격
- 파일 안 씀(캐시용) · 기준값은 지식DB 후보 임시값(사용자 확정 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-29 09:59:24 +09:00
eomsangdonandClaude Opus 5.5 2082cd98a5 feat(B05): 계획노선 편집 창 — 초기노선 갈래 화면 틀(로직 단추 셋 · 비중 슬라이더 · 초기화 · 현재 초기값 · 비교값)
- 「예상노선으로」를 「초기화」로 — 마지막에 적용한 초기값(처음엔 예상노선 추종)으로 편집값만 되돌림 · 즉시 재계산 없앰
- 옛 「초기화」(창을 연 상태로)는 「연 상태로」
- 로직 단추 토공 최소 · 기울기 순한 노선 · 비중 계산(예상 · 토공 · 기울기 슬라이더 · 합 100 자동 · 기본 50/25/25 · [계산])
- 누르면 서버 갈래(POST /route/initial)를 불러 받은 노드를 편집값에 싣고(되돌리기 한 걸음) 결과 캐시(routeedit-initial)에 새 초기값으로 적음
- 「현재 초기값: ○○」 · 결과 비교값(연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격) · 공통 제약 기준값은 서버 값만 보임(확정 표시 없으면 「후보 · 사용자 확정 대기」)
- 다시 계산은 노선이 바뀐 뒤 [확인]에서만 — 그대로면 계산 없이 닫음 · [확인] 몸통에 초기값(initial) 실음 · [취소]는 이 창에서 바꾼 초기값도 되돌림
- 서버 길이 아직 없으면 알림만(고친 적 없는 노선의 [초기화]는 창을 연 노선으로)
- 700줄 한도 — 노드 끼우기·빼기 배선은 _Input(bindNodeSplice), 셈은 _Initial_Model
- 시험 test_b05_routeedit_initial.py (비중 합 100 · 서버 답 두 꼴 펴기 · 글 · 캐시 칸)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 09:57:03 +09:00
eomsangdonandClaude Opus 5.5 cc3a8a7109 fix(B05): 옵션 · 메모만 고쳐 저장해도 구조물 · 관 측점이 그대로 · 계획고 수정 안 지워짐
PLAN 19장 sub6 ①. 측점 칸(stationFields)이 잔여거리를 소수 1자리로 보이고 저장 때 그 표시값을
다시 읽어, 옵션 · 메모만 고쳐도 측점이 0.1m 단위로 옮겨 저장되던 흠(123.37 → 123.4 · 관 85.05 →
85.0 은 updatePipeFacility 0.005m 기준을 넘어 실제 관 이동). 칸에 실어 둔 누가거리를 기억해
사용자가 칸 글을 안 고쳤으면 그 값을 그대로 돌려줌(읽는 곳 모두 이 한 함수를 거침) · 고치면 친 값.
계획고 수정(station_offsets)은 비정규 측점이 바뀔 때 옛 키를 지우던 것을, 새 목록에 0.005m 안의
측점이 있으면(서버 관 측점 0.01m 반올림) 그 키로 옮김(resetStationEdit · resetStation 의 moveTo).

확인 — ORCA(IP 5181 · 메인 폴더 탭 · 랩탑_보조 프로젝트): 옹벽 기준 6+3.37 저장 → 123.37 ·
메모만 고쳐 저장 → structures.json 123.37 그대로(표시 6+3.4) · 다시 열기 같음. 배수관 1(85.05 ·
표시 4+5.0)에 계획고 ▲ 뒤 관경만 1000 → 800 저장 → pipe_points 85.05 그대로 · station_offsets
유지 · 다시 열기 같음. 시험 뒤 프로젝트 파일은 백업으로 되돌림(diff 0).
시험 test_b05_station_roundtrip(칸 왕복 · 편집 옮김) 통과 · 타입검사 통과.
② 정수 m 격자 붙이기에서 manual 빼기는 횡단 파일 이름 · 도면 ID(cross_{정수m}m)가 B06 · B07 · B08
열쇠라 브레인에게 물음 — 이 커밋에 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-29 09:52:29 +09:00
eomsangdonandClaude Opus 5.5 2a9b343e2e fix(B05): 시설 속성은 측점이 같은 관에만 붙임 · 옛 관 이동 예약 적용 제거
- 원인: 화면 attach · 서버 carry_facility_attributes 가 거리 제한 없이 가장 가까운 관에 속성을 붙여, 관 수 > 속성 수이거나 관을 이웃 쪽으로 옮기면 BOX · 세월교가 옆 관 측점으로 넘어감
- 고침: 화면 carryIndices ↔ 서버 carry_indices 짝 — 허용 오차 0.05m 안 가장 가까운 원본만 · 한 원본 한 번 · 못 붙인 관은 속성 없음(추천은 기존대로 빈칸에만) · 두 파일 머리에 짝 표기
- 관을 옮기면 속성이 따라감: 지도 끌기(onMove → move) · 되돌리기 · 구조물 폼(follow — 사라진 관 하나 · 새 관 하나일 때)
- B06 옛 「관 이동 예약」(culvertmove): 저장 때 읽지도 적용하지도 않음 — flushCulvertOptions · createCulvertOptionWriter 에서 이동 키 제거
- 시험: 거울 시험 test_b05_facility_carry(화면 · 서버 같은 답 · 끌기 · 되돌리기) · 옛 최근접 · 이동 예약 적용 시험을 새 규칙으로 고침
- 확인: ORCA 5180 랩탑_보조 — 세월교(173.09) 8m 앞 165m 에 옵션 없는 관 추가 → 저장 → pipe_points.json 165 = 속성 없음 · 173.09 = ford_bridge · 나머지 11건 전후 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-29 09:51:55 +09:00
eomsangdonandClaude Opus 5.5 f72252a5ca Merge remote-tracking branch 'origin/dev' into sub_desktop_1 — 계획노선 편집 창 dmain(기울기 라벨 · 1m 등고선 · 보던 화면) 과 합침
재기 배선은 dmain 의 bindMeasureControls 를 취하고, 두 노선 재기(lines) · 횡단 반폭 선은 살림.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WeXB1zh5hbYLgoeGS5kQu
2026-09-29 09:43:45 +09:00
eomsangdonandClaude Opus 5.5 b2cbd852a0 feat(B05): 계획노선 편집 — 예상노선 거리 재기 · 측점 눈금 라벨 비켜 놓기 · 횡단 반폭 선
- 거리·기울기 재기가 예상노선(점선)에서도 됨 — 두 점은 한 노선 위 · 다른 노선을 짚으면 새로 시작
- 측점 눈금 라벨이 겹치면 빼지 않고 비켜 놓음(반대쪽 → 한 칸 더 멀리 · 가는 선으로 이음) — 공용 drawStationTicks
- 오른쪽에 횡단이 뜬 측점에 좌·우 설계 반폭 선 — 노드를 끄는 동안 브라우저 셈으로 바로 따라감
- 측점 틀 파이썬·TS 짝(_interpolate_xy·_tangent_at ↔ stationFrameAt) + 거울 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WeXB1zh5hbYLgoeGS5kQu
2026-09-29 09:41:39 +09:00
eomsangdon c48ed4c309 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-29 09:34:08 +09:00
eomsangdonandClaude Opus 5.5 207c2e40eb feat(B05): 계획노선 편집 창 — 측점 사이 기울기 라벨 · 1m 등고선 켬끔 · 보던 화면 기억
- 측점 사이 종단기울기(지반고 %) 라벨 — 측점 간격이 화면 90px 이상일 때만 · 측점 라벨 맞은편 · 회전해도 글 바로 섬 · 끄는 동안 안 물음
- 「1m 등고선」 단추(기본 꺼짐 · LAS 없으면 잠김) — 1m 선 얇고 옅게 · 켰을 때 처음 한 번만 준비 · 5m 선은 한 단 짙게(--map-contour-major)
- 회전 · 가운데 · 배율을 화면 취향(routeedit-view · 프로젝트마다 · 계정에도 올라감)에 적고 다시 열면 그대로
- 700줄 한도 — 화면 셈·기억은 _View, 재기 배선은 _Measure, 바탕 읽기는 _Contour, 노드 끼우기·빼기는 _Edits 로 나눔
- 시험 test_b05_routeedit_view.py (창 크기 달라도 같은 자리 · 저장소 왕복 · 노드 편집값 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 09:33:57 +09:00
eomsangdonandClaude Opus 5.5 071676e0b8 fix(M02): 조합 패널 [이 열에 쓰기]를 고른 줄 바로 아래로 — 목록 밖 단추가 스크롤된 줄 글과 겹치던 것
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-29 09:16:29 +09:00
eomsangdonandClaude Opus 5.5 1bba901834 feat(M02): 면적산출 · 재료산출 오른쪽 조합 패널 — 조합 목록 · 이 열에 쓰기 · 조합 고치기
- 오른쪽 안쪽 패널: 공용 사이드바 카드 · 접기(attachCollapsible) · 공용 폭 손잡이(createPanelResizer · 폭 기억 · 최소 280 · 최대 60%) · 공용 접기 손잡이(createWorkflowPanelHandle)
- 「이 열의 조합」 칸: 면적 = 일위대가 조합 줄 + [조합 고치기] · 재료 = 담은 자재 표(옆 판 aside 없앰)
- 조합 목록: 면적 = 일위대가(UA) · 재료 = 자재 조합(UM) · 키 · 이름 · 수 · 본뜬키 · 찾기 · 이 열 조합 강조
- [이 열에 쓰기]: 서버 POST /api/m02/structures/{column}/use-combo(집계표 판 검사 · 그 열 키만 바꿈 · 조합은 그대로) → 알약 다시 받음
- 상태 등록표에 m02-combo-panel-width · collapsed · 시험 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-29 09:04:19 +09:00
eomsangdon acc4408028 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_2 2026-09-29 08:46:16 +09:00
eomsangdonandClaude Opus 5.5 5e92e37b77 feat(spreadsheet): 알약 막대를 시트 아래 · 상태줄 위로 옮김 — 자재 고르기 창은 막대 위로 엶
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-29 08:45:24 +09:00
eomsangdon fc87859511 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-09-29 08:41:03 +09:00
eomsangdonandClaude Opus 5.5 afcb59d11b feat(M02): 표 도구 막대 줄 더하기 · 줄 지우기 · 손 입력 열 뺌 · 상세 화면 돌아가기
- 표 부품 도구 막대에서 세 단추를 빼고 쓰이지 않게 된 addRow · deleteRow · canDeleteRow · 글자 정리(데이터 규칙 · 시험 견본은 그대로)
- 구조물 도면 · 면적산출 · 재료산출 상세 화면 제목 오른쪽 [돌아가기] — 구조물집계표로 돌아가 그 열을 고른 채(카드 셋) · 저장 안 한 고침은 떠나기 확인 창
- 표 부품 손잡이 selectColumn

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-29 08:41:02 +09:00
eomsangdonandClaude Opus 5.5 155d1eefbe feat(M02): 좌측 패널 재구성 — 구조물 집계 양식 아래 하위 넷 · 첫 화면 구조물집계표
- 「표 양식」 → 「구조물 집계 양식」 · 구조물집계표 줄 하나만 · 새로 · 본떠 만들기 · 지우기 단추 삭제
- 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 컨테이너를 그 아래 하위로(들여 씀 · 같이 접힘) · 도면 양식은 별도 최상위
- 들어오면 구조물집계표를 엶 · 양식 지움 · 자재 조합 상세 닫음 뒤에도 빈 화면 대신 구조물집계표
- 표 새 양식 틀 · 표 복사 머리 이름 바꿈(이제 안 쓰는 길) 걷어냄 · 시험 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-29 08:40:09 +09:00
eomsangdon 7cac596afb Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_1 2026-09-29 00:19:00 +09:00
eomsangdonandClaude Opus 5.5 2de2c23c9a refactor(master_data): check_master 본문 검사를 check_master_body 로 나눔 — 700줄 한도
- (2) 본문 검사(section_of · numbers_in · check_body 등)를 그대로 옮김 · check_master 는 569줄
- cm.check_body · cm.DIVISIONS 는 check_master 에서 그대로 부름
- 직접 실행(전부 · 계산전부) 결과가 나누기 전과 같음 · 시험 254 통과(전후 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qWfH6MMhLwjhtLYnDVUXv
2026-09-29 00:18:46 +09:00
eomsangdon c96d173f58 Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into main_laptop_1 2026-09-29 00:04:48 +09:00
eomsangdonandClaude Opus 5.5 5c0f12a59a refactor(spreadsheet): 격자 1052 줄을 셋으로 나눔 — 칸 그리기 · 머리 · 판(순수 옮기기)
- spreadsheet_grid_cells.ts: 판 하나 칸 그리기 · 값 글 · #### · 긴 글 넘침 붙이기 · 칸 재사용 ·
  자리 재기 도우미
- spreadsheet_grid_heads.ts: 행열 머리 칸 · 숨김 표시 · 묶음 윤곽 단추 · 폭 · 높이 끌기 잇기
- spreadsheet_grid.ts: 틀고정 4 판 · 스크롤 · 확대 · 고름 · 참조 테두리 · 채우기 손잡이 · 누른 자리
  (649 줄) — 자리 재기 · 병합 · 확대는 읽기 전용 `GridView` 로 건넴(끌기 중에도 지금 값)
- 동작 · 겉모양 그대로 · 화면 조각 gzip 33.4 → 33.8 KB(한도 72)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt
2026-09-28 23:29:24 +09:00
eomsangdonandClaude Opus 5.5 8e6815bfd5 fix(ui): 어두운 테마에서 브라우저 기본 부품(체크 · 색 고르개 · 펼침 목록 · 스크롤 막대)이 테마를 따름 — color-scheme
- 밝은 쪽 light · 어두운 쪽(data-theme=dark · 시스템 어두움) dark

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 23:27:38 +09:00
eomsangdonandClaude Opus 5.5 8eceee987d fix(M02): 조합 적용 알림에 새로 만듦 · 같은 내용 다시 씀 구분 · 첫 안내에 다시 씀 덧붙임
- 알림: 「새 자재 조합 UM… 「이름」 을 만듦」 / 「같은 내용의 자재 조합 UM… 「이름」 을 씀 — 새로 안 만듦」 · 일위대가 조합 고치기도 같은 꼴
- 재료산출 첫 적용 안내와 일위대가 갈라 내기 안내에 「같은 내용의 조합이 있으면 그것을 씀」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 23:25:27 +09:00
eomsangdonandClaude Opus 5.5 4425bd4fea fix(ui): 표 칸 F2 · Enter 는 커서를 글 끝에 — 이어 쓰기(엑셀과 같음)
- 원인: 칸 고치기가 늘 글 전부를 고른 채 열려 F2 뒤에 치면 이어 쓰지 않고 바뀜(3 → F2 → 4 → 4)
- 고침: 표 부품 `edit` 한 곳 — F2 · Enter 는 커서를 글 끝(고름 없음) · 바로 치기는 누른 글자로 바꿈 · 두 번 누르기는 그대로 전부 고름 · M01 `startEdit` 는 안 건드림
- 확인: ORCA 5180 구조물집계표 단위 칸 「m」 — F2 커서 1·1 → 4 → 「m4」 · Enter 같음 · 바로 치기 「7」 · 두 번 누르기 0·1 · 모두 Esc 로 「m」 되돌림 · 저장 안 함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zGnt85s95baQ72rhKBCJ4
2026-09-28 23:23:27 +09:00
eomsangdonandClaude Opus 5.5 5982748ef6 fix(M02): 자재 조합 [새로] · [본떠 만들기] 열자마자 이름 칸에 초점 · 본떠 만들기는 이름 칸 비움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwJQ5eRrCKf3nH9EFeKvJz
2026-09-28 23:19:17 +09:00
eomsangdon 85e7e7eaf7 Merge remote-tracking branch 'origin/dev' into sub_laptop_6
# Conflicts:
#	A00_Common/spreadsheet/spreadsheet_grid.ts
#	A00_Common/spreadsheet/spreadsheet_links.ts
2026-09-28 22:59:00 +09:00
eomsangdonandClaude Opus 5.5 7389335ca9 fix(spreadsheet): 16장 도구 막대 · 메뉴 · 대화 창 전수 점검 — 흠 스무 곳 고침
M02 면적산출 SA01010100 에서 도구 막대 · 데이터 메뉴 · 우클릭 · 셀 서식 여섯 탭 · 찾기 · 메모 ·
이름 관리 · 조건부 서식 · 유효성 · 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽 ·
수식 분석 · 되돌리기/다시 · 저장 안 하고 떠나기 · 다시 열기를 빈 값 · 같은 이름 · 긴 이름 ·
취소 · Esc · Enter · 밝은/어두운 테마 예외까지 돌려 찾은 흠.
- 격자(C): 틀고정이 있으면 본문 판이 그릴 행열을 틀고정 폭만큼 덜 셈 — 아래 · 오른쪽 끝이
  빈 칸으로 남던 것(29 행 고정 때 1 행만 그림) · visibleRange 도 같음 · reveal 은 바로 그림
- 도구 막대: 세로 정렬 기본 표시 「위」 → 「아래」(칸 실제와 맞춤) · 크기 409 넘으면 409 ·
  테두리 · 단위 판이 단추 위로 떠 머리까지 덮던 것 → 단추 아래 · ✕ · 수식 안내 · 확대 글
  대비(흐림 투명도 없앰 · --color-text-secondary · 밝은 5.88 · 어두운 7.91)
- 병합(도구 막대 · 우클릭): 왼위 밖 값이 말없이 지워지던 것 → 엑셀처럼 먼저 물음(confirmMerge)
- 셀 서식 · 이동 · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 메모 창: 열 때
  초점이 창 밖(BODY)이라 Esc · Enter 가 안 먹던 것 → dialogKeys(창 안 초점 · Esc 닫기 ·
  Enter 실행) · 셀 서식 창 탭마다 높이가 널뛰던 것 → 본문 최소 높이
- 우클릭 메뉴(ui_template 공용): Esc 로 닫힘 · 화면 밖이면 안쪽으로 당김 · 데이터 ▾ 메뉴와 알약
  우클릭 메뉴가 화면 좌표를 부품 기준 자리로 써 오른쪽 아래로 떨어지던 것 고침 · 행 · 열 머리
  메뉴에 잘라내기 · 붙여넣기(안내) 더함
- 자동 필터 ▼ 가 칸 밑에 깔려 눌리지 않던 것(z-index) · 값 목록이 격자 아래 끝에서 잘리면
  위로 뒤집음
- Σ: 여러 칸을 고르면 첫 칸에 =SUM() 편집을 열어 Enter 로 값을 덮던 것 → 엑셀처럼 열마다
  바로 아래(또는 고름 끝 빈 칸)에 합계를 바로 넣음(autoSumCells)
- 조건부 서식: 빈 값이 「보다큼 0」 규칙이 되던 것 · #RRGGBB 아닌 색 · 색 없는 규칙 → 안내 후 막음
- 이름 관리: 255 자 넘는 이름 막음 · 긴 이름에 창이 화면 끝까지 늘던 것 · 시트 id(s1) 대신
  시트 이름 · 「이름 바꾸기」 가 브라우저 prompt(자동 취소됨)라 안 되던 것 → 줄 안에서 고침
- 표시 형식 「@」 를 수에 걸면 빈 칸이 되던 것 → 엑셀처럼 일반으로 보임
- 연속 데이터 채우기 · 텍스트 나누기가 칸 서식을 지우던 것 → 서식 유지
- 틀 고정 「지금 칸 기준」 을 먼 칸(Z1000)에서 하면 고정 판이 격자를 다 덮어 못 움직이던 것 →
  화면 안쪽 칸에서만(안내)

ORCA(IP http://100.72.32.27:5181 · 메인 폴더 탭 · 판정은 수치): 목록 80 항목 밝은 80/80 ·
어두운 80/80 · 사각형 겹침 · 잘림 · 화면 밖 · 대비 3 미만 0 건. 시험 — test_stage3_autosum
(여러 칸 넷 더함) · test_stage2_data_tools · test_stage2_format_code · spreadsheet_b_test_entry
197/197(@ 하나 더함) · test_spreadsheet_size(css gzip 3121 · 합 67403) 통과. 시험으로 바꾼 칸은
저장 안 하고 「다시 열기」 로 버림.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
2026-09-28 22:57:48 +09:00
eomsangdonandClaude Opus 5.5 9a3645bcaa fix(M02): 좌측 한 곳 다시 그리기에 「자재 조합」 줄도 넣음 — 고름 표시 늘 한 줄
- dev(sub5 990e8e67 「좌측 고름 표시 한 줄만」) 받기 충돌을 두 쪽 다 살려 풂
- 자재 조합 상세를 열 때도 편집 칸이 onOpened 로 좌측 paint 를 부름 · paint 는 양식이 열려 있으면 자재 조합 고름을 지움
- 시험: 자재 조합 상세도 같은 한 곳을 거치는지 확인 줄 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwJQ5eRrCKf3nH9EFeKvJz
2026-09-28 22:48:44 +09:00
eomsangdon 9dfb9d61e6 Merge remote-tracking branch 'origin/dev' into sub_laptop_7
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts
2026-09-28 22:45:08 +09:00
eomsangdon 2700746110 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-09-28 22:40:26 +09:00
eomsangdon 3a508ac1e1 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-09-28 22:40:23 +09:00
eomsangdonandClaude Opus 5.5 ee0475d0a4 fix(ui): 공용 확인 창 닫힐 때 초점을 연 자리로 되돌림 · Esc 로 취소
- showConfirmDialog: 열기 전 초점을 기억해 취소 · 확인 · Esc 로 닫힐 때 그 요소가 아직 화면에 있으면 되돌림
- 열리면 [취소]에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 · Esc 는 확인 창만 닫음(뒤 창 · 페이지로 안 넘김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:38:37 +09:00
eomsangdonandClaude Opus 5.5 96b7409411 fix(ui): 입력칸 · 고르기칸 높이 크기마다 한 값 — 같은 줄 위아래 맞춤 · 로직 고르기 창이 자기 모양 파일 직접 불러옴
- 공용 높이 변수(보통 42 · 작은 30) · 입력칸 작은 크기(compact) 더함
- M01 조합 미리 보기 입력칸 작은 크기 · M01 로직 계산 칸 작은 높이
- 서식 창 글꼴 이름 칸 28 로(속성 없는 칸이 규칙에서 빠졌음)
- M02 가 대신 불러오던 M01 로직 고르기 창 모양 걷어냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 22:35:50 +09:00
eomsangdonandClaude Opus 5.5 cd2690450e feat(spreadsheet): 긴 글이 옆 빈 칸으로 넘쳐 보이게(엑셀)
- 왼쪽 · 일반 맞춤 글은 오른쪽으로 · 오른쪽 맞춤은 왼쪽으로 · 가운데는 양쪽으로 — 옆 칸이 빌 때만
- 비지 않은 칸 · 병합 칸 · 틀 고정 경계에서 멈춤 · 가운데 맞춤이 한쪽만 막히면 그쪽을 잘라 냄
- 줄 바꿈 켠 칸 · 숫자(####) · 오류는 안 넘침 · 덮은 칸 사이 눈금선 감춤 · 고치는 중에는 활성 칸 넘침 감춤
- 폭 계산은 새 파일 spreadsheet_spill.ts(격자 파일이 이미 700 줄 넘어 따로) · 정확한 글 폭은 글 요소
  scrollWidth 를 판마다 한 번에 읽음
- ORCA(M02 면적산출 편집 창) 수치 확인 — 왼 · 오른 · 가운데 넘침 글 잘림 0 · 막힘 · 병합 · 틀 고정 멈춤 ·
  스크롤 · 확대 뒤 칸에 붙어 있음 · 비지 않은 칸과 겹침 0 · 실제 문서 새 그림 겹침 0 · 크기 시험 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt
2026-09-28 22:33:55 +09:00
eomsangdon db8d3d02d4 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-28 22:33:30 +09:00
eomsangdonandClaude Opus 5.5 60246df689 fix(M02): 떠나기 확인 창 취소 뒤 초점을 시트 · 집계표 · 도면으로 되돌림
- 「저장 안 한 고침이 있음」 창에서 취소하면 고른 칸 입력 자리(시트) · 집계표 · 도면 iframe 으로 초점 복귀 — 키보드 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:31:10 +09:00
eomsangdonandClaude Opus 5.5 990e8e6733 fix(M02): 좌측 고름 표시가 두 줄 동시에 남던 것 — 열린 것 한 줄만
- 원인: 좌측 목록 줄은 목록만 · 구조물 열 줄은 열 줄만 다시 그려 이전 컨테이너 고름이 남음 · 카드 [도면 수정] 등은 좌측에 안 알림
- 고침: 편집 칸이 무엇을 열든(좌측 · 카드 · 다시 읽기) `onOpened` 로 좌측 `paint` 한 곳을 부름 — 목록 · 열 줄 · 단추를 함께 그림 · 따로 든 고름 상태(`picked` · 안 쓰던 `select`) 지움
- 확인: ORCA 5180 에서 카드 [도면 · 면적산출 · 재료산출 수정] → 다른 컨테이너 · 같은 컨테이너 다른 줄 · 집계표로 돌아옴 17 걸음 모두 고름 줄 1 개(고치기 전 2 개 재현) · 만든 것 없음 · 탭 닫음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zGnt85s95baQ72rhKBCJ4
2026-09-28 22:28:15 +09:00
eomsangdon 5f500ab8b9 Merge remote-tracking branch 'origin/dev' into sub_laptop_7
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts
#	M02_MasterTemplete/M02_MasterTemplete_Branch.py
2026-09-28 22:28:01 +09:00
eomsangdon e5118855e2 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7 2026-09-28 22:25:53 +09:00
eomsangdon ff1436c85b Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7 2026-09-28 22:25:52 +09:00
eomsangdon 59c394efaf Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7 2026-09-28 22:25:51 +09:00
eomsangdon fe8136fb3f Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-09-28 22:25:50 +09:00
eomsangdonandClaude Opus 5.5 4abfc5b265 refactor(M02): 자재 조합 표를 마스터 템플릿으로 옮김 · M02 좌측 「자재 조합」 컨테이너
- 표 resources/master_data/자재조합.json → resources/master_template/자재조합.json(git mv) · 키 UM 은 표 안 「다음」 번호 · _키대장 · master_keys 의 UM 뺌 · check_master 틀이 새 자리를 같이 봄
- 서버: M01 /api/m01/matset* 뺌 · M02 저장소 M02_MasterTemplete_Matset.py(목록 · 새로 · 고치기 · 지우기 · 쓰는 열 검사) · 길 GET/POST /api/m02/material-combos · PUT/DELETE /api/m02/material-combos/{키} · 갈라 내기(branchMaterialCombo)도 이 저장소로
- 화면: M01 좌측 「자재 조합」 컨테이너 뺌 · M02 좌측 「자재 조합」 컨테이너(찾기 · 키 맨 앞 목록 · 새로 · 본떠 만들기 · 지우기) · 메인 칸 상세(이름 · 비고 · 본뜬키 · 담은 자재 표 · 자재 더하기 · 빼기 · 저장) · 쓰는 열이 있으면 지우기는 확인 창 없이 알림
- 시험: test_m01_matset → test_m02_matset · test_m02_combo_branch 사본 자리 · 실제 자재 코드

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwJQ5eRrCKf3nH9EFeKvJz
2026-09-28 22:25:30 +09:00
eomsangdon 5932f5d260 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_8 2026-09-28 22:24:26 +09:00
eomsangdonandClaude Opus 5.5 e4e2dc2aac fix(M02): 조합 갈라 내기 같은 내용 판정에 차례 · 메모 포함 · 서버 거절 까닭 그대로 알림
- 일위대가 조합: 담은 로직 차례 · 메모까지 같아야 있는 키(원래 키 먼저) — 차례 · 메모만 바꿔도 새 조합
- 자재 조합: 담은 자재 차례까지 비교
- M02 Api_Fetch: detail 문장 · errors 목록 · 입력 검사 목록을 알림 글로(HTTP 422 대신)
- 새 조합 자동 이름: 원래 이름 끝 「 (n)」 뗌(겹친 (2) (2) 막음)
- test_m02_combo_branch.py 새 규칙으로 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:24:08 +09:00
eomsangdon 513cebe023 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_5 2026-09-28 22:21:46 +09:00
eomsangdonandClaude Opus 5.5 52d441e3a9 fix(spreadsheet): 칸 다루기 전수 점검 흠 11 고침 — 고름 테두리 · 채우기 손잡이 · 틀고정 빈 띠 · 입력 형식 · 잘라 붙이기 옮김
- 격자: 고름을 옮겨도 옛 활성 칸 파란 테두리가 남던 것 · 채우기 손잡이가 범위 오른아래가 아니라
  활성 칸 모서리에 있던 것(여러 범위 · 읽기 전용 카드에서는 손잡이 없음) · 틀 고정 뒤 본문 판
  아래 · 오른 끝(틀 고정 크기만큼)이 안 그려져 비던 것(보이는 행열을 틀 고정만큼 밀어 잼) ·
  오류 칸 글자색을 테마 토큰으로(어두운 테마 대비 2.7 → 5.0)
- 입력: `50%` → 0% 형식 · `1,234.5` → #,##0.0 형식을 함께 붙임(엑셀 · 되돌리기 한 번) · 식의
  함수 · 참조 · 참거짓을 대문자로 · 닫는 괄호만 모자라면 채움 · 「식이 덜 끝남」 알림 글
- 클립보드: 잘라낸 칸을 같은 문서에 처음 붙이면 `옮기기` 로 — 그 칸을 가리키던 식도 따라감
  (전에는 식이 0 · 빈 칸을 가리킴) · 그 사이 잘라낸 자리에 새로 쓰면 보통 붙여넣기
- 시험: test_spreadsheet_saved_values.py(면적 · 재료 저장본 102 권 서버 재계산 = 저장 계산값) ·
  d_input 에 형식 · 대문자 · 괄호 규칙 · ORCA M02 면적산출 편집 창 목록 38 항목 재실행 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt
2026-09-28 22:20:35 +09:00
eomsangdonandClaude Opus 5.5 26772b87bd fix(B07 CAD·M02): 16장 점검 — 테마 어긋남 · 첫 맞춤 · 불러온 양식 떠나기 · 작도 영역 안내
- CAD 테마: 저장값이 없으면 부모 문서 테마(호스트 기본 밝음)를 따름 — 시스템 설정을 따라 CAD 만 어둡던 것
- 맞춤 뒤 화면을 안 옮겼으면 캔버스 크기가 바뀔 때 다시 맞춤 — 첫 표시 때 iframe 이 늦게 커져 어긋나던 것 · 빈 도면은 원점을 가운데로
- 도면 양식 📂 불러온 뒤 저장 안 하고 떠나면 물음
- 작도 영역 칸: 빈 칸 · 숫자 아님은 그 칸만 짚음
- 화면 검증용 창구에 기준점 · 보기 전용 상태 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qWfH6MMhLwjhtLYnDVUXv
2026-09-28 22:18:59 +09:00
eomsangdon de7032bc3b Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-28 22:15:35 +09:00
eomsangdonandClaude Opus 5.5 214c55a389 fix(M02): 쓰는 양식 지우기는 확인 창 전에 바로 막음
- 서버 목록이 쓰는 양식(구조물집계표 · B07 도각 · 방위표 · 표지)에 `쓰는곳` 을 실음 — IN_USE 한 벌
- 좌측 [지우기] 가 `쓰는곳` 이 있으면 확인 창 없이 「쓰는 양식이라 못 지움 「이름」 — 쓰는 곳」 알림
- 서버 409 막기(6606ef16)는 그대로 두 겹 · M01 화면은 안 건드림
- 시험: 목록의 `쓰는곳` 대조 · ORCA 5180 에서 방위표 · 구조물집계표는 확인 창 없이 알림, 임시 양식은 확인 창 뒤 지워짐(만든 것 되돌림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zGnt85s95baQ72rhKBCJ4
2026-09-28 22:14:35 +09:00
eomsangdon a7ffde0247 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-28 22:09:35 +09:00
eomsangdonandClaude Opus 5.5 841af0a033 fix(M02): 16장 점검 — 면적산출 · 재료산출 · 조합 고치기 · 알약 · 자재 옆 판 흠 고침
- 조합 고치기 [로직 더하기] 창이 M02 에서 화면 밖(페이지 아래)으로 뜨던 것 — 고르기 창 겉틀 CSS 를 같이 받음
- 조합 고치기 창을 고친 채 닫기 · Esc · 바깥 누르기 — 버릴지 물음(공용 창 틀에 beforeClose 더함)
- 새 조합 이름: 빈 이름 · 60자 넘음은 까닭 글로 막음(서버 422 「HTTP 422」 대신) · Enter 로 만듦 · 자동 이름 겹침 없앰(구조물 이름이 조합 이름을 품으면 하나만)
- 담은 로직 · 담은 자재를 다 빼고 [적용] — 서버 400 대신 미리 까닭 알림
- 옆 판에서 이은 자재 [빼기] — 확인 글대로 연결을 바로 풂(전엔 적용 전까지 알약이 연결된 채 남음)
- 이미 담은 자재를 또 고르면 알림 · 메모 칸 전체 글을 title 로
- 알약 우클릭 메뉴가 누른 자리에서 부품 원점만큼 밀려 뜨던 것 바로잡음
- 저장 전 새 열(분류 더하기)의 [수정] — 집계표 고침을 버리고 빈 화면으로 가던 것 막고 「집계표를 먼저 저장」 알림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:09:24 +09:00
eomsangdon db52cdabae Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-28 22:03:52 +09:00
eomsangdonandClaude Opus 5.5 ae47bcd921 fix(M01): 일위대가 조합 화면 점검 흠 고침 — 찾기 · 떠나기 확인 · 본뜬키 칸 · 표 폭 · 글
- 왼쪽 컨테이너에 이름 · 키 찾기 칸 · 거름 · 찾기를 만지면 다른 화면이 떠 있어도 조합 화면으로 · 걸러서 없으면 「찾은 조합 없음」
- 저장 안 한 고침이 있으면 [←] 에서 물음 · 다른 컨테이너 갔다 와도 고침 판정이 안 풀림
- 본뜬키를 다른 칸과 같은 읽기 전용 칸으로 · 표 · 미리 보기가 칸 폭을 채움 · 금액 오른쪽 맞춤 · 긴 이름 풍선
- 로직 고르기 창 열면 찾기 칸에 글쇠 · Enter 로 찾기 · 이름 칸 60자까지 · 서버 검사 글 머리(파일 · 새 키) 뗌 · 지우기 확인에 키 · 이름
- 흐린 안내 글을 보조 글색으로(밝은 테마 대비 3.1 → 4.5 넘김) · 계산 · 저장 규칙은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 22:03:46 +09:00
eomsangdonandClaude Opus 5.5 f8dda99fc8 fix(ui): 표 도움말 글 대비 3.1 → 6.3(보조 글 색)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 22:01:34 +09:00
eomsangdonandClaude Opus 5.5 16de9eed8b fix(M02): 16장 점검 — 지운 열 번호 재사용 막음 · 이름 창 커서 · Enter 만들기 · 바인딩 열 손 입력 막음
- 지운 구조물 열의 도번 8자리를 조합 장부 지운번호에 남겨 새 열이 다시 안 받음(다음 저장에서도)
- 공용 창 틀: 몸의 첫 입력 칸에 커서(붙기 전 focus 가 안 먹던 것)
- 좌측 새로 · 본떠 만들기 이름 창에서 Enter = 만들기
- 표: 바인딩 · 계산 열은 손 입력 열 단추 꺼짐(켜도 프로젝트에서 잠김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 21:56:51 +09:00
eomsangdonandClaude Opus 5.5 6606ef167a fix(M02): 쓰는 양식(구조물집계표 · B07 도각 · 방위표 · 표지) 지우기 막음
- 서버 delete 가 409 「쓰는 양식이라 못 지움」 · 이름은 B07 엔진 상수와 시험으로 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 21:35:24 +09:00
eomsangdonandClaude Sonnet 5 527e68db85 fix(ui): 공용 창 틀 뒤를 어둡게 · 창 그림자 · 표시 형식 칸 흐린 글
- ui-modal 뒤 배경을 공용 확인 창처럼 어둡게 가리고 창에 테두리와 테마 그림자(--shadow-lg)를 줌 — 창이 겹치면 앞 창이 또렷이 섬
- M01 LogicLab 창(.m01-logic__backdrop · .m01-logic__pick) 도 같은 겉모양으로 맞춤
- 단추 아래 뜨는 작도 영역 작은 창(.m02-pop)은 뒤를 가리지 않음
- 스프레드시트 도구 막대 표시 형식 입력 칸에 흐린 글 「표시 형식」

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 20:59:06 +09:00
eomsangdonandClaude Sonnet 5 fb5aee7ea2 fix(M02): 옛 알약 연결 이름+단위로 맞춤 · 옆 판 이은 칸 즉시 갱신 · 새 조합 이름 겹침 · 첫 자재 조합 모달 글
- 알약 막대: 키가 목록에 없는 옛 연결(로직 키)을 이름 + 단위가 같은 알약으로 이어 새 키로 바꿈 — 따로 붙던 「연결」 알약 제거
- 재료산출 옆 판: 알약을 이음 · 풂 즉시 「이은 칸」 갱신
- 새 조합 이름: 원래 이름이 구조물 이름으로 끝나면 덧붙이지 않음(일위대가 · 자재)
- 자재 조합이 없는 열의 첫 생성 모달 글: 「이 열에 새 자재 조합을 만듦」

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 20:22:06 +09:00
eomsangdon 453095f6db Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-09-28 19:43:40 +09:00
eomsangdon 8851668024 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-28 19:43:38 +09:00
eomsangdonandClaude Opus 5.5 f528378f2c chore(M02): 사용자 화면 저장분 — 돌기슭막이 구조물 도면 · c4 면적산출 · 재료산출
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNJBegcapQYMwkKAf8D7S
2026-09-28 19:43:30 +09:00
eomsangdonandClaude Sonnet 5 2d70be32ea fix(spreadsheet): 어두운 테마에서 글자가 검정으로 남던 메뉴 · 팝업을 테마 토큰으로
브레인 지시로 M02 면적산출(SA01010100) · 재료산출(SM01010100) 화면의 메뉴 · 팝업을 ORCA eval 로
전수로 열어 글자색 대 실제 바탕색 대비를 잼(투명이면 조상으로 올라가 겹침 · 4.5 미만 + 어두운
테마에서 흰 바탕 폼 칸 표시). 원인은 color 를 안 준 브라우저 기본 색(button · input · textarea)과
없는 토큰 · 검정 고정.
- spreadsheet.css: 서식 붓 단추(.ss-brush · 서식… 붓 ↑ ↓ 필터 Σ 데이터 ▾) · 찾기/바꾸기 입력 ·
  찾기 단추 · 메모 · 골라 붙여넣기 단추 · 목록 ▼ 단추에 color 를 줌 · 주소 상자(.ss-addr) ·
  수식 입력줄(.ss-formula)은 흰 바탕 · 검정 글이던 것을 투명 바탕 · 글 상속으로
- spreadsheet_links.css: 자재 찾기 입력 바탕 · 글 토큰
- spreadsheet_toolbar.css: 시트 탭 이름 바꾸기 입력 · select 의 option 바탕 · 글 토큰
- spreadsheet_sortfilter.css · spreadsheet_analysis.css · spreadsheet_links.css: 정의 안 된
  --color-surface-sunken(마우스 올림 바탕이 안 나오던 것)을 --color-mist-violet 로(색 줄만 ·
  주인 A · D 파일이라 최소로)
- spreadsheet_sortfilter.css: 정렬 창 select 의 option 바탕 · 글 토큰
- spreadsheet_format_dialog.ts: 글꼴 미리보기 글자색 · 테두리 미리보기 기본 선색이 검정 고정이던 것을
  「자동」(검정 · windowtext · auto)이면 테마 글자색으로(isAutoInk 재사용)
- spreadsheet_data_tools.ts: 텍스트 나누기 · 연속 채우기 창의 select 두 곳에 ss-toolbar__select 를 붙여
  흰 바탕 · 검정 글이던 것을 테마로

ORCA(IP http://100.72.32.27:5181 · 전환 애니메이션 끄고 잼) — 어두운 테마: 기본 화면 · 셀/행 머리/열 머리
우클릭 메뉴 · 데이터 메뉴와 하위 · 조건부 서식 · 유효성 · 이름 관리 · 정렬 · 텍스트 나누기 · 중복 제거 ·
연속 채우기 · 칸 넣기/지우기 · 이동 · 셀 서식 창 여섯 탭 · 찾기/바꾸기 · 수식 편집 + 함수 자동완성 ·
메모 창 · 단위 · 테두리 패널 · 자재 알약 찾기 · 자동 필터 값 목록 · 풍선 · 붙여넣기 선택 · 유효성 목록 ·
오류 ⚠ 메뉴 · 수식 계산 · 알림, SM · SA 두 시트 모두 대비 4.5 미만 · 흰 바탕 폼 칸 0 건(고치기 전에는
서식 붓 계열 7 개가 글 rgb(0,0,0) 대 바탕 rgb(37,31,56) 대비 1.33 등). 밝은 테마도 같은 목록으로
재서 새로 깨진 곳 없음(남은 것은 --color-text-muted 가 밝은 테마 바탕에서 3.08 인 기존 토큰 값).
css 8KB 한도: 가장 큰 spreadsheet_toolbar.css 7432 바이트.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
2026-09-28 19:42:25 +09:00
eomsangdon 1c371fca85 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_6 2026-09-28 19:19:51 +09:00
eomsangdonandClaude Sonnet 5 e06d43a6bc feat(M02): 조합 고치기 · 자재 목록을 실제 갈라 내기 길에 연결 · 알약 자재 항목 = 옆 판 목록 · 줄 누르면 이은 칸으로
- 가정하던 서버 함수를 실제 branchCombo · branchMaterialCombo · fetchMaterialCombo(M02 Api_Fetch)로 바꾸고 가정 파일은 열 조합 읽기만 남김
- 재료산출 알약 막대 자재 항목을 그 열 자재 조합 담은 자재로 · 알약 더하기 · 빼기(onItemsChange)와 옆 판 표를 같은 목록으로 맞춤
- 옆 판 줄을 누르면 이은 알약을 눌러 그 칸으로 이동(깜빡임) · 옆 판에서 고치면 알약 막대만 다시 만듦(시트 고침 유지)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 19:19:27 +09:00
eomsangdonandClaude Sonnet 5 4c8ee6828f feat(M01): 자재 조합 화면 — 목록 · 상세 · 자재 더하기 · 지우기 알림
- 좌측 「자재 조합」 컨테이너(찾기) + 우측 목록(키 · 이름 · 자재 수 · 본뜬키 · 소유) · 상세(이름 · 비고 · 본뜬키 · 담은 자재 표 · 빼기)
- [자재 더하기] = 자재DB 낱말 찾기 → 하나씩 고름(이미 담은 자재는 잠김) · 저장 · 지우기는 M01 길 /api/m01/matset…
- 쓰는 구조물 열이 있어 지울 수 없으면 서버가 준 구조물 이름들을 알림으로 보임
- 알약 키 「이름|단위」 주석 갱신

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 19:14:10 +09:00
eomsangdon 76bb9aec55 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-09-28 19:11:00 +09:00
eomsangdonandClaude Sonnet 5 d35269e32a feat(M01): 자재 조합 M01 길 — 목록 · 하나 · 새로 · 고치기 · 지우기(쓰는 열 있으면 막음)
- /api/m01/matsets · matset · matset/new|edit|delete — sub4 의 자재조합.json(UM) 위에 얹음 · 담은 자재 코드가 마스터에 없으면 422 · 본뜬키는 있는 다른 UM 만
- 지우기 전 집계표 · 구조물집계표조합의 「자재조합」 칸을 훑어 쓰는 열이 있으면 409 + 구조물 이름들
- 일위대가 · 자재 조합 지우기 검사가 users_of 한 곳을 씀 · 계약 9장 「자재 조합」 표 더함
- 시험 다섯(resources/tester/test_m01_matset.py)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 19:08:20 +09:00
eomsangdonandClaude Sonnet 5 625c1012c8 Merge origin/sub_laptop_4 into sub_laptop_3 — 본뜬키 칸 · 자재 조합 표는 sub4 판 기준
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 19:01:22 +09:00
eomsangdonandClaude Sonnet 5 1904cd5072 wip(M01): 자재 조합 M01 길 초안 — 합치기 전 임시(sub4 자재조합 표 위에 얹을 예정)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 19:00:53 +09:00
eomsangdon ea636fb6fc Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-28 19:00:13 +09:00
eomsangdonandClaude Opus 5.5 e76e638013 feat(M02): 조합 갈라 내기 길 · 자재 조합(UM) 표 · 본뜬키 칸 · 분류 더하기 열 복사
- POST /api/m02/structures/{열}/combo — 고친 담은 로직으로 새 UA(같은 로직 집합이면 있는 키) · 집계표 그 열만 새 키 · 판 어긋나면 조합 만들기 전 409 · SS 장부 맞춤
- 일위대가 조합 줄에 본뜬키 칸(원래 키) · 기존 37 줄 null · M01 고치기에서 안 보내도 유지
- 자재 조합 표 resources/master_data/자재조합.json(키 UM · 대장 등록 · 틀 검사) · POST /structures/{열}/material-combo · GET /material-combos/{키}
- 구조물집계표조합 칸 자재조합 · 분류 더하기로 복사한 열은 저장 때 원본 열의 일위대가 · 자재조합 키와 구조물 도면 · 면적산출 · 재료산출 내용을 새 번호로 받음
- Api_Fetch branchCombo · branchMaterialCombo · fetchMaterialCombo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 18:56:51 +09:00
eomsangdonandClaude Sonnet 5 0fb69c053b feat(M01): 일위대가 조합에 칸 「본뜬키」 더함 · 목록 · 상세에 보임 (계약 더함)
- 열 한 벌에 「본뜬키」(소유 다음 · 비고 앞) — 비면 원본 · 있으면 본뜬 원본 조합 키
- 서버가 없는 키 · 자기 키를 400 으로 막고 검사(check_master)도 같은 규칙
- 정본 조합 37 줄에 본뜬키 null 채움 · 목록 「본뜬키」 열 · 상세 「원본 / 본뜬키」 줄
- _틀.md 10장 · _화면_계약.md 9장 갱신 · 시험 하나 더함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 18:56:28 +09:00
eomsangdonandClaude Sonnet 5 0c3b089719 feat(M02): 면적산출 조합 줄 · 조합 고치기 · 재료산출 자재 목록 옆 판
- 면적산출 편집 화면 알약 막대 위 한 줄(일위대가 키 · 이름 · 담은 로직 수 · 같이 쓰는 다른 열 수 · 본뜬키) + [조합 고치기](시스템 관리자만)
- 담은 로직 표(더하기 = M01 로직 찾기 · 빼기 · 차례 · 메모) → [적용] 때 갈라 내기 모달(이름 자동 미리 채움) → 새 조합 알림 → 알약만 다시 받음(시트 고침 유지)
- 재료산출 엑셀 옆 접히는 판에 자재 조합 담은 자재 표 · 더하기 · 빼기(이은 칸이면 연결도 풂 확인) · [적용] 모달
- 서버 길(branchCombo · branchMaterialCombo · 자재 조합 읽기)은 아직 안 올라와 M02_MasterTemplete_Combo_Api.ts 한 곳에 가정 모양으로 둠

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 18:51:03 +09:00
eomsangdon 820a44f79d Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_3 2026-09-28 18:46:26 +09:00
eomsangdon 9d24cb04b9 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-28 18:46:24 +09:00
eomsangdonandClaude Sonnet 5 b6e71c729d feat(M01): 조합 알약 키 「이름|단위」 · 쓰는 구조물 열이 있으면 조합 지우기 막음
- 알약 키를 첫 로직 코드에서 「이름|단위」 로 — 로직을 빼고 더하고 차례를 바꿔도 같은 물량이면 같은 키
- 조합 지우기 전 집계표 · 구조물집계표조합의 「일위대가」 칸을 훑어 쓰는 열이 있으면 409 + 구조물 이름들
- 시험 셋 더함(키 안 바뀜 · 쓰는 조합 못 지움 · 안 쓰는 조합 지워짐)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
2026-09-28 18:46:12 +09:00
eomsangdon c32e2cbd5d auto: 2026-09-28 18:46 (ESD_LAPTOP) 2026-09-28 18:46:00 +09:00
eomsangdonandClaude Sonnet 5 ff0d9775ec feat(ui): 공용 단추 작은 크기 size sm 추가 · M01 표 단추 · M02 단추 적용 · M02 상세 머리 이름 줄바꿈
- createButton 에 size: "sm"(글자 캡션 크기 · 위아래 여백 2px) — 다섯 모양 모두에 먹음 · 기본은 그대로
- M01 표 안 단추는 sm 으로 바꾸고 M01 자체 축소 규칙 삭제
- M02 좌측 새로 · 본떠 만들기 · 지우기 · 카드 수정 단추에 sm
- M02 상세 화면 머리 번호 + 이름 잘림 없이 줄바꿈 · 집계표 카드 머리는 번호 · 수정 한 줄 + 이름 아래 전체 폭

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 18:42:23 +09:00
eomsangdon 3f39b5b6c3 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into main_laptop_1 2026-09-28 17:15:21 +09:00
eomsangdonandClaude Sonnet 5 00b08bf152 fix(M02): 구조물 도면에서 작도 영역 단추 · 자리표 표시 제거
- 구조물 도면(집계표 도면 정보 미리보기 · 도면 수정 편집)은 도각이 없어 작도 영역이 필요 없음
- 저장 문서의 작도 영역 값은 읽지도 쓰지도 않음
- 도면 양식(A1 · 표지 등) 편집은 그대로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qWfH6MMhLwjhtLYnDVUXv
2026-09-28 17:13:23 +09:00
eomsangdonandClaude Sonnet 5 6198fad176 feat(M02): 좌측 새로 · 본떠 만들기 · 지우기를 표 양식 · 도면 양식 컨테이너 안으로 · 본떠 만들기 흠 고침
- 맨 아래 단추 줄 없앰 · 표 양식 · 도면 양식 컨테이너마다 [새로] [본떠 만들기] [지우기] · 새로 = 그 종류로 이름만 · 본떠 · 지우기 = 그 컨테이너에서 고른 양식이 있을 때만 켜짐 · 구조물 도면 · 면적산출 · 재료산출 컨테이너는 단추 없음 · 시스템 관리자만
- 본떠 만들기 흠: 표 복사본의 머리 이름(양식)이 원본 이름으로 남던 것을 새 이름으로
- 이름 규칙: 80자 넘는 이름이 서버 500 이던 것 · 앞 밑줄 이름이 새 프로젝트 복사를 깨던 것을 서버 · 화면 둘 다 막음
- 시험: 복사 독립 · 새 프로젝트 복사 · 이름 규칙 · 컨테이너 안 단추

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 17:08:53 +09:00
eomsangdon 73829e7bde Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-28 17:01:07 +09:00
eomsangdon 4ea695907e auto: 2026-09-28 17:00 (ESD_LAPTOP) 2026-09-28 17:00:57 +09:00
eomsangdonandClaude Sonnet 5 c4735e4669 feat(M02): 집계표 아래 카드 — 표 아래 남은 창 높이만큼만 차지(메인 화면 세로 스크롤 없음) · 세 카드 같은 높이 · 최소 16rem 에서 멈추고 그때만 스크롤
- 집계표 카드가 보이면 메인 칸 = 창 높이 · 카드 영역 flex 로 남은 높이 · 카드 본문이 높이를 채움 · 표는 줄지 않음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
2026-09-28 16:48:14 +09:00
eomsangdon f85aa9136a Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-28 16:38:48 +09:00
eomsangdon f1e0809d6c auto: 2026-09-28 16:38 (ESD_LAPTOP) 2026-09-28 16:38:10 +09:00
eomsangdon 2a0f359c7c Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-28 16:30:24 +09:00
eomsangdonandClaude Opus 5.5 32c3081b68 fix(B07 CAD·M02): 가운데 단추 끌기 때 페이지가 같이 움직이던 것 · 집계표 미리보기는 보기 전용 조작
- 캔버스 가운데 단추 mousedown · auxclick 의 기본 동작(자동 스크롤 · 붙여넣기)을 막음 — 휠은 이미 passive:false 로 막음.
  pointerdown 은 막지 않음(막으면 mousedown 이 안 와 팬이 끊김).
- 미리보기(viewOnly — M02 구조물집계표 「도면 정보」 카드): 왼쪽 끌기 = 화면 옮기기 · 휠 확대 ·
  두 번 누름 = 전체 맞춤 · 선택 · 가까운 개체 강조 · 스냅 · 십자 커서 · 명령 안내 · 좌표 풍선 없음(손 모양 커서).
  [도면 수정] 편집 화면 · 도면 양식 · B07 은 그대로.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 16:27:55 +09:00
eomsangdon a9ad227068 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-09-28 16:27:15 +09:00
eomsangdonandClaude Sonnet 5 c8c0698c91 fix(ui_template): 펼친 좌측 패널 손잡이를 패널 안쪽 가장자리로
- 손잡이가 패널 밖 24px 로 나와 본문 카드 왼쪽 끝을 덮던 조각 제거
- 펼침: 폭 16px · 패널 오른쪽 안쪽 · 접힘: 지금처럼 화면 왼쪽 끝
- M01 · M02 · B03~B09 손잡이 오른쪽 끝 319 · 본문 시작 320 이상(겹침 0)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-28 16:22:23 +09:00
eomsangdon 14c4a507d6 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-28 16:15:27 +09:00
eomsangdonandClaude Opus 5.5 44a6ec51db fix(B07 CAD): 어두운 테마에서 검정 선이 안 보이던 것 — 자동색(흰/검정)
검정 · 흰색(DXF 색 7) · 기본색(#f5f7fa = 새 개체 · DXF ByLayer)은 오토캐드처럼 자동색으로 보아
어두운 바탕에서는 흰색 · 밝은 바탕에서는 검정으로 그림(그릴 때만 · 저장 값은 그대로).
다른 색은 그대로 · 어두운 바탕에서 어두운 색을 밝히지 않음 · 밝은 바탕의 밝은 색 대비 보정은 기존대로.
좌표 입력 칸 바탕도 자동색(검정)으로 바꿔 두 테마에서 글이 읽힘.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 16:14:22 +09:00
eomsangdonandClaude Opus 5.5 6c0014a430 feat(M02): 구조물집계표조합 표 — 구조물 열마다 한 줄 · 키 SS · 집계표 [저장] 때 서버가 맞춤
- resources/master_template/구조물집계표조합.json (그룹 · 판 · 비고 · 다음 · 줄) · 지금 열 51개로 처음 만듦(SS000001~SS000051)
- 줄 칸: 키 · 열 · 종류 · 분류1~3 · 단위 · 일위대가 · 도번 · 면적번 · 재료번 · 바인딩 · 펼침 · 꼴=수 열만
- 새 열 = 새 SS 키 · 지운 열 = 줄 지움(키 다시 안 씀) · 고친 열 = 칸 갱신 · 새 프로젝트 복사에도 실림
- GET /api/m02/structure-combos · Api_Fetch fetchStructureCombos()
- 시험: 견본 표로 키 유지 · 새 열 · 지운 열 · 글 열 제외 · 키대장 머리글과 안 겹침 · 프로젝트 복사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 16:12:12 +09:00
eomsangdon 165901d58d Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-09-28 16:09:19 +09:00
eomsangdonandClaude Opus 5.5 eed7ffaf42 feat(cad): 구조물 도면 미리보기 좌측 패널은 접힌 채로 엶 · 리본 접힘 모드에서 탭으로 편 리본은 밖을 누르면 다시 접힘
- M02 미리보기: 특성 · 도면층 패널을 숨기던 스타일에서 .cad-inspector 뺌 · load 곁값 panelsCollapsed: true 로 접힌 채 시작(› 누르면 펼침)
- cad_host CadLoadExtra · 다리에 panelsCollapsed 칸 · B07 은 안 보냄 = 지금처럼 펼침
- 좌측 패널 접힘 상태를 ui-state 로 옮김
- 리본: 접힘 모드에서 탭 = 도면 위에 겹쳐 잠깐 펼침 · 리본 밖 누름 = 다시 접힘 · 접기 단추 = 고정 펼침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 16:08:57 +09:00
eomsangdonandClaude Sonnet 5 15715c2bda feat(spreadsheet): C 격자 — 엑셀 「자동」 색(검정 글 · 검정 선 · 흰 채움)이 테마를 따름
엑셀에서 붙여넣은 칸의 글자색 #000000 · 테두리색 검정이 저장돼 어두운 테마에서 검정 글이
안 보이던 문제. 그릴 때만 「자동」으로 봄(저장 값은 그대로).
- 글자색 · 테두리색: #000 · #000000 · #FF000000 · rgb(0,0,0) · black · windowtext · auto →
  테마 글자색(--aislo-grid-fg) 을 따름
- 채움: #fff · #ffffff · white · window → 채움 없음(테마 바탕이 비침)
- 다른 색(#c00000 · #FFFF00 · #1F3864 등)과 조건부 서식 색은 그대로
- 테두리 기본색을 currentColor 에서 --aislo-grid-fg 로 — 글자색이 빨강이어도 선은 자동색
- 숫자 형식 [Black] 색도 자동으로 봄

ORCA(시험 틀 · localhost:5181 과 100.72.32.27:5181 둘 다) — 밝은: 자동 칸 글 rgb(51,51,51) ·
바탕 rgb(255,255,255) · 선 rgb(51,51,51). 어두운: 글 rgb(228,224,240) · 바탕 rgb(37,31,56) ·
선 rgb(228,224,240). 지정색 칸(#c00000 글 · #FFFF00 채움 · #0000FF 선 · #1F3864 · #D9E1F2)은 두
테마에서 값 그대로. 붙여넣기 쪽은 안 바꿔도 됨(저장 값 그대로 두고 그릴 때만).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-28 16:01:21 +09:00
eomsangdon fa572cd0b7 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into main_laptop_1 2026-09-28 15:47:42 +09:00
eomsangdonandClaude Sonnet 5 ac83e1b550 feat(M02): 집계표 아래 카드 — 도면 정보 · 면적산출 · 재료산출 3열 같은 폭 · 카드 최소 300px 아래로 좁으면 2열 → 1열 자동
- grid auto-fit/minmax 한 규칙 · 도면 정보 전체 폭 고정 제거 · 높이는 통일 변수 그대로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 15:37:58 +09:00
eomsangdonandClaude Opus 5.5 ec41d50d92 fix(A00·B07 CAD): http + IP 주소(Tailscale)에서 CAD 가 죽던 것 — crypto.randomUUID 대체
보안 문맥이 아닌 http + IP 에서는 crypto.randomUUID 가 없어 CAD 개체 만들기에서 멈춤.
앱 본체(A00_Common/main.ts)와 CAD(openwebcad/src/main.tsx) 진입 맨 앞에서
getRandomValues 로 v4 UUID 를 채움 · 이미 있으면 그대로.
crypto.subtle(B03 지문) · serviceWorker(B03) 는 이미 없을 때 건너뜀 · navigator.clipboard 는 안 씀.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 15:37:22 +09:00
eomsangdonandClaude Sonnet 5 fedc860365 feat(M02): 집계표 아래 카드 — 도면 정보 CAD 높이 = 면적산출 · 재료산출 표 높이(한 값 · 창 높이에 맞춰 함께 변함)
- 세 카드 내용 높이를 CSS 변수 하나(clamp 18rem · 60vh · 40rem)로 통일 · 한 줄씩일 때도 같음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 15:36:05 +09:00
eomsangdonandClaude Sonnet 5 93c817658a feat(M02): 집계표 아래 카드 — 창 크기에 맞춰 자동 분할(두 열 최대 · 카드 최소 360px) · 카드 높이도 창 높이에 맞춤
- 칸 폭 728px 이상이면 면적산출 · 재료산출 반반 · 그보다 좁으면 한 줄에 하나씩 · 창 크기를 바꾸면 바로 반영
- 미리보기 · 표 높이를 창 높이 비율(clamp)로 · 작은 창에서 카드가 화면을 다 먹지 않게

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 15:28:49 +09:00
eomsangdonandClaude Sonnet 5 73040246ae feat(M02): 집계표 아래 카드 — 면적산출 · 재료산출 두 카드 + 간격이 도면 정보 폭과 같음 · 카드 최소 폭 520px
- grid auto-fit/minmax 한 규칙 · 카드 하나가 520px 안 되면 한 줄에 하나씩(각각 도면 정보와 같은 전체 폭)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 15:13:12 +09:00
eomsangdonandClaude Opus 5.5 c608b9554f test(M02): 구조물 번호 시험이 실제 구조물집계표 대신 견본 표를 읽음
- 사용자가 화면에서 제목 · 열을 고쳐도 번호 시험이 안 밀림 · 번호 규칙 시험은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 14:52:52 +09:00
eomsangdonandClaude Opus 5.5 ec8812e829 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 — 사용자 열 c2~c5 살림
- 구조물집계표.json 충돌: 이쪽 4칸 제목(분류 없는 열 빈칸)을 바탕으로 사용자 열 c2~c5 를 사용자 자리 · 값 그대로 넣음 · rv · c1 제목 · 열 너비(h_soil_ditch)도 사용자 것
- 번호: c2 SD15010000 · c3 SD02010600 · c4 SD01010101 · c5 SD01020000(Store 규칙과 맞음 · 파일 셋씩 · 셋 같은 8자리)
- area/rv.json 사용자 것 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 14:43:57 +09:00
eomsangdon 7b62305e12 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-09-28 14:41:14 +09:00
eomsangdon 2388d02da9 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-28 14:41:13 +09:00
eomsangdon 3660924f80 auto: 2026-09-28 14:40 (ESD_LAPTOP) 2026-09-28 14:40:18 +09:00
eomsangdonandClaude Opus 5.5 789b62be9d fix(M02): 집계표 [저장] 뒤 새 열 도면 정보가 안 열리던 것 — 번호 다시 받고 카드 새로
- 원인: 새 열은 [저장] 때 서버가 구조물 · 면적산출 · 재료산출 파일과 번호를 만드는데 화면은 번호 목록을 처음 한 번만 받음 · 같은 열을 다시 고르면 카드를 안 다시 읽어 「도면 없음」 · 번호 빈칸이 다시 열 때까지 남음
- [저장] 뒤 번호 다시 받기 · 도면 · 면적산출 · 재료산출 줄 다시 그림 · 보던 열 카드 다시 읽음
- 같은 열을 다시 고르면 이름만 새로(머리 고친 열)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 14:39:39 +09:00
eomsangdonandClaude Opus 5.5 ccd9bc89c7 feat(M02): 집계표 아래 카드 — 도면 정보 한 줄 전체 · 면적산출 · 재료산출 나란히 두 열
- 좁은 화면(카드 폭 30rem 안 됨)에서는 한 열

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 14:29:20 +09:00
eomsangdonandClaude Opus 5.5 3869852d3d chore(tools): 오토캐드 WEBCLIP LISP 지움 — 별도 설치 없이 WBLOCK·DXFOUT 파일 끌어다 놓기로 바꿈
끌어다 놓기 길(서버 /api/projects/dxf-clip/file · CAD 흐린 테두리 · 안내 글)은 8ebb5532(auto:)에 함께 올라감.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 14:27:56 +09:00
eomsangdon 1d35254c90 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_2 2026-09-28 14:26:15 +09:00
eomsangdon 17e0e92604 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-28 14:26:14 +09:00
eomsangdonandClaude Opus 5.5 f33fa9dac0 feat(M02): 구조물집계표 분류 없는 열도 제목 4칸(종류 · 분류1 · 분류2 · 분류3)
- 소단 · 대피소 · 폐기물처리 등 머리 한 칸 열 · 세월교 · 골막이 · 기타 · 돌붙임 — 세로로 합치던 빈 층을 각각 빈칸으로(빈값 = 분류 없음)
- 글 열(NO · 측점 · 비고)은 그대로 합침 · 번호(도번 · 면적번 · 재료번)는 빈칸도 00 으로 읽어 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 14:26:06 +09:00
eomsangdonandClaude Sonnet 5 a927ae64bc feat(spreadsheet): 엑셀 수식 복사 안내 · 값만 온 붙여넣기 세션 알림
- 도구 모음 오른쪽 끝에 흐린 글씨 안내 추가 — 「엑셀 수식까지: 엑셀에서 Ctrl+`
  → 복사 → 여기 Ctrl+V · 끝나면 엑셀 Ctrl+` 다시」 · 누르면 왜 그런지 두세 줄
  설명(엑셀 Ctrl+` = 「수식 표시」 켜기 · 그 상태로 복사해야 수식이 실림 · 그냥
  복사하면 값만 옴 · 붙인 뒤 엑셀에서 Ctrl+` 다시 눌러 원래대로)
- 도구 모음은 readOnly 면 안 붙는 기존 길을 그대로 써 보기 전용 카드에서는
  자동으로 숨음(새 분기 없음)
- 엑셀 HTML 붙여넣기(출처: "excel")인데 뭉치 전체에 식이 하나도 없으면 세션에
  한 번만 토스트 — 「값만 들어왔음 — 수식까지는 엑셀 Ctrl+` 뒤 복사」
  (sessionStorage 로 한 번 표시 기억 · 못 쓰면 매번 뜰 수 있으나 치명적이지 않음)
- ORCA 로 확인 — 안내 글 그대로 보임 · 눌러 설명 펼침/접힘 · 식 없는 엑셀
  붙여넣기 첫 번은 토스트 뜨고 세션 안 지운 채 두 번째는 안 뜸 · 식이 있으면
  (「=A1+B1」) 애초에 안 뜸 · 기존 클립보드 회귀 시험(TSV) 통과

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 14:24:04 +09:00
eomsangdon 8ebb553259 auto: 2026-09-28 14:23 (ESD_LAPTOP) 2026-09-28 14:23:39 +09:00
eomsangdon 1b229fa4a7 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into main_laptop_1 2026-09-28 13:58:09 +09:00
eomsangdonandClaude Sonnet 5 c5ad429dc3 fix(spreadsheet): 회계 형식 따옴표 CSS 유니코드 이스케이프(\0022) 안 풀리던 것 고침
- 사용자 실제 엑셀 견본(resources/tester/spreadsheet/fixture_excel_user_won.html,
  origin/main_desktop_1)으로 붙여넣기 시험 중 회계류 형식(예: `.xl126`)의
  `\0022` — 진짜 CSS 유니코드 이스케이프(16진 1~6 자리, 따옴표 `"` 를 이렇게
  실음) — 가 한 글자만 풀리고(`\0` → `0`) 나머지 `022` 가 그대로 남아 형식에
  글자 "0022" 가 끼던 것 발견 · `unescapeMsoNumFmt` 가 `\x` 한 글자 이스케이프
  앞에 `\` + 16진 1~6 자리 이스케이프를 먼저 찾게 고침
- 고친 뒤 회계 4구역 형식이 `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"??_-;_-@_-`
  처럼 온전히 들어옴(글자 "0022" 없음 · 남는 홑 역슬래시 없음 · ₩ 없음)
- ORCA 로 이 견본 통째 붙여넣기 확인 — 칸 133개 값 · 병합 9개 · Fixed·0·General·
  0% 이름 형식 그대로 넘김 · 「 =」(앞에 공백 있어 원래도 식 아님) · 「1식」·
  「3식」 글자 칸 모두 글로 남음(식으로 안 잡힘), 기존 회계 형식 견본 회귀 없음
- 수식 흔적(x:fmla) 없음 재확인 — 이 실 견본에도 x:str/x:num 만 있고 수식은
  칸 글 자체가 「=...」 로 시작하는 형태(HTML 클립보드엔 수식이 안 실림)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 13:45:41 +09:00
eomsangdonandClaude Opus 5.5 5fdd5f8a4e feat(tools): 오토캐드 WEBCLIP 명령(AutoLISP)
고른 개체만 DXFOUT(ASCII) → %TEMP%\aislo_webclip.dxf → PowerShell Set-Clipboard 로
파일 글을 클립보드 글로(LISP 로 읽으면 UTF-8 한글이 깨져서) → 명령줄에 「N개 복사됨 ·
웹 CAD 에서 Ctrl+V」. 설치(APPLOAD · 시작하기 세트)는 파일 머리 주석.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 13:44:48 +09:00
eomsangdonandClaude Opus 5.5 e3a9cb65d0 feat(B07 CAD): Ctrl+V 로 오토캐드 DXF 붙여넣기 · 오른쪽 위 WEBCLIP 안내
Ctrl+V 기본 동작을 막지 않아 paste 이벤트로 시스템 클립보드 글을 읽음 — DXF 면 서버에서
도형으로 바꿔 원좌표 그대로 붙이고 고른 상태로 둠(같은 이름 층은 기존 층 · 없는 층은 DXF
층 색으로 새로 · id 는 모두 새로). DXF 가 아니면 앱 안 클립보드 붙여넣기(PASTECLIP).
볼 수만 있는 도면은 안 붙임. 캔버스 오른쪽 위 흐린 안내 「오토캐드에서 WEBCLIP → 여기서
Ctrl+V」 · 누르면 설치 한 줄(보기 전용이면 숨김 · B07 · M02 양식 둘 다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 13:44:48 +09:00
eomsangdonandClaude Opus 5.5 be4f3d33ac feat(B07): 클립보드 DXF 글을 웹 CAD 도형으로 바꾸는 서버 길
오토캐드 WEBCLIP 이 클립보드에 넣은 DXF 글을 받는 POST /api/projects/dxf-clip 추가
(프로젝트 무관 · M02 양식도 씀 · 저장 안 함). 변환은 기존 도각 불러오기 엔진을 그대로 쓰고
dxf_text_to_entities 만 더함 — 엔티티 layerId 는 DXF 도면층 이름 그대로 · 쓰인 층 이름과
층 색을 함께 돌려줌. 시험 = 견본 DXF(선 · 폴리선 · 원 · 글 · 도면층 둘) 3건.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 13:44:47 +09:00
eomsangdon 289daf899a Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/sub_laptop_5' into sub_laptop_8 2026-09-28 13:41:03 +09:00
eomsangdon fa01245b8b Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-28 13:41:02 +09:00
eomsangdonandClaude Opus 5.5 8739027872 test(spreadsheet): 엑셀 칸 범위 복사 원본 fixture(HTML·글)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cTe8U3dnigRfEmiscHHpp
2026-09-28 13:39:56 +09:00
eomsangdonandClaude Sonnet 5 41a717f62b fix(spreadsheet): 형식 끝·구역 나눔 앞 홀로 남은 역슬래시가 ₩ 로 보이던 것 고침
브레인이 증거로 찾은 rv.json(사용자 붙여넣기 저장분)의 형식 문자열이 끝에
역슬래시 하나만 남아(`_-* #,##0.00_-\` 등) 있었음 — 엑셀 mso-number-format
의 `\;`(구역 나눔 이스케이프) 자르기가 깨진 자투리. numfmt 가 이 홀로 남은
역슬래시를 글자로 찍었고 굴림 등 한글 글꼴은 역슬래시(0x5C)를 ₩ 모양으로
그려 사용자 화면에 ₩ 가 붙어 보였음. 세 자리(splitSections · tokenize ·
detectDateFormat)에서 ① 형식 끝에 다음 글자가 없는 역슬래시 ② 구역 나눔
`;` 바로 앞 역슬래시를 모두 버림(뒤 `;` 는 그대로 나눔으로 처리)으로 고침.
이미 저장된 rv.json 의 13 곳도 자투리 역슬래시를 지움. 사용자 견본값
2.20 · 1.70 · -6.82 로 회귀 시험 세 건 더함 — 시험 196건 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-28 13:39:50 +09:00
eomsangdonandClaude Opus 5.5 137796158b chore: 남은 엑셀 잠금 파일 2개 저장소에서 뺌
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cTe8U3dnigRfEmiscHHpp
2026-09-28 13:37:41 +09:00
eomsangdonandClaude Sonnet 5 e0f9df9ba9 fix(spreadsheet): 엑셀 회계 숫자 형식 끝 역슬래시 남던 것 고침
- mso-number-format 처럼 값 안에 이스케이프된 `\;`(구역 나눔 자리표시, 회계·통화
  형식처럼 구역이 여럿인 형식에 흔함)를 선언 나눔 `parseStyleAttr` 가 진짜 `;`
  로 착각해 값을 그 자리서 잘라 버리던 것 고침 — `\` 뒤 글자는 통째로 건너뛰고
  이스케이프 안 된 `;` 에서만 나누는 `splitDecls` 로 바꿈
- 잘리면 값 끝에 역슬래시 하나만 남고 뒤 구역이 통째로 날아가던 것(사용자 저장본
  material/rv.json 서식 형식 끝 '\' 로 나타남)이었음 — 고친 뒤 4구역 회계 형식
  (양수·음수·0·글자)이 통째로 살아 들어옴
- resources/tester/spreadsheet/fixture_excel_accounting_fmt.html 새 견본으로
  ORCA 실 엔진 확인 — 형식이 `_-* #,##0.00_-;-* #,##0.00_-;_-* "-"??_-;_-@_-`
  로 온전히 들어옴(역슬래시 없음), 기존 fixture_excel_style_paste.html 회귀
  없음 확인
- 수식 흔적(x:fmla) 확인 — 기존 견본엔 x:str/x:num 만 있고 x:fmla 없음(수식 칸도
  x:str 이 `=...` 글 그대로 담아 기존 「=」 앞머리 잡기로 이미 됨). 사용자 실제
  클립보드 견본(main_desktop_1)은 아직 안 옴 — 오면 그걸로 다시 봄

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 13:35:18 +09:00
eomsangdonandClaude Opus 5.5 b98fbccdd0 fix(spreadsheet): 크기 예산 합 128 KB — 엔진 48 · 화면 72 · css 8 KB(사용자 지시)
- 계약 머리 주석 · 크기 시험 숫자 같이 올림 · 지금 크기 엔진 31.5 · 화면 28.8 · css 2.9 KB(합 63.2 KB)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 13:30:50 +09:00
eomsangdon 91cc795102 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_2 2026-09-28 13:29:02 +09:00
eomsangdon 7eacf332ee auto: 2026-09-28 13:28 (ESD_LAPTOP) 2026-09-28 13:28:17 +09:00
eomsangdonandClaude Sonnet 5 c4edc751be fix(spreadsheet): 회계 0 구역 '?' 자리 빈칸 처리 · 통화 로캘 태그 대조 확인
브레인이 짐작한 [$-412](로캘만 · 기호 없음) 이 통화 기호로 잘못 바뀌는지
여러 실제 꼴(단순 태그·회계 네 구역·접두/접미 위치)로 대조해 봤으나 지금
코드는 이미 옳게 기호를 안 보임(회귀 시험으로 고정). 그 김에 회계 0 구역의
`"-"??` 관용구가 진짜 0 값에서 정수부 `?` 를 「0」 으로 채우던 것을 고쳐
엑셀처럼 빈칸으로 보이게 함. 사용자가 보고한 ₩ 는 이 두 경로로는 재현이
안 돼 — main_desktop_1 의 fixture_excel_user_won.html 이 아직 안 와서
실제 원문으로는 대조 못 함(오면 바로 대조).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-28 13:15:24 +09:00
eomsangdon a268859540 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-28 13:10:12 +09:00
eomsangdonandClaude Opus 5.5 bb0bd97c84 chore: 엑셀 잠금 파일 무시 규칙 더함
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cTe8U3dnigRfEmiscHHpp
2026-09-28 13:10:05 +09:00
eomsangdonandClaude Opus 5.5 d1f627ea31 chore: 엑셀 잠금 파일 저장소에서 뺌
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cTe8U3dnigRfEmiscHHpp
2026-09-28 13:09:46 +09:00
eomsangdonandClaude Opus 5.5 b813de897f fix(M02): 식 없는 계산 열(pv_a · pv_jt · pp_cp)은 빈 값 — 줄눈 간격 줄 식도 열 식이 있을 때만
- 값은 면적산출 알약 연결로 받음(PLAN 13장) · 표 채우기 시험을 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:47:09 +09:00
eomsangdonandClaude Opus 5.5 023c5838c4 refactor(M02): Api_Fetch 옛 이름 별칭 지움 — copyBasis · 표번 · Kind basis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:47:09 +09:00
eomsangdon 0ca69fbc47 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into sub_laptop_4 2026-09-28 12:45:10 +09:00
eomsangdonandClaude Opus 5.5 b60c10e73e feat(M02): 집계표 카드 = 도면 정보 · 면적산출 · 재료산출 · 편집 화면 알약 막대
- 집계표 아래 카드 셋(보기 + [수정] → 전체 편집) · 번호 = 도번 · 면적번 · 재료번
- 면적산출 · 재료산출 줄 조회 후 선택 = 다른 열 것 복사해 가져오기(copyArea · copyMaterial · 덮어쓰기 확인)
- 면적산출 · 재료산출은 표(스프레드시트) 편집 화면으로 엶(좌측 줄 · 카드 [수정] 둘 다 · 옛 CAD 로 뜨던 것 고침)
- 알약 막대 — 면적 = 그 열 일위대가 입력값(fetchComboInputs · 없으면 안내 글) · 재료 = 자재DB 찾기(searchMaterials)
- 옛 산출근거 이름(표번 · copyBasis) 안 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 12:16:25 +09:00
eomsangdon 9ae91ea5da Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-09-28 12:13:16 +09:00
eomsangdonandClaude Opus 5.5 ba91e9e63d feat(M01): fetchComboInputs · searchMaterials 알약 자료 함수
- fetchComboInputs(조합 코드) → {키, 이름, 물량: [{키, 이름, 단위, 로직, 배?}], 조건}
- searchMaterials(글) = /pick?kind=price 감쌈 → [{코드, 이름, 규격, 단위}]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-28 12:11:49 +09:00
eomsangdonandClaude Opus 5.5 32d73aa2c9 feat(M01): 조합 알약 물량 길 GET /combo/{키}/inputs
- 담은 로직 결과단위 분모(원/㎥ → ㎥)가 물량 하나 · 설계 조건은 안 넣음(조건 자리만)
- 이름 = 로직 이름 끝 괄호 뗀 것 · 같은 이름·단위는 합침 · 원/10㎡ 은 배 10
- 시험 test_m01_combo_inputs.py(UA000004 · 031 · 002 · 없는 조합 404)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-28 12:11:49 +09:00
eomsangdon 14092e715b Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_2 2026-09-28 12:11:09 +09:00
eomsangdon 017d559401 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-09-28 12:09:33 +09:00
eomsangdonandClaude Opus 5.5 5de2f75828 test(spreadsheet): D 알약 연결 — 행열 따라가기 · 되돌리기 규칙 시험 · 시험 틀(harness_links)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 12:09:33 +09:00
eomsangdonandClaude Opus 5.5 854531cfd8 feat(spreadsheet): D 알약 막대 — createSpreadsheet({links}) · 칸 연결 · 풀기 · 자재 고르기
- 칸을 고른 채 알약을 누르면 연결(흐림 + 주소 · 칸 왼아래 세모) · 흐린 알약은 연결 칸으로 · 우클릭 연결 풀기
- 자재 모드 [자재 더하기] → 자재찾기 창 → 새 알약
- 행열 넣기 · 지우기에 연결 칸 따라감(지운 칸이면 빠짐) · 되돌리기 짝 · getLinkedValues()
- 늦게 불러옴(links 선택 또는 연결 있는 문서만) · 화면 28,518 B(예산 28,672)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 12:09:33 +09:00
eomsangdonandClaude Opus 5.5 d3c93b4520 feat(spreadsheet): 계약 더함 — Sheet.연결 · CellLink · LinkMaterial(알약 연결)
- 시트마다 알약 ↔ 칸 연결 목록 · 자재DB 한 줄 모양
- 시트에 둠 — 바꿈 · 행열 지우기 되돌림이 A 의 시트통째 한 길로 돎(칸 주소에 시트 id 불필요)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 12:09:33 +09:00
eomsangdonandClaude Opus 5.5 79a0994ffb chore(M02): 구조물 양식 파일 8자리 번호 · 산출근거 → 면적산출 옮김 · 재료산출 47벌 새로
- structure 47 도번 SD+8자리 · basis 47 → area(면적번 SA) · material 47 빈 시트(재료번 SM)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:08:57 +09:00
eomsangdonandClaude Opus 5.5 0e5a19eab8 feat(M02): 좌측 구조물 도면 · 면적산출 · 재료산출 줄 · Api_Fetch 새 이름
- copyArea · copyMaterial · fetchStructureNumbers → {도번, 면적번, 재료번} · Kind area · material
- 옛 이름 copyBasis · 표번 · Kind basis 는 sub2 가 옮길 때까지 별칭(@deprecated)
- 좌측 목록 셋 · 줄 글 = 번호 맨 앞 + 이름 + 변수값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:08:56 +09:00
eomsangdonandClaude Opus 5.5 89fc686962 feat(M02): 구조물 번호 8자리(분류3) · 면적산출 SA · 재료산출 SM 서버
- 번호 [종류][분류1][분류2][분류3] · 없는 층 00 · 12장 6자리는 끝에 00 을 붙여 읽음
- 양식 종류 basis → area(면적산출) · material(재료산출) 새로 · 집계표 저장 때 세 파일 자동 만들기/지우기
- 복사 길 POST /structures · /area · /material/{받는 열}/copy?from= · 알약 연결은 문서와 같이 복사
- 번호 목록 {열 id: {도번, 면적번, 재료번}} · 저장 때 번호 칸은 서버 것 그대로
- renumber: basis/ → area/ 옮김(표번 → 면적번 · 시트 이름 면적산출) · 빠진 재료산출 만듦

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 12:08:56 +09:00
eomsangdonandClaude Sonnet 5 74b6937bc0 feat(spreadsheet): 도구 모음 단위 기호 골라 넣기
- [단위 ▾] 단추 → 자주 쓰는 기호 판(넓이·부피·길이 · 무게·부피(액체) · 개수 ·
  속도·각도·비율 · 연산·비교로 무리 지음) · 최근 쓴 것 앞(localStorage, 최대 8개)
- 누르면 편집 중이면 커서 자리에 끼워 넣고(D 편집기 textarea 캐럿 위치) · 아니면
  고른 칸 글 끝에 붙임(ctx.dispatch 라 되돌리기 짝 그대로)
- 체크박스로 「숫자 형식으로 붙이기」 고르면 값은 그대로 두고 형식만
  0"㎡" 처럼 단위를 붙임(기존 형식 끝 따옴표 단위는 새 것으로 교체)
- e_harness.ts 에 편집 중 커서 갈래를 시험할 숨은 textarea + __setEditing 시험
  전용 걸이 더함
- ORCA 실 엔진 확인 — 칸 글 끝 붙이기(터파기 계산서 → …㎡) · 형식 붙이기(12.5 값
  그대로 · 형식 0"㎡") · 편집 중 커서 자리 끼우기(f|oo → fkgoo, 캐럿 이동) · 최근
  목록 순서·중복 제거(개 본 개 식 → 식·개·본) 모두 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 12:08:26 +09:00
eomsangdonandClaude Opus 5.5 326d5ec61a feat(M02): 구조물집계표 제목행 4층(종류 · 분류1 · 분류2 · 분류3) · 계산 열 식 지움
- 층 = 종류 · 분류1 · 분류2 · 분류3 · 지금 열은 분류3 빈칸(분류2 가 합쳐진 열은 그대로 합침)
- pv_a · pv_jt · pp_cp 식 지움(pp_cp 펼침 식틀 포함 · 사용자 확정 PLAN 13장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 12:07:11 +09:00
eomsangdonandClaude Opus 5.5 5bdedb5da9 feat(sheet): 층마다 열 더하기(분류1 · 분류2 · 분류3) · 마스터 줄 = 일위대가 · 도면 · 면적산출 · 재료산출
- 분류2 더하기 안 됨 고침 — 옛 「열 더하기」는 고른 열 맨 아래 글을 「새 열」로 바꿔 넣어, 같은 글이면 머리가 가로로 합쳐져 새 열이 안 보였음 · 같은 무리에 같은 글이면 뒤에 수를 붙임
- 단추 = 층 이름 + 더하기(「분류1 더하기」 · 「분류2 더하기」 · 「분류3 더하기」) · 고른 열과 그 층 위 머리가 같은 이웃 끝에 둠 · 아래 층은 빈칸
- 마스터 줄에서 식 · 들어갈 것 줄 지움 · 산출근거 줄 → 면적산출 · 재료산출 줄(areaLabel · materialLabel · 조회 후 선택 · 글 열은 빈칸 잠김)
- 쓰지 않게 된 열 딱지 글 · 줄임 겉모양 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 12:07:11 +09:00
eomsangdon e010f72505 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-09-28 10:56:08 +09:00
eomsangdonandClaude Opus 5.5 dbda85e8ee Merge origin/main_laptop_1 — 구조물집계표 열 c1 살림 · 새 번호 SD010102 · SC010102
제목행은 종류 · 분류1 · 분류2 그대로 · 사용자가 더한 열 c1(돌기슭막이 · (형태마다) · 새 열)을 rv 뒤에 둠 · c1 구조물 도면 · 산출근거 번호를 새 체계로(옛 구-50 · 표-50 없앰) · 빈 산출근거는 빈 시트

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 10:55:32 +09:00
eomsangdon a0845bb978 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-09-28 10:51:57 +09:00
eomsangdon b9820cff1a Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-09-28 10:51:57 +09:00
eomsangdon 25f1276d7b Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-09-28 10:51:55 +09:00
eomsangdon 403e3acfda Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-09-28 10:51:54 +09:00
eomsangdon 59b83efe0c Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-28 10:51:52 +09:00
eomsangdon 24b63ca1ea auto: 2026-09-28 10:51 (ESD_LAPTOP) 2026-09-28 10:51:27 +09:00
eomsangdon cb5858bb64 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts
2026-09-28 10:05:10 +09:00
eomsangdonandClaude Sonnet 5 22835ca9e2 fix(M02): 집계표 열 고른 뒤 CAD 불러오기 죽는 자리 고침
M02_MasterTemplete_UI_Main.ts 의 표 onSelect 가 `sheet?.getDoc().열 ?? []` 로
짜여 있었음 — 옵셔널 체이닝이 `sheet?.getDoc()` 까지만 걸리고 그 뒤 `.열` 은
그대로라, [다시 열기](재열기)가 drop() 으로 sheet 를 null 로 되돌린 그 짧은 사이에
옛 표 부품에 남은 클릭 이벤트가 이 줄을 부르면 `undefined.열` 을 읽어 죽음
(사용자 보고: 여러 열 · 빠르게 바꿔 고르기 · 새로 고친 직후에 CAD 불러오기 오류).
`.열` 앞에도 `?.` 를 이어 붙여 sheet 가 없으면 조용히 빈 배열로 감 — 그 뒤
StructurePanel.show() 는 이미 빈 배열 · 없는 colId 를 안전하게 처리함.

ORCA 로 정상 경로(열 고르면 도면 정보 패널이 오류 없이 뜸)는 확인함 — 이
브라우저 환경이 오늘 자주 끊겨(Tab was closed while command was running,
crashpad 등록 실패가 매 명령마다 뜸) 경합(레이스) 그 자체의 재현까지는
못 잡음. 원인은 코드를 끝까지 읽어 확인함(정황 증거 아님).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-28 10:02:41 +09:00
eomsangdonandClaude Opus 5.5 c25db2636e fix(M02): 일위대가 고르기 목록 — 이름에 붙은 단위를 겹쳐 쓰지 않음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 10:01:10 +09:00
eomsangdonandClaude Opus 5.5 9b9787f60a fix(sheet): 글 열(꼴=글)의 일위대가 · 도면 · 산출근거 칸은 빈칸 · 잠김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 10:01:09 +09:00
eomsangdonandClaude Opus 5.5 86ae6db5a0 feat(M02): 산출근거 카드 · 전체 편집 — 시트 탭 없이(tabs: false)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 09:59:09 +09:00
eomsangdonandClaude Opus 5.5 b79793ea31 feat(spreadsheet): D 시트 탭 없앰 선택 — createSpreadsheet({tabs: false})
- 탭 줄을 안 그림 · Ctrl+PageDown/PageUp · Shift+F11 을 받지 않음(브라우저 몫) · [데이터 ▾] 의 시트 숨기기 · 탭 색 빠짐
- 산출근거(시트 하나)용 · 크기 화면 28,199 B(예산 28,672)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 09:59:08 +09:00
eomsangdon 31bcad6454 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_Structure.ts
2026-09-28 09:58:38 +09:00
eomsangdon 4b6602484d Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_2 2026-09-28 09:57:46 +09:00
eomsangdon 3990ca1c28 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-28 09:57:46 +09:00
eomsangdon ba47d33877 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-28 09:57:36 +09:00
eomsangdonandClaude Opus 5.5 04f2476741 feat(M02): 집계표 조회 후 선택 — 일위대가 목록 · 다른 열 도면 · 산출근거 복사해 가져오기 · 산출근거 줄 표번
- 일위대가 칸 = M01 일위대가 목록(fetchCombos)에서 찾아 고름 · 「비움」
- 도면 · 산출근거 칸 = 다른 열(번호 + 이름) 골라 덮어쓰기 확인 뒤 copyStructure · copyBasis(sub4) — 번호 · 열은 그대로
- 집계표 산출근거 줄 칸 글 = 표번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 09:57:34 +09:00
eomsangdonandClaude Opus 5.5 2623633b86 feat(m02): 옛 구조물 도면 · 산출근거 46 벌 새 번호로 옮김 · 빈 산출근거 머리 줄 지움
- renumber() 한 번 — 구-NN · 표-NN 버리고 SD · SC 6자리(돌기슭막이 SD010101 … 폐기물처리 SD360000) · 내용 그대로
- 산출근거 46 개 1 행 기본 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)) 지워 진짜 빈 시트
- 글 열 no · sta · memo 의 구조물 도면 · 산출근거 여섯 파일 지움
- 두 번째 renumber() 바뀜 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 09:55:53 +09:00
eomsangdonandClaude Opus 5.5 fc249c00f6 feat(m02): 좌측 · 카드 · 편집 머리 번호 맨 앞 「SD010101 돌기슭막이 …」 · copyStructure · copyBasis
- Api_Fetch — copyStructure(열, from) · copyBasis(열, from): Promise<void> · fetchStructureNumbers(): Record<열 id, {도번, 표번}>(브레인 확정 이름 · 모양) · fetchBasisNumbers 뺌
- 좌측 구조물 도면 · 산출근거 줄 = 번호 + 구조물 이름 + 변수값(제목행) · 구조물 열(꼴=수)만
- 설계 컨테이너 카드 머리 = 번호 · 이름 차례 · 글 열 고르면 컨테이너 닫음
- 편집 화면 머리 = 「SD010101 …」 · 「SC010101 …」
- 시험: test_m02_structure_panel(이름 · 모양 · 줄 글)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 09:55:44 +09:00
eomsangdonandClaude Opus 5.5 3a4b306636 feat(m02): 구조물 번호 SD·SC 6자리 · 집계표 저장 때 파일 만들기/지우기 · 복사 가져오기 길
- 도번 SD · 표번 SC + [종류][분류1][분류2] — 제목행(열 머리 세 층) 차례 · 없는 층 00 · 한 열 두 번호 같은 6자리 · 준 번호 유지(새 열 = 그 무리 다음 번호 · 지운 번호 비움)
- 구조물 = 꼴=수 열만(NO · 측점 · 비고 빠짐 · 브레인 판정)
- 집계표 [저장] — 새 열 두 파일 만들고 지운 열 두 파일 지움
- 새 산출근거 = 진짜 빈 시트(머리 줄 없음) · renumber() = 옛 번호 한 번 옮김 + 옛 기본 머리 줄 지움
- POST /structures/{열}/copy?from= · /basis/{열}/copy?from= — 내용만 · 번호 · 열은 받는 쪽
- GET /structures/numbers = {열 id: {도번, 표번}} · /basis/numbers 뺌
- 시험: test_m02_structure · test_spreadsheet_store 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-28 09:55:35 +09:00
eomsangdonandClaude Sonnet 5 ee64781a7c fix(spreadsheet): D · sub4 대신 색 줄만 — 두 css 도 테마 토큰으로
브레인 승인(sub7 · sub4 는 같은 파일의 다른 부분 작업 중이라 색 줄만 최소로 고침 · 주인은
그대로 D(spreadsheet.css) · sub_laptop_4(spreadsheet_stage3.css)).

spreadsheet.css — 부품 틀 · 주소 상자 · 수식 입력줄 · 칸 편집기 · 채우기 미리보기 · 알림 ·
찾기 패널 · 메모 · 골라 붙여넣기 · 자동완성 · 데이터 메뉴의 하드코딩 색(#fff · #1f2328 ·
#d0d7de · #217346 · #6e7781 · #57606a · #24292f · #dafbe1 · #bf8700 · #fff8c5 · #ddf4ff ·
#8c959f · #f6f8fa · 그림자 rgb(0 0 0/15%))을 전역 토큰(--color-surface-raised · -text-body ·
-border · -focus-ring · -text-muted · -text-secondary · -primary/-primary-text · -warning ·
-accent · -surface · --shadow-lg/-sm)으로 바꿈. 메모(주석) 배경 · 자동완성 강조 · 서식 붓
켜짐 배경은 이름 없는 톤(포스트잇 옅은 노랑 등)이라 color-mix 로 기존 토큰에서 옅게 섞어냄
(새 토큰 안 만듦).
spreadsheet_stage3.css — 조건부 서식 · 유효성 드롭다운 그림자 한 줄(rgba(0,0,0,0.12))만
남아 있던 것을 --shadow-sm 으로.

ORCA 수치(C 시험 틀에 두 css 임시로 얹어 밝은 · 어두운 전환) — 밝은: .ss 배경
rgb(255,255,255)·글 rgb(51,51,51) · 알림(toast) 배경 rgb(38,17,74)·글 흰색 · 메모 테두리
rgb(184,134,11). 어두운: .ss 배경 rgb(37,31,56)·글 rgb(228,224,240) · 알림 배경
rgb(185,154,255)·글 rgb(22,18,31)(반전) · 메모 테두리 rgb(224,182,74) — 대화상자 · 찾기 ·
자동완성 · 조건부 서식 패널 · 상태 표시줄까지 모두 두 테마에서 배경 · 글 대비 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-28 09:53:53 +09:00
eomsangdonandClaude Sonnet 5 4bbef4ac54 fix(spreadsheet): 엑셀 칸 형식 이름(Fixed·Standard·Currency 등) 오독 고침
엑셀이 클립보드로 칸 형식을 코드 대신 이름으로 넘길 때(mso-number-format:Fixed
등) numfmt 가 그 이름을 코드로 잘못 읽어 `d·m·y` 낱자를 날짜 자리표시로 잡아
글자가 깨지던 것(Fixed→Fixe11 등, 2026-09-27 날짜 형식 추가의 부작용)을 고침.
엑셀 이름표(General·General Number·Fixed·Standard·Currency·Percent·
Scientific·Short Date·Long Date)를 실제 형식 코드로 바꿔치기하고, 모르는
이름은 일반으로 떨어뜨리되 aaa·yyyy 같은 진짜 날짜 자리표시는 그대로 살림
(날짜 자리표시 탐지도 앞뒤가 딴 낱말 글자가 아닐 때만 인정하게 다듬음).
지수(과학) 형식(0.00E+00)도 새로 구현. Currency 는 사용자 실제 화면 확인대로
통화 기호 없이 렌더 — ₩ 가 붙는 자리는 이 여섯 이름으로는 재현 안 됨(실제
붙여넣기 원문이 다시 나타나면 추가 확인 필요). 사용자 표 견본(43.91·14,182·
0.48·21.07·1.93·15,166·23.20)으로 시험 191건 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-28 09:53:16 +09:00
eomsangdon db899349a4 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into sub_laptop_6 2026-09-28 09:49:23 +09:00
eomsangdonandClaude Sonnet 5 4f7470bbbd feat(spreadsheet): C 격자 — 앱 밝은 · 어두운 테마 따라가게
.aislo-grid 안 색 변수(--aislo-grid-bg · -fg · -line · -head-bg · -head-fg ·
-head-border · -selected) 를 전부 앱 전역 토큰(ui_template_theme.css 의
--color-surface-raised · -text-body · -border · -surface · -text-secondary ·
-accent)에 연결 — 새 값 안 적음, 앱이 data-theme 를 바꾸면 그대로 따라감(다른
스프레드시트 파일(spreadsheet_toolbar.css)과 같은 방식). 칸 · 머리 배경 · 눈금선 ·
선택 테두리 · 채우기 손잡이 테두리의 하드코딩 흰색 · 회색 · 파랑을 이 변수로 바꿈.
테두리 기본 색(사용자가 색 안 지정)은 #000000 대신 currentColor 로 — 칸 글자색과
같이 테마를 따라감. 사용자가 지정한 채움색 · 글자색(조건부 서식 포함)은 그대로 둠
(엑셀과 같음 — 이미 빈 문자열 인라인 스타일로 CSS 기본값에 떨어지는 구조라 따로 안 고침).
시험 틀(html)에 ui_template_theme.css 를 넣어 data-theme 전환을 실제로 시험.

ORCA 수치(시험 틀 · data-theme 전환) — 밝은: 격자 배경 rgb(255,255,255) · 글자
rgb(51,51,51) · 선택 #3e0079 · 눈금선 #e6e2e3. 어두운: 격자 배경 rgb(37,31,56) ·
글자 rgb(228,224,240) · 선택 #b99aff · 눈금선 #322a45 — 모두 전환됨. 사용자 지정
채움(A3 #F2F2F2)·조건부 서식 글자색(H4 #c00000)은 두 테마에서 값 그대로(안 바뀜)
확인. A3 무색 테두리는 currentColor 로 밝은 rgb(51,51,51)/어두운 rgb(228,224,240)
로 글자색과 같이 바뀜 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-28 09:48:20 +09:00
eomsangdonandClaude Opus 5.5 dde6f0c2f8 feat(M02): 구조물집계표 제목행 층 이름 — 종류 · 분류1 · 분류2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 09:48:05 +09:00
eomsangdonandClaude Opus 5.5 119a390bd7 feat(sheet): 산출근거 줄 · 조회 후 선택(onPick) · 둘째 층 묶음 더하기 · 층 이름 고치기
- 마스터 첫 쪽 도면 줄 아래 산출근거 줄 s:basis — 칸 글 basisLabel(colId) · 없으면 「없음」 · 손으로 못 침
- onPick 을 주면 일위대가 · 도면 · 산출근거 칸은 두 번 누르기 · Enter · F2 로 조회 후 선택(글을 돌려주면 일위대가 칸에 넣음)
- 「<둘째 층 이름> 더하기」 단추 — 같은 종류 묶음 끝에 [종류, 새 분류, 새 열] 열(층 셋 이상일 때)
- 머리 층 줄 머리 칸 두 번 누르기 = 층 이름 고치기(master)
- 시험 test_sheet_group_layer.py · 줄 차례 시험에 산출근거 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 09:47:58 +09:00
eomsangdonandClaude Sonnet 5 b2e2fcff91 fix(spreadsheet): 엑셀 붙여넣기 서식 — 묶음 선택자 클래스 · 선 굵기별 종류
- <style> 안 클래스 규칙 읽기가 「.xl65, .xl70 {...}」 같은 묶음 선택자에서 앞쪽 클래스를
  놓치던 것 고침(전체 선택자를 쉼표로 나눠 각 클래스에 선언 병합)
- 테두리 pt 굵기 → 우리 선 종류 매핑 추가(.5pt 이하 hair · ~1.25pt thin · ~2pt medium · 그 위
  thick, dashed·dotted·double 은 굵기와 상관없이 그 모양) — 「합계 줄 위 굵은 선」처럼 같은
  칸 안 굵기가 다른 테두리를 구별해 살림
- 실제 엑셀 내보내기 짜임(머리 채움 · 가는 테두리 · 합계 줄 위 굵은 테두리 · #,##0 숫자 형식)을
  재현한 새 견본 resources/tester/spreadsheet/fixture_excel_style_paste.html 로 시험
- ORCA 실 엔진 확인 — 머리 칸 굵게·채움 #d9d9d9·가운데 정렬·사방 hair, 숫자 칸 오른쪽 정렬·
  형식 #,##0·사방 hair, 합계 줄 굵게·위 테두리만 medium(나머지 hair)까지 전부 반영 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-28 09:45:50 +09:00
eomsangdonandClaude Opus 5.5 e4eef2a47b fix(M02): 산출근거 전체 편집 격자 높이 0 — 메인 칸을 화면 높이로 채움
- .ss 는 height:100% 로 부모 높이를 받는데 host 에 정해진 높이가 없어 격자 0
- 도면 편집과 같은 규칙(.m02-main:has(> .m02-main__host > .ss) · host flex) 으로 메인 칸을 채움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 00:51:20 +09:00
eomsangdonandClaude Opus 5.5 87417b3a31 perf(spreadsheet): D 크기 줄임 — sub2 · sub8 병합 뒤 화면 예산 넘침 해소
- 함수 자동 완성 · 인자 도움말, 이름 상자 · 이름 관리, 서식 붓, 메모 편집(새 spreadsheet_memo.ts), 열 너비 맞춤을 처음 쓸 때 불러옴
- 화면 30,126 B → 28,102 B(예산 28,672) · 합 62.6 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-28 00:46:08 +09:00
eomsangdon 0f7617f99d Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into sub_laptop_7 2026-09-28 00:33:31 +09:00
eomsangdonandClaude Sonnet 5 69f9c64c98 feat(M02): 산출근거 전체 편집 화면 — 집계표 카드는 보기 + [산출근거 수정]
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:30:48 +09:00
eomsangdonandClaude Sonnet 5 e31e1b0016 feat(M02): 산출근거 표번 — 도번과 같은 규칙 자동 채번 · 좌측 「산출근거」 목록
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:30:32 +09:00
eomsangdonandClaude Sonnet 5 7a8d37d544 fix(M02): 상세 산출근거 카드 — 칸 높이 없어 빈 격자가 안 보이던 것 고침(약 30행 · 스크롤)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:14:07 +09:00
eomsangdonandClaude Sonnet 5 444aed129f fix(M02): 도면 정보 카드 미리보기가 cad-host 최소 높이 탓에 카드 밖으로 넘침
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:13:55 +09:00
eomsangdonandClaude Sonnet 5 66812026c1 chore(M02): 임시 시험 단추 호출부 · inspect 옵션 · 시험 정리(이어서)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:13:19 +09:00
eomsangdonandClaude Sonnet 5 860e251425 chore(M02): 임시 스프레드시트 시험 단추 · 서버 tmp 견본 길 · inspect 통로 지움
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-28 00:13:06 +09:00
eomsangdonandClaude Sonnet 5 cd7a81a4af fix(spreadsheet): 도구 모음 글꼴·크기 빈칸 — 기본값 흐리게 표시
- 칸·행·열·시트 기본 어디에도 글꼴·크기가 없으면 통합문서 기본 → 앱 기본(굴림·10, 격자
  fontCss 와 같은 값)을 value 대신 placeholder 로 보여 흐리게(기본값임을 표시)
- 직접 지정하면 다시 실값으로
- ORCA 실 엔진 확인 — 굵게만 있는 칸·아무 서식 없는 칸 모두 글꼴 placeholder "굴림"·크기
  placeholder "10" 표시, 글꼴 Arial·크기 14 로 지정하면 value 로 바뀌고 placeholder 비워짐

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-27 23:56:40 +09:00
eomsangdonandClaude Sonnet 5 ede12fbc0e feat(spreadsheet): 상태 표시줄 확대 비율 — sub6 zoom() 이음
sub6 격자 확대 커밋(origin/sub_laptop_6 7b294adc) 받아 GridHandle.zoom() 으로 처음 값 · 격자 root 「aislo-grid-zoom」 사건으로 바뀔 때마다 「확대 N%」 다시 씀.
가짜 SpreadsheetContext(test_stage2_helpers.ts) 격자에 zoom() 스텁 더함(0 계약 필수 자리라 안 채우면 모양 안 맞음).
ORCA(포트 5179) — 실제 createSpreadsheet 를 띄워 Ctrl+휠(WheelEvent ctrlKey) 로 확인: 「확대 100%」→「확대 332%」로 실시간 바뀜.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 23:12:57 +09:00
eomsangdonandClaude Sonnet 5 7b294adca2 feat(spreadsheet): C 격자 — 확대 이음(zoom() · aislo-grid-zoom 이벤트)
계약 더함 — GridHandle.zoom(): number(1 = 100%). 확대가 바뀔 때마다 격자 root 에서
CustomEvent("aislo-grid-zoom", { detail: { zoom }, bubbles: true }) 를 쏨(sub4 상태줄이 들음).
ORCA 시험 — WheelEvent(ctrlKey · deltaY -300) 로 확대 흉내 → zoom() 1→1.568 · 이벤트
detail.zoom 도 같은 값으로 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 22:35:54 +09:00
eomsangdon 5bea31ee5c Merge remote-tracking branches 'origin/sub_laptop_4', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_6 2026-09-27 22:33:04 +09:00
eomsangdon a9a9b73961 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-27 22:33:02 +09:00
eomsangdonandClaude Sonnet 5 35d0ce3853 fix(spreadsheet): 엑셀 붙여넣기 서식 · 병합 부분 덮어쓰기 막기 · 도구 모음 기본 서식
- parseHtmlTable 이 td 인라인 style · <style> 클래스(mso-number-format 이스케이프 포함) 를 읽어
  굵게 · 기울임 · 글자색 · 채움 · 가로 정렬 · 숫자 형식 · 테두리를 칸 서식으로 살림
- 붙일 자리가 기존 병합을 일부만 걸치면 막고 알림(엑셀처럼) · 통째로 덮으면 먼저 풀고 칸마다 값
- 도구 모음 · 클립보드 styleAt 이 Sheet.기본.서식(시트 기본)까지 물려받게 고쳐 글꼴 · 크기 칸이 빔 방지
- ORCA 실 엔진 확인 — 엑셀 견본(xl65/xl66 클래스) 붙여넣기로 굵게 · 가운데 정렬 · 0.00 형식 반영,
  병합 부분 덮기 막음 · 알림 · 값 안 바뀜, 병합 통째 덮기는 자동 풀림 + 칸마다 값, 시트 전체 서식
  지정 뒤 새 칸에서 도구 모음 글꼴 · 크기 표시 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs
2026-09-27 22:32:20 +09:00
eomsangdonandClaude Sonnet 5 b59de25664 fix(spreadsheet): 상태 표시줄 어두운 화면 글자 안 보임
spreadsheet_stage3.css .ss-statusbar·.ss-statusbar__zoom 에 테마 글자색 토큰(--color-text-body·--color-text-muted) 지정 — 전엔 색을 안 지정해 어두운 바탕에 기본 검정 글자가 겹침.
ORCA(포트 5179)에서 라이트·다크 둘 다 계산된 색으로 확인: 다크 글자 rgb(228,224,240)/바탕 rgb(30,25,48), 라이트 글자 rgb(51,51,51)/바탕 rgb(246,247,250) — 둘 다 읽힘.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 22:25:22 +09:00
eomsangdonandClaude Opus 5.5 76f917813b fix(spreadsheet): D 편집기 — 틀린 식(인자 수 등)은 Enter 로 확정하지 않음
- 확정 때 = 로 시작하면 parseFormula(sub3 a3434e3c) — 실패면 편집 유지 · 오류 알림 · 커서를 오류 자리로(엑셀처럼)
- M02 [스프레드시트 시험] ORCA: =SUM() Enter 막힘 · 「SUM 인수가 너무 적음(1 개부터)」 알림 · =SUM(1,2) → 3
- 크기 시험 통과(화면 28,613 B) · D 규칙 시험 9 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 22:24:30 +09:00
eomsangdonandClaude Sonnet 5 7c1e95482d fix(spreadsheet): C 격자 — 브레인 최종 검증 흠 네 가지 고침
① 스크롤 영역이 빈 시트 기본 200행에서 안 늘어나 이름 상자 · Ctrl+방향키로 먼 칸 가도
   화면이 안 따라가던 것 — reveal() 이 그 칸 밖이면 스페이서를 늘림(ensureExtent) · 스크롤
   끝 가까이 닿아도 미리 늘림(growOnScrollEdge, 엑셀처럼).
② 열 폭 자동 맞춤 상한 없어 아주 긴 글로 약 4만 px 까지 늘던 것 — colCharsToPx 에 엑셀
   상한(255 자) 캡을 걸어 렌더 쪽에서 막음.
③ 칸 폭보다 긴 수는 줄임표 대신 `#####`(엑셀과 같음) — 캔버스로 재 폭 넘으면 바꿔치기 ·
   글은 그대로 줄임표.
④ 틀 고정을 풀어도 왼위 · 위 · 왼 판에 칸이 하나 남던 것(A1 이 DOM 에 네 벌) —
   `Math.max(0, frozenCols-1)` 이 0 일 때도 0 을 줘 범위가 안 비던 계산 버그 고침.

ORCA 수치 — ① 스크롤 영역 201,901px→1,001,901px 로 늘고 scrollTop 목표 자리로 이동 확인 ·
② 5,702 자 요청에도 1,790px(255 자) 로 막힘 확인 · ③ 「123456789012345」→「#######」 확인 ·
④ 해제 뒤 A1 DOM 칸 수 1 로 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 22:24:11 +09:00
eomsangdonandClaude Opus 5.5 a3434e3c74 fix(spreadsheet): 함수 인자 수를 입력 때 막음 — =SUM() 이 0 이 되던 흠
- 파서가 함수 표(B) 최소 · 최대 인자 수로 검사 → 「SUM 인수가 너무 적음(1 개부터)」 식 오류(편집기가 이 실패로 Enter 를 막는 이음은 sub7)
- B 표 min 0 인 SUM · MIN · MAX · COUNT · COUNTA · PRODUCT 는 엑셀대로 1 로 덧댐(sub5 가 표를 고치면 지움)
- 풀이 때 인자 수 검사는 파서로 옮겨 뺌(엔진 31,787 → 31,603 B)
- 시험: 엔진 오류 묶음에 인자 수 사례

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 22:21:59 +09:00
eomsangdonandClaude Opus 5.5 a144d4019e perf(spreadsheet): D — sub6 조건부 서식 · 초록 세모 받은 뒤 크기 예산 맞춤
- 골라 붙여넣기 몸통을 고를 때 불러옴 · 조건부 서식 창은 격자가 이미 끌어오므로 바로 import(정적 끌어옴 시험)
- M02 [스프레드시트 시험] ORCA: 조건부 서식 색(3 · 2 칠함 · 1 안 칠함 · 고치면 다시 칠함) · #DIV/0! 초록 세모 · 필터 행 숨김 수치 확인
- 크기 시험 통과 — 화면 27.6 KB · 합 61.5 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:54:35 +09:00
eomsangdon a3fa9f152a Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_7 2026-09-27 21:52:12 +09:00
eomsangdonandClaude Opus 5.5 fca4463768 feat(spreadsheet): D 잇기 3 — 칸 넣기 · 이동 · 틀 고정 · 데이터 도구 · 묶기 · 단축키 표 「안됨」 잇기
- spreadsheet_actions(키 · 메뉴 → 이름) + actions_run(몸통 · 늦게): Ctrl+Shift++ · Ctrl+- (행 · 열 통째면 바로) · F5 · Ctrl+G ·
  Ctrl+9/0 · Ctrl+Shift+9/0 · Ctrl+B/I/U/5 · Ctrl+Shift+& · Ctrl+Shift+_ · Ctrl+; · Ctrl+PageDown/PageUp · Shift+F11 · Shift+Alt+→/←
- [데이터 ▾] 에 이동 · 틀 고정 ▸ · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 윤곽 ▸
- 식 편집 중 F4 참조 $ 돌리기(cycleAbs) · 접힌 묶음 행 · 열은 방향키가 건너뜀
- 크기: 수식 분석 · 자동 필터 · 찾기 패널을 처음 쓸 때 불러옴 — 화면 27.0 KB · 합 60.9 KB
- M02 [스프레드시트 시험] ORCA 수치 확인 · D 규칙 시험 9 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:51:06 +09:00
eomsangdonandClaude Sonnet 5 e48870765e feat(spreadsheet): C 격자 — 조건부 서식(sub4) 얹음 · 오류 칸 초록 세모(왼 위)
resolveCondFmtStyle 결과를 칸 서식 위에 덮어씀(채움·글자색·굵게) · 글자색은 조건부 서식이
숫자 형식([Red] 등)보다 이김(엑셀과 같음). 오류 칸은 기존 title 풍선에 더해 왼 위 초록
세모(엑셀 오류 검사 표시)도 그림. 필터로 걸러진 행 숨김은 이전 커밋(ebdee04f)에서 이미
buildRowGeometry 에 같이 넣어 둠(걸러짐 · 숨김 · 묶음 접힘 모두 처리).
ORCA 시험 틀 — 조건 맞는 칸 배경·글자색·굵기 수치로 확인 · 오류 칸 title·세모 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:34:58 +09:00
eomsangdon 117ea76ec2 Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_7 2026-09-27 21:34:22 +09:00
eomsangdonandClaude Sonnet 5 ebdee04faa feat(spreadsheet): C 격자 — 행·열 묶어 접기(윤곽 +/- 단추, 계약 더함)
RowInfo·ColumnInfo.묶음{층,접힘} · Command「묶음설정」더함(계약 더함) — 접힘 상태 저장 ·
접으면 숨김처럼 그리되 머리 이중선 표시는 숨김·걸러짐과 갈라 안 보임(따로 표시).
commands.ts 는 기존 setAxis 틀에 「묶음」prop·case 만 최소로 얹음(exhaustive switch 깨짐 막음).
ORCA 시험 틀에서 접기·펴기 dispatch·저장·머리 이동·표시 구분 수치로 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:31:11 +09:00
eomsangdon 8d49a38df2 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_8 2026-09-27 21:29:55 +09:00
eomsangdonandClaude Sonnet 5 4f1c048bb2 fix(spreadsheet): func_help 자동 완성에 B 2단계 함수 이어 붙임(브레인 지시로 B 가 sub1 파일 대신 고침)
spreadsheet_func_help.ts(주인 sub_laptop_1)가 B 의 FUNC_HELP_BATCH2 · 3(57개)을
안 읽어 자동 완성에 새 함수가 안 뜨던 것을 고침 — sub1 이 지금 다른 파일을 만져
브레인 지시로 B 가 이번만 대신 고침. sub1 의 test_stage2_func_help.ts 도 늘어난
개수(48→105)에 맞춰 고치고 2단계 함수 자동 완성 검증을 더함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 21:29:46 +09:00
eomsangdon ddfff14fb6 feat(spreadsheet): 탭에 숨긴 시트 · 탭 색 반영(sub4 spreadsheet_sheet_extra 잇기)
- 숨긴 시트(Sheet.숨김)는 탭 목록에서 뺌
- 탭 우클릭에 [숨기기] · 숨긴 시트가 있으면 [숨긴 시트 보이기] 하위 목록(이름 눌러 바로 보임)
- Sheet.탭색 은 탭 밑줄 색으로
- ORCA 실 엔진 확인 — 숨기기 → 탭 사라짐 → 하위 목록에서 보이기 → 다시 나타남 · 탭색 지정 → 밑줄 반영
2026-09-27 21:29:39 +09:00
eomsangdonandClaude Opus 5.5 610ac8564f fix(spreadsheet): 이름 정의 풀이 — =SUM(값들) 이 범위로 풀림 · 행열 넣기 · 지우기 때 이름 범위 따라감
- 엔진: 식 나무의 이름을 Sheet.이름정의 범위로 바꿔 풂(자기 시트 먼저 · 없으면 다른 시트 · 없는 이름 #NAME?) · 의존 그래프도 범위로 이어짐
- 식 캐시는 시트마다 · rebuild 때 비움 — 이름 바꾸기 · 지우기(시트통째) 뒤 새로 풂
- 행열 넣기 · 지우기: 병합과 같은 범위 옮김으로 이름 범위도 옮김 · 통째로 지워진 이름은 뺌
- 수식 계산 걸음도 이름을 풂
- 시험: test_spreadsheet_analysis.py names 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 21:29:17 +09:00
eomsangdonandClaude Opus 5.5 4e7f2e2c2a fix(M02): 표 부품은 진입 파일만 늦게 받음 — 스프레드시트가 compass 조각을 끌지 않게
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 21:28:14 +09:00
eomsangdon 36e33a0e3b auto: 2026-09-27 21:27 (ESD_LAPTOP) 2026-09-27 21:27:50 +09:00
eomsangdonandClaude Sonnet 5 de94486af7 feat(spreadsheet): 2단계 — 데이터 도구(텍스트 나누기·중복 제거·연속 채우기)
텍스트 나누기(구분 기호 탭·세미콜론·쉼표·공백·사용자 지정 또는 고정 폭 · 미리보기) ·
중복된 항목 제거(비교할 열 고르기 · 머리글 포함 여부 · 지운 수를 토스트로 알림) ·
채우기 연속 데이터(단계 값 · 끝 값 · 행/열 방향)를 새 파일로 짬. 셋 다 「칸」 명령
하나로만 문서를 바꿈. 순수 로직(나누기 · 중복 찾기 · 등차수열 짓기)은 DOM 없이도
시험 가능해 resources/tester/spreadsheet/test_stage2_data_tools.ts 로 다 검증함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 21:27:45 +09:00
eomsangdonandClaude Opus 5.5 c6e826fe1f perf(spreadsheet): D — 누를 때만 쓰는 창을 늦게 불러옴(크기 예산 화면 28 KB 안)
- spreadsheet_data_menu.ts — 정렬(↑ ↓ · 정렬…) · 조건부 서식 · 시트 숨김/탭 색 창은 import()
- spreadsheet_extras.ts — 셀 서식 창(Ctrl+1 · [서식…]) import()
- 크기 시험: 엔진 31.4 · 화면 27.2 · css 3.9 · 합 62.6 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:23:54 +09:00
eomsangdon e222ddc172 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-09-27 21:22:03 +09:00
eomsangdonandClaude Opus 5.5 16c1443b93 feat(spreadsheet): D 잇기 — 수식 분석 · 자동 합계 · 상태 표시줄 · 시트 숨김/탭 색(sub3 · sub4)
- spreadsheet_data_menu.ts — 데이터 ▾ 안 「수식 분석」(선행 · 종속 추적 · 화살표 지우기 · 수식 계산) · 오류 ⚠ 부품 · Σ 단추 · 「시트 숨기기 · 탭 색…」 창
- spreadsheet_keys.ts — Alt+= 자동 합계(=SUM(잡은 범위) 로 편집을 열고 Enter 는 사용자)
- spreadsheet.ts — 상태 표시줄(합계 · 평균 · 개수 …)을 맨 아래

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:21:46 +09:00
eomsangdon 7aa9f2229d Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_1 2026-09-27 21:18:44 +09:00
eomsangdon 18117e16b4 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-27 21:18:42 +09:00
eomsangdonandClaude Sonnet 5 4804caa9bc feat(spreadsheet): 통계 함수 13개 · 날짜 함수 9개 · numfmt 날짜 형식
MEDIAN·MODE·STDEV(.S/.P)·VAR·RANK(.EQ)·LARGE·SMALL·PERCENTILE·QUARTILE·
COUNTBLANK을 새 spreadsheet_functions_stat.ts에 둠. NOW·WEEKDAY·EDATE·
EOMONTH·DATEDIF·NETWORKDAYS·HOUR·MINUTE·TIME을 misc.ts에 더함(EDATE는
월말 넘침을 다음 달로 안 넘기고 그 달 말일로 눌러 담음 — 엑셀과 같게).
numfmt에 날짜 형식(yyyy-mm-dd·m/d·요일 aaa/aaaa)을 더함. sub1 func_help용
이름 목록은 functions.ts의 FUNC_HELP_BATCH3에 둠(그 파일은 안 고침).
경계값 시험 180건 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 21:18:23 +09:00
eomsangdon d887a8cec3 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_7 2026-09-27 21:17:32 +09:00
eomsangdonandClaude Opus 5.5 73ad5c7070 feat(spreadsheet): D 잇기 — 셀 서식 창(Ctrl+1 · [서식…] 단추 · sub1)
- spreadsheet_keys.ts — Ctrl+1 자리
- spreadsheet_extras.ts — openFormatDialog 잇기 · 수식 입력줄 앞 [서식…](Ctrl+1 을 ORCA · 브라우저가 가로챌 때) · 창이 닫히면 초점을 격자로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:17:16 +09:00
eomsangdonandClaude Opus 5.5 6acc8d0c52 feat(spreadsheet): 수식 분석 — 선행 · 종속 추적 화살표 · 수식 계산 단계 보기 · 오류 검사 ⚠ 풍선
- 추적 화살표(spreadsheet_trace.ts): 누를 때마다 한 단계 · 범위는 상자 · 오류 칸 화살 빨강 · 다른 시트 점선 · 지우기
- 수식 계산 창(spreadsheet_eval_steps.ts): 왼쪽부터 한 마디씩 값으로 · 밑줄 · IF 류 첫 인자만 먼저 · 값은 엔진 풀이
- 오류 검사(spreadsheet_errcheck.ts): 순환 · 식 오류 · 숫자처럼 보이는 글 까닭 · ⚠ 메뉴(숫자로 변환 · 계산 단계 · 선행 추적 · 오류 무시)
- 격자 초록 세모는 C(sub6) · 잇기는 sub7 — 머리 주석에 잇는 법
- 엔진: evalSafe 내보냄 · formulaToText 에 갈음 · 밑줄 자리 · 계약 파일 목록 줄만 더함
- 시험: test_spreadsheet_analysis.py(3 묶음) · harness_analysis(ORCA) · 시험 틀은 inspect 로 문맥 꺼냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 21:14:40 +09:00
eomsangdonandClaude Sonnet 5 4aca316ace feat(spreadsheet): 3단계 — 시트 숨김·보이기·탭 색(계약 더함)
spreadsheet_sheet_extra.ts 새로 만듦 — 시트 관리 창(숨김 토글·탭 색 고르기), 마지막 보이는 시트는 숨김 거절, 활성 시트를 숨기면 다른 시트로 옮김.
계약(spreadsheet_types.ts)에 Sheet.숨김·탭색 최소로 더함(git-sync 뒤 더함) · 파일 목록 표에 이번 셋(상태 표시줄·자동 합계·시트 여분) 적음 · css(spreadsheet_stage3.css)에 세 파일 스타일 얹음.
엔진 A·격자 C 안 건드림 — 탭 줄(spreadsheet_tabs.ts) 이음은 sub7 몫(머리 주석에 적음).
시험 resources/tester/spreadsheet/test_stage3_sheet_extra.ts 9개 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 21:14:21 +09:00
eomsangdonandClaude Sonnet 5 6275e48396 feat(spreadsheet): 3단계 — 아래 상태 표시줄
spreadsheet_statusbar.ts 새로 만듦 — 고른 범위 합계·평균·개수·숫자개수·최소·최대, 우클릭으로 보일 줄 고름, 확대 비율 자리(아직 안 이어짐).
계약 안 건드림 · 엔진 A·격자 C 안 건드림 — 화면 붙이기·확대 이음은 sub7 몫(머리 주석에 적음).
시험 resources/tester/spreadsheet/test_stage3_statusbar.ts 9개 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 21:14:09 +09:00
eomsangdonandClaude Sonnet 5 647ae91469 feat(spreadsheet): 3단계 — 자동 합계(Alt+=·Σ)
spreadsheet_autosum.ts 새로 만듦 — 위 또는 왼쪽 연속 숫자 범위를 찾아 =SUM() 식을 활성 칸에 넣음(엑셀 규칙), 여러 칸을 고른 채면 그 안에서만 찾음.
계약 안 건드림 · 엔진 A·격자 C 안 건드림 — 실제 단축키·Σ 단추 이음은 sub7 몫(머리 주석에 적음).
시험 resources/tester/spreadsheet/test_stage3_autosum.ts 6개 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 21:13:58 +09:00
eomsangdon 0c3e4028e5 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_6' into sub_laptop_7 2026-09-27 21:13:23 +09:00
eomsangdonandClaude Opus 5.5 61d9c0de58 feat(spreadsheet): D 잇기 — 데이터 메뉴(조건부 서식 · 유효성 · 이름 · 눈금선) · 유효성 검사 · 이름 상자 · 여러 범위
- spreadsheet_data_menu.ts(새) — [↑] [↓] [필터] [데이터 ▾] · 패널 창(sub4 조건부 서식 · 유효성 · 이름 관리) · 눈금선 · 유효성 목록 ▼ · Alt+↓
- spreadsheet_editor.ts — 확정 때 유효성 검사(막음 = 편집 유지 · 경고 = 알림) · 주소 상자에 이름 = 그 범위로 · 새 이름 = 지금 고름에 정의
- spreadsheet_mouse.ts · spreadsheet_selection.ts — Ctrl+누르기 여러 범위 · Shift 는 마지막 범위
- spreadsheet_keys.ts · spreadsheet.ts — 유효성에 막히면 이동 · 시트 바꿈 멈춤
- spreadsheet_extras.ts — 정렬 · 필터를 데이터 메뉴로 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:13:10 +09:00
eomsangdonandClaude Sonnet 5 327226e252 test(spreadsheet): C 시험 틀 — ctx 도 전역에 걸어 여러 범위 선택 시험 가능하게
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:12:21 +09:00
eomsangdonandClaude Sonnet 5 69694a3f6b fix(spreadsheet): C 격자 — 고름 테두리·채우기 손잡이 머리 칸만큼 겹쳐 밀리던 것 고침
overlay 가 이미 headColW·headRowH 만큼 밀려 있는데 renderSelection·setRefHighlights 가
screenBoxOf(격자 뿌리 기준)를 그대로 써서 두 번 밀렸다. overlay 안 전용 좌표 함수(overlayBoxOf)로
갈라 고침. M02 [스프레드시트 시험] 화면에서 ORCA 수치로 칸 자리와 정확히 일치함을 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:11:12 +09:00
eomsangdonandClaude Sonnet 5 e1fc529f45 feat(spreadsheet): 2단계 — 셀 서식 대화상자(Ctrl+1)
엑셀과 같은 탭 여섯 — 표시 형식(범주·소수 자리·천 단위·음수 모양·사용자 지정 코드·미리보기,
B의 numfmt로 실제 그림) · 맞춤(가로·세로·줄 바꿈·셀 병합·들여쓰기는 자리만) · 글꼴(실시간
미리보기) · 테두리(선 모양·색·위치별 적용·미리보기) · 채우기 · 보호(브레인 지시대로 자리만).
[확인] 누를 때 서식 명령 하나(+ 병합 바뀌면 병합·병합풀기)로 나감 — 도구 모음처럼 바로 안 바꿈.

표시 형식 코드 짓기는 DOM 없는 별도 파일(spreadsheet_format_code.ts)로 빼서 시험 가능하게 함
(대화상자 자체는 이 저장소에 jsdom 이 없어 단위 시험 밖 — Ctrl+1 을 이은 뒤 ORCA 로 확인할 것).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 21:10:46 +09:00
eomsangdon 015c35ad0e Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-27 21:06:02 +09:00
eomsangdonandClaude Opus 5.5 500424d7ae feat(spreadsheet): D 잇기 — 정렬 · 자동 필터(sub3)
- spreadsheet_extras.ts — 수식 입력줄 앞 데이터 단추(↑ ↓ 정렬… 필터) · attachFilter 부품 붙임 · Alt+↓ 머리 칸 값 목록
- spreadsheet_keys.ts — Ctrl+Shift+L 필터 · Alt+↓ 자리
- spreadsheet_selection.ts — 걸러진 행도 방향키 · Enter 가 건너뜀
- spreadsheet.ts — recalc 뒤 부품 refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:05:39 +09:00
eomsangdon acdac34572 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-27 21:04:12 +09:00
eomsangdonandClaude Sonnet 5 85ada11e9d feat(spreadsheet): C 격자 — Ctrl+휠 확대·축소(10~400%, 칸·머리·선 굵기 같이)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 21:04:03 +09:00
eomsangdon e02a3c9a26 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_8 2026-09-27 21:03:03 +09:00
eomsangdon fc20316c0c Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-27 21:03:01 +09:00
eomsangdon 851fdb63e5 fix(spreadsheet): E 글자표가 앱 전체 사전을 끌어오던 것 끊음
- spreadsheet_text.ts 가 @ui/ui_template_locale 의 currentLanguageIndex 만 쓰려다 페이지별
  사전(a·b1~4·common·m1·m2) 전부를 끌어옴 — 자기 언어 상태(setSpreadsheetLanguage)로 대신
- 확인: toolbar+tabs+menu+clipboard 만 묶어 본 압축 크기 59 KB → 18.8 KB(gzip)
2026-09-27 21:02:46 +09:00
eomsangdonandClaude Sonnet 5 1a4e24b8a9 feat(spreadsheet): 2단계 — 엑셀 단축키 전수 표(데이터 파일)
키 53 개(이동·고르기·편집·복사붙이기·찾기·메모·서식·시트·범위밖) — 뜻·동작 이름·지금
상태(됨·안됨·범위밖)·자리를 적음. `spreadsheet_keys.ts`(D) 를 오늘 기준으로 읽고 매김.
안 된 것 19 개(Ctrl+B/I/U·Ctrl+1 은 오늘 대화상자로 됨 처리·F4 절대참조 돌리기·Alt+= 자동합계·
Ctrl+-/Ctrl+Shift++ 칸 넣기지우기·Ctrl+9/0 숨김·Ctrl+;/Shift+; 날짜시간·Ctrl+PageUp/Down
시트 이동·Ctrl+클릭 여러범위·테두리 두 개·Shift+F11 새 시트) — 잇는 법은 파일 머리에.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 21:02:28 +09:00
eomsangdon 3656f02704 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3
# Conflicts:
#	A00_Common/spreadsheet/spreadsheet_types.ts
2026-09-27 21:01:37 +09:00
eomsangdon d299ca3d3c Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-09-27 21:01:16 +09:00
eomsangdon 8606272f5c auto: 2026-09-27 21:00 (ESD_LAPTOP) 2026-09-27 21:00:23 +09:00
eomsangdonandClaude Opus 5.5 a939d0bf55 fix(spreadsheet): 크기 예산 고침 — 엔진 32 KB · 합 64 KB(브레인 판정)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 21:00:16 +09:00
eomsangdonandClaude Sonnet 5 5718aa7f9d feat(spreadsheet): 3단계 — 조건부 서식(계약 더함)
Sheet.조건부서식·유효성·이름정의 계약 더함(0 계약 · git-sync 뒤 더함) · spreadsheet_condfmt.ts —
값 비교·사이·글 포함·수식 조건 · 채움·글자색 · 규칙 관리 창 · 우선순위 옮김 · 행열 넣기·지우기 범위
따라가기(remapCondFmtRanges). 엔진(A)·격자(C) 파일은 안 건드림 — 잇는 법을 머리 주석에 적어 둠(잇기는 sub7).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 21:00:12 +09:00
eomsangdon 6dad7497dc Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_7 2026-09-27 21:00:03 +09:00
eomsangdonandClaude Opus 5.5 c7f18d533b test(spreadsheet): 크기 시험을 실제 앱 빌드 조각으로 잼 · 정적으로 끌어오는 곳 없음 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:59:43 +09:00
eomsangdon 57ddb3866d feat(spreadsheet): 칸 우클릭 메뉴에 메모 삽입 · 편집 · 삭제
- sub1 `spreadsheet_comments.ts` 의 setComment · deleteComment · getComment 호출
- 글 입력은 E 자체 작은 오버레이(native prompt 안 씀 — 자동화 창 자동 취소 문제)
- ORCA 실 엔진 시험 — 삽입 → 메뉴 글자가 「메모 편집·삭제」로 바뀜 → 삭제 → 취소(저장 안 됨) 확인
2026-09-27 20:59:25 +09:00
eomsangdonandClaude Sonnet 5 d60b1ccc7e feat(spreadsheet): 분수 형식 · 함수 2차 묶음(30개) · misc 파일 쪼갬
numfmt — 분수 형식(0/0 · # ?/? · # ??/?? · 분모 고정 # ?/8) 엑셀과 같게 구현.
함수 — MROUND·ODD·EVEN·LOG·LOG10·LN·EXP(math) · SUMIFS·COUNTIFS·AVERAGEIF(S)·MAXIFS·MINIFS·
IFS·SWITCH·XLOOKUP(찾기·조건부 집계) · TEXTJOIN·TRIM·UPPER·LOWER·SUBSTITUTE·REPLACE·FIND·
SEARCH·REPT·ISNUMBER·ISTEXT·ISBLANK·ISERROR·NA·TODAY·DATE·YEAR·MONTH·DAY(misc) 더함.
misc.ts 가 700 줄을 넘어 찾기·조건부 집계를 spreadsheet_functions_lookup.ts(새 파일)로 쪼갬.
sub1 의 func_help 이름 목록에 더할 것을 spreadsheet_functions.ts 의 FUNC_HELP_BATCH2 에 둠
(그 파일은 안 고침 · 머리 주석에 읽는 길 적음). 경계값 시험 135건 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:59:21 +09:00
eomsangdon 6b08058a58 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into sub_laptop_6 2026-09-27 20:57:31 +09:00
eomsangdonandClaude Opus 5.5 ce3df39206 feat(spreadsheet): D 잇기 마무리 — 진짜 부품으로 createSpreadsheet · 2단계 찾기 · 메모 · 서식 붓 · 골라 붙여넣기 · 폭 맞춤 · 함수 도움말
- spreadsheet.ts — 가짜 부품 자리(parts) 걷고 A · B · C · E 직접 잇기 · 2단계는 spreadsheet_extras.ts 로
- spreadsheet_extras.ts(새) — 메모 풍선(마우스 올림) · Shift+F2 메모 편집 · 서식 붓 단추 · Ctrl+Shift+V 값만 · 서식만 · 수식만 · 도구 모음 뒤 초점 되돌림
- spreadsheet_find_panel.ts(새) — Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 패널
- spreadsheet_editor.ts — C editorSlot(격자 뿌리 기준) → 층 안 좌표 맞춤(slotOf) · 함수 자동 완성(Tab) · 인자 도움말
- spreadsheet_keys.ts · spreadsheet_mouse.ts — 2단계 잇기 자리 · 열 경계 두 번 누르면 폭 자동 맞춤
- 시험 — 함수 도움말 자리 판정 추가(8 묶음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 20:57:25 +09:00
eomsangdonandClaude Opus 5.5 77c132adb5 feat(spreadsheet): 자동 필터 화면 — 머리 행 단추 · 값 목록 체크 · 이 열 정렬 · 필터 켜기 · 끄기
- spreadsheet_filter.ts — attachFilter(ctx) 단추 층(격자 overlay) · 값 목록 창(모두 선택 · 빈 칸 · 이 열 필터 지움 · 오름 · 내림) · toggleFilter · open(열)
  · 걸러진 행은 엔진이 행.걸러짐 으로 적음(사용자 숨김과 따로) · 잇는 법(D · C)은 머리 주석
- 시험 틀 harness_sortfilter.html · .ts · _bundle.cjs — 진짜 부품에 정렬 · 필터를 얹어 ORCA 로 봄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:56:38 +09:00
eomsangdonandClaude Opus 5.5 a4ad54571c feat(spreadsheet): 정렬 — 엑셀 차례 · 여러 기준 · 머리 행 · 식 복사식 옮김 · 대화 상자 — 계약 더함(정렬 · 필터 명령 · Sheet.필터 · RowInfo.걸러짐)
- 계약 더함: Command 「정렬」(차례를 박음 → 다시 하기가 같음) · 「필터」(걸러짐 행을 박음) · AutoFilter · Sheet.필터 · RowInfo.걸러짐 · 파일 목록
- spreadsheet_sortfilter.ts(A 엔진) — 수 < 글 < 참거짓 < 오류 · 빈 칸 늘 맨 뒤 · 같은 값 원래 차례 · 대소문자 옵션 · 현재 영역
  · 정렬 옮김 = 범위 열만 · 식은 상대 참조만 복사처럼 · 범위 밖 식 그대로 · 서식 · 메모 따라감 · 걸러진 행 제자리 · 병합 걸치면 막음
  · 자동 필터 엔진(값 목록 · 걸러짐 셈 · 적용 · 행열 넣기 · 지우기 때 필터 따라감) · 되돌림 짝 = 시트통째
- spreadsheet_sort_dialog.ts — 정렬 대화 상자(범위 · 머리 행 짐작 · 기준 더하기 · 오름 · 내림) · 빠른 정렬 sortSelection
- spreadsheet_commands_sheet.ts — 시트 명령을 떼어 냄(commands 700 줄 안쪽)
- 시험 test_spreadsheet_sortfilter.py 엑셀 규칙표 4 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:56:28 +09:00
eomsangdon a52f8fcd87 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-27 20:54:31 +09:00
eomsangdon fddaa4edfb feat(spreadsheet): E 화면 시험 진짜 A · B 엔진으로 다시 · 클립보드 HTML 견본 추가
- 화면 시험 틀 가짜(fakes/) 걷고 진짜 `applyCommand` · `createCalcEngine` · `createHistory` · 주소 · 숫자 형식으로 재시험
- 서식 · 병합 · 탭 이름 바꾸면 다른 시트 참조 식도 옮김 · 안↔안 클립보드 수식 상대 이동을 실제 엔진 계산값으로 확인
- 엑셀(<table> + mso 스타일 · colspan · x:str/x:num · 「수식 표시」) · 구글(google-sheets-html-origin · data-sheets-formula) 클립보드 HTML 견본 추가 · `parseClipboard` 로 붙여넣기 시험
- 이 PC 는 엑셀 앱 없음 · 구글 시트는 로그인 상태로 새 문서까지 열었으나 자동화 창이 OS 포커스를 못 받아(Document is not focused) 실제 클립보드 왕복은 못 함 — 견본 파일 시험으로 갈음
- toolbar · tabs · menu · clipboard 머리 주석에 D(`spreadsheet.ts`) 잇는 자리 설명 추가
2026-09-27 20:54:17 +09:00
eomsangdon a644b2617c Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-09-27 20:46:10 +09:00
eomsangdon 83a3a57568 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-09-27 20:46:09 +09:00
eomsangdonandClaude Sonnet 5 7d9fc6eea5 feat(spreadsheet): C 격자 — 시트 기본값 반영 · data-addr · 판별 editorSlot · 메모 세모 · 시험 틀
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 20:45:30 +09:00
eomsangdon d1164e29e4 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-27 20:44:09 +09:00
eomsangdon 5c570e5a95 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-09-27 20:44:08 +09:00
eomsangdon 4f1510bb48 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_3 2026-09-27 20:44:04 +09:00
eomsangdon 0f970c8c20 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3 2026-09-27 20:44:03 +09:00
eomsangdonandClaude Sonnet 5 c8bb862a5c Merge remote-tracking branch 'origin/dev' into sub_laptop_4
충돌 49개 구조물 json + Store.py + test_m02_structure.py — dev 쪽 산출근거 분리(basis/) 구조를 따르고 내 쪽 도면.layers 기본 도면층을 얹어 합침.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 20:43:35 +09:00
eomsangdon 0415177f44 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-09-27 20:42:49 +09:00
eomsangdon e214350683 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-09-27 20:40:28 +09:00
eomsangdonandClaude Opus 5.5 85fa62ae4d feat(M02): 임시 스프레드시트 시험 단추가 inspect 문맥을 켬(브레인 검증용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:39:35 +09:00
eomsangdonandClaude Sonnet 5 b93e03e505 feat(spreadsheet): fill.ts를 A 엔진 실 구현으로 잇기 · numfmt 실무 서식 대조 고침
fill.ts가 A의 실제 moveFormula·toA1을 쓰도록 정리(식 채우기 시험 통과).
실무 구조도 xlsx 세 벌 실측 형식 코드 66개 대조 — `_x` trailing 공백을 trim이 삼키던 버그,
글 값에 순수 수 형식이 적용되던 버그, `General"단위"` 접미 버릇, `[$통화-로캘]` 태그 미지원을 고침.
분수 형식(`0/0`)은 1단계 형식 부분집합 밖이라 남겨둠. 회귀 시험 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:39:03 +09:00
eomsangdon 88866fb4a5 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-09-27 20:38:18 +09:00
eomsangdon 8ad456d1e1 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8 2026-09-27 20:37:43 +09:00
eomsangdonandClaude Opus 5.5 6f8ab6e067 test(spreadsheet): 늦게 불러옴 시험에서 시험용 진입(resources/tester) 뺌
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:37:05 +09:00
eomsangdon 3dc523ef41 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-09-27 20:36:08 +09:00
eomsangdonandClaude Opus 5.5 48a2135132 feat(spreadsheet): D 잇기 — 진짜 A 엔진과 재검증 · inspect 선택(window.__aisloSheet · 임시) · 시험 틀 한 번 시나리오
- spreadsheet.ts — `inspect` 선택이면 문맥을 window.__aisloSheet 에 걺(M02 [스프레드시트 시험] 단추 전용 · destroy 때 뗌 · 1단계 검증 뒤 지움)
- 시험 틀 — ssScenario()(페이지 안 한 번에 한글 · 가리키기 · 재계산 · 되돌리기 · Delete · Ctrl+Enter · 수식 입력줄 · F2) · 가짜 격자 data-addr · 진짜 분수 값 보임 · 오류 모음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 20:33:55 +09:00
eomsangdon bfd7123829 feat(spreadsheet): E 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 구현
- 도구 모음: 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식 · 되돌리기
- 시트 탭: 더하기 · 이름 바꾸기 · 지우기 · 복사 · 끌어 옮기기
- 우클릭 메뉴: 칸 · 행 머리 · 열 머리별 명령(공용 `ui_template_context_menu` 재사용)
- 클립보드: 안 → 안 서식 · 병합 · 수식 상대 이동 왕복 · 밖 → 안(엑셀 값 + 「수식 표시」 · 구글 R1C1) · 안 → 밖(TSV + 인라인 서식 HTML)
- E 화면 시험 틀(A · B 몫 가짜 대역) · TSV 파싱 Node 시험 추가
2026-09-27 20:33:26 +09:00
eomsangdon b8312343eb Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-09-27 20:30:35 +09:00
eomsangdonandClaude Sonnet 5 d5c5090b79 feat(spreadsheet): C 격자 그리기 — DOM 4 판 · 병합 · 틀고정 · 숨김 · 폭 끌기 · 서식 CSS
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
2026-09-27 20:30:17 +09:00
eomsangdon b184d18a50 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2 2026-09-27 20:29:47 +09:00
eomsangdon 095eca0ddf Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-09-27 20:29:44 +09:00
eomsangdon f77b14b51c Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2 2026-09-27 20:29:43 +09:00
eomsangdon 9f240931f5 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-09-27 20:28:16 +09:00
eomsangdon d8db2683f9 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-09-27 20:28:14 +09:00
eomsangdon 1bdbba72f9 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-09-27 20:26:38 +09:00
eomsangdon f27c83cd93 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_4 2026-09-27 20:26:21 +09:00
eomsangdon 82910bec0a Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4 2026-09-27 20:26:21 +09:00
eomsangdonandClaude Opus 5.5 b58073730f test(spreadsheet): A 엔진 무게 시험 — 칸 1만 · 식 5천 재계산 · 6만 8천 칸 불러오기 · 나눗셈 사슬 분모 상한
- bench_spreadsheet_engine.cjs — 얕은 시트 · 깊은 사슬 5천 · 큰 시트(271 행 × 253 열) · 소수 역수 합 · 곱나눗셈 사슬 수치
- test_spreadsheet_bench.py — 딸린 칸 수 · 시간 한도(실측 몇 배) · 분모 41 자리 안 · double 과 상대 오차 1e-12 안
- helper_spreadsheet_engine.cjs — 적재 · 가짜 함수를 무게 시험이 같이 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:25:30 +09:00
eomsangdonandClaude Sonnet 5 0d2d5614aa feat(spreadsheet): 2단계 여섯째 — 메모(풍선 설명 · 빨간 세모)
브레인 답(2026-09-27) — 저장 자리는 `Sheet.comments?: Record<A1, 글>`(sub3 가 0 계약에 보탬 ·
이 창은 손대지 않고 `SheetWithComments` 로 모양만 가정). 명령 전용 「메모」 종류가 아직 없어
`시트통째` 명령에 얹음(전용 명령이 생기면 `applyComments` 한 곳만 고치면 됨). 행열 넣기·지우기
때 메모 주소 옮김은 A 의 refshift 를 부르는 자리로 파일 머리에 적어 둠(지금은 비움).

시험 harness(test_stage2_helpers.ts) 에 「시트통째」 명령 적용도 더함(다른 2단계 시험도 씀).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 20:24:39 +09:00
eomsangdonandClaude Sonnet 5 7725b39ecb feat(spreadsheet): 스프레드시트 B(함수 · 숫자 형식 · 채우기) 1단계 구현
수학 26 · 논리/글/찾기/세기 22 함수 · 엑셀 숫자 형식 코드 해석기 · 채우기 핸들(복사 · 등차 연속 · 글 끝번호 · 식 상대이동) 구현.
자기 시험 69건(resources/tester/test_spreadsheet_b.py, vite ssr 번들 → node) 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:23:45 +09:00
eomsangdonandClaude Opus 5.5 279ef5fb4f feat(spreadsheet): A 엔진 뺀 셋 채움 · 메모 옮김 — 계약 더함(Sheet.기본 · Sheet.comments)
- 계약 더함: Sheet.기본 { 서식 · 열폭 · 행높이 } — 모두 고르기 서식 · 폭 · 높이 · 칸 서식 물려받기 = 칸 → 행 → 열 → 시트 기본 → 0
- 계약 더함: Sheet.comments — A1 → 메모 글 · 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 · 덮은 칸 메모는 지움)
- 행 · 열 넣기 = 위(왼) 줄 칸 서식 · 높이 · 폭 따라감(숨김은 안 따라감 · 첫 줄 앞 넣기는 안 따라감)
- 범위를 칸 하나 자리에 넣으면 엑셀 암묵 교차(한 열 범위 = 식 든 행 · 한 행 범위 = 식 든 열)
- 풀이 차례를 반복 위상 순서로 — 2만 칸 사슬도 스택 안 넘침
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py 규칙 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:23:32 +09:00
eomsangdon bc5b4217c1 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-09-27 20:22:13 +09:00
eomsangdonandClaude Opus 5.5 a2011c8035 feat(spreadsheet): D 입력 — 고름 · 칸 편집기 · 한글 조합 · 수식 입력줄 · 참조 가리키기 · 단축키 · 채우기 끌기 · 잇기
- spreadsheet.ts — createSpreadsheet 잇기(문맥 · 명령 → 되돌림 → 엔진 → 격자 · onChange · onSelect · 시트마다 고름 기억) · parts 한 곳(시험 틀이 빈 몸 부품을 바꿔 끼움)
- spreadsheet_selection.ts — 병합으로 늘림 · Shift/Ctrl 방향 · 숨김 건너뜀 · 행열 머리 · 고름 안 Enter/Tab 돌기
- spreadsheet_editor.ts — 숨은 textarea 늘 초점 · compositionstart/input 로 편집 시작 · enter/edit 방식 · 수식 입력줄 · 주소 상자 · 참조 넣기 · 색 테두리 · 입력 글 규칙(' · % · 천 단위)
- spreadsheet_keys.ts · spreadsheet_mouse.ts — 엑셀 단축키 · 끌기 고르기 · 채우기 핸들(늘림 · 줄여 지움 · 두 번 누르면 아래로) · 우클릭 자리
- 시험 resources/tester/spreadsheet — 규칙 7 묶음 · 가짜 부품 번들 시험 틀 html(ORCA)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 20:22:02 +09:00
eomsangdon 5807313d21 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-27 20:20:23 +09:00
eomsangdonandClaude Sonnet 5 d38ef573e0 feat(spreadsheet): 2단계 다섯 — 찾기 · 붙여넣기 골라서 · 서식 붓 · 폭 자동 맞춤 · 함수 도움말
PLAN 11-8 스프레드시트 2단계. 0 계약(spreadsheet_types.ts · spreadsheet_view_types.ts)
타입만 보고 새 파일 다섯 짬 — 잇기는 1단계 끝에 D(sub7)가 파일 머리 주석대로:

- spreadsheet_find.ts        찾기 · 바꾸기(대소문자 · 전체칸 · 수식 안)
- spreadsheet_paste_special.ts 붙여넣기 골라서(값만 · 서식만 · 수식만)
- spreadsheet_format_painter.ts 서식 붓(칸→행→열→기본 차례로 서식을 찾음)
- spreadsheet_autofit.ts      열 머리 두 번 눌러 폭 자동 맞춤(어림 재기 · 실제 재기 자리 열어 둠)
- spreadsheet_func_help.ts    함수 자동 완성 · 인자 도움말(1단계 함수 48 개)

여러 범위 · 확대 · 눈금선 끄기는 이미 계약에 있어 새 파일 없이 붙일 자리만 확인(autofit.ts 머리).
시험 = resources/tester/spreadsheet/test_stage2_*.ts(공용 견본 · 가짜 컨텍스트는 test_stage2_helpers.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 20:20:16 +09:00
eomsangdon cd05bfef3b Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-09-27 20:18:44 +09:00
eomsangdon 0750b2f0d3 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-09-27 20:17:26 +09:00
eomsangdonandClaude Sonnet 5 dd7300aa02 fix(M02): 구조물·도면 빈 문서에 기본 도면층 채움
- Store.py create_structure · UI_Side.ts stubDoc(도면 양식 「새로」) 둘 다
  빈 CAD 문서에 기본 도면층 하나(id·name "기본") 넣음 — 없으면 sub1 이 고친
  CAD import 가드가 대신 채우지만 뿌리도 처음부터 채움
- 이미 만들어진 구조물 도면 49개(resources/master_template/structure/*.json)
  도면.layers 채움(한 번 도는 정리) — 다른 칸·도번은 그대로 · 줄바꿈도 그대로
- 시험(test_m02_structure.py) — 새 문서 layers 1개 확인으로 바꿈

검증: M02 시험 28개 · 루트 npm run typecheck 통과. ORCA 로 확인 —
빈 구조물 도면(rv) 오류 없이 열림 · 도면 양식 「새로」 빈 도면 오류 없이 열림 ·
실제 선 하나 넣어 저장 → 다시 열기 → 화면에 그대로 · [저장] 단추로도 저장됨
(검증 뒤 rv 는 원래 빈 문서로 되돌림 · 검증용 임시 양식은 지움).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 20:16:46 +09:00
eomsangdonandClaude Opus 5.5 736dac4eb5 feat(spreadsheet): 산출근거 스프레드시트 A 엔진 핵심 — 주소 · 파서 · 풀이 · 의존 재계산 · 참조 옮김 · 명령 · 되돌리기
- address · parser — A1 ↔ 행열 · 낱말(편집기 색칠용 자리) · 엑셀 우선순위 나무(-2^2=4 · % · & · 비교) · 나무 → 글
- eval — 분수 풀이 · 오류 값 전파(왼쪽 먼저) · 형 바꾸기(빈 칸 · 수 글 · 참거짓) · 일반 형식 글 15 자리 · 무리수 15 자리
- graph — 의존 그래프 · 고친 칸에 딸린 칸만 재계산 · 순환 = 고리 칸마다 #CYCLE! + 까닭
- refshift — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 · 시트 지우기 참조 옮김(지운 칸 #REF!) · 복사 상대 옮김 · 구글 R1C1 → A1
- commands · history · recalc — 명령마다 되돌림 짝 · 병합 규칙 · 서식 한 벌 표 · 묶음 원자 · Node 한 벌 풀이
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py — 엑셀 규칙표 9 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:14:31 +09:00
eomsangdonandClaude Opus 5.5 cd824aca25 feat(spreadsheet): F 서버 재계산 · 산출근거 따로 저장 · M02 끼우기 · 금빛 시험
- common_util_spreadsheet_node.ts · common_util_spreadsheet.py · build:spreadsheet — [저장] 때 화면과 같은 TS 엔진을 Node 로 돌려 계산값 정본(브라우저 값 버림 · 못 풀면 503)
- M02 새 종류 basis — resources/master_template/basis/<열 id>.json(구조물집계표 열마다) · 층 복사 · 뼈대 검사 · 옛 구조물 문서 산출근거 49 벌 옮김(migrate_basis) · 새 열도 같이 만듦
- 상세 산출근거 카드 = 새 스프레드시트(import() 로 늦게 받음) · basis 읽기 · 저장
- ⚠ 임시 [스프레드시트 시험] 단추 — 빈 통합문서(시트 셋) · 저장 자리 tmp/spreadsheet_trial.json · 1단계 검증 뒤 지움
- 시험 resources/tester/spreadsheet/ — 저장 · 서버 다리 · 크기 예산 · M02 만 늦게 불러옴 · 실무 구조도 xlsx 세 벌 수식 2,699 칸 엑셀 캐시값 대조(엔진 빈 몸이면 건너뜀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 20:12:39 +09:00
eomsangdon d53ef8d3bf Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_1 2026-09-27 20:02:04 +09:00
eomsangdon 14bf7b1def Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-27 20:02:02 +09:00
eomsangdonandClaude Sonnet 5 2dc0a71ba7 feat(cad): 리본 접기 기능 · M02 도면 편집을 접힌 채로 엶
- ui-state.ts ribbonCollapsed · Ribbon.tsx 접기 단추 · 탭 누르면 펼침
- App.css --cad-ribbon-height 를 Toolbar.tsx 가 접힘 때 26px 로
- aislo-drawing-bridge.ts load 곁값 ribbonCollapsed 받아 반영
- cad_host.ts CadLoadExtra 칸 추가 · M02_MasterTemplete_Drawing.ts 가 true 로 보냄
  (도면 양식 · 구조물 도면 둘 다 이 파일 한 곳을 씀)
- B07 은 곁값을 안 보내 펼침 그대로(코드 확인 · cad.load 호출 둘 다 5번째 인자 없음)
- 구조물 편집 머리 제목(② 열 머리 글)은 이미 다른 창이 끝냈음(commit 65f43ca0) — 손대지 않음

검증: CAD 빌드 · 루트 npm run typecheck · M02 시험 18개(구조물·도면 영역) 통과 ·
ORCA 로 도면 양식(00_template_A1) 처음 접힘(26px) → 단추로 펼침(122px) 확인.
구조물 도면(rv 등) 은 서버가 만드는 빈 문서에 `layers` 키가 없어 CAD import 단계에서
조용히 실패(레이어 접근 에러) — 리본 접기와 무관한 기존 결함 · 손대지 않음(브레인에게 보고).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
2026-09-27 20:00:53 +09:00
eomsangdonandClaude Sonnet 5 4a6e0ffb3a fix(B07): CAD 파일 뿌리 서식으로 되돌림(방금 커밋에 딸려 온 서식 사고)
바로 앞 커밋에 뿌리 prettier 서식(큰따옴표·스페이스)이 실수로 같이 실려 옴 —
이 파일은 openwebcad 자기 서식(biome · tab · 홑따옴표)을 그대로 씀 · 논리는 그대로.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 19:58:39 +09:00
eomsangdonandClaude Sonnet 5 1c5a803e1b fix(M02): 구조물 도면 미리보기 특성·도면층 숨김 · CAD 빈 도면 불러오기 오류 고침
- 구조물 도면 미리보기(도면 정보 카드)에서 CAD 특성·도면층 패널(.cad-inspector)도 같이 숨김
- B07 CAD `layers` 칸이 없는 도면(빈 구조물 도면 · 「새 도면」 빈 도면 공통)을 열면
  "Cannot read properties of undefined (reading 'length')" 로 죽던 것을 고침
  (`layers` 없으면 새 도면층 하나로 채움)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 19:57:24 +09:00
eomsangdonandClaude Opus 5.5 26fd4fb72e feat(spreadsheet): 산출근거 스프레드시트 0 계약 — 타입 · 엔진 머리 · 저장 견본
- spreadsheet_types.ts · spreadsheet_view_types.ts — 문서 · 칸 · 서식 · 수식 나무 · 값 · 명령 · 계산 엔진 · 화면 문맥 계약
- 머리 주석에 파일 · 주인(A~F) · 예산 줄 수 · 크기 예산(gzip 54 KB) · 규칙(외부 라이브러리 0 · M02 만 늦게 불러옴)
- A · B 엔진 · C 격자 · D 진입 · E 도구 공개 함수 머리(빈 몸)
- spreadsheet_sample.json — basis/<열 id>.json 한 벌 견본(병합 · 서식 · 다른 시트 참조 · 숨김 · 계산값)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
2026-09-27 19:48:20 +09:00
eomsangdonandClaude Sonnet 5 65f43ca083 feat(M02): 구조물 도면 = 좌측 셋째 컨테이너 · 열마다 미리 만듦
- 좌측 「구조물 도면」을 「표 양식」·「도면 양식」과 같은 층 컨테이너로
- 구조물집계표 열마다 빈 구조물 도면을 서버가 미리 만들어 둠(도번 자동 · 도각 없는
  빈 CAD 문서 · 산출근거 빈 줄 셋) — 좌측 줄에 항상 도번이 붙음
- 새 열을 더해 집계표를 저장하면 그 열의 빈 구조물 도면도 서버가 같이 만듦
  (`M02_MasterTemplete_Store.write` → `backfill_missing_structures`)
- 좌측 구조물 도면 줄을 누르면 그 CAD 화면이 곧장 열림(열 머리글 · 도번을 제목에)
- 지금 있는 49개 열의 구조물 도면을 미리 만들어 커밋(`resources/master_template/structure/`)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 19:17:17 +09:00
eomsangdonandClaude Sonnet 5 ef5a7e9771 fix(M02): 좌측 패널 공용 틀 M01 과 맞춤
- ui_template_overlay.css 에 ui-sidebar-panel · ui-sidebar-card · ui-sidebar-list ·
  ui-sidebar-row 공용 클래스 추가
- M01·M02 좌측 코드가 공용 클래스를 씀 — 카드 여백·둥글기·바탕 · 목록 간격 ·
  줄 선택 표시 한 벌
- M02 좌측 전용 CSS 중복 규칙 제거(없는 토큰 --radius-md 로 둥글기 안 먹던 것도 해소)
- M02 표/도면/구조물 카드 처음 접힘으로 M01 과 맞춤 · 새로·본떠·지우기 단추 줄 카드 안으로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 18:24:34 +09:00
eomsangdonandClaude Sonnet 5 243d00c218 fix(M02): 구조물 설계 컨테이너 미리보기에서 CAD 도구 모음 숨김
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EprXZenk62RAhh6G4v1iEF
2026-09-27 12:04:47 +09:00
eomsangdonandClaude Sonnet 5 7e694a8825 fix(M02): 구조물 산출근거 빈 자리 글 「표 없음」 으로 맞춤
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EprXZenk62RAhh6G4v1iEF
2026-09-27 11:58:34 +09:00
eomsangdon 65a6b6e942 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-27 11:35:10 +09:00
eomsangdonandClaude Opus 5 9d1a4f9bdb feat(M02): 구조물집계표 열마다 설계 컨테이너 · 도면 줄 도번
- 집계표(시스템 층) 도면 줄에 `구-0N` 도번 — `fetchStructureNumbers` 를 `drawingLabel` 로
- 칸 고르기(`onSelect`)마다 표 밖 형제 자리에 컨테이너 — 열 머리·도번 · 도면 미리보기(읽기만) · [도면 수정]
- [도면 수정] = 고침 확인 뒤 없으면 `createStructure` · 같은 페이지에서 그 구조물 도면 CAD 편집 · 좌측 고름 표시
- 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 그대로
- 컨테이너 아래 상세 산출근거 표(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)) · 줄 더하기 · 저장
- 새 시험 `test_m02_structure_panel.py` — 집계표 이름 대조 · 창끼리 이름 계약 · `columnHead`

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TamXZKonwSwyGT74XwwCbC
2026-09-27 11:32:06 +09:00
eomsangdon a7f2383d90 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-27 11:22:24 +09:00
eomsangdon 8ae1111f16 auto: 2026-09-27 11:22 (EOMSANGDON-HOME) 2026-09-27 11:22:10 +09:00
eomsangdon 38a13bcb0b Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_desktop_1 2026-09-27 11:14:27 +09:00
eomsangdonandClaude Opus 5.5 1ae10e64e2 feat(sheet): 마스터 첫 쪽 도면 줄 · 칸 고르기 신호 onSelect
- 일위대가 줄 아래 도면 줄 s:drawing — 칸 글은 SheetOptions.drawingLabel(colId) · 없거나 빈 글이면 「없음」 · 읽기만
- 줄 차례(navRows) · MASTER_ROWS · KEY_UNIT 를 ops 로 옮김(Node 시험 가능)
- 칸을 고르면 SheetOptions.onSelect({ row, col, colId }) — 누르기 · 방향키 · 머리 칸
- 시험 test_sheet_drawing_select.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-27 11:14:10 +09:00
eomsangdonandClaude Sonnet 5 83e08c2c7f feat(M02): 좌측 표 양식 · 도면 양식 접히는 컨테이너 · 구조물 도면 묶음 · 롱기튜디널 두 양식 지움
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53E6xKBaqhUZucMJ1wBYX
2026-09-27 11:12:12 +09:00
eomsangdonandClaude Opus 5.5 fabc033c53 feat(M02): 양식 종류 structure — 구조물 도면 새로(도번 자동) · 도번 목록 길 · 프로젝트 복사에 실림
- 종류 셋(table · drawing · structure) · 뼈대 검사 한 벌(도면.entities · 산출근거.열)
- POST /api/m02/structures/{열 id} — A1 도각 + 빈 산출근거 표(머리 다섯) · 도번 = 있는 것 중 가장 큰 번호 + 1 · 있으면 409 · 없는 열 404
- GET /api/m02/structures/numbers — {열 id: 도번}
- Api_Fetch — Kind 에 structure · StructureDoc · listStructures · createStructure · fetchStructureNumbers
- 폴더 resources/master_template/structure/ · 시험 test_m02_structure.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yTsevyL7QysasVVgWW7vW
2026-09-27 11:09:55 +09:00
eomsangdon db46810f7b auto: 2026-09-27 10:31 (EOMSANGDON-HOME) 2026-09-27 10:31:36 +09:00
eomsangdon ddfe562c2e Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-25 22:30:23 +09:00
eomsangdon c6af3b9b2a Merge remote-tracking branch 'origin/main_desktop_1' into main_desktop_1 2026-09-25 22:30:02 +09:00
eomsangdon 4f71b4a5ce feat(M02): 마스터 템플릿 라우터 연결 · formula 빌드 경로 정리
- main.py: M02_MasterTemplete 라우터 3개(시스템·층·도면) 등록
- tsconfig.json: include 목록에 M0* 추가
- package.json: build:formula 대상을 common_util_sheet_recalc_node.ts 로 변경
2026-09-25 22:29:21 +09:00
eomsangdonandClaude Sonnet 5 db489c7c3a fix(M02): 작도 영역 창을 단추 바로 아래 오른쪽 맞춤으로 · 창 밖으로 안 나가게
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 18:18:10 +09:00
eomsangdonandClaude Sonnet 5 ef63904866 fix(M02): 작도 영역을 값이 다 보이는 단추 하나로 — 누르면 칸 넷이 작은 창으로 펼쳐짐
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 18:04:27 +09:00
eomsangdonandClaude Sonnet 5 39c79f05d0 refactor(M02): 좌측 패널을 시스템 양식 목록만으로 — 층 · 프로젝트 · 프로젝트 단추 다섯 뺌 · 제목 줄 headerSlot 연결
작도 영역 칸은 제목 줄 가운데로. 그림 칸 위쪽 246px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 17:55:38 +09:00
eomsangdonandClaude Sonnet 5 5b6cf0103e feat(M02): 도면 칸 위쪽을 줄임 — 자리표 글 줄 뺌 · CAD 제목 줄 숨김 · 작도 영역 한 줄 · headerSlot 옵션
CAD 그림 칸 위쪽 448px 에서 285px 로, 그림 높이 598 에서 760 으로.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 17:43:43 +09:00
eomsangdonandClaude Sonnet 5 847f0e44de fix(M02): 개인 층 지우기에 판을 보냄 · 저장 안 한 고침이 있으면 머리 메뉴·뒤로 가기로 떠날 때도 확인(취소하면 M02 에 남고 주소 되돌림) · 내 양식·회사 공식 저장 뒤 열던 양식 유지
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 17:19:24 +09:00
eomsangdonandClaude Sonnet 5 7b2c22d920 fix(M02): 병합에서 빠진 CAD 호스트 생성 · 💾 저장 통로를 도면 부품에 되살림
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 17:11:24 +09:00
eomsangdonandClaude Sonnet 5 8f55821a6a merge: origin/dev 받음 — 도면 칸 CAD 💾 · 접기와 sub1 저장 안 한 고침 알림 통로를 다 살림
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 17:06:37 +09:00
eomsangdon b6b4d7772d Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-09-25 17:04:53 +09:00
eomsangdon d3ca77fcb5 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-09-25 17:04:52 +09:00
eomsangdonandClaude Sonnet 5 6555313a22 fix(M02): 재검증 흠 넷 — 같은 이름 토스트 · 쓸 수 없는 글자 막기(화면·서버 400) · 저장 안 한 고침 버리기 확인 · 덮어쓰기 확인과 개인·회사 지우기 단추
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 17:01:47 +09:00
eomsangdonandClaude Sonnet 5 1cd52cfdfb fix(M02): 도면 칸 CAD 💾 · Ctrl+S 가 페이지 [저장] 을 부름 · 엉터리 도각 파일은 400 · 자리표 패널 접기 단추 (PLAN 10-3)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
2026-09-25 16:56:05 +09:00
eomsangdonandClaude Sonnet 5 c411d866bb fix(sheet): 합계 칸 풍선에 알림 까닭과 식을 함께 (M02 전수 재검증)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 16:55:05 +09:00
eomsangdonandClaude Sonnet 5 1e29f5e0b2 fix(sheet): 수 칸에 수 아닌 글이면 오류 색 + 풍선 · 합계 칸에도 알림 (M02 전수 재검증)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 16:53:17 +09:00
eomsangdonandClaude Sonnet 5 576b012eae feat(M02): 개인 · 회사 층 양식 지우기 — DELETE /layers/{층}/templates/{종류}/{이름} · 개인 본인만 · 회사 관리자만 · 시스템 · 프로젝트 거절 · 판 다르면 409 · 이름 검사 400 + 까닭
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
2026-09-25 16:52:52 +09:00
eomsangdonandClaude Sonnet 5 2aa4c80577 fix(M02): 저장 직후 지우기가 409 로 거절되던 흠 — 저장 뒤 좌측 목록 다시 읽음 · 지우기 전에도 최신 판으로
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 13:03:01 +09:00
eomsangdonandClaude Sonnet 5 acca7d1fb6 fix(sheet): 머리 칸 / 로 층 나눔 · 줄 머리 칸 고정 · 줄 머리 칸 폭 자동 (M02 재검증)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 12:45:57 +09:00
eomsangdon 4679261634 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into sub_laptop_3 2026-09-25 12:44:02 +09:00
eomsangdonandClaude Sonnet 5 28b7e70f28 fix(M02): 층 길 저장 PUT 이 뼈대 없는 문서(빈 {} · 표 열 목록 없음 · 도면 entities 없음)를 400 으로 거절 — 파일 안 씀 (브레인 급한 흠)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
2026-09-25 12:43:25 +09:00
eomsangdonandClaude Sonnet 5 9bf3258509 fix(M02): 도면 양식 저장이 빈 {} 로 덮이던 흠 — getDoc 을 await · 서버는 뼈대 없는 문서를 400 으로 거절
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 12:43:03 +09:00
eomsangdon dd60b6a1cb Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-09-25 12:16:18 +09:00
eomsangdonandClaude Opus 5.5 07953fa523 feat(M02): 옛 표 틀 새 판으로 갱신 — Migrate --refresh-tables · 손 값 · 변수 · 보기 지킴 · _initial 도 같은 판 (브레인 최종 확인)
- 계산 열에 바인딩이 붙은 옛 틀만 골라 시스템 새 판으로
- 손댄 것이 없으면 시스템 파일 그대로 복사(판이 시스템과 같음) · 있으면 고쳐 쓰고 manifest 에 출처 판
- 기존 프로젝트 6개 작업본 · _initial 갱신 완료

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
2026-09-25 12:03:10 +09:00
eomsangdonandClaude Opus 5.5 6cc98d68f1 fix(sheet): 마스터 딱지는 식 먼저 · 식 줄은 열 이름 · 머리 글 안 잘리는 최소 폭 · 쪽 나눔 시험 (M02 브레인 확인)
- 식 있는 열 = 계산 · 바인딩만 = 설계값(펼침이면 값마다 열) · 손 = 손 입력
- 식 줄은 [열id] 대신 머리 글 이름 · 같은 종류는 짧게 · 풍선은 긴 이름 + id 식 · 변수도 이름
- 열 폭 최소 = 머리 글 · 단위 폭(캔버스로 잼) · 들어갈 것 · 식 줄은 세 줄로 자르고 풍선에 전문 · 머리 칸도 풍선
- 쪽 나눔을 pagePlan 으로 떼어 시험 test_sheet_pages.py(줄 120 → 쪽 3 · 합계 마지막 쪽만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
2026-09-25 12:02:13 +09:00
eomsangdonandClaude Opus 5.5 abd0d4b126 fix(M02): 구조물집계표 묶음 열은 값마다 한데 · 빈 설계값 머리는 「높이 미입력」 꼴 · 계산 열 설계값 딱지 뗌 (브레인 최종 확인)
- 관공 등 묶음 열 차례 = 관종·관경마다 관매설 · 커플링 짝 — 머리 합치기가 값마다 묶임
- 머리틀은 맨 자리표 + 값꼴(H={} · T={}cm · Φ{}) · 빈 값은 빈값 글 · 열 id 는 그대로(빈 값 -)
- 포장 A · 수축줄눈 · 커플링 · 큰돌쌓기 면적 = 바인딩 대신 펼침틀(묶음 · 머리틀 · 식틀)
- 식틀 자리 값이 비면 식을 빼고 설명에 적음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
2026-09-25 12:00:54 +09:00
eomsangdonandClaude Sonnet 5 e8615db238 fix(M02): 층 단추 눌린 표시 · 한 줄 폭 맞춤, 프로젝트 기본값은 마지막 고른 것 → 내 최근 것 → 빈 칸
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
2026-09-25 11:57:41 +09:00
782 changed files with 117445 additions and 264313 deletions
+3
View File
@@ -67,6 +67,8 @@ config/corridor_node/
config/server_calc_node/
# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13).
config/formula_node/
# 산출근거 스프레드시트 재계산 번들 — `npm run build:spreadsheet` 산출물.
config/spreadsheet_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
@@ -77,3 +79,4 @@ docs/wiki/graphify-out/.graphify_incremental.json
# 까닭 — 코드 창에서 graphify 가 돌 때마다 이 폴더가 바뀌어, 위키 브랜치를 받을 때
# 「덮으면 네 것이 날아간다」로 **병합이 막혔다**. 4.3MB·443파일도 함께 가벼워진다.
docs/wiki/graphify-out/cache/
~$*
+23
View File
@@ -57,6 +57,15 @@ export const STATE_REGISTRY = {
legacy: () => "b05-route-drainage-collapsed",
},
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
"drainage-visible": { bucket: "pref", scope: "global" },
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
"routeedit-locks": { bucket: "pref", scope: "project" },
"masshaul-open": {
bucket: "pref",
scope: "global",
@@ -103,6 +112,8 @@ export const STATE_REGISTRY = {
scope: "project",
legacy: (p) => `aislo:structure-pick:${p}`,
},
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
selection: { bucket: "draft", scope: "project" },
/** 배수관·암거 조정창에서 예약한 옵션 값. */
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
/** 배수관 이동(측점 옮김) 예약. */
@@ -147,10 +158,19 @@ export const STATE_REGISTRY = {
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
crossdesign: { bucket: "draft", scope: "route" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
"alignment-draft": {
bucket: "draft",
scope: "route",
legacy: (_p, r) => `b05-profile-alignment-draft:${r}`,
},
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
/** 종단도 최소 흙덮기 강제 여부 — 종단 보기 「페이지 설정」 값을 두 보기가 같이 본다(24-2c). */
"profile-enforce-min-cover": { bucket: "result", scope: "project" },
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
* 노선이 바뀌면 `latest` 와 함께 버린다. */
@@ -169,6 +189,9 @@ export const STATE_REGISTRY = {
scope: "project",
legacy: (p) => `b06:rock-boundary-default:${p}`,
},
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
"routeedit-initial": { bucket: "result", scope: "project" },
} as const satisfies Record<string, StateEntry>;
export type StateName = keyof typeof STATE_REGISTRY;
+7
View File
@@ -0,0 +1,7 @@
/* 좌측 패널 아래 고정 영역의 단추 넷([초기화][저장][확정][보기 전환]) — 좁은 패널에서도 글자가
두 줄로 꺾이지 않게 안쪽 여백을 줄이고 줄바꿈을 막는다(계획서 24-2b). */
.b05-route__dock .b05-route__actions .ui-btn,
.b05-route__dock .b06-profile__actions .ui-btn {
padding-inline: var(--spacing-8);
white-space: nowrap;
}
+130
View File
@@ -0,0 +1,130 @@
/* =============================================================================
* design_dock.ts
* B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b).
*
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 넷은 두 보기가 **같다**:
* [초기화] [저장] [확정] [보기 전환](종단 보기에서는 「횡단 보기」 · 횡단 보기에서는
* 「종단 보기」). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
* 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫).
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import "./design_dock.css";
/** 보기 이름 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
export type DesignView = "profile" | "cross";
/** 단추 하나의 동작 — `enabled` 가 거짓이면 눌리지 않고 `title` 이 까닭을 알린다. */
export interface DesignDockAction {
run: () => void | Promise<void>;
enabled?: boolean;
title?: string;
}
export interface DesignDockActions {
reset: DesignDockAction;
save: DesignDockAction;
confirm: DesignDockAction;
/** [보기 전환] — 지금은 주소 이동이다(2단계에서 같은 페이지 안 전환으로 바뀜). */
switchView: DesignDockAction;
}
export interface DesignDock {
root: HTMLElement;
/** 단추 넷의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
setActions: (actions: Partial<DesignDockActions>) => void;
/** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */
setView: (view: DesignView) => void;
}
function label(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 보기 전환 단추 글자 — 가려는 **쪽** 이름이다. */
export function switchViewLabel(view: DesignView): string {
return label(view === "profile" ? "Design_Btn_ToCross" : "Design_Btn_ToProfile");
}
/**
* 아래 고정 영역을 만든다. `above` 는 단추 행 위에 놓을 것(구조물 목록) — 목록을 「구조물 배치」
* 본문에 두면 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
*/
export function createDesignDock(
view: DesignView,
above: ReadonlyArray<HTMLElement>,
initial: DesignDockActions,
): DesignDock {
let current = view;
let actions = { ...initial };
const root = document.createElement("div");
root.className = "b05-route__dock ui-sidebar-actions";
const divider = document.createElement("hr");
divider.className = "b05-structure__divider";
const row = document.createElement("div");
row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions";
const press = (name: keyof DesignDockActions) => (): void => {
void Promise.resolve(actions[name].run());
};
const resetButton = createButton({
label: label("Common_Btn_Reset"),
variant: "danger",
onClick: press("reset"),
});
const saveButton = createButton({
label: label("B06_Profile_Btn_Save"),
variant: "ghost",
onClick: press("save"),
});
const confirmButton = createButton({
label: label("B06_Profile_Btn_Confirm"),
variant: "filled",
onClick: press("confirm"),
});
const switchButton = createButton({
label: switchViewLabel(view),
variant: "ghost",
onClick: press("switchView"),
});
resetButton.dataset.dockAction = "reset";
saveButton.dataset.dockAction = "save";
confirmButton.dataset.dockAction = "confirm";
switchButton.dataset.dockAction = "switchView";
const buttons = {
reset: resetButton,
save: saveButton,
confirm: confirmButton,
switchView: switchButton,
};
const sync = (): void => {
(Object.keys(buttons) as Array<keyof DesignDockActions>).forEach((name) => {
const action = actions[name];
buttons[name].disabled = action.enabled === false;
if (action.title) buttons[name].title = action.title;
else buttons[name].removeAttribute("title");
});
(switchButton.querySelector(".ui-btn__label") ?? switchButton).textContent =
switchViewLabel(current);
row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions";
};
row.append(resetButton, saveButton, confirmButton, switchButton);
root.append(...above, divider, row);
sync();
return {
root,
setActions: (next) => {
actions = { ...actions, ...next };
sync();
},
setView: (next) => {
current = next;
sync();
},
};
}
+48
View File
@@ -0,0 +1,48 @@
/* 한 페이지 두 보기(계획서 24-3a · 24-3b) — 감싸는 칸은 자리를 차지하지 않아(display: contents)
진행 단계 틀이 예전처럼 아울렛에 바로 붙은 것과 같게 놓인다. 숨긴 보기만 빠진다. */
.design-page,
.design-page__view,
.design-page__profile {
display: contents;
}
.design-page__view[hidden] {
display: none;
}
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 3D 위에 떠 있는
판(absolute · 3D 크기가 안 바뀜)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */
.design-page__main {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.design-page__center {
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.design-page__center > .design-page__view > * {
flex: 1 1 auto;
min-height: 0;
}
.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile {
position: relative;
flex: none;
}
/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼
안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */
.b05-route__panel .b06-adjust-dock,
.b05-route__panel [class*="b06-structure-panel__struct--"] {
display: none;
}
+265
View File
@@ -0,0 +1,265 @@
/* =============================================================================
* design_page.ts
* B05 · B06 **한 페이지 두 보기**(계획서 24-3a · 24-3b).
*
* 주소 둘(`#/b05-profile` · `#/b06-section`)이 이 페이지 하나를 연다. 같은 페이지 안에서 주소만
* 바뀌면 라우터가 `show(route)` 만 부른다 — 보기를 다시 만들지 않고 숨김 · 보임만 바꾼다.
* 다른 페이지로 떠나면 `dispose` 로 3D · 종단도 · 횡단 · 창 듣기를 정리한다.
*
* 틀은 하나 — 진행 단계 · 좌측 패널(칸 · 아래 단추) · 가운데 아래 종단도는 한 벌(한 노드)이고
* (`design_shared`), 보기는 가운데 위 칸만 채운다:
* mountCenter(center, host, shared) 종단 보기 = 3D 화면 · 횡단 보기 = 횡단 카드(처음 보일 때)
* 보기가 바뀌면 좌측 칸 · 아래 단추 글자 · 종단도 판 · 진행 단계 켜진 칸 · 제목이 따라간다.
*
* 보기는 `host.onShow` · `onHide` · `onDispose` 로 숨김 · 보임 · 떠남 때 할 일을 건다 —
* 숨으면 3D 그리기 루프 · 포인터 듣기를 떼고, 보이면 가게 값으로 다시 그린다.
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../b_workflow_nav";
import { currentRoute, navigateTo, type MountedPage } from "../router";
import type { DesignView } from "./design_dock";
import type { DesignPageShared } from "./design_shared";
import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
/** 보기 ↔ 주소. */
export const DESIGN_VIEW_ROUTES: Record<DesignView, RoutePath> = {
profile: ROUTES.B05_PROFILE,
cross: ROUTES.B06_SECTION,
};
export function designViewOf(route: RoutePath): DesignView | null {
if (route === ROUTES.B05_PROFILE) return "profile";
if (route === ROUTES.B06_SECTION) return "cross";
return null;
}
/** 보기마다 진행 단계 칸 · 제목. */
const VIEW_STEP: Record<DesignView, number> = { profile: 2, cross: 3 };
const VIEW_TITLE: Record<DesignView, keyof typeof ui_locales> = {
profile: "B05_Route_Title",
cross: "B06_Profile_Title",
};
/** 보기가 페이지에서 받는 것 — 숨김 · 보임 · 떠남 때 할 일을 건다. */
export interface DesignPageHost {
readonly view: DesignView;
/** 지금 보이는가(떠났으면 false). */
visible: () => boolean;
/** 떠났는가 — 늦게 온 자료가 떠난 화면을 채우지 않게 본다. */
disposed: () => boolean;
/** 다시 보일 때(처음 마운트는 빼고) — 다른 보기에서 바뀐 값을 가게에서 다시 읽는다. */
onShow: (fn: () => void) => void;
/** 숨을 때 — 그리기 루프 · 창 듣기를 뗀다. */
onHide: (fn: () => void) => void;
/** 떠날 때 — 건 차례의 거꾸로 부른다. */
onDispose: (fn: () => void) => void;
}
export interface DesignPageView {
/** 가운데 위 칸만 채운다 — 좌측 패널 · 단추 · 종단도는 `shared` 한 벌. 자료가 서면 끝난다. */
mountCenter: (
center: HTMLElement,
host: DesignPageHost,
shared: DesignPageShared,
) => void | Promise<void>;
}
/** 보기 정의 — 처음 보일 때 읽는다(번들을 보기별로 나눠 둔다). */
const VIEWS: Record<DesignView, () => Promise<DesignPageView>> = {
profile: async () => ({ mountCenter: (await import("./design_view_profile")).mountProfileView }),
cross: async () => ({ mountCenter: (await import("./design_view_cross")).mountCrossView }),
};
interface ViewSlot {
box: HTMLElement;
/** 자료가 섰다(실패해도 끝남). */
ready: Promise<void>;
show: () => void;
hide: () => void;
dispose: () => void;
}
const run = (fn: () => void): void => {
try {
fn();
} catch (error) {
console.error("[design_page]", error);
}
};
/** 한 페이지를 세운다 — 라우터가 `MountedPage` 로 받아 같은 페이지 안 이동에 쓴다. */
export function renderDesignPage(root: HTMLElement): MountedPage {
const page = document.createElement("div");
page.className = "design-page";
root.replaceChildren(page);
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
const left = document.createElement("div");
left.className = "design-page__left";
const main = document.createElement("div");
main.className = "design-page__main";
const center = document.createElement("div");
center.className = "design-page__center";
const profileSlot = document.createElement("div");
profileSlot.className = "design-page__profile";
main.append(center, profileSlot);
const slots = new Map<DesignView, ViewSlot>();
let current: DesignView = designViewOf(currentRoute()) ?? "profile";
let layout: WorkflowLayoutHandle | null = null;
let disposedPage = false;
const shared: DesignPageShared = {
projectId,
left,
profileSlot,
view: () => current,
parts: {},
bindings: {},
panel: null,
profile: null,
structures: null,
pipeOptionQueue: [],
revealLeft: () => layout?.setOptionsOpen(true),
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
};
/** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */
const applyView = (view: DesignView): void => {
main.dataset.view = view;
shared.panel?.setView(view);
shared.profile?.setView(view);
layout?.setTitle(ui_locales[VIEW_TITLE[view]][currentLanguageIndex]);
layout?.setActiveStep(VIEW_STEP[view]);
layout?.root.classList.toggle("b05-route-layout", view === "profile");
layout?.root.classList.toggle("b06-profile-layout", view === "cross");
};
const buildLayout = (state: WorkflowState | null): void => {
layout = createWorkflowLayout({
title: ui_locales[VIEW_TITLE[current]][currentLanguageIndex],
steps: workflowSteps(),
activeStep: VIEW_STEP[current],
leftPanel: left,
mainContent: main,
stages: state?.stages,
currentStage: state?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
// 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다.
onStepClick: (stepIndex) => {
const route = WORKFLOW_STEP_ROUTES[stepIndex];
if (designViewOf(route)) navigateTo(route);
else goToWorkflowStage(projectId, route);
},
});
layout.root.classList.add("design-page__layout");
page.append(layout.root);
applyView(current);
};
if (!projectId) {
showToast("프로젝트를 먼저 선택하세요.", "error");
buildLayout(null);
} else {
void fetchWorkflowState(projectId)
.catch(() => null)
.then((state) => {
if (!disposedPage) buildLayout(state);
});
}
function mountView(view: DesignView, visibleAtStart: boolean): ViewSlot {
const life = { show: [] as Array<() => void>, hide: [] as Array<() => void> };
const cleanups: Array<() => void> = [];
let visible = visibleAtStart;
let disposed = false;
const box = document.createElement("div");
box.className = "design-page__view";
box.dataset.view = view;
box.hidden = !visibleAtStart;
center.append(box);
const host: DesignPageHost = {
view,
visible: () => visible && !disposed,
disposed: () => disposed,
onShow: (fn) => life.show.push(fn),
onHide: (fn) => life.hide.push(fn),
onDispose: (fn) => cleanups.push(fn),
};
const ready = VIEWS[view]()
.then((def) => {
if (disposed) return;
const mounting = def.mountCenter(box, host, shared);
// 부품 · 동작을 건 뒤(마운트의 동기 앞부분) 한 벌을 지금 보기로 맞춘다.
if (view === current) applyView(view);
return mounting;
})
.catch((error) => console.error(`[design_page] 보기 마운트 실패: ${view}`, error));
const slot: ViewSlot = {
box,
ready,
// 숨긴 채 세운 보기(종단 보기 [확정])도 처음 보일 때 이 알림으로 가게 값을 다시 읽는다.
show() {
visible = true;
box.hidden = false;
life.show.forEach(run);
},
hide() {
visible = false;
box.hidden = true;
life.hide.forEach(run);
},
dispose() {
disposed = true;
cleanups.reverse().forEach(run);
},
};
slots.set(view, slot);
return slot;
}
const show = (view: DesignView): void => {
const started = performance.now();
if (slots.has(current) && current !== view) slots.get(current)?.hide();
current = view;
const slot = slots.get(view);
if (slot) {
slot.show();
applyView(view);
} else mountView(view, true);
// 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다.
const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } };
probe.__designPage ??= { view, switchMs: [] };
probe.__designPage.view = view;
probe.__designPage.switchMs.push(Math.round(performance.now() - started));
};
show(current);
return {
covers: (route) => designViewOf(route) !== null,
show: (route) => {
const view = designViewOf(route);
if (view && view !== current) show(view);
},
dispose() {
disposedPage = true;
slots.forEach((slot) => slot.dispose());
slots.clear();
shared.profile?.dispose();
delete (window as unknown as { __designPage?: unknown }).__designPage;
},
};
}
+151
View File
@@ -0,0 +1,151 @@
/* =============================================================================
* design_panel.ts
* B05 · B06 한 페이지 두 보기의 **좌측 패널 칸 등록부**(계획서 24-1 · 24-2b).
*
* 칸(구조물 배치 · 페이지 설정 · 표준 횡단면 설정 …)은 **한 번만 정의**하고 보일 보기만 적는다.
* 페이지 파일은 칸도 단추도 직접 만들지 않고 `createDesignPanel(ctx, view)` 한 줄만 부른다 —
* 시험(`test_design_view_parity.py`)이 이를 지킨다.
*
* registerPanelSection({ id, title, views, order, build }) ← 칸 정의(모듈이 읽힐 때 한 번)
* createDesignPanel(ctx, view) → { root, setView } ← 페이지가 부름
*
* 칸이 쓰는 페이지 쪽 부품(구조물 패널 · 입력 칸 묶음 …)은 `ctx.parts` 에서 이름으로 꺼낸다 —
* 칸 정의가 B05 · B06 의 구체 타입을 몰라도 되게 하려는 것이다.
* ========================================================================== */
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createDesignDock,
type DesignDock,
type DesignDockActions,
type DesignView,
} from "./design_dock";
export type { DesignView } from "./design_dock";
/** 좌측 패널 하나가 페이지에서 받는 것 — 값은 바뀌므로 부품은 이름으로, 단추 동작은 함수로. */
export interface DesignPanelContext {
projectId: string | null;
/** 이름 → 페이지가 만든 부품. 칸 정의가 `part()` 로 꺼낸다. */
parts: Record<string, unknown>;
/** 아래 단추 넷의 동작 — 페이지가 채운다(저장 흐름은 `design_save`). */
dock: DesignDockActions;
}
/** 칸 정의가 돌려주는 것 — 칸 뿌리 하나, 그리고 (있으면) 아래 고정 영역 위에 얹을 것. */
export type PanelSectionBuild = HTMLElement | { root: HTMLElement; dock?: HTMLElement };
export interface PanelSectionDef {
id: string;
title: string;
/** 이 칸이 보일 보기 — 둘 다 적으면 한 정의로 두 보기(구조물 배치). */
views: ReadonlyArray<DesignView>;
/** 칸 차례 — 작을수록 위. */
order: number;
build: (ctx: DesignPanelContext, view: DesignView) => PanelSectionBuild;
}
const registry = new Map<string, PanelSectionDef>();
/** 칸 정의를 등록한다. 같은 id 는 덮어쓴다(같은 모듈이 다시 읽혀도 한 벌). */
export function registerPanelSection(def: PanelSectionDef): void {
registry.set(def.id, def);
}
/** 그 보기에 보일 칸 정의 — 차례대로. 시험이 「두 보기가 같은 칸을 쓰는지」 볼 때도 쓴다. */
export function panelSectionsFor(view: DesignView): PanelSectionDef[] {
return [...registry.values()]
.filter((def) => def.views.includes(view))
.sort((a, b) => a.order - b.order);
}
/** 칸이 부품을 이름으로 꺼낸다 — 페이지가 안 건넸으면 바로 알리도록 던진다. */
export function part<T>(ctx: DesignPanelContext, name: string): T {
const found = ctx.parts[name];
if (found === undefined)
throw new Error(`좌측 패널 부품 「${name}」 을 페이지가 건네지 않았습니다.`);
return found as T;
}
/**
* 칸 틀 — 접는 제목 + 본문. 두 보기가 **다른 클래스**를 쓰던 것(`b05-route__panel-section` ·
* `b06-profile__group`)을 그대로 살린다(2단계에서 한 벌로 합침). 종단 보기 본문은 별도 div,
* 횡단 보기는 칸 뿌리에 바로 붙인다.
*/
export function createSectionFrame(
view: DesignView,
title: string,
collapsed = false,
): { root: HTMLElement; body: HTMLElement } {
const root = document.createElement("section");
const heading = document.createElement("h3");
heading.textContent = title;
if (view === "profile") {
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
heading.className = "ui-collapsible__title";
const body = document.createElement("div");
body.className = "b05-route__panel-body";
root.append(heading, body);
return { root, body };
}
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
heading.className = "b06-profile__group-legend ui-collapsible__title";
root.append(heading);
return { root, body: root };
}
export interface DesignPanel {
root: HTMLElement;
/** 아래 고정 영역 — 단추 켬·끔을 페이지가 다시 줄 때 쓴다. */
dock: DesignDock;
/** 보기가 바뀌면 그 보기에 없는 칸은 숨기고 있는 칸은 (처음이면 만들어) 보인다. */
setView: (view: DesignView) => void;
}
/** 좌측 패널 — 그 보기의 칸을 차례대로 세우고 아래 고정 영역을 붙인다. */
export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): DesignPanel {
const root = document.createElement("div");
root.className = view === "profile" ? "b05-route__panel" : "b06-profile__form";
const built = new Map<string, HTMLElement>();
const dockParts: HTMLElement[] = [];
const dock = createDesignDock(view, dockParts, ctx.dock);
const show = (target: DesignView): void => {
const wanted = new Set(panelSectionsFor(target).map((def) => def.id));
for (const def of panelSectionsFor(target)) {
if (built.has(def.id)) continue;
const made = def.build(ctx, target);
const sectionRoot = "root" in made ? made.root : made;
if ("root" in made && made.dock) dock.root.prepend(made.dock);
built.set(def.id, sectionRoot);
root.insertBefore(sectionRoot, dock.root);
}
// 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게.
for (const def of panelSectionsFor(target)) {
const element = built.get(def.id);
if (element) root.insertBefore(element, dock.root);
}
built.forEach((element, id) => {
// 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다.
element.style.display = wanted.has(id) ? "" : "none";
});
};
root.append(dock.root);
show(view);
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
// 접힘 항목은 손대지 않는다.
attachCollapsible(root);
return {
root,
dock,
setView: (next) => {
dock.setView(next);
root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form";
show(next);
},
};
}
+698
View File
@@ -0,0 +1,698 @@
/* =============================================================================
* A00_Common/design/design_profile.ts
* 종단도 한 벌(계획서 24-1 · 24-2c) — 종단 보기 · 횡단 보기가 **같은 종단도**를 가운데
* 아래쪽에 둔다. 도구(▲▼ 고치기 · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 ·
* 뱃지)와 막기 규칙(최소 흙덮기 · 기울기 상한)은 보기와 무관하게 하나.
*
* 그래프와 도면 테이블이 **하나의 가로 스크롤러** 안에 같은 폭으로 쌓여 X축이 자동으로
* 맞물린다. 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [저장]·[확정]
* 시점에 `saveProfileAlignment()`로 편집 델타만 보낸다.
*
* 판 자리 약속(side · overlay)은 `design_profile_slots.ts` 에 있고 여기서 다시 내보낸다.
* ========================================================================== */
import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans";
import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import type {
StructureInstance,
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
import {
hasLegacyAlignment,
readAlignment,
toDesignProfile,
} from "../../B05_Profile/B05_Profile_UI_Profile_Data";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
import {
blocksMinCover,
findMinCoverViolations,
minCoverPoints,
type MinCoverPipe,
type MinCoverPoint,
} from "../../B05_Profile/B05_Profile_UI_Profile_MinCover";
import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
chainageKey,
emptyEdits,
toAlignmentBase,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import {
structureGroupMenuItems,
stationIdAtStructure as stationIdAtStructureOf,
structureIdAtStation as structureIdAtStationOf,
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
createRouteMassHaulPanel,
type MassHaulSummaryValues,
type RouteMassHaulContext,
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
import {
irregularStationId,
type IrregularStation,
} from "../../B05_Profile/B05_Profile_UI_IrregularStations";
import { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Preview";
import { createPanelTools } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Tools";
import { createProfileZoom } from "../../B05_Profile/B05_Profile_UI_Profile_Zoom";
import {
adoptLegacyProfileKeys,
bindProfileCollapse,
createElevationHold,
createProfileDrag,
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import {
planCurveWarning,
sampleAt,
snapToMasterStations,
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
export type {
DesignOverlayBoard,
DesignSideBoard,
DesignSideFactory,
DesignSideHost,
DesignView,
} from "./design_profile_slots";
/** 종단도를 만들 때 넘기는 것. */
export interface DesignProfileContext {
projectId: string;
/** 처음 보기. */
view: DesignView;
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
side?: DesignSideFactory;
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */
sideTool?: (board: DesignSideBoard) => HTMLElement;
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
onSelectStation: (stationId: string | null) => void;
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
onResetAll?: () => void;
callbacks?: RouteProfilePanelCallbacks;
}
/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms).
* ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다.
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다.
* 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */
const CROSS_PREVIEW_DEBOUNCE_MS = 16;
export function createDesignProfile(ctx: DesignProfileContext) {
const { projectId, onSelectStation, onResetAll, callbacks } = ctx;
adoptLegacyProfileKeys();
const root = document.createElement("section");
root.className = "b05-route-profile";
root.dataset.view = ctx.view;
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
const balanceBar = document.createElement("div");
balanceBar.className = "b05-route-profile__balance";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
// 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정).
// 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다.
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
// 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw.
const subPanelChanged = (): void => {
if (drag.isSubDragging()) scheduleLightSync();
else draw();
};
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
* 유토곡선을 접어도 이 값은 남아야 한다. */
const massBadge = createMassHaulBadge();
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
bodyWrap.append(massBadge.root);
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
const haulPrefetch = createHaulPlanPrefetch(
projectId,
() => routeId ?? undefined,
subPanelChanged,
);
const massHaul = createRouteMassHaulPanel(
subPanelChanged,
(summary) => {
const before = massHaulSummary;
massHaulSummary = summary;
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
},
haulPrefetch,
);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(subPanelChanged);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
tableOverlay.attachScrollSync(body);
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
body.addEventListener("contextmenu", (event) => event.preventDefault());
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
const content = document.createElement("div");
content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
let minCoverTargets: MinCoverPoint[] = [];
/** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제).
* 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */
let enforceMinCover = readState<boolean>("profile-enforce-min-cover", projectId) === true;
/** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */
const takePipes = (pipes: ReadonlyArray<MinCoverPipe>): void => {
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
minCoverTargets = minCoverPoints([...pipes]);
renderBalance();
};
// 오른쪽 판(배수유역도) — 판 쪽이 만들어 넘긴다(판 자리 약속 `DesignSideFactory`).
const drainagePanel: DesignSideBoard | null =
ctx.side?.({
projectId,
view: ctx.view,
onPipesChanged: (pipes) => {
takePipes(pipes);
callbacks?.onPipesChanged?.(pipes);
},
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM),
// 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록
// (2026-08-18 일원화). 선택하면 사이드 폼 자동 지정(addAt 경로).
structureMenuItems: (chainage) =>
structureGroupMenuItems(structureTypes, (typeId) =>
callbacks?.onStructureTypeAdd?.(chainage, typeId),
),
}) ?? null;
content.append(bodyWrap);
if (drainagePanel) content.append(drainagePanel.root);
const drag = createProfileDrag({
root,
body,
massHaul,
tableOverlay,
draw: () => draw(),
});
root.append(panelHandle.root, drag.resizerRoot, content);
drainagePanel?.load(projectId);
let detail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
let stationInterval: number | undefined;
let routeId: number | null = null;
let irregularStations: IrregularStation[] = [];
// 구조물 정본(structures.json) 목록과 타입 레지스트리 — 그래프 서클마크·벌룬에 쓴다.
let structures: StructureInstance[] = [];
let structureTypes: StructureType[] = [];
let selectedStructureId: string | null = null;
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
let savedEdits: AlignmentEdits = emptyEdits();
let redrawPending = false;
let lastWidth = 0;
let lastHeight = 0;
/** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 몫(700줄 한계). */
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
const structureIdAtStation = (stationId: string | null): string | null =>
stationId !== null && stationIdAtStructure(selectedStructureId) === stationId
? selectedStructureId
: structureIdAtStationOf(stationId, irregularStations, structures);
const stationIdAtStructure = (structureId: string | null): string | null =>
stationIdAtStructureOf(structureId, irregularStations, structures);
/**
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 페이지에 알린다.
* 같은 자리 알약도 함께 켜고 끈다(2026-08-17 사용자 지시 — 둘은 한 구조물이다).
*/
function selectStation(stationId: string | null): void {
const next = stationId !== null && stationId === selectedStationId ? null : stationId;
selectedStationId = next;
selectedStructureId = structureIdAtStation(next);
draw();
onSelectStation(next);
}
function renderBalance(): void {
const minCoverViolations = findMinCoverViolations(
minCoverTargets,
(chainageM) => sampleAt(alignment, chainageM, "ground_elevation_m"),
(chainageM) => sampleAt(alignment, chainageM, "elevation_m"),
);
renderBalanceBar({
planCurve: planCurveWarning(alignment, detail),
minCoverViolations,
balanceBar,
tools: tools.render(),
trailing,
alignment,
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
edited: store.edited(),
hasIrregularStations: irregularStations.length > 0,
dirty: store.dirty(),
onResetAll: () => {
store.resetAll();
onResetAll?.();
},
});
}
/* 줌 조작구(가로 배율) — 상태를 종단도가 들고 있어 편집·재계산으로 다시 그려도 유지된다.
세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */
const profileZoom = createProfileZoom(() => draw());
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw());
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : []));
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
const { tools, history, handleToolPick, setListening } = createPanelTools({
root,
base: () => base,
alignment: () => alignment,
edits: () => store.edits(),
applyEdits: (next) => applyEdits(next),
selectedStationId: () => selectedStationId,
irregularStations: () => irregularStations,
stationIdOf: (station) => irregularStationId(station.id),
moveStation: (station, toChainageM) =>
callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station),
// 판이 없는 보기는 관 자리를 되돌리기에 태우지 않는다.
drainage: drainagePanel ?? { pipeChainages: () => [], setPipeChainages: () => undefined },
restore: () => {
// 세션이 정본이므로 편집 초안을 다시 읽어 그린다.
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
rebuild();
},
restoreSaved: () => store.restoreSaved(),
canRestoreSaved: () => store.canRestoreSaved(),
refresh: () => renderBalance(),
});
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
function applyEdits(next: AlignmentEdits): void {
if (!base || !alignment) return;
const candidate = buildAlignment(base, next);
if (
base.policy.grade_violation_policy === "block" &&
candidate.violations.length > alignment.violations.length
) {
showToast(
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
"error",
);
return;
}
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;
store.replace(next);
history.record();
}
/**
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
* 실제 렌더링하도록 모은다.
*/
function rebuild(): void {
if (base) alignment = buildAlignment(base, store.edits());
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
scheduleCrossPreview();
if (redrawPending) return;
redrawPending = true;
requestAnimationFrame(() => {
redrawPending = false;
draw();
});
}
/** 횡단 설계 프리뷰는 `_Profile_Preview` 몫(700줄 한계). */
const crossPreview = createCrossPreview({
projectId,
detail: () => detail,
routeId: () => routeId,
edits: () => store.edits(),
debounceMs: CROSS_PREVIEW_DEBOUNCE_MS,
onApplied: (updated) => {
draw();
callbacks?.onCrossDesignsUpdated?.(updated);
},
});
const scheduleCrossPreview = (): void => crossPreview.schedule();
/**
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
*
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
*/
const heights = createHeightCascade({
root,
body,
massHaul,
tableOverlay,
isMainDragging: drag.isMainDragging,
isSubDragging: drag.isSubDragging,
mainDragRef: drag.mainDragRef,
isMainDragCooldown: drag.isMainDragCooldown,
hasDetail: () => detail !== null,
});
const applyHeightCascade = (allowGrow: boolean): { chartHeight: number } =>
heights.apply(allowGrow);
const scheduleLightSync = (): void => heights.scheduleLightSync();
function draw(): void {
renderProfile({
body,
callbacks,
massHaul,
tableOverlay,
store,
detail: () => detail,
alignment: () => alignment,
base: () => base,
stationInterval: () => stationInterval,
irregularStations: () => irregularStations,
minCoverTargets: () => minCoverTargets,
structures: () => structures,
structureTypes: () => structureTypes,
selectedStationId: () => selectedStationId,
setSelectedStationId: (value) => {
selectedStationId = value;
},
selectedStructureId: () => selectedStructureId,
setSelectedStructureId: (value) => {
selectedStructureId = value;
},
stationDisplay: () => stationDisplay,
setLastSize: (width, height) => {
lastWidth = width;
lastHeight = height;
},
renderBalance,
applyHeightCascade,
clearMainDragCooldown: drag.clearMainDragCooldown,
selectStation,
applyEdits,
handleToolPick,
zoom: profileZoom.state,
holdElevationRange: elevationHold.hold,
toolActive: () => tools.mode() !== "none",
selectedRuns: () => tools.selectedRuns(),
stationIdAtStructure,
redraw: draw,
setElevationWindowUpdater: (update) => {
updateElevationWindow = update;
},
});
}
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
let settledScrollLeft = 0;
let scrollSettleTimer = 0;
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
body.addEventListener("scroll", () => {
if (elevationHold.editing()) return;
const fitted = updateElevationWindow?.() ?? null;
window.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
scrollSettleTimer = window.setTimeout(() => {
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = body.scrollLeft;
draw();
}, SCROLL_SETTLE_MS);
});
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
attachWheelHorizontalScroll(body);
const resizeObserver = new ResizeObserver(() => {
if (
body.clientWidth <= 0 ||
body.clientHeight <= 0 ||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
)
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
scheduleLightSync();
drag.scheduleDraw();
});
resizeObserver.observe(body);
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw));
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
function syncDrainageSpans(): void {
drainagePanel?.setIntervalSpans(routeSpansFromStructures(structures, structureTypes));
}
return {
root,
/** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */
setView(next: DesignView) {
root.dataset.view = next;
drainagePanel?.setView?.(next);
},
render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) {
detail = nextDetail;
stationInterval = nextStationInterval;
const stored = readAlignment(nextDetail.longitudinal);
// 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다.
// 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭).
const routeChanged = nextRouteId !== routeId;
const carried = routeChanged && store.edited() ? store.edits() : null;
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
if (routeChanged || !store.dirty()) {
routeId = nextRouteId ?? routeId;
savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
}
base = stored ? toAlignmentBase(stored) : null;
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null;
// 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다.
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다.
configureBalloonOffsets(
`${projectId}:${routeId ?? "-"}`,
nextDetail.balloon_offsets ?? undefined,
);
draw();
requestAnimationFrame(draw);
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
//
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
// (2026-09-03 사용자 보고 — 편집 측점 2개가 세션에 남은 상태에서 재현).
if (
hasStaleDesigns({
longitudinal: {
design_profiles: alignment
? [toDesignProfile(alignment, nextDetail.longitudinal.design_profiles?.[0])]
: nextDetail.longitudinal.design_profiles,
},
cross_sections: nextDetail.cross_sections,
})
) {
scheduleCrossPreview();
}
},
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
alignmentSamples: () => alignment?.samples ?? null,
/**
* 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭).
* 페이지가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다.
*/
setEarthworkContext(next: RouteMassHaulContext | null) {
massHaul.setContext(next);
draw();
},
setSelectedStation(stationId: string | null) {
selectedStationId = stationId;
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
selectedStructureId = structureIdAtStation(stationId);
draw();
},
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
enforceMinCover = enforce;
writeState("profile-enforce-min-cover", enforce, projectId);
},
/** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */
setMinCoverPipes(pipes: ReadonlyArray<MinCoverPipe>) {
takePipes(pipes);
},
/** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
setGradeLimit(maxGradePct: number) {
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
alignment = buildAlignment(base, store.edits());
draw();
},
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
drainagePanel?.setRoute(points);
},
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) {
irregularStations = snapToMasterStations(alignment, stations);
draw();
},
/** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */
setStructureTypes(types: StructureType[]) {
structureTypes = types;
syncDrainageSpans();
draw();
},
/** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */
setStructures(next: StructureInstance[]) {
structures = next;
if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) {
selectedStructureId = null;
}
syncDrainageSpans();
draw();
},
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
setSelectedStructure(structureId: string | null) {
selectedStructureId = structureId;
selectedStationId = stationIdAtStructure(structureId);
draw();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
setStationDisplay(next: { station: number; cumulative: number }) {
stationDisplay = next;
draw();
},
/**
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
*/
resetStationEdit(chainageM: number, moveTo?: number) {
const key = chainageKey(chainageM);
const edits = store.edits();
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
store.resetStation(chainageM, moveTo);
},
isDirty: () => store.dirty(),
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> {
if (!routeId || !store.dirty()) return;
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
base = toAlignmentBase(next);
alignment = next;
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
draw();
},
clear() {
detail = null;
base = null;
alignment = null;
selectedStationId = null;
balanceBar.replaceChildren();
body.replaceChildren(empty);
},
/** 밖에서 바뀐 횡단 면적(카드 갱신)을 유토곡선 · 뱃지에 다시 싣는다. */
redraw: () => draw(),
/** 오른쪽 판(배수유역도) — 판 없이 만들었으면 null. */
side: drainagePanel,
/** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */
setLoading(label: string | null) {
progress.root.hidden = label === null;
if (label !== null) progress.set(null, label);
},
/** 보기가 숨으면 false — 창 키 듣기를 뗀다. 다시 보이면 잇고, 다른 보기가 고친 계획고 초안
* (같은 가게) · 관(판)을 다시 읽어 그린다(계획서 24-3a). */
setActive(active: boolean) {
setListening(active);
if (!active) return;
if (base) alignment = buildAlignment(base, store.edits());
draw();
drainagePanel?.load(projectId);
},
dispose() {
setListening(false);
drag.dispose();
crossPreview.dispose();
resizeObserver.disconnect();
},
};
}
/** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */
export type DesignProfile = ReturnType<typeof createDesignProfile>;
+47
View File
@@ -0,0 +1,47 @@
/* =============================================================================
* A00_Common/design/design_profile_slots.ts
* 종단도 한 벌의 **판 자리 약속**(계획서 24-1 · 24-2c) — `design_profile.ts` 가 다시
* 내보낸다(부르는 쪽은 `design_profile.ts` 에서 가져간다).
*
* 판 자리 둘 — 종단도는 자리만 내주고 판은 밖에서 만들어 끼운다(나중에 떼기 쉽게).
* · side 종단도 오른쪽 판(배수유역도 · sub7). `DesignSideFactory` 로 받는다.
* · overlay 종단도 위 겹침 판(유토곡선 · 표). 종단도가 직접 만든다.
* ========================================================================== */
import type {
DrainagePanel,
DrainagePanelCallbacks,
} from "../../B05_Profile/B05_Profile_UI_Drainage_Panel_Types";
/** 가운데 보기 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
export type DesignView = "profile" | "cross";
/**
* 오른쪽 판을 만들 때 종단도가 넘겨 주는 것 — 판이 알릴 일(관 목록 · 유역 · 관 고름 ·
* 우클릭 메뉴 항목)과 처음 보기. 판은 `onPipesChanged` 를 **반드시** 불러야 종단도가
* 최소 흙덮기를 판정한다.
*/
export interface DesignSideHost extends DrainagePanelCallbacks {
projectId: string;
view: DesignView;
}
/**
* 오른쪽 판(배수유역도). 종단도는 만든 직후 `load(projectId)` · 노선이 오면 `setRoute` ·
* 구조물이 바뀌면 `setIntervalSpans` · 되돌리기에 `pipeChainages`/`setPipeChainages` ·
* 보기가 바뀌면 `setView` 를 부른다. 뿌리(`root`)는 종단 열 오른쪽에 붙는다.
*/
export type DesignSideBoard = DrainagePanel & {
/** 보기가 바뀌었다 — 횡단 보기에서도 켜고 끈다(판이 정함). */
setView?: (view: DesignView) => void;
};
/** 오른쪽 판을 끼우는 함수 모양 — 종단도를 만들 때 한 번 부른다. */
export type DesignSideFactory = (host: DesignSideHost) => DesignSideBoard;
/** 위 겹침 판(유토곡선 · 표) — 종단 스크롤러와 가로 스크롤을 맞추는 떼었다 붙이는 판. */
export interface DesignOverlayBoard {
overlay: HTMLElement;
handle: HTMLElement;
attachScrollSync: (scroller: HTMLElement) => void;
}
+59
View File
@@ -0,0 +1,59 @@
/* =============================================================================
* design_reset.ts
* [초기화] 한 줄기 — 두 보기의 아래 단추가 **같은 함수**를 부른다(계획서 24-2b).
*
* 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. 네이티브 confirm 은
* 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다(2026-08-19 진단) — 화면 안 모달로
* 확인받는다. (`B05_Profile_UI_Page_Actions.resetDesignAction` 에서 옮겨 왔다 — 동작 그대로.)
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { clearDrafts } from "./../b_page_state";
import { remountRoute } from "./../router";
import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/**
* [초기화] — 확인을 받고, 서버 초기값 복원 뒤 옛 경로 기준 캐시를 전부 비우고 종단 보기로 다시 들어간다.
* `onCleared` 는 페이지만 아는 조작 캐시(상단측 변경분 등)를 비울 자리다.
*/
export async function resetDesign(
projectId: string,
routeId: number | null,
onCleared?: () => void,
): Promise<void> {
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
showLoadingOverlay();
try {
await resetRouteDesign(projectId);
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
clearRouteLatestCache(projectId);
invalidateSectionDetail(projectId);
// 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화).
// 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
clearDrafts(projectId, routeId);
onCleared?.();
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
// 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남).
clearAlignmentDrafts();
showToast(L("B05_Route_Reset_Success"), "success");
remountRoute(ROUTES.B05_PROFILE); // 같은 페이지라도 다시 세운다 — 보기만 바꾸면 옛 화면이 남음
} catch (error) {
showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
+145
View File
@@ -0,0 +1,145 @@
/* =============================================================================
* A00_Common/design/design_save.ts
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
*
* 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
*
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
* 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다.
*
* ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은
* 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12).
* 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음).
* ========================================================================== */
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { flushPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures";
import {
flushUphillOverrides,
saveProfileAlignment,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import {
markAlignmentSaved,
pendingAlignment,
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import { stateKey } from "../b_page_state";
/** 저장 차례 — 이 순서 그대로 돈다. */
export const DESIGN_SAVE_ORDER = [
"pipes",
"structures",
"alignment",
"culvertOptions",
"uphill",
"cross",
] as const;
export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number];
export interface DesignSaveContext {
projectId: string;
routeId: () => number | null;
/** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */
prepare?: () => Promise<void>;
/**
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
*/
steps?: Partial<Record<Exclude<DesignSaveStep, "cross">, () => Promise<void>>>;
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
cross: (confirm: boolean) => Promise<void>;
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
done?: (confirm: boolean) => Promise<void> | void;
/** 멈춘 저장을 알린다. */
failed: (error: unknown, confirm: boolean) => void;
}
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
function reportOnly(label: string): (error: unknown) => void {
return (error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${label} 저장에 실패했습니다.${detail}`, "error");
};
}
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
const draft = pendingAlignment(routeId);
if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft);
markAlignmentSaved(routeId);
}
/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */
const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
pipes: "배수관",
structures: "구조물",
alignment: "계획선",
};
/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */
function defaultStep(
step: Exclude<DesignSaveStep, "cross">,
projectId: string,
routeId: number,
): () => Promise<void> {
switch (step) {
case "pipes":
return () => flushPendingPipes(projectId);
case "structures":
return () => flushPendingStructures(projectId);
case "alignment":
return () => flushAlignmentDraft(projectId, routeId);
case "culvertOptions":
return () =>
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch(
() => undefined,
);
case "uphill":
return () => flushUphillOverrides(projectId).catch(() => undefined);
}
}
/**
* [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다.
* 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true.
*/
export async function saveDesign(
ctx: DesignSaveContext,
options: { confirm?: boolean } = {},
): Promise<boolean> {
const confirm = options.confirm ?? false;
const routeId = ctx.routeId();
if (routeId === null) return false;
showLoadingOverlay();
try {
await ctx.prepare?.();
for (const step of DESIGN_SAVE_ORDER) {
if (step === "cross") {
await ctx.cross(confirm);
continue;
}
const own = ctx.steps?.[step];
const base = defaultStep(step, ctx.projectId, routeId);
const run =
step === "pipes"
? async () => {
await base();
await own?.();
}
: (own ?? base);
const label = TOLERANT[step];
await (label ? run().catch(reportOnly(label)) : run());
}
await ctx.done?.(confirm);
return true;
} catch (error) {
ctx.failed(error, confirm);
return false;
} finally {
hideLoadingOverlay();
}
}
+31
View File
@@ -0,0 +1,31 @@
/* =============================================================================
* design_sections.ts
* 두 보기가 **함께 쓰는 좌측 칸 정의** — 「구조물 배치」(계획서 24-2b).
*
* 한 정의에 `views: ["profile", "cross"]` 를 적어 두 보기가 같은 칸을 같은 자리(맨 위)에 세운다.
* 칸 알맹이는 페이지가 건네는 부품 `structures`(뿌리 · 아래 고정 영역에 얹을 목록 뿌리)다 —
* 종단 보기는 배치 콜백, 횡단 보기는 거기에 횡단 조정 콜백을 더해 만들지만 **칸 정의는 하나**다.
* ========================================================================== */
import { part, registerPanelSection } from "./design_panel";
/** 페이지가 건네는 「구조물 배치」 부품의 모양 — 뿌리와 목록 뿌리만 안다. */
export interface StructuresPart {
root: HTMLElement;
listRoot: HTMLElement;
}
export const STRUCTURES_SECTION_ID = "structures";
registerPanelSection({
id: STRUCTURES_SECTION_ID,
title: "구조물 배치",
views: ["profile", "cross"],
order: 10,
build: (ctx) => {
const structures = part<StructuresPart>(ctx, "structures");
// 구조물 목록은 「구조물 배치」 본문이 아니라 아래 고정 영역에 놓는다 — 본문에 두면
// 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
return { root: structures.root, dock: structures.listRoot };
},
});
+174
View File
@@ -0,0 +1,174 @@
/* =============================================================================
* A00_Common/design/design_selection.ts
* 측점 · 구조물 고름 **한 곳** (PLAN 24-2d).
*
* 24-1 약속의 `design_store` 「selection({chainageM, structureId})」 키 자리다. 그 모듈이
* 서면 이 파일의 `readSelection` · `selectDesign` · `subscribeSelection` 세 함수를 그쪽
* `get` · `set` · `subscribe("selection")` 으로 잇는다 — 부르는 쪽 코드는 바뀌지 않는다.
*
* 값은 등록표 키 `structure-pick`(② 설계 초안 · 페이지를 오가도 남음)에 실린다.
* 어느 화면(3D · 종단 알약 · 목록 · 횡단 카드)에서 골라도 같은 값이 적히고, 다른 쪽은
* 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 도 여기 한 값뿐이다.
* ========================================================================== */
import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
export interface DesignSelection {
chainageM: number | null;
structureId: string | null;
}
export const NO_SELECTION: DesignSelection = { chainageM: null, structureId: null };
/** 두 누가거리가 같은 자리인가. */
export function isNear(a: number, b: number): boolean {
return Math.abs(a - b) < PIPE_MATCH_M;
}
/** 그 누가거리에서 찾는 거리 안에 있는 항목 중 **가장 가까운 것** — 이웃한 항목이 둘 걸려도
* 자리가 맞는 쪽을 고른다. */
export function nearestWithin<T>(
items: ReadonlyArray<T>,
chainageM: number,
chainageOf: (item: T) => number,
): T | undefined {
let best: T | undefined;
let bestGap = PIPE_MATCH_M;
for (const item of items) {
const gap = Math.abs(chainageOf(item) - chainageM);
if (gap < bestGap) {
best = item;
bestGap = gap;
}
}
return best;
}
/** 선택을 풀 때 필요한 구조물의 최소 모양 — B05 `StructureInstance` 가 그대로 맞는다. */
export interface SelectableStructure {
structure_id?: string | null;
chainage_m?: number | null;
start_m?: number | null;
end_m?: number | null;
}
/** 풀린 선택 — 관이면 그 관의 누가거리, 아니면 구조물 id. 둘 다 없으면 고를 항목이 없다. */
export interface ResolvedSelection {
pipeChainageM: number | null;
structureId: string | null;
}
/** 이 구조물이 그 측점을 덮는가 — 점형은 기준점 근처, 구간형은 시작~종료 안(양끝 여유 포함). */
export function coversChainage(structure: SelectableStructure, chainageM: number): boolean {
const anchor = structure.chainage_m ?? structure.start_m;
const start = structure.start_m ?? anchor;
const end = structure.end_m ?? anchor;
if (start == null || end == null) return false;
return (
chainageM >= Math.min(start, end) - PIPE_MATCH_M &&
chainageM <= Math.max(start, end) + PIPE_MATCH_M
);
}
/**
* 선택 값을 폼이 열 항목으로 푼다 — 어느 화면에서 골랐든 **같은 순서 · 같은 거리**다.
* ① 구조물 id 가 있고 목록에 있으면 그 구조물 ② 측점 근처 관(계곡 통과 시설이 먼저 —
* 같은 측점에 구조물이 겹쳐도 횡단도에 그려지는 것은 관이다) ③ 측점을 덮는 구조물.
*/
export function resolveSelection(
selection: DesignSelection,
pipes: ReadonlyArray<{ chainage_m: number }>,
structures: ReadonlyArray<SelectableStructure>,
): ResolvedSelection {
if (selection.structureId) {
const byId = structures.find((entry) => entry.structure_id === selection.structureId);
if (byId) return { pipeChainageM: null, structureId: byId.structure_id ?? null };
}
const chainageM = selection.chainageM;
if (chainageM === null) return { pipeChainageM: null, structureId: null };
const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m);
if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null };
const hit = structures.find((entry) => coversChainage(entry, chainageM));
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
}
/* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */
interface Stored {
at?: unknown;
key?: unknown;
sid?: unknown;
}
/** 세션에 남은 고름 — 없으면 null. `key` 는 횡단도가 부재를 부르는 이름(조정창 여는 데 씀). */
export function readSelection(
projectId: string | null,
): (DesignSelection & { partKey?: string }) | null {
if (!projectId) return null;
try {
const value = readState<Stored>("structure-pick", projectId);
if (!value || typeof value.at !== "number" || !Number.isFinite(value.at)) return null;
return {
chainageM: value.at,
structureId: typeof value.sid === "string" ? value.sid : null,
partKey: typeof value.key === "string" ? value.key : undefined,
};
} catch {
return null;
}
}
export type SelectionListener = (selection: DesignSelection, from: string | undefined) => void;
const listeners = new Map<string, Set<SelectionListener>>();
let dispatching = false;
/** 고름이 바뀔 때 부른다. 돌려주는 함수로 구독을 끊는다. */
export function subscribeSelection(projectId: string | null, fn: SelectionListener): () => void {
if (!projectId) return () => undefined;
let set = listeners.get(projectId);
if (!set) listeners.set(projectId, (set = new Set()));
set.add(fn);
return () => set.delete(fn);
}
/**
* 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로
* 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다.
* `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다.
* 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지).
*/
export function selectDesign(
projectId: string | null,
selection: DesignSelection,
options: { partKey?: string; from?: string } = {},
): void {
if (!projectId) return;
if (selection.chainageM === null) {
writeState("structure-pick", null, projectId);
} else {
const before = readSelection(projectId);
const same =
before !== null &&
before.chainageM === selection.chainageM &&
before.structureId === selection.structureId &&
(options.partKey === undefined || options.partKey === before.partKey);
if (same) return;
const key = options.partKey ?? before?.partKey;
writeState(
"structure-pick",
{ at: selection.chainageM, key, sid: selection.structureId ?? undefined },
projectId,
);
}
if (dispatching) return;
dispatching = true;
try {
for (const fn of [...(listeners.get(projectId) ?? [])]) fn(selection, options.from);
} finally {
dispatching = false;
}
}
+304
View File
@@ -0,0 +1,304 @@
/* =============================================================================
* design_shared.ts
* B05 · B06 한 페이지의 **한 벌 자리**(계획서 24-3b) — 좌측 패널(칸 · 아래 단추 넷) · 종단도 ·
* 「구조물 배치」 폼을 페이지에 **한 번만** 만들고 두 보기가 같은 노드를 함께 쓴다.
*
* 한 벌이 받은 사용자 조작(종단도 측점 고름 · 구조물 폼 · 우클릭)은 **지금 보이는 보기**가
* `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도
* 받는다 — 다시 보일 때 같은 값을 들고 있게.
* [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의
* [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`).
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
import { navigateTo } from "../router";
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 종단도가 보기에게 알리는 것 — 측점 고름 · [초기선 복원] · 우클릭 · 끌기 · 판(배수유역도). */
export interface DesignProfileBinding {
onSelectStation: (stationId: string | null) => void;
onResetAll?: () => void;
callbacks?: RouteProfilePanelCallbacks;
}
/** 저장 한 줄기에 보기가 보태는 몫(계획고 · 관은 한 벌 종단도가 낸다). */
export interface DesignViewSave {
routeId: () => number | null;
prepare?: () => Promise<void>;
steps?: Partial<
Record<Exclude<DesignSaveStep, "cross" | "pipes" | "alignment">, () => Promise<void>>
>;
cross?: (confirm: boolean) => Promise<void>;
/** 저장 뒤 이 보기만의 캐시 비우기 · 다시 읽기(알림 · 다음 단계는 여기서 한다). */
done?: (confirm: boolean) => Promise<void> | void;
}
export interface DesignViewBinding {
profile?: DesignProfileBinding;
/** 「구조물 배치」 폼 알림 — 그 보기가 보일 때만 받는다. */
structures?: Partial<StructuresCallbacks>;
save?: DesignViewSave;
/** [초기화] 때 이 보기만 아는 조작 캐시를 비운다. */
onReset?: () => void;
/** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */
onPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
}
/** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */
export interface DesignPageShared {
projectId: string;
/** 좌측 패널 자리(아래 단추 넷은 좌측 패널 안). */
left: HTMLElement;
/** 가운데 아래 종단도 자리. */
profileSlot: HTMLElement;
/** 지금 보이는 보기. */
view: () => DesignView;
/** 좌측 칸 부품 — 두 보기가 제 몫을 넣고, 칸 정의가 이름으로 꺼낸다. */
parts: Record<string, unknown>;
bindings: Partial<Record<DesignView, DesignViewBinding>>;
/** 먼저 쓰는 쪽이 만든다 — 뒤 보기는 있는 것을 쓴다. */
panel: DesignPanel | null;
profile: DesignProfile | null;
structures: StructuresSection | null;
/** 배수유역도가 마지막으로 알린 관 목록 — 뒤늦게 서는 보기가 처음부터 같은 관 목록을 쥔다(24-7a). */
lastPipes?: Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
/** 횡단 보기가 서기 전에 종단 보기가 고친 관 옵션 — 횡단 보기가 서면 캐시에 얹는다. */
pipeOptionQueue: Array<[number, Record<string, number | string>]>;
/** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */
revealLeft: () => void;
/** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */
ensureView: (view: DesignView) => Promise<void>;
}
const bound = (shared: DesignPageShared): DesignViewBinding => shared.bindings[shared.view()] ?? {};
/** 보기가 제 몫을 건다 — 같은 보기가 다시 걸면 덮는다. */
export function bindDesignView(
shared: DesignPageShared,
view: DesignView,
binding: DesignViewBinding,
): void {
shared.bindings[view] = { ...shared.bindings[view], ...binding };
}
/**
* 종단 보기 폼에서 고친 관 옵션을 횡단 캐시에도 얹는다 — 세월교 · BOX 폼 값은 횡단 스펙(캐시)이
* 정본이라, 안 얹으면 한 벌 폼이 횡단 보기로 가는 순간 옛 값으로 돌아간다. 횡단 보기가 아직
* 안 섰으면 담아 두었다가 설 때 얹는다.
*/
export function forwardPipeOptions(
shared: DesignPageShared,
chainageM: number,
patch: Record<string, number | string> | undefined,
): void {
if (!patch || !Object.keys(patch).length) return;
const apply = shared.bindings.cross?.onPipeOptions;
if (apply) apply(chainageM, patch);
else shared.pipeOptionQueue.push([chainageM, patch]);
}
/** 횡단 보기가 캐시 창구를 건다 — 담아 둔 옵션을 먼저 얹는다. */
export function bindPipeOptions(
shared: DesignPageShared,
apply: (chainageM: number, patch: Record<string, number | string>) => void,
): void {
bindDesignView(shared, "cross", { onPipeOptions: apply });
shared.pipeOptionQueue.splice(0).forEach(([chainageM, patch]) => apply(chainageM, patch));
}
/** 지금 보기의 종단도 알림으로 보낸다. */
function toView<K extends keyof RouteProfilePanelCallbacks>(
shared: DesignPageShared,
name: K,
): NonNullable<RouteProfilePanelCallbacks[K]> {
return ((...args: unknown[]) =>
(bound(shared).profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(
...args,
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
}
/** 두 보기 모두에 보낸다 — 값이 바뀐 알림. */
function toAll<K extends keyof RouteProfilePanelCallbacks>(
shared: DesignPageShared,
name: K,
): NonNullable<RouteProfilePanelCallbacks[K]> {
return ((...args: unknown[]) =>
Object.values(shared.bindings).forEach((binding) =>
(binding?.profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(...args),
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
}
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
export function sharedProfile(shared: DesignPageShared): DesignProfile {
if (shared.profile) return shared.profile;
const profile = createDesignProfile({
projectId: shared.projectId,
view: shared.view(),
side: drainageSide,
sideTool: drainageSideTool,
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
onResetAll: () => bound(shared).profile?.onResetAll?.(),
callbacks: {
onPipesChanged: (pipes) => {
shared.lastPipes = pipes;
toAll(shared, "onPipesChanged")(pipes);
},
onCrossDesignsUpdated: toAll(shared, "onCrossDesignsUpdated"),
onStructureMove: toView(shared, "onStructureMove"),
onStructureRemove: toView(shared, "onStructureRemove"),
onPipeAdd: toView(shared, "onPipeAdd"),
onStructureAdd: toView(shared, "onStructureAdd"),
onIrregularSelect: toView(shared, "onIrregularSelect"),
onStructureSelect: toView(shared, "onStructureSelect"),
onStructureMarkMove: toView(shared, "onStructureMarkMove"),
onStructureTypeAdd: toView(shared, "onStructureTypeAdd"),
onBasinSelected: toView(shared, "onBasinSelected"),
onPipeSelected: toView(shared, "onPipeSelected"),
},
});
shared.profileSlot.append(profile.root);
shared.profile = profile;
return profile;
}
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
export function sharedStructures(shared: DesignPageShared): StructuresSection {
if (shared.structures) return shared.structures;
const on = (): Partial<StructuresCallbacks> => bound(shared).structures ?? {};
shared.structures = createStructuresSection(
{
onChange: (next) => on().onChange?.(next),
onSelect: (structure) => on().onSelect?.(structure),
getInterval: () => on().getInterval?.() ?? 20,
onPipeAdd: (chainageM, attributes) => on().onPipeAdd?.(chainageM, attributes),
onPipeUpdate: (from, to, attributes) => on().onPipeUpdate?.(from, to, attributes),
onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM),
onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM),
onReveal: () => shared.revealLeft(),
},
{ includeDetail: () => shared.view() === "cross", projectId: shared.projectId },
);
shared.parts.structures = shared.structures;
return shared.structures;
}
/** 지금 두 보기가 아는 노선 — 횡단 보기가 먼저(확정 본문이 그쪽이다). */
function routeIdOf(shared: DesignPageShared): number | null {
return shared.bindings.cross?.save?.routeId() ?? shared.bindings.profile?.save?.routeId() ?? null;
}
/** 좌측 패널 한 벌 — 부품은 두 보기가 `shared.parts` 에 넣고, 칸은 보기가 처음 보일 때 선다. */
export function sharedPanel(shared: DesignPageShared): DesignPanel {
if (shared.panel) return shared.panel;
const other = (): DesignView => (shared.view() === "profile" ? "cross" : "profile");
const panel = createDesignPanel(
{
projectId: shared.projectId,
parts: shared.parts,
dock: {
reset: {
run: () =>
resetDesign(shared.projectId, routeIdOf(shared), () =>
Object.values(shared.bindings).forEach((binding) => binding?.onReset?.()),
),
},
// 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다.
save: { run: () => saveSharedDesign(shared, false), enabled: false },
confirm: { run: () => saveSharedDesign(shared, true), enabled: false },
// [보기 전환] = 주소만 바꾼다 — 같은 페이지라 라우터가 보기만 바꾼다(다시 안 만듦).
switchView: {
run: () => navigateTo(other() === "profile" ? ROUTES.B05_PROFILE : ROUTES.B06_SECTION),
},
},
},
shared.view(),
);
shared.left.append(panel.root);
shared.panel = panel;
return panel;
}
/** 자료가 섰다 — [저장] · [확정]을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */
export function designReady(shared: DesignPageShared): void {
shared.panel?.dock.setActions({
save: {
run: () => saveSharedDesign(shared, false),
enabled: true,
title: L("B06_Profile_Btn_Save_Tip"),
},
confirm: { run: () => saveSharedDesign(shared, true), enabled: true },
});
}
/**
* [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳.
*/
export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise<void> {
// [확정]은 횡단 본문(표준 횡단면 · 전 측점 면적)이 있어야 한다 — 숨긴 채라도 횡단 보기를 세운다.
if (confirm) await shared.ensureView("cross");
const profileSave = shared.bindings.profile?.save;
const crossSave = shared.bindings.cross?.save;
if (confirm && !crossSave?.cross) {
showToast(L("B06_Profile_Confirm_Failed"), "error");
return;
}
const profile = shared.profile;
const side = profile?.side;
await saveDesign(
{
projectId: shared.projectId,
routeId: () => routeIdOf(shared),
prepare: crossSave?.prepare,
steps: {
// 세션 초안이 나간 뒤 배수유역도 목록으로 한 번 더 맞춘다(시설 되붙이기 · 응답으로 화면 갱신).
...(side
? {
pipes: async () => {
await side.savePipes();
},
}
: {}),
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
...(profile ? { alignment: () => profile.save() } : {}),
...profileSave?.steps,
...crossSave?.steps,
},
cross: async (isConfirm) => {
if (crossSave?.cross) await crossSave.cross(isConfirm);
else await profileSave?.cross?.(isConfirm);
},
done: async (isConfirm) => {
if (isConfirm) {
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
return;
}
await profileSave?.done?.(false);
await crossSave?.done?.(false);
showToast(L("B06_Profile_Save_Success"), "success");
},
failed: (error, isConfirm) => {
const key = isConfirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed";
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L(key)}${detail}`, "error");
},
},
{ confirm },
);
}
+154
View File
@@ -0,0 +1,154 @@
/* =============================================================================
* A00_Common/design/design_store.ts
* B05 · B06 설계 상태 **한 곳**(계획서 24-1) — 두 보기가 같은 값을 같은 창구로 읽고 쓴다.
*
* const store = useDesignStore(projectId, routeId);
* store.get("pipes") · store.set("pipes", next) · store.subscribe("pipes", fn)
*
* 밑은 지금 캐시 그대로다 — 값마다 `b_page_state` 등록 키 하나(새 키는 등록부에 먼저).
* 가게는 값을 따로 들고 있지 않는다: 읽을 때마다 캐시를 읽고, 쓸 때 캐시에 쓴 뒤 듣는 쪽을
* 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다
* (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것).
*
* `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`)
* 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다.
* ========================================================================== */
import { readState, writeState } from "../b_page_state";
import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend";
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
/** 한 페이지의 두 보기(계획서 24-1). */
export type DesignView = "profile" | "cross";
/** 고른 자리 — 측점 · 구조물. 둘 다 비면 고른 것이 없다. */
export interface DesignSelection {
chainageM: number | null;
structureId: string | null;
}
/** 가게가 드는 값 — 없으면 null(= 만진 적 없음 · 저장분을 쓸 것). */
export interface DesignStoreValues {
/** 미저장 구조물 목록(정본 `structures.json`). */
structures: StructureInstance[] | null;
/** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */
pipes: DetailPipeInput[] | null;
/** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */
alignmentDraft: AlignmentEdits | null;
/** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */
sectionDetail: SectionDetailResponse | null;
/** 고른 측점 · 구조물. */
selection: DesignSelection | null;
}
export type DesignStoreKey = keyof DesignStoreValues;
export interface DesignStore {
get<K extends DesignStoreKey>(key: K): DesignStoreValues[K];
set<K extends DesignStoreKey>(key: K, value: DesignStoreValues[K]): void;
/** 가게로 쓸 때마다 부른다. 돌려받은 함수로 끊는다. */
subscribe<K extends DesignStoreKey>(
key: K,
fn: (value: DesignStoreValues[K]) => void,
): () => void;
}
/** 옛 계획고 초안 키 — 위 머리 주석 참조(남은 옛 값을 쓸어 낼 때만 쓴다). */
export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
/** 계획고 초안 칸의 프로젝트 — 부르는 쪽이 모르면 지금 연 프로젝트, 그것도 없으면 `-`
* (노선 번호는 프로젝트를 건너 겹치지 않는다). */
export function draftProjectId(projectId: string | null = null): string {
if (projectId) return projectId;
try {
return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-";
} catch {
return "-";
}
}
interface Slot<T> {
/** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */
id: string | null;
read(): T | null;
write(value: T | null): void;
}
function slotOf<K extends DesignStoreKey>(
key: K,
projectId: string | null,
routeId: number | null,
): Slot<DesignStoreValues[K]> {
type V = DesignStoreValues[K];
if (key === "alignmentDraft") {
const project = draftProjectId(projectId);
if (routeId === null) return { id: null, read: () => null, write: () => undefined };
return {
id: `alignment-draft:${project}:${routeId}`,
read: () => {
const parsed = readState<Partial<AlignmentEdits>>("alignment-draft", project, routeId);
if (!parsed) return null;
return {
station_offsets: parsed.station_offsets ?? {},
curve_radii: parsed.curve_radii ?? {},
} as V;
},
write: (value) => writeState("alignment-draft", value, project, routeId),
};
}
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
const name = (
{
structures: "structures",
pipes: "pipes",
sectionDetail: "section-detail",
selection: "selection",
} as const
)[key as Exclude<DesignStoreKey, "alignmentDraft">];
const routeScoped = name === "section-detail";
if (!projectId || (routeScoped && routeId === null)) {
return { id: null, read: () => null, write: () => undefined };
}
const scopeRoute = routeScoped ? routeId : undefined;
return {
id: `${name}:${projectId}${routeScoped ? `:${routeId}` : ""}`,
read: () => readState<V>(name, projectId, scopeRoute),
write: (value) => writeState(name, value, projectId, scopeRoute),
};
}
const listeners = new Map<string, Set<(value: unknown) => void>>();
/**
* 그 프로젝트 · 노선의 가게. 부를 때마다 새로 만들어도 된다 — 값도 듣는 쪽도 가게 밖
* (캐시 · 모듈 표)에 있어서 같은 칸을 가리키면 같은 가게다.
*
* 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고
* 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다.
*/
export function useDesignStore(projectId: string | null, routeId: number | null): DesignStore {
return {
get(key) {
return slotOf(key, projectId, routeId).read() as DesignStoreValues[typeof key];
},
set(key, value) {
const slot = slotOf(key, projectId, routeId);
slot.write(value);
if (slot.id) listeners.get(`${key}|${slot.id}`)?.forEach((fn) => fn(value));
},
subscribe(key, fn) {
const id = slotOf(key, projectId, routeId).id;
if (!id) return () => undefined;
const bucket = `${key}|${id}`;
const set = listeners.get(bucket) ?? new Set();
listeners.set(bucket, set);
const listener = fn as (value: unknown) => void;
set.add(listener);
return () => set.delete(listener);
},
};
}
@@ -1,133 +1,108 @@
import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync";
import { createGradeEdit } from "./B06_Section_UI_Page_Grade_Edit";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
import { navigateTo } from "../A00_Common/router";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
/* =============================================================================
* design_view_cross.ts
* 한 페이지의 **횡단 보기**(계획서 24-3b) — 가운데 위 칸 = 횡단 카드(처음 보일 때 그린다 ·
* 그 안에서 스크롤). 좌측 패널 · 아래 단추 · 종단도는 한 벌(`design_shared`)을 종단 보기와
* 같은 노드로 쓴다.
*
* 여기서는 횡단 보기 몫만 건다 — 좌측 횡단 칸 재료(횡단 보기 설정 · 표준 횡단면) · 구조물 폼
* 알림 · 카드 조정 제어 · 저장 몫. (옛 `B06_Section_UI_Page.ts` 의 본체다. 저장 흐름은
* `_Page_Persist`, 설계 반영 · 재계산은 `_Page_Design_Sync` 가 맡는다.)
*
* 종단 보기의 [확정]이 횡단 자료를 얻으려고 이 보기를 **숨긴 채** 세우기도 한다 — 숨긴 칸은
* 폭이 0 이라 카드는 그리지 않고, 처음 보일 때 보임 알림이 그린다.
* ========================================================================== */
import { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync";
import { leaveForDashboard } from "../b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
import type {
StructureInstance,
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import {
fetchSectionContext,
getSections,
type SectionContextResponse,
type SectionDetailResponse,
type StandardCrossSection,
} from "./B06_Section_Api_Fetch";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
} from "../../B06_Section/B06_Section_Api_Fetch";
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
import {
confirmCurrentSections,
createCutSlopeStore,
createRockBoundaryStore,
saveCurrentSections,
crossSaveParts,
type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import {
readStructurePick,
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
import { createSectionView } from "./B06_Section_UI_Section_View";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
} from "../../B06_Section/B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "../../B06_Section/B06_Section_UI_Cross_Fit";
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
replayDraftPipeOptions,
type PipeOptionsContext,
} from "./B06_Section_UI_Page_Pipe_Options";
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
import {
createStandardPanel,
effectiveStandardCross,
rememberRockBoundaryDefault,
type StandardPanelController,
} from "./B06_Section_UI_Standard_Panel";
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
import {
createB06StructuresPanel,
isWallSelecting,
wireStructureSelection,
} from "./B06_Section_UI_Page_Structures_Panel";
import "./B06_Section_UI_Style.css";
import "./B06_Section_UI_Style_Cross.css";
import "./B06_Section_UI_Style_Cross_Controls.css";
import "./B06_Section_UI_Style_Cross_Areas.css";
import { loadSectionDetail } from "./B06_Section_Section_Store";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common";
} from "../../B06_Section/B06_Section_UI_Page_Structures_Panel";
import "../../B06_Section/B06_Section_UI_Style.css";
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import {
applyStructureAreaRows,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
import { createSampleWidener, L } from "../../B06_Section/B06_Section_UI_Page_Common";
import {
createCrossViewPart,
createStandardSlot,
} from "../../B06_Section/B06_Section_UI_Page_Panel";
import type { DesignPageHost } from "./design_page";
import {
bindDesignView,
bindPipeOptions,
designReady,
sharedPanel,
type DesignPageShared,
} from "./design_shared";
import "@util/common_util_mass_haul.css";
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
/** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유 샘플 폭은
* 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시 범위만 좁힌다. */
const DISPLAY_HALF_WIDTH_DEFAULT_M = 12;
export async function mountCrossView(
center: HTMLElement,
host: DesignPageHost,
shared: DesignPageShared,
): Promise<void> {
const projectId = shared.projectId || null;
let currentRouteId: number | null = null;
let sectionDetail: SectionDetailResponse | null = null;
let stationInterval: number | undefined;
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
// 표준 횡단면 설정은 기본 접힘(2026-08-23 사용자 지시) — 자주 여는 칸이 아니다.
const standardGroup = buildGroup(L("B06_Std_Title"), true);
const standardPanelSlot = document.createElement("div");
standardGroup.append(standardPanelSlot);
const standardPanelSlot = createStandardSlot();
let standardPanel: StandardPanelController | null = null;
// 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시).
// 횡단 반폭은 **자체 컨테이너 + 자체 [보기 반폭 적용] 버튼**이다(2026-08-23 사용자 지시)
// — 표준 횡단면 설정의 [전체 측점 반영]에 물려 있어 반폭만 바꿔도 전 측점 설계
// 재계산을 기다려야 했다. 요청 반폭이 보유 샘플 폭(기본 20m, config
// SECTION_CROSS_HALF_WIDTH_M) 이하면 표시만 바꾸므로 즉시 끝나고, 넘을 때만 재생성한다.
const crossHalfWidthField = createInputField({
label: L("B05_Route_Field_CrossHalfWidth"),
type: "number",
});
crossHalfWidthField.input.min = "0.1";
crossHalfWidthField.input.step = "0.1";
const applyWidthButton = createButton({
label: L("B06_View_Apply"),
variant: "filled",
onClick: () => void applyCrossHalfWidth(),
});
applyWidthButton.title = L("B06_View_Apply_Tip");
const viewGroup = buildGroup(L("B06_View_Title"));
// 반폭 입력 + [적용]은 한 줄(2026-08-23 사용자 지시).
const viewRow = document.createElement("div");
viewRow.className = "b06-profile__field-row";
viewRow.append(crossHalfWidthField.root, applyWidthButton);
viewGroup.append(viewRow);
/** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유
* 샘플 폭은 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시
* 범위만 좁힌다(12 ≤ 20이므로 재계산 없음). 계산 범위와 혼동 금지. */
const DISPLAY_HALF_WIDTH_DEFAULT_M = 12;
// 하단 액션 [종단 이동][임시저장][확정] — 종단 이동은 저장 없이 B05로 페이지 이동만,
// 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로 넘어간다
// (2026-08-08 워크플로우 재정의).
const goProfileButton = createButton({
label: L("B06_Profile_Btn_GoProfile"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.B05_PROFILE),
});
const saveButton = createButton({
label: L("B06_Profile_Btn_Save"),
variant: "ghost",
onClick: () => void saveCurrentSections(persistContext),
});
saveButton.title = L("B06_Profile_Btn_Save_Tip");
saveButton.disabled = true;
const confirmButton = createButton({
label: L("B06_Profile_Btn_Confirm"),
variant: "filled",
onClick: () => void confirmCurrentSections(persistContext),
});
confirmButton.disabled = true;
const actionRow = document.createElement("div");
actionRow.className = "b06-profile__actions";
actionRow.append(goProfileButton, saveButton, confirmButton);
// 「횡단 보기 설정」 칸 재료(반폭 입력 + [보기 반폭 적용])는 `_Page_Panel` 이 만든다.
const crossView = createCrossViewPart(() => void applyCrossHalfWidth());
const crossHalfWidthField = crossView.field;
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
const focusStationAt = (chainageM: number): void => {
@@ -138,30 +113,35 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
);
sectionView.focusStation(nearest.station_id);
};
/** 그 누가거리를 가진 측점 카드(관 찾는 거리 한 값). */
const ownerAt = (chainageM: number) =>
sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
);
// 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화).
// 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행].
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
let structureMarksSink:
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
let pipeReloadTimer = 0;
const structuresPanel = createB06StructuresPanel({
projectId,
// 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
reveal: () => layout.setOptionsOpen(true),
// 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4).
// 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다.
shared,
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
onMarks: (structures, types) => {
structureMarksSink?.(structures, types);
syncBermSpans(structures);
},
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시
// 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정).
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
onStructuresChanged: () => void refreshDetailForStructures(),
onPipesWritten: () => {
window.clearTimeout(pipeReloadTimer);
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
},
detail: () => sectionDetail,
// 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산).
wallHeight: (chainageM, role) => {
const owner = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
);
const owner = ownerAt(chainageM);
const culvert = owner?.culvert;
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
@@ -172,13 +152,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
.pureHeight;
},
// 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라
// 저장된 옵션보다 앞선다 — 안 맞추면 폼은 저장값, 그림은 조작값이 되어
// "유출구 형태를 바꿔도 안 바뀐다"로 보인다(2026-08-30 사용자 보고).
// 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라 저장된 옵션보다
// 앞선다(2026-08-30 사용자 보고: 유출구 형태를 바꿔도 안 바뀐다).
wallForm: (chainageM, role) => {
const owner = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
);
const owner = ownerAt(chainageM);
const culvert = owner?.culvert;
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
@@ -188,32 +165,25 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
},
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
wallSideLabels: (chainageM) => {
const owner = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
);
const owner = ownerAt(chainageM);
if (!owner) return null;
const inletLeft = (owner.uphill_side ?? "left") === "left";
return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" };
},
stationInterval: () => stationInterval ?? 20,
focusChainage: focusStationAt,
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
// 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저
// 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다.
focusChainage: (chainageM) => {
if (!isWallSelecting()) focusStationAt(chainageM);
},
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
});
const dockDivider = document.createElement("hr");
dockDivider.className = "b05-structure__divider";
const actionDock = document.createElement("div");
actionDock.className = "b05-route__dock ui-sidebar-actions";
actionDock.append(structuresPanel.listRoot, dockDivider, actionRow);
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
attachCollapsible(leftForm);
// 좌측 패널 = 한 벌 등록부 — 횡단 보기 칸 재료만 넣는다. 칸 차례: 구조물 배치(최상단 —
// 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. 아래 단추 넷 · 저장은 한 벌이 낸다.
Object.assign(shared.parts, { crossView, standardSlot: standardPanelSlot });
sharedPanel(shared);
// 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
const {
@@ -233,12 +203,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
async function refreshDetailForStructures(): Promise<void> {
if (!projectId || currentRouteId === null) return;
try {
if (projectId && currentRouteId !== null) {
sectionDetail = await loadSectionDetail(projectId, currentRouteId);
}
sectionDetail = await loadSectionDetail(projectId, currentRouteId);
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측: 카드는 바뀌는데
// 최종 누가토량이 그대로였음).
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
if (sectionDetail) {
applyStructureAreaRows(
sectionDetail.cross_sections,
@@ -291,23 +258,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
recompute: (chainageM) => recomputeIfRock(chainageM),
});
const rockOffsets = rockStore.offsets;
const rockBoundaryControl = rockStore.control;
// 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다.
// 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다.
const cutSlopeStore = createCutSlopeStore({
sessionKey: () => stateKey("cutslope", projectId, currentRouteId),
standard: () => (projectId ? effectiveStandardCross(projectId) : null),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
recompute: (chainageM) => recomputeIfRock(chainageM),
});
const cutSlopeRatios = cutSlopeStore.ratios;
const stationControls = createStationControls({
// 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다.
// 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다
// (2026-09-06 `extraspan` 누락으로 B06 이 안 뜬 뒤 막음).
// 키는 등록표(`b_page_state`)가 만든다 — 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다.
sessionKey: (kind) => stateKey(kind, projectId, currentRouteId),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
detail: () => sectionDetail,
@@ -317,16 +278,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
projectId: () => projectId,
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
});
const { stationWidth: stationWidthControl, revetOffset: revetOffsetControl } = stationControls;
const { widths: stationWidths, inletStructures, basinAdjustments } = stationControls;
const sectionView = createSectionView(
(chainageM, change) => {
void handleDesignChange(chainageM, change);
},
rockBoundaryControl,
stationWidthControl,
revetOffsetControl,
rockStore.control,
stationControls.stationWidth,
stationControls.revetOffset,
stationControls.inletStructure,
stationControls.extraWalls,
stationControls.structureSpan,
@@ -334,21 +293,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
stationControls.ford,
stationControls.box,
cutSlopeStore.control,
shared,
);
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
// 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
const gradeEditFor = createGradeEdit({
routeId: () => currentRouteId,
detail: () => sectionDetail,
view: () => sectionView,
reconcile: () => reconcileStaleDesigns({ force: true }),
});
sectionView.setGradeEdit(gradeEditFor);
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
// 같은 함수를 타므로 목록·폼·알약이 함께 선다.
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
// 들어와도 좌측 「구조물 배치」와 같은 함수를 타므로 목록·폼·알약이 함께 선다.
sectionView.setStructureEdit({
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
@@ -376,8 +328,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
stationControls,
() => sectionDetail,
structuresPanel,
// 횡단도에서 고른 부재도 같은 세션 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼).
(chainageM, key) => writeStructurePick(projectId, chainageM, key),
// 횡단도에서 고른 부재도 같은 선택 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼).
(chainageM, key) =>
selectDesign(projectId, structuresPanel.selectionAt(chainageM), {
partKey: key,
from: "b06",
}),
);
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
@@ -387,13 +343,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
: null;
if (!target) {
structuresPanel.showAtChainage(null);
writeStructurePick(projectId, null); // 카드 해제 — 세션 선택도 비운다.
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
return;
}
const owner = stationControls.structureSpan.ownerOf(target);
structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
// 카드만 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
if (!isWallSelecting()) writeStructurePick(projectId, owner?.chainage_m ?? target.chainage_m);
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
// 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다.
if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" });
structureSelection.syncInletStructure();
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
@@ -403,10 +359,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
});
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
// 가운데 위 칸: 횡단 카드(sectionView) 또는 안내 메시지.
const mainArea = document.createElement("div");
mainArea.className = "b06-profile__main";
mainArea.append(sectionView.root);
center.append(mainArea);
function showSectionView(): void {
if (!mainArea.contains(sectionView.root)) mainArea.replaceChildren(sectionView.root);
@@ -435,7 +392,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
);
}
/** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */
/** 표시 반폭 세션 키 — 보기를 떠났다 와도 조절값이 유지되게 한다. */
const displaySessionKey = (): string | null =>
stateKey("cross-display", projectId, currentRouteId);
@@ -450,46 +407,48 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
}
function updateActionState(): void {
saveButton.disabled = sectionDetail === null;
confirmButton.disabled = sectionDetail === null;
}
/** 세션에 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼).
* **넘김값이 바뀌었을 때만** 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이
* 튀고, 진입당 한 번으로 막으면 자료가 늦게 온 경우 영영 안 열린다(2026-09-06). */
/** 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼). **넘김값이 바뀌었을 때만**
* 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 튄다(2026-09-06). */
let appliedPick: string | null = null;
function restoreStructurePick(): void {
if (!sectionDetail) return;
const handoff = readStructurePick(projectId);
const signature = handoff ? `${handoff.at}|${handoff.key ?? ""}` : null;
const signature = handoff
? `${handoff.at}|${handoff.key ?? ""}|${handoff.structureId ?? ""}`
: null;
if (signature === null || signature === appliedPick) return;
appliedPick = signature;
applyStructurePick(handoff, sectionDetail, {
focusStation: (stationId) => sectionView.focusStation(stationId),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
});
// 카드를 못 찾았으면(구조물 목록이 아직 안 실림) 적용한 것으로 치지 않는다 — 목록이 실린 뒤
// `load().then(restoreStructurePick)` 가 다시 부른다.
const applied = applyStructurePick(
handoff,
sectionDetail,
{
focusStation: (stationId) => sectionView.focusStation(stationId),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
},
structuresPanel.getStructures(),
);
if (applied) appliedPick = signature;
}
function renderSectionDetail(): void {
if (sectionDetail) {
showSectionView();
// 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지).
sectionView.render(
sectionDetail,
1,
crossHalfWidth(),
stationInterval,
context?.earthwork_conversion,
context?.haul_equipment_limits,
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
context?.natural_spoil_min_ground_slope ?? undefined,
);
restoreStructurePick();
}
if (!sectionDetail) return;
showSectionView();
// 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지).
sectionView.render(
sectionDetail,
1,
crossHalfWidth(),
stationInterval,
context?.earthwork_conversion,
context?.haul_equipment_limits,
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
context?.natural_spoil_min_ground_slope ?? undefined,
);
restoreStructurePick();
}
// [저장]·[확정]과 편집분 수집은 저장 흐름 모듈에 있다(2026-09-02 분리).
@@ -504,11 +463,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 실려 나가던 자리(2026-09-09 실측).
reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
patchSources: () => ({
rockOffsets,
cutSlopeRatios,
stationWidths,
inletStructures,
basinAdjustments,
rockOffsets: rockStore.offsets,
cutSlopeRatios: cutSlopeStore.ratios,
stationWidths: stationControls.widths,
inletStructures: stationControls.inletStructures,
basinAdjustments: stationControls.basinAdjustments,
revetAdjusts: stationControls.revetAdjustsByChainage(),
extraCounts: stationControls.extraCountsByChainage(),
extraSpans: stationControls.extraSpansByChainage(),
@@ -517,39 +476,31 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
linkFlags: stationControls.linkFlagsByChainage(),
}),
};
bindDesignView(shared, "cross", { save: crossSaveParts(persistContext) });
let workflowState: WorkflowState | undefined;
let context: SectionContextResponse | null = null;
if (projectId) {
const [contextResult, workflowResult] = await Promise.allSettled([
fetchSectionContext(projectId),
fetchWorkflowState(projectId),
]);
if (contextResult.status === "fulfilled") context = contextResult.value;
else showToast(L("B06_Profile_Context_Failed"), "error");
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
}
const layout = createWorkflowLayout({
title: L("B06_Profile_Title"),
steps: workflowSteps(),
activeStep: 3,
leftPanel: leftForm,
mainContent: mainArea,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
// 한 페이지 두 보기(계획서 24-3a · 24-3b) — 다시 보이면 종단 보기에서 바뀐 구조물 · 관 ·
// 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다.
host.onShow(() => {
if (currentRouteId === null) return;
void structuresPanel.load().then(restoreStructurePick);
void refreshDetailForStructures().then(() => reconcileStaleDesigns());
});
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
sectionView.dispose();
});
layout.root.classList.add("b06-profile-layout");
root.replaceChildren(layout.root);
let context: SectionContextResponse | null = null;
if (!projectId) {
renderMessage(L("B06_Profile_Error_Project"));
return;
}
try {
context = await fetchSectionContext(projectId);
} catch {
showToast(L("B06_Profile_Context_Failed"), "error");
}
if (host.disposed()) return;
if (!context) {
renderMessage(L("B06_Profile_Context_Failed"));
return;
@@ -562,7 +513,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
rockStore.setDefaults(context.rock_boundary_default_offset_m, context.rock_boundary_step_m);
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
// 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다.
rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m);
@@ -578,15 +528,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
rockStore.load();
cutSlopeStore.load();
stationControls.load();
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다.
void structuresPanel.load().then(restoreStructurePick);
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. 숨긴 채
// 섰으면(종단 보기 [확정]) 한 벌 폼은 종단 보기 몫이라 싣지 않는다 — 보일 때 싣는다.
if (host.visible()) void structuresPanel.load().then(restoreStructurePick);
try {
const existing = await getSections(projectId, context.route_id);
if (!existing.longitudinal) {
leaveForDashboard();
return;
}
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).
// 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다.
sectionDetail = await loadSectionDetail(projectId, context.route_id);
// 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면
// 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측).
@@ -608,16 +559,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시).
if (storedOptions?.standard_cross_section)
standardPanel?.applyStored(storedOptions.standard_cross_section);
/* 입력칸의 기준값은 **표시 기본 12m**(2026-08-19 사용자 지시, 위에서 이미 넣었다).
*
* ⚠ `data.options.cross_half_width_m`은 종단·횡단을 만들 때 쓴 **샘플링(계산)
* 반폭**이지 사용자가 고른 표시 폭이 아니다(엔진이 offsets 범위를 만들 때 쓰고
* 그대로 저장한다). 그 값으로 입력칸을 덮으면 표시 기본값이 언제나 계산 기본
* 20m으로 되돌아온다 — 2026-08-19 사용자 보고의 원인이 이것이다. 그래서 여기서는
* 읽지 않고, 사용자가 실제로 고른 표시 폭만 되살린다:
* ① 측점 개별 표시 반폭의 저장값(design.display_half_width_m) 대표값
* ② 세션에 남은 마지막 조절값(같은 노선으로 돌아왔을 때)
* 표시값이 보유 샘플 폭보다 크면 [전체 측점 반영]이 알아서 재생성한다. */
/* 입력칸의 기준값은 **표시 기본 12m**. ⚠ `data.options.cross_half_width_m`은 종단·횡단을
* 만들 때 쓴 **샘플링(계산) 반폭**이지 사용자가 고른 표시 폭이 아니다 — 그 값으로 덮으면
* 표시 기본값이 언제나 20m로 되돌아온다(2026-08-19 사용자 보고의 원인). 사용자가 실제로
* 고른 표시 폭만 되살린다: ① 측점 개별 표시 반폭 저장값의 대표값 ② 세션의 마지막 조절값. */
const savedDisplayWidths = sectionDetail.cross_sections
.map((section) => section.design?.display_half_width_m)
.filter((width): width is number => typeof width === "number" && width > 0);
@@ -625,7 +570,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 측점마다 다를 수 있다 — 전역 입력칸에는 가장 넓은 값을 올린다(잘림 방지).
crossHalfWidthField.input.value = Math.max(...savedDisplayWidths).toFixed(1);
}
// 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값).
const sessionDisplayKey = displaySessionKey();
if (sessionDisplayKey) {
const sessionDisplay = Number(readByKey(sessionDisplayKey));
@@ -634,12 +578,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
stationInterval = storedOptions.station_interval_m;
// 종단 보기에서 고친 관 옵션(세월교 · BOX 스펙)을 캐시에 얹는 창구 — 담아 둔 것부터(24-3b).
bindPipeOptions(shared, (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
);
// 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a).
replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId));
renderSectionDetail();
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
void mountMissingStationNotice(root, projectId, refreshDetailForStructures);
void mountMissingStationNotice(mainArea, projectId, refreshDetailForStructures);
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
updateActionState();
designReady(shared);
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
renderMessage(L("B06_Profile_Calculate_In_B05"));
+665
View File
@@ -0,0 +1,665 @@
/* =============================================================================
* design_view_profile.ts
* 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 3D 화면. 좌측 패널 · 아래 단추 ·
* 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다.
*
* 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫.
* (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는
* `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.)
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "../b_asset_cache";
import { createProgressCircle } from "@ui/ui_template_progress";
import {
fetchConfirmedSurface,
type SurfaceConfirmedResponse,
} from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
updateContourInterval,
type RouteLatestResponse,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
import { leaveForDashboard } from "../b_missing_data_guard";
import { remountRoute } from "../router";
import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
import { createSelectionSync } from "../../B05_Profile/B05_Profile_UI_Selection";
import {
readStructurePick,
restoreStructurePick,
wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection";
import type { DesignPageHost } from "./design_page";
import {
bindDesignView,
designReady,
forwardPipeOptions,
type DesignPageShared,
} from "./design_shared";
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures";
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
import {
irregularStationId,
isPipeStation,
} from "../../B05_Profile/B05_Profile_UI_IrregularStations";
import {
rememberRockBoundaryDefault,
rememberStandardDefaults,
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
import {
fetchSectionContext,
type SectionDetailResponse,
} from "../../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import {
loadCorridorIfFresh,
refreshCorridor,
saveCorridorIfDirty,
} from "../../B05_Profile/B05_Profile_UI_Corridor";
import {
profileSaveParts,
solveRouteAction,
type PageActionContext,
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import "../../B05_Profile/B05_Profile_UI_Style.css";
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import {
DEFAULT_ROAD_WIDTHS,
fetchRoadWidths,
loadUphillOverrides,
restorePoints,
saveUphillOverrides,
toBounds,
toGradeClass,
} from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
const migratedRoutes = new Set<string>();
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
export async function mountProfileView(
center: HTMLElement,
host: DesignPageHost,
shared: DesignPageShared,
): Promise<void> {
const activeProjectId = shared.projectId;
if (!activeProjectId) return;
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
void purgeOtherProjects(activeProjectId);
const viewer = createRouteViewer();
// 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일).
const LEGACY_TYPE_IDS: Record<string, string> = {
기성막이: "revetment",
대피로: "refuge",
기타: "etc",
};
/** 고른 것을 selection 한 곳에 적는다 — 횡단 보기로 이어지는 값이다(chainageM null = 해제). */
const rememberPick = (chainageM: number | null, structureId: string | null): void =>
selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, {
from: "b05",
});
/** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */
let lastPipes: PipeReport | null = shared.lastPipes ?? null;
const applyPipes = (pipes: PipeReport): void => {
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
bridge.setPipes(pipes);
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
profilePanel.setMinCoverPipes(pipes);
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
};
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
const profilePanel = createRouteProfilePanel(
shared,
(stationId) => {
viewer.markers.selectStation(stationId);
syncIrregularSelection(stationId);
},
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
() => bridge.clearProjectedStations(),
{
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
// (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다.
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의
// 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫).
onPipesChanged: (pipes) => {
lastPipes = pipes;
if (host.visible()) applyPipes(pipes);
},
onStructureMove: (from, to, station) => {
if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to);
},
onStructureRemove: (station) => {
if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m);
},
// 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18).
onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"),
onStructureAdd: (chainage, type) =>
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
const chainage = bridge.pipeMarkChainage(structureId);
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
return;
}
panel.structures.selectById(structureId);
},
onStructureMarkMove: (structureId, toChainage) => {
const chainage = bridge.pipeMarkChainage(structureId);
if (chainage !== null) {
profilePanel.drainage.movePipe(chainage, toChainage);
return;
}
panel.structures.moveById(structureId, toChainage);
},
onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId),
},
);
/** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */
let confirmedSurface: SurfaceConfirmedResponse | null = null;
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | null = null;
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
let corridorVisible = true;
let routeReady = false;
let restoring = true;
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
let selectionSyncing = false;
/* ── 측점 상단측(=측구 방향) 사용자 변경분 ─────────────────────────────
* solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */
const uphillOverrides = loadUphillOverrides(activeProjectId);
const uphillKey = (chainage: number): string => chainage.toFixed(3);
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(),
isSyncing: () => selectionSyncing,
setSyncing: (value) => {
selectionSyncing = value;
},
selectMarker: (id) => viewer.markers.selectStation(id),
selectGraph: (id) => profilePanel.setSelectedStation(id),
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM),
});
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void {
if (panel.structures.hasSelection()) return;
const pick = viewer.structurePick;
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
}
const latestSource = createLatestSource(activeProjectId);
const loadLatest = latestSource.load;
const panel = createRoutePanel(
{
onSolve: () => void solveRouteAction(actionContext),
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
onEditPlannedRoute: () =>
void openRouteEditModal(activeProjectId, () => remountRoute(ROUTES.B05_PROFILE), {
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: confirmedSurface?.model_id ?? null,
contourIntervalM: latest?.surface_params.contour_interval_m,
smooth: latest?.surface_params.smooth,
}),
onContourApply: (interval) => applyContours(interval),
onSurfaceVisible: viewer.setSurfaceVisible,
onCorridorVisible: (visible) => {
corridorVisible = visible;
viewer.setCorridorVisible(visible);
// 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다.
if (currentSectionDetail) renderStationLines(currentSectionDetail);
},
onContoursVisible: viewer.setContoursVisible,
onAxesVisible: viewer.setAxesVisible,
onStationLinesVisible: viewer.setStationLinesVisible,
onStationLabelsVisible: viewer.setStationLabelsVisible,
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onProjection: viewer.setProjection,
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
onCorridorRefresh: async () => {
if (!currentSectionDetail || !latest?.route?.id) return;
const routePoints = latest.route_points ?? [];
if (routePoints.length <= 1) return;
await refreshCorridor(
viewer,
activeProjectId,
latest.route.id,
currentSectionDetail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
renderStationLines(currentSectionDetail);
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다.
void saveCorridorIfDirty(activeProjectId, latest.route.id);
},
onMovePoint: viewer.beginMoveSelected,
onDeletePoint: viewer.markers.deleteSelected,
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
onInputChange: markStale,
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
onStructuresChange: (next) => bridge.applyStructures(next),
// 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합).
onPipeFacilityAdd: (chainage, attributes) =>
profilePanel.drainage.addPipe(chainage, attributes),
onPipeFacilityUpdate: (from, to, attributes) => {
profilePanel.drainage.updatePipeFacility(from, to, attributes);
// 한 벌 폼 — 횡단 보기 캐시(세월교 · BOX 스펙)도 같은 값으로(계획서 24-3b).
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
},
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
onPipeFacilitySelect: (chainage) => {
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다.
if (selectionSyncing) return;
selectionSyncing = true;
try {
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
const station =
chainage === null
? undefined
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
syncBasinHighlight(id);
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
profilePanel.drainage.selectPipeAtChainage(chainage);
} finally {
selectionSyncing = false;
}
},
onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
if (selectionSyncing) return;
selectionSyncing = true;
try {
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
} finally {
selectionSyncing = false;
}
},
},
shared,
);
wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe);
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
function markStale(): void {
if (restoring || !routeReady) return;
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
viewer.markers.onChange(markStale);
viewer.markers.onSelectionChange(panel.setSelected);
viewer.markers.onStationSelectionChange((stationId) => {
profilePanel.setSelectedStation(stationId);
syncIrregularSelection(stationId);
});
// 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더.
viewer.markers.onUphillPick((stationId, side) => {
if (!currentSectionDetail) return;
const base = currentSectionDetail.longitudinal.stations.find(
(station) => station.station_id === stationId,
);
let chainage = base?.chainage_m;
if (chainage === undefined) {
const prefix = irregularStationId("");
if (stationId.startsWith(prefix)) {
const id = stationId.slice(prefix.length);
chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m;
}
}
if (chainage === undefined) return;
uphillOverrides.set(uphillKey(chainage), side);
saveUphillOverrides(activeProjectId, uphillOverrides);
renderStationLines(currentSectionDetail);
});
viewer.root.append(panel.viewControls);
function restorePanel(next: RouteLatestResponse): void {
panel.restore(panelValuesOf(next));
viewer.markers.setPoints(restorePoints(next));
}
function renderStationLines(detail: SectionDetailResponse): void {
const lines = stationLinesOf({
detail,
irregular: bridge.irregularStations(),
routePoints: latest?.route_points ?? [],
liveSamples: profilePanel.alignmentSamples(),
corridorVisible,
uphillOverrides,
});
viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2);
}
/** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */
const bridge = createStructuresBridge({
projectId: activeProjectId,
panel: () => panel,
profilePanel: () => profilePanel,
renderStationLines: () => {
if (currentSectionDetail) renderStationLines(currentSectionDetail);
},
isRestoring: () => restoring,
});
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
currentSectionDetail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
// 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19).
profilePanel.setGradeLimit(panel.gradeLimitPct());
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
profilePanel.setStationDisplay(panel.stationDisplayOffset());
profilePanel.setIrregularStations(bridge.irregularStations());
renderStationLines(detail);
// 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기
// 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다).
const routePoints = latest?.route_points ?? [];
if (routePoints.length > 1) {
void loadCorridorIfFresh(
viewer,
activeProjectId,
routeId ?? latest?.route?.id,
detail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
).then((fresh) => panel.setCorridorPending(!fresh));
}
}
async function restoreSections(routeId: number): Promise<void> {
let detail: SectionDetailResponse;
profilePanel.setLoading("종단면 자료를 불러오는 중…");
try {
// 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다.
detail = await loadSectionDetail(activeProjectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
currentSectionDetail = null;
profilePanel.setLoading(null);
profilePanel.clear();
viewer.renderStationLines([], 0);
return;
}
profilePanel.setLoading(null);
try {
renderSections(detail, routeId);
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측).
const legacy = detail.longitudinal.stations
.filter((station) => station.kind === "irregular")
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure ?? "",
}));
const migrateKey = `${activeProjectId}:${routeId}`;
if (legacy.length && !migratedRoutes.has(migrateKey)) {
migratedRoutes.add(migrateKey);
void migrateLegacyStations(activeProjectId, legacy)
.then((result) => {
if (result.migrated > 0) {
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
return bridge.refreshFromServer().then(() => undefined);
}
return undefined;
})
.catch(() => {
/* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */
});
}
} catch (error) {
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다.
console.error("B05 종단면도 렌더 실패", error);
showToast(
error instanceof Error
? `종단면도 표시 실패: ${error.message}`
: "종단면도 표시에 실패했습니다.",
"error",
);
}
}
function renderLatest(next: RouteLatestResponse): void {
latest = next;
routeReady = Boolean(next.route && next.route_points.length > 1);
profilePanel.setRoutePolyline(next.route_points ?? []);
if (next.route) {
const stored = next.route.algorithm_params ?? {};
viewer.markers.renderRoute(
next.route_points,
(stored.curve_warning_segments as Array<{
polyline_start_index: number;
polyline_end_index: number;
}>) ?? [],
);
}
}
async function applyContours(interval: number): Promise<void> {
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
try {
await viewer.reloadContours(interval);
await updateContourInterval(activeProjectId, interval);
// 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다.
const cached = latestSource.read();
if (cached) {
latestSource.write({
...cached,
surface_params: { ...cached.surface_params, contour_interval_m: interval },
});
}
} catch (error) {
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
}
}
// [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02).
const actionContext: PageActionContext = {
projectId: activeProjectId,
latest: () => latest,
confirmedSurface: () => confirmedSurface,
routeReady: () => routeReady,
viewer: () => viewer,
panel: () => panel,
profilePanel: () => profilePanel,
bridge: () => bridge,
uphillOverrides,
persistUphillOverrides: () => saveUphillOverrides(activeProjectId, uphillOverrides),
loadLatest,
renderLatest,
restoreSections,
};
// [저장] · [확정] · [초기화]에 보태는 종단 보기 몫 — 한 벌이 두 보기 몫을 모아 보낸다.
bindDesignView(shared, "profile", {
save: profileSaveParts(actionContext),
onReset: () => uphillOverrides.clear(),
});
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
bindProfileViewLife(host, {
projectId: activeProjectId,
viewer,
ready: () => !restoring,
// 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다.
reload: async () => {
await bridge.load();
if (lastPipes) applyPipes(lastPipes);
},
syncPick: () => {
if (readStructurePick(activeProjectId)) {
restoreStructurePick(
viewer.structurePick,
activeProjectId,
panel.structures,
selectStationOfPipe,
);
return;
}
viewer.markers.selectStation(null);
profilePanel.setSelectedStation(null);
},
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => {
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
},
});
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
* 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시).
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시). 서클은 3D 뷰포트
* 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */
const LOAD_STEP_COUNT = 3;
const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true });
viewer.root.append(progress.root);
let loadedSteps = 0;
function advanceLoading(label: string): void {
loadedSteps += 1;
progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label);
}
const mainContent = document.createElement("div");
mainContent.className = "b05-route__main";
mainContent.append(viewer.root);
center.append(mainContent);
try {
// ① 좌측 폼·노선 설정값 — 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다
// (2026-09-06). 캐시가 없을 때만 DB를 기다린다.
const cachedLatest = latestSource.read();
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true),
fetchSectionContext(activeProjectId),
fetchRoadWidths(activeProjectId),
]);
if (host.disposed()) return;
roadWidths = configuredRoadWidths;
panel.restore({
stationInterval: sectionContext.defaults.station_interval_m,
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
// 임도 종류는 프로젝트 등록(B02)이 정본이다 — 화면에서 고르지 않는다(2026-08-19).
gradeClass: toGradeClass(sectionContext.road_type),
});
// 유토곡선용 설정 — config_system 정의 그대로 받아 횡단 보기와 같은 계산을 태운다.
profilePanel.setEarthworkContext({
conversion: sectionContext.earthwork_conversion,
haulLimits: sectionContext.haul_equipment_limits,
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
});
// 표준횡단 config 기본값도 기억한다 — 계획선 편집 중 횡단 재계산이 브라우저에서 돈다
// (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다.
rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section);
rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m);
restorePanel(latestResponse);
renderLatest(latestResponse);
advanceLoading("확정 지표면 모델을 확인하는 중…");
// ② 확정 지표면 — 모델 id·범위를 한 번에 받는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
confirmedSurface = confirmed.model_id === null ? null : confirmed;
if (!confirmedSurface) {
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
leaveForDashboard();
return;
}
advanceLoading("종단면 자료를 불러오는 중…");
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
if (latestResponse.route) await restoreSections(latestResponse.route.id);
// 횡단 보기가 먼저 서서 배수유역도가 관 목록을 이미 알렸다면 그것을 먼저 싣는다 — 빈 목록으로
// 시작하면 아래 구조물 로딩이 관 대조로 관을 지우고 초안 옵션이 자동 배치로 되돌아간다(24-7a).
if (lastPipes) applyPipes(lastPipes);
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
await bridge.load();
advanceLoading("");
designReady(shared);
// 캐시로 그렸다면 뒤에서 신선도만 확인한다 — 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다.
if (cachedLatest) {
void latestSource.verify(latestResponse, async (fresh) => {
renderLatest(fresh);
if (fresh.route?.id) await restoreSections(fresh.route.id);
});
}
} catch (error) {
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
} finally {
progress.remove();
restoring = false;
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
}
// ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다.
void (async () => {
const surface = confirmedSurface;
const current = latest as RouteLatestResponse | null; // 닫힘 안에서 채워져 흐름 분석이 못 봄
if (!surface || !current || host.disposed()) return;
try {
// 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다.
if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
activeProjectId,
surface.model_id as number,
current.surface_params.method,
current.surface_params.smooth,
current.surface_params.contour_interval_m,
toBounds(surface.bounds),
);
if (host.disposed()) return; // 올리는 사이 페이지를 떠났다.
renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
if (currentSectionDetail) renderStationLines(currentSectionDetail);
showToast("3D 지형 준비 완료", "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
}
})();
}
+27 -2
View File
@@ -8,11 +8,19 @@
* - hashchange 1회 구독: 셸 chrome 갱신 + 현재 라우트 렌더
* ========================================================================== */
import "./random_uuid_polyfill";
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
import { currentRoute, renderCurrentRoute, ensureInitialHash } from "./router";
import {
currentRoute,
renderCurrentRoute,
ensureInitialHash,
setLeaveGuard,
showWithinPage,
takeLeaveGuard,
} from "./router";
const FOOTERLESS_ROUTES: readonly RoutePath[] = [
ROUTES.B01_ACCOUNT,
@@ -44,14 +52,31 @@ function bootstrap(): void {
initThemeAndLang();
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
let shown = "";
let shownHash = "";
const renderAll = (): void => {
const within = showWithinPage(currentRoute()); // B05 ↔ B06 — 보기만 바꿈(24-3a)
shown = currentRoute();
shownHash = window.location.hash;
if (within) return;
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
};
// 4. hashchange 1회 구독 (리스너 누적 방지)
window.addEventListener("hashchange", renderAll);
window.addEventListener("hashchange", () => {
const guard = takeLeaveGuard();
if (!guard || currentRoute() === shown) return renderAll();
void guard().then((ok) => {
if (ok) {
setLeaveGuard(null);
renderAll();
} else {
history.replaceState(null, "", shownHash); // 취소 — 머문 채 주소만 되돌림
}
});
});
// 5. 최초 진입
ensureInitialHash();
+16
View File
@@ -0,0 +1,16 @@
/**
* http + IP 주소(예: Tailscale)는 보안 문맥이 아니라 `crypto.randomUUID` 가 없음.
* getRandomValues(보안 문맥 아니어도 됨)로 v4 UUID 를 채움 · 이미 있으면 그대로.
* `main.ts` 가 맨 먼저 부름. CAD 짝 = `B07_DesignDetail/openwebcad/src/random-uuid-polyfill.ts`.
*/
if (typeof crypto !== "undefined" && typeof crypto.randomUUID !== "function") {
crypto.randomUUID = () => {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` as `${string}-${string}-${string}-${string}-${string}`;
};
}
export {};
+57 -11
View File
@@ -9,8 +9,18 @@
import { ROUTES, DEFAULT_ROUTE, PROTECTED_ROUTES, type RoutePath } from "@config/config_frontend";
/**
* 여러 주소를 맡는 페이지 — 같은 페이지 안 주소 이동은 다시 만들지 않고 `show` 만 부른다
* (B05 · B06 한 페이지 · 계획서 24-3a). 다른 페이지로 떠날 때 `dispose` 로 정리한다.
*/
export interface MountedPage {
covers: (route: RoutePath) => boolean;
show: (route: RoutePath) => void;
dispose: () => void;
}
/** 페이지 렌더 함수 시그니처. root(마운트 대상)를 받아 내용을 채운다. */
export type PageRenderer = (root: HTMLElement) => void | Promise<void>;
export type PageRenderer = (root: HTMLElement) => void | MountedPage | Promise<void | MountedPage>;
/** 라우트별 지연 로더 등록 테이블.
* 페이지 구현이 완료되는 대로 아래에 한 줄씩 추가한다. */
@@ -41,10 +51,9 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../B03_FileInput/B03_FileInput_UI_Page")).renderB03FileInput,
[ROUTES.B04_PREPROCESS]: async () =>
(await import("../B04_PreProcess/B04_PreProcess_UI_Page")).renderB04Surface,
[ROUTES.B05_PROFILE]: async () =>
(await import("../B05_Profile/B05_Profile_UI_Page")).renderB05Route,
[ROUTES.B06_SECTION]: async () =>
(await import("../B06_Section/B06_Section_UI_Page")).renderB06ProfileCross,
// B05 · B06 = 한 페이지 두 보기(계획서 24-3a) — 주소만 바뀌면 보기만 바꾼다.
[ROUTES.B05_PROFILE]: async () => (await import("./design/design_page")).renderDesignPage,
[ROUTES.B06_SECTION]: async () => (await import("./design/design_page")).renderDesignPage,
[ROUTES.B07_DESIGN_DETAIL]: async () =>
(await import("../B07_DesignDetail/B07_DesignDetail_UI_Page")).renderB07DesignDetail,
// B08_QUANTITY: 수량 본문은 재작성 예정(구 구현은 B08_Quantity/0_old_260726_codex.zip 백업).
@@ -65,21 +74,51 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
};
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
let leaveGuard: (() => Promise<boolean>) | null = null;
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
leaveGuard = guard;
};
export const takeLeaveGuard = (): (() => Promise<boolean>) | null => leaveGuard;
/** 지금 떠 있는 여러 주소 페이지 · 렌더 차례(늦게 끝난 옛 렌더를 버림). */
let mounted: MountedPage | null = null;
let renderSeq = 0;
/** 같은 페이지 안 주소면 보기만 바꾸고 true — 셸 · 페이지를 다시 그리지 않는다. */
export function showWithinPage(route: RoutePath): boolean {
if (!mounted?.covers(route)) return false;
mounted.show(route);
return true;
}
/** 같은 페이지 안이라도 처음부터 다시 세운다 — [초기화] · 계획노선 편집처럼 옛 캐시를 버린 뒤. */
export function remountRoute(route: RoutePath): void {
mounted?.dispose();
mounted = null;
navigateTo(route);
}
/** 로그인 여부 (토큰 존재 확인) */
export async function isAuthenticated(): Promise<boolean> {
const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch");
return fetchSession();
}
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home) */
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home · `?이름=값` 뒤는 뗌) */
export function currentRoute(): RoutePath {
const hash = window.location.hash.replace(/^#\/?/, "").trim();
const hash = window.location.hash.replace(/^#\/?/, "").split("?")[0].trim();
return (hash || DEFAULT_ROUTE) as RoutePath;
}
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) */
export function navigateTo(route: RoutePath): void {
const target = `#/${route}`;
/** 현재 해시의 `?이름=값` 인자 (#/m02-master-template?layer=personal → layer=personal) */
export function currentQuery(): URLSearchParams {
return new URLSearchParams(window.location.hash.split("?")[1] ?? "");
}
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) · `query` = `?이름=값` 인자 */
export function navigateTo(route: RoutePath, query?: Record<string, string>): void {
const target = `#/${route}${query ? `?${new URLSearchParams(query)}` : ""}`;
if (window.location.hash === target) {
// 동일 해시면 hashchange가 안 뜨므로 수동 트리거
window.dispatchEvent(new HashChangeEvent("hashchange"));
@@ -103,6 +142,10 @@ function renderPlaceholder(root: HTMLElement, route: RoutePath): void {
*/
export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
const route = currentRoute();
const seq = ++renderSeq;
// 다른 페이지로 떠남 — 3D · 그리기 루프 · 창 듣기를 정리한다(계획서 24-3a).
mounted?.dispose();
mounted = null;
if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) {
navigateTo(ROUTES.A06_LOGIN);
@@ -149,8 +192,11 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
try {
const renderer = await loader();
if (seq !== renderSeq) return; // 그 사이 다른 주소로 떠났다.
outlet.innerHTML = "";
await renderer(outlet);
const page = await renderer(outlet);
if (page && seq !== renderSeq) page.dispose();
else if (page) mounted = page;
window.scrollTo(0, 0);
} catch (err) {
console.error(`[router] 페이지 로드 실패: ${route}`, err);
+320
View File
@@ -0,0 +1,320 @@
/* spreadsheet.css (주인 D) — 부품 틀 · 주소 상자 · 수식 입력줄 · 칸 편집기 · 채우기 미리보기 · 알림 */
.ss {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
font-size: 13px;
color: var(--color-text-body);
background: var(--color-surface-raised);
}
.ss-grid-host {
flex: 1 1 auto;
min-height: 0;
}
.ss-bar {
display: flex;
flex: none;
align-items: stretch;
height: 26px;
border-bottom: 1px solid var(--color-border);
}
.ss-addr {
width: 96px;
padding: 0 6px;
border: none;
border-right: 1px solid var(--color-border);
background: transparent;
color: inherit;
font: inherit;
outline: none;
}
.ss-addr:focus {
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
}
.ss-fx {
align-self: center;
padding: 0 8px;
color: var(--color-text-muted);
font-style: italic;
user-select: none;
}
.ss-formula {
flex: 1;
padding: 4px 6px;
border: none;
border-left: 1px solid var(--color-border);
background: transparent;
color: inherit;
font: inherit;
line-height: 18px;
white-space: pre;
overflow: hidden;
resize: none;
outline: none;
}
.ss--readonly .ss-formula {
color: var(--color-text-secondary);
}
/* 칸 편집기 = 숨은 textarea — 쉴 때는 활성 칸 자리에 투명하게(한글 조합 창 자리) */
.ss-input {
position: absolute;
z-index: 5;
box-sizing: border-box;
margin: 0;
padding: 1px 3px;
border: 2px solid var(--color-focus-ring);
background: var(--color-surface-raised);
color: var(--color-text-body);
font: inherit;
line-height: 1.25;
white-space: pre;
overflow: hidden;
resize: none;
outline: none;
}
.ss-input--idle {
width: 1px;
height: 1px;
min-width: 0 !important;
min-height: 0 !important;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
.ss-fill-preview {
position: absolute;
z-index: 4;
box-sizing: border-box;
border: 1px dashed var(--color-text-secondary);
pointer-events: none;
}
.ss-toast {
position: absolute;
right: 12px;
bottom: 40px;
z-index: 20;
padding: 6px 10px;
border-radius: 4px;
background: var(--color-primary);
color: var(--color-primary-text);
}
/* 2단계 잇기 — 서식 붓 · 찾기 패널 · 메모 · 골라 붙여넣기 */
.ss-brush {
flex: none;
padding: 0 8px;
border: none;
border-right: 1px solid var(--color-border);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.ss-brush.is-on {
background: color-mix(in srgb, var(--color-focus-ring) 20%, var(--color-surface-raised));
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
}
.ss--painting .ss-grid-host {
cursor: copy;
}
.ss-find {
position: absolute;
top: 4px;
right: 16px;
z-index: 20;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-find[hidden],
.ss-find__row[hidden],
.ss-memo[hidden],
.ss-pick[hidden],
.spreadsheet-comment-balloon[hidden] {
display: none;
}
.ss-find__row {
display: flex;
align-items: center;
gap: 4px;
}
.ss-find__input {
width: 180px;
padding: 2px 6px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-body);
font: inherit;
}
.ss-find__btn {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
cursor: pointer;
}
.ss-find__opt {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
}
.ss-find__status {
font-size: 12px;
color: var(--color-text-secondary);
}
.ss-memo,
.spreadsheet-comment-balloon {
position: absolute;
z-index: 15;
min-width: 140px;
max-width: 260px;
padding: 4px 6px;
border: 1px solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface-raised));
color: var(--color-text-body);
font: inherit;
white-space: pre-wrap;
box-shadow: var(--shadow-sm);
}
.ss-memo {
min-height: 60px;
resize: both;
outline: none;
}
.ss-pick {
position: absolute;
z-index: 20;
display: flex;
gap: 2px;
padding: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-pick > button {
padding: 2px 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
cursor: pointer;
}
/* 함수 자동 완성 · 인자 도움말 */
.ss-hint {
position: absolute;
z-index: 6;
min-width: 200px;
max-width: 360px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
font-size: 12px;
box-shadow: var(--shadow-lg);
}
.ss-hint[hidden] {
display: none;
}
.ss-hint__item,
.ss-hint__help {
padding: 2px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ss-hint__item {
cursor: pointer;
}
.ss-hint__item.is-on {
background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-raised));
}
/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */
.ss-dialog {
position: absolute;
top: 4px;
right: 16px;
z-index: 21;
max-width: min(720px, calc(100% - 32px));
max-height: calc(100% - 8px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-dialog[hidden],
.ss-list-arrow[hidden] {
display: none;
}
.ss-dialog__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--color-border);
font-weight: bold;
}
.ss-dialog__body {
padding: 8px;
}
.ss-list-arrow {
position: absolute;
z-index: 8;
width: 16px;
padding: 0;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-body);
font-size: 9px;
cursor: pointer;
}
.ss-validation__dropdown {
position: absolute;
z-index: 20;
}
+294
View File
@@ -0,0 +1,294 @@
/* =============================================================================
* spreadsheet.ts (주인 D)
* 산출근거 스프레드시트 진입 — `createSpreadsheet(칸, 통합문서, {readOnly, onChange, onSelect})` →
* `{getDoc, setDoc, recalc, destroy}`(표 부품 `createSheet` 와 같은 모양 · M02 는 부르는 줄만 바꿈).
* 문맥(`SpreadsheetContext`)을 만들어 C 격자 · E 도구 · 탭 · 메뉴 · 클립보드를 붙이고 D 입력(고름 · 편집기 ·
* 키 · 마우스)을 잇음. 한글 조합 = 숨은 textarea 에 늘 초점 · compositionstart 로 편집 시작(keydown 글자 방식 금지).
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E) · 상태 표시줄(sub4).
* 2단계 부품(찾기 · 메모 · 서식 붓 · 골라 붙여넣기)은 `spreadsheet_extras.ts` 로 이음.
* ========================================================================== */
import "./spreadsheet.css";
import { el } from "@ui/ui_template_elements";
import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor, slotOf } from "./spreadsheet_editor";
import { toCalcValue } from "./spreadsheet_eval";
import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph";
import { createGrid } from "./spreadsheet_grid";
import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys";
import type { LinkOptions, Links } from "./spreadsheet_links";
import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
import { createStatusBar } from "./spreadsheet_statusbar";
import { mountTabs } from "./spreadsheet_tabs";
import { mountToolbar } from "./spreadsheet_toolbar";
import type {
CalcValue,
CalcValues,
Command,
CommandEffect,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
import type {
EditorHandle,
GridHandle,
PartHandle,
Selection,
SpreadsheetContext,
} from "./spreadsheet_view_types";
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
export interface SpreadsheetOptions {
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
readOnly?: boolean;
/** 명령마다 문서 사본 — 페이지가 캐시(sessionStorage)에 쌓고 [저장] 단추를 켬 */
onChange?: (book: Workbook) => void;
/** 고름이 바뀜 — 시트 id · 활성 칸 A1 · 범위 A1 */
onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
/** 아래 시트 탭 줄 — false 면 안 그림(산출근거 = 시트 하나) · 시트 오가기 · 새 시트 단축키 · 시트 메뉴도 끔 */
tabs?: boolean;
/** 아래 알약 막대(상태줄 아래) — 입력값 · 자재를 칸에 연결(`spreadsheet_links.ts` · 이때만 불러옴) */
links?: LinkOptions;
/** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 ·
* 알약 막대만. 다른 화면 모양은 그대로 */
preview?: boolean;
}
export interface SpreadsheetHandle {
/** 문서 사본 */
getDoc(): Workbook;
/** 문서 바꿔 끼움 — 되돌리기 이력 비움 · 고름 A1 */
setDoc(book: Workbook): void;
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
recalc(): CalcValues;
/** 알약 키 → 연결 칸 값(빈 칸 0) */
getLinkedValues(): Record<string, CalcValue>;
destroy(): void;
}
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
function farCorner(sel: Selection): { r: number; c: number } {
const g = sel.범위[sel.범위.length - 1];
const a = sel.기준;
const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last;
return {
r: whole(g.r0, g.r1, LAST_R)
? sel.활성.r
: Math.abs(g.r1 - a.r) >= Math.abs(a.r - g.r0)
? g.r1
: g.r0,
c: whole(g.c0, g.c1, LAST_C)
? sel.활성.c
: Math.abs(g.c1 - a.c) >= Math.abs(a.c - g.c0)
? g.c1
: g.c0,
};
}
export function createSpreadsheet(
host: HTMLElement,
book: Workbook,
opts: SpreadsheetOptions = {},
): SpreadsheetHandle {
const readOnly = !!opts.readOnly;
const noTabs = opts.tabs === false;
const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음
const root = el("div", {
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`,
});
host.append(root);
const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0];
/** 시트마다 마지막 고름(탭을 오가도 그대로) */
const memory = new Map<string, Selection>();
const ctx: SpreadsheetContext = {
root,
book,
readOnly,
engine: createCalcEngine(book),
history: createHistory(),
selection: selectCell(firstSheet(book), 0, 0),
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
dispatch,
undo: () => replay(ctx.history.undo()),
redo: () => replay(ctx.history.redo()),
select,
showSheet,
grid: null as unknown as GridHandle,
editor: null as unknown as EditorHandle,
};
const grid = createGrid(ctx);
ctx.grid = grid;
const ed = createEditor(ctx);
ctx.editor = ed;
const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx);
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
const menu: PartHandle | null = bare ? null : attachMenu(ctx);
const clip = attachClipboard(ctx);
const status = createStatusBar(ctx);
const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p);
grid.root.classList.add("ss-grid-host");
for (const node of [toolbar?.root, ed.bar, grid.root, tabs?.root, status.root])
if (node) root.append(node);
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys);
const detachMouse = attachMouse(ctx, ed, extras.mouse);
const toast = el("div", { className: "ss-toast" });
let toastTimer = 0;
/** 알약 막대 · 연결 따라가기 — 선택이 있거나 문서에 연결이 있을 때만 불러옴(크기 예산) */
let links: Links | null = null;
let linking = false;
function loadLinks(): void {
if (linking || !(opts.links || ctx.book.시트.some((s) => s.연결?.length))) return;
linking = true;
void import("./spreadsheet_links").then((m) => {
if (!root.isConnected) return; // 받기 전에 닫힘
links = m.mountLinks(ctx, opts.links, () => ed.focus(), slotOf);
others.push(links);
links.refresh();
});
}
grid.render();
select(ctx.selection);
loadLinks();
function notify(message: string): void {
toast.textContent = message;
root.append(toast);
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => toast.remove(), 3000);
}
function apply(command: Command): CommandEffect | null {
try {
return (links?.apply ?? applyCommand)(ctx.book, command);
} catch (e) {
if (e instanceof CommandError) return (notify(e.message), null);
throw e;
}
}
function dispatch(command: Command): void {
if (readOnly) return;
const eff = apply(command);
if (!eff) return;
ctx.history.push(command, eff.undo);
after(command, eff);
}
function replay(command: Command | null): void {
if (readOnly || !command || ed.editing()) return;
const eff = apply(command);
if (eff) after(command, eff);
}
function after(command: Command, eff: CommandEffect): void {
if (eff.rebuild) {
ctx.engine.rebuild(ctx.book);
if (!ctx.book.시트.some((s) => s.id === ctx.selection.시트)) {
const s = firstSheet(ctx.book);
ctx.selection = memory.get(s.id) ?? selectCell(s, 0, 0);
}
grid.render();
} else {
const again = ctx.engine.update(eff.cells);
if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]);
else grid.render();
}
select(ctx.selection);
opts.onChange?.(structuredClone(ctx.book));
}
/** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */
function touched(sheet: string, cells: Record<string, unknown>): SheetCellAddress[] {
const out: SheetCellAddress[] = [];
for (const key of Object.keys(cells)) {
const a = parseA1(key);
if (a) out.push({ 시트: sheet, ...a });
}
return out;
}
function select(selection: Selection, reveal = false): void {
ctx.selection = selection;
memory.set(selection.시트, selection);
grid.renderSelection();
if (reveal) {
const far = farCorner(selection);
grid.reveal(far.r, far.c);
}
ed.sync();
for (const p of others) p.refresh();
const g = selection.범위[0];
opts.onSelect?.({
시트: selection.시트,
칸: toA1(selection.활성.r, selection.활성.c),
범위: g ? rangeToA1(g) : toA1(selection.활성.r, selection.활성.c),
});
}
function showSheet(sheetId: string): void {
const sheet = ctx.book.시트.find((s) => s.id === sheetId);
if (!sheet || sheetId === ctx.selection.시트) return;
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
ctx.book.활성 = sheetId;
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
grid.render();
select(ctx.selection, true);
ed.focus();
}
return {
getDoc: () => structuredClone(ctx.book),
setDoc(next: Workbook) {
ed.cancel();
ctx.book = next;
ctx.engine.rebuild(next);
ctx.history.clear();
memory.clear();
ctx.selection = selectCell(firstSheet(next), 0, 0);
grid.render();
select(ctx.selection);
loadLinks();
},
recalc() {
ctx.engine.rebuild(ctx.book);
grid.render();
for (const p of others) p.refresh();
return ctx.engine.snapshot();
},
getLinkedValues() {
const out: Record<string, CalcValue> = {};
for (const s of ctx.book.시트)
for (const l of s.연결 ?? []) {
const p = parseA1(l.칸);
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
}
return out;
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
detachMouse();
for (const p of others) p.destroy();
ed.destroy();
grid.destroy();
root.remove();
},
};
}
@@ -0,0 +1,178 @@
/* =============================================================================
* spreadsheet_actions.ts (주인 D)
* 단축키 표(sub1 `spreadsheet_shortcuts.ts`) 「안됨」 잇기 + 3차 부품 메뉴 자리([데이터 ▾] 에 붙임).
* Ctrl+Shift++ · Ctrl+- 칸 넣기 · 지우기(행 · 열 통째면 바로 · 아니면 sub1 창) · F5 · Ctrl+G 이동 ·
* Ctrl+9/0 숨김 · Ctrl+Shift+9/0 풀기 · Ctrl+B/I/U/5 글꼴 뒤집기 · Ctrl+Shift+& 바깥 테두리 ·
* Ctrl+Shift+_ 테두리 없앰 · Ctrl+; 오늘 날짜 · Ctrl+PageDown/PageUp 시트 · Shift+F11 새 시트 ·
* Shift+Alt+→/← 행 · 열 묶기 · 풀기(C 윤곽 +/- 가 접음).
* 여기는 키 · 메뉴 → 이름만 · 몸통은 `spreadsheet_actions_run.ts`(늦게 — 크기 예산).
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import { showConfirmDialog } from "@ui/ui_template_elements";
import { parseA1 } from "./spreadsheet_address";
import { st } from "./spreadsheet_text";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
goTo: "이동…(F5)",
freeze: "틀 고정",
freezeRow: "첫 행 고정",
freezeCol: "첫 열 고정",
freezeAt: "지금 칸 기준 고정",
unfreeze: "틀 고정 풀기",
insert: "칸 넣기…(Ctrl+Shift++)",
remove: "칸 지우기…(Ctrl+-)",
split: "텍스트 나누기…",
dedupe: "중복된 항목 제거…",
series: "연속 데이터 채우기…",
group: "묶기(Shift+Alt+→)",
ungroup: "묶음 풀기(Shift+Alt+←)",
outline: "윤곽",
};
export type Act =
| "goTo"
| "insert"
| "remove"
| "hideRows"
| "hideCols"
| "showRows"
| "showCols"
| "group"
| "ungroup"
| "outline"
| "clearBorders"
| "today"
| "next"
| "prev"
| "newSheet"
| "freezeRow"
| "freezeCol"
| "freezeAt"
| "unfreeze"
| "split"
| "dedupe"
| "series"
| `flip:${"굵게" | "기울임" | "밑줄" | "취소선"}`;
/** Ctrl+글자(e.code) → 이름 */
const CTRL: Record<string, Act> = {
KeyB: "flip:굵게",
KeyI: "flip:기울임",
KeyU: "flip:밑줄",
Digit5: "flip:취소선",
KeyG: "goTo",
Minus: "remove",
NumpadSubtract: "remove",
NumpadAdd: "insert",
Digit9: "hideRows",
Digit0: "hideCols",
Semicolon: "today", // ponytail: 시각(Ctrl+Shift+;)은 numfmt 시간 형식이 오면
PageDown: "next",
PageUp: "prev",
};
/** Ctrl+Shift+글자 → 이름 */
const CTRL_SHIFT: Record<string, Act> = {
Equal: "insert",
NumpadAdd: "insert",
Minus: "clearBorders",
Digit9: "showRows",
Digit0: "showCols",
Digit7: "outline",
PageDown: "next",
PageUp: "prev",
};
const SHEET_ACTS = new Set<Act>(["next", "prev", "newSheet"]);
/** 병합 전 확인 — 왼위 밖 칸에 값이 있으면 지워짐을 물음(엑셀과 같음) · 없으면 바로 true */
export async function confirmMerge(ctx: SpreadsheetContext, range: CellRange): Promise<boolean> {
const lost = Object.entries(ctx.sheet().칸).some(([a1, cell]) => {
const p = parseA1(a1);
if (!p || (cell.값 === undefined && cell.식 === undefined)) return false;
const inside = p.r >= range.r0 && p.r <= range.r1 && p.c >= range.c0 && p.c <= range.c1;
return inside && (p.r !== range.r0 || p.c !== range.c0);
});
return !lost || showConfirmDialog(st("MergeLoseConfirm"), st("Merge"));
}
/** 창 키 — Esc 닫기 · Enter 실행(여러 줄 글 · 단추 · 한글 조합 중은 그대로) · 창 안에 초점을 넣음 */
export function dialogKeys(overlay: HTMLElement, close: () => void, run?: () => void): void {
overlay.addEventListener("keydown", (e) => {
const t = e.target as HTMLElement;
if (e.key === "Escape") close();
else if (e.key !== "Enter" || !run || e.isComposing) return;
else if (t.tagName === "TEXTAREA" || t.tagName === "BUTTON") return;
else run();
e.preventDefault();
e.stopPropagation();
});
queueMicrotask(() => {
if (overlay.contains(document.activeElement)) return;
const first = overlay.querySelector<HTMLElement>("input:not([type=hidden]), select, textarea");
if (first) return first.focus();
overlay.tabIndex = -1;
overlay.focus();
});
}
/** body 에 뜬 창(.ui-confirm)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */
export function watchClose(back: () => void): void {
const w = new MutationObserver(() => {
if (document.querySelector("body > .ui-confirm, body > .ui-confirm__panel")) return;
w.disconnect();
back();
});
w.observe(document.body, { childList: true });
}
function pick(e: KeyboardEvent): Act | undefined {
const ctrl = e.ctrlKey || e.metaKey;
if (e.altKey)
return e.shiftKey && !ctrl
? ({ ArrowRight: "group", ArrowLeft: "ungroup" } as Record<string, Act>)[e.key]
: undefined;
if (ctrl) return (e.shiftKey ? CTRL_SHIFT : CTRL)[e.code];
if (e.key === "F5" && !e.shiftKey) return "goTo";
if (e.key === "F11" && e.shiftKey) return "newSheet";
}
export interface Actions {
key(e: KeyboardEvent): boolean;
items: MapContextMenuItem[];
}
export function mountActions(ctx: SpreadsheetContext, back: () => void): Actions {
const go = (act: Act): void =>
void import("./spreadsheet_actions_run").then((m) => m.run(ctx, back, act));
const item = (label: string, act: Act): [string, () => void] => [label, () => go(act)];
return {
key(e) {
const act = pick(e);
// 시트 탭이 없으면(산출근거) 시트 오가기 · 새 시트를 받지 않음 — 브라우저 몫으로 둠
if (!act || (SHEET_ACTS.has(act) && ctx.root.classList.contains("ss--notabs"))) return false;
go(act);
return true;
},
items: [
item(TEXT.goTo, "goTo"),
{
label: TEXT.freeze,
children: [
item(TEXT.freezeRow, "freezeRow"),
item(TEXT.freezeCol, "freezeCol"),
item(TEXT.freezeAt, "freezeAt"),
item(TEXT.unfreeze, "unfreeze"),
],
},
item(TEXT.insert, "insert"),
item(TEXT.remove, "remove"),
item(TEXT.split, "split"),
item(TEXT.dedupe, "dedupe"),
item(TEXT.series, "series"),
{ label: TEXT.outline, children: [item(TEXT.group, "group"), item(TEXT.ungroup, "ungroup")] },
],
};
}
@@ -0,0 +1,170 @@
/* =============================================================================
* spreadsheet_actions_run.ts (주인 D)
* `spreadsheet_actions.ts` 단축키 · 메뉴가 부르는 몸통 — 처음 누를 때 늦게 불러옴(크기 예산).
* ========================================================================== */
import { toA1 } from "./spreadsheet_address";
import { watchClose, type Act } from "./spreadsheet_actions";
import { cellStyleAt } from "./spreadsheet_cellstyle";
import { LAST_C, LAST_R } from "./spreadsheet_selection";
import type { CellRange, CellStyle, Command } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
/** 엑셀 날짜 일련번호 0 = 1899-12-30 */
const EPOCH = Date.UTC(1899, 11, 30);
export function run(ctx: SpreadsheetContext, back: () => void, act: Act): void {
const insert = () => import("./spreadsheet_insert_dialog");
const view = () => import("./spreadsheet_view_menu");
const tools = () => import("./spreadsheet_data_tools");
/** 늦게 불러 창을 열고 닫히면 격자로 */
const dialog = <M>(load: () => Promise<M>, open: (m: M) => void) =>
void load().then((m) => (open(m), watchClose(back)));
const freezeBy = (pick: (m: Awaited<ReturnType<typeof view>>) => void) =>
void view().then((m) => (pick(m), back()));
const range = (): CellRange => ctx.selection.범위[0];
const fullRows = (g: CellRange) => g.c0 === 0 && g.c1 === LAST_C;
const fullCols = (g: CellRange) => g.r0 === 0 && g.r1 === LAST_R;
const span = (a: number, b: number) => Array.from({ length: b - a + 1 }, (_, i) => a + i);
const id = () => ctx.selection.시트;
/** 칸 넣기 · 지우기 — 행 · 열 통째 고름이면 엑셀처럼 창 없이 바로 */
function cells(add: boolean): void {
const g = range();
if (fullRows(g) || fullCols(g)) {
const rows = fullRows(g);
ctx.dispatch({
종류: rows ? (add ? "행넣기" : "행지우기") : add ? "열넣기" : "열지우기",
시트: id(),
at: rows ? g.r0 : g.c0,
수: rows ? g.r1 - g.r0 + 1 : g.c1 - g.c0 + 1,
});
} else
dialog(insert, (m) => (add ? m.openInsertCellsDialog(ctx) : m.openDeleteCellsDialog(ctx)));
}
function hide(rows: boolean, 숨김: boolean): void {
const g = range();
const 번호 = rows ? span(g.r0, g.r1) : span(g.c0, g.c1);
ctx.dispatch({ 종류: "숨김", 시트: id(), 축: rows ? "행" : "열", 번호, 숨김 });
}
function group(on: boolean): void {
const g = range();
const cols = fullCols(g) && !fullRows(g);
const 번호 = cols ? span(g.c0, g.c1) : span(g.r0, g.r1);
const 묶음 = on ? { 층: 1 } : null;
ctx.dispatch({ 종류: "묶음설정", 시트: id(), 축: cols ? "열" : "행", 번호, 묶음 });
}
function flip(key: keyof CellStyle): void {
const { r, c } = ctx.selection.활성;
const on = !!cellStyleAt(ctx.book, ctx.sheet(), r, c)[key];
ctx.dispatch({ 종류: "서식", 시트: id(), 범위: ctx.selection.범위, 바꿀: { [key]: !on } });
}
/** 바깥 테두리(가는 선) — 가장자리 칸마다 지금 테두리에 변을 더함 */
function outline(): void {
const g = range();
const n = (g.r1 - g.r0 + 1) * (g.c1 - g.c0 + 1);
// ponytail: 가장자리 칸만 셈 · 행 · 열 통째(백만 칸)는 안 함 — 쓴 범위로 자르면 풀림
if (fullRows(g) || fullCols(g) || n > 1e5) return;
const cmds: Command[] = [];
for (let r = g.r0; r <= g.r1; r++)
for (let c = g.c0; c <= g.c1; c++) {
const sides = [
r === g.r0 && "위",
r === g.r1 && "아래",
c === g.c0 && "왼",
c === g.c1 && "오른",
].filter(Boolean) as ("위" | "아래" | "왼" | "오른")[];
if (!sides.length) continue;
const 테두리 = { ...cellStyleAt(ctx.book, ctx.sheet(), r, c).테두리 };
for (const s of sides) 테두리[s] = { 선: "thin" };
cmds.push({
종류: "서식",
시트: id(),
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: { 테두리 },
});
}
ctx.dispatch({ 종류: "묶음", 명령: cmds });
}
function today(): void {
const d = new Date();
const 값 = Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - EPOCH) / 864e5);
const { r, c } = ctx.selection.활성;
const key = toA1(r, c);
const 서식 = ctx.sheet().칸[key]?.서식;
ctx.dispatch({
종류: "묶음",
명령: [
{ 종류: "칸", 시트: id(), 칸: { [key]: 서식 === undefined ? { 값 } : { 값, 서식 } } },
{
종류: "서식",
시트: id(),
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: { 형식: "yyyy-mm-dd" },
},
],
});
}
/** 다음 · 이전 보이는 시트 */
function sheetStep(d: number): void {
const list = ctx.book.시트.filter((s) => !s.숨김);
const i = list.findIndex((s) => s.id === id());
const to = list[i + d];
if (to) ctx.showSheet(to.id);
}
const goTo = () => dialog(view, (m) => m.openGoToDialog(ctx));
function clearBorders(): void {
ctx.dispatch({ 종류: "서식", 시트: id(), 범위: ctx.selection.범위, 바꿀: { 테두리: null } });
}
if (act.startsWith("flip:")) return flip(act.slice(5) as keyof CellStyle);
switch (act) {
case "goTo":
return goTo();
case "insert":
case "remove":
return cells(act === "insert");
case "hideRows":
case "hideCols":
case "showRows":
case "showCols":
return hide(act.endsWith("Rows"), act.startsWith("hide"));
case "group":
case "ungroup":
return group(act === "group");
case "outline":
return outline();
case "clearBorders":
return clearBorders();
case "today":
return today();
case "next":
case "prev":
return sheetStep(act === "next" ? 1 : -1);
case "newSheet":
return ctx.root.querySelector<HTMLElement>(".ss-tabs__add")?.click();
case "freezeRow":
return freezeBy((m) => m.freezeFirstRow(ctx));
case "freezeCol":
return freezeBy((m) => m.freezeFirstColumn(ctx));
case "freezeAt":
return freezeBy((m) => m.freezeAtActiveCell(ctx));
case "unfreeze":
return freezeBy((m) => m.unfreezePanes(ctx));
case "split":
return dialog(tools, (m) => m.openTextToColumnsDialog(ctx));
case "dedupe":
return dialog(tools, (m) => m.openRemoveDuplicatesDialog(ctx));
case "series":
return dialog(tools, (m) => m.openFillSeriesDialog(ctx));
}
}
@@ -0,0 +1,115 @@
/* =============================================================================
* spreadsheet_address.ts (주인 A)
* 주소 — A1 ↔ 행열(0 부터) · 범위 글 · 열 글자 · 시트 이름 따옴표 · Map 열쇠.
* 계약은 `spreadsheet_types.ts`.
* ========================================================================== */
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellAddress, CellRange } from "./spreadsheet_types";
/** 0 → `A` · 27 → `AB` */
export function colName(c: number): string {
let s = "";
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) {
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
}
return s;
}
/** `AB` → 27 · 못 읽으면 -1 (대소문자 가리지 않음) */
export function colIndex(letters: string): number {
if (!/^[A-Za-z]{1,3}$/.test(letters)) return -1;
let n = 0;
for (const ch of letters.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64;
return n <= MAX_COLS ? n - 1 : -1;
}
/** 행 글(1 부터) → 0 부터 · 못 읽으면 -1 */
export function rowIndex(digits: string): number {
const n = /^\d{1,7}$/.test(digits) ? Number(digits) : 0;
return n >= 1 && n <= MAX_ROWS ? n - 1 : -1;
}
/** (30, 4) → `E31` */
export function toA1(r: number, c: number): string {
return colName(c) + (r + 1);
}
/** `E31` · `$E$31` → {r:30, c:4} · 못 읽으면 null */
export function parseA1(text: string): CellAddress | null {
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(text.trim());
if (!m) return null;
const c = colIndex(m[1]);
const r = rowIndex(m[2]);
return c < 0 || r < 0 ? null : { r, c };
}
/** 칸 하나면 `E31` · 아니면 `C3:AD3` · 행 전체 `3:3` · 열 전체 `A:A` */
export function rangeToA1(range: CellRange): string {
const { r0, c0, r1, c1 } = range;
if (c0 === 0 && c1 === MAX_COLS - 1) return `${r0 + 1}:${r1 + 1}`;
if (r0 === 0 && r1 === MAX_ROWS - 1) return `${colName(c0)}:${colName(c1)}`;
const a = toA1(r0, c0);
return r0 === r1 && c0 === c1 ? a : `${a}:${toA1(r1, c1)}`;
}
/** 닫힌 범위로 세움(뒤집힌 것 바로) */
export function normRange(r0: number, c0: number, r1: number, c1: number): CellRange {
return {
r0: Math.min(r0, r1),
c0: Math.min(c0, c1),
r1: Math.max(r0, r1),
c1: Math.max(c0, c1),
};
}
/** `C3:AD3` · `E31` · `A:A` · `3:5` → 범위(뒤집힌 글은 바로 세움) · 못 읽으면 null */
export function parseRange(text: string): CellRange | null {
const parts = text.trim().split(":");
if (parts.length > 2) return null;
const [a, b = a] = parts;
const p = parseA1(a);
const q = parseA1(b);
if (p && q) return normRange(p.r, p.c, q.r, q.c);
if (parts.length !== 2) return null;
const strip = (s: string) => s.replace(/^\$/, "");
const ca = colIndex(strip(a));
const cb = colIndex(strip(b));
if (ca >= 0 && cb >= 0) return normRange(0, ca, MAX_ROWS - 1, cb);
const ra = rowIndex(strip(a));
const rb = rowIndex(strip(b));
if (ra >= 0 && rb >= 0) return normRange(ra, 0, rb, MAX_COLS - 1);
return null;
}
export function inRange(range: CellRange, r: number, c: number): boolean {
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
}
export function rangesOverlap(a: CellRange, b: CellRange): boolean {
return a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
}
/** `a` 가 `b` 안에 통째로 듦 */
export function rangeInside(a: CellRange, b: CellRange): boolean {
return a.r0 >= b.r0 && a.r1 <= b.r1 && a.c0 >= b.c0 && a.c1 <= b.c1;
}
/** 식에 넣을 시트 이름 — 글자 · 숫자 · _ 밖이 있으면 `'…'`(안의 `'` 는 `''`) */
export function quoteSheet(name: string): string {
const plain =
/^[\p{L}_][\p{L}\p{N}_]*$/u.test(name) &&
!/^[A-Za-z]{1,3}\d+$/.test(name) && // 칸 주소처럼 보이는 이름
!/^(TRUE|FALSE)$/i.test(name) &&
!/^R\d*C\d*$/i.test(name); // R1C1 처럼 보이는 이름
return plain ? name : `'${name.replace(/'/g, "''")}'`;
}
/** Map 열쇠 — r × MAX_COLS + c */
export function cellId(r: number, c: number): number {
return r * MAX_COLS + c;
}
export function fromCellId(id: number): CellAddress {
return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS };
}
@@ -0,0 +1,187 @@
/* spreadsheet_analysis.css (주인 A) — 추적 화살표 겹판 · 수식 계산 창 · 오류 ⚠ 단추 */
/* 추적 화살표 — 크기 0 겹판(격자 조작을 막지 않음) */
.ss-trace {
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: visible;
pointer-events: none;
}
.ss-trace__line {
stroke-width: 1.5;
}
.ss-trace__line.is-plain,
.ss-trace__dot.is-plain,
.ss-trace__head.is-plain {
stroke: var(--color-info);
fill: var(--color-info);
}
.ss-trace__line.is-error,
.ss-trace__dot.is-error,
.ss-trace__head.is-error {
stroke: var(--color-danger);
fill: var(--color-danger);
}
.ss-trace__line.is-sheet,
.ss-trace__dot.is-sheet,
.ss-trace__head.is-sheet {
stroke: var(--color-text-body);
fill: var(--color-text-body);
}
.ss-trace__line.is-sheet {
stroke-dasharray: 4 3;
}
.ss-trace__box {
fill: none;
stroke-width: 2;
stroke: var(--color-info);
}
.ss-trace__box.is-error {
stroke: var(--color-danger);
}
.ss-trace__sheet {
fill: var(--color-surface);
stroke: var(--color-text-body);
}
/* 수식 계산 창 */
.ss-steps {
position: absolute;
inset: 0;
z-index: var(--z-dropdown);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 18%);
}
.ss-steps__box {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 420px;
max-width: 90%;
padding: var(--spacing-12);
background: var(--color-surface-raised);
color: var(--color-text-body);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
font-size: 13px;
}
.ss-steps__title {
font-weight: 600;
}
.ss-steps__meta {
color: var(--color-text-secondary);
}
.ss-steps__text {
min-height: 60px;
padding: var(--spacing-8);
font-family: var(--font-mono, monospace);
white-space: pre-wrap;
word-break: break-all;
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-steps__foot {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 오류 ⚠ 단추 · 풍선 · 메뉴 */
.ss-errtip {
position: absolute;
z-index: var(--z-dropdown);
width: 0;
height: 0;
overflow: visible;
}
.ss-errtip__btn {
position: absolute;
width: 18px;
height: 18px;
padding: 0;
font-size: 11px;
line-height: 16px;
cursor: pointer;
color: var(--color-warning);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-errtip__balloon {
display: none;
position: absolute;
left: 0;
top: 22px;
z-index: var(--z-dropdown);
width: max-content;
max-width: 320px;
padding: var(--spacing-4) var(--spacing-8);
font-size: 12px;
color: var(--color-text-body);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.ss-errtip__btn:hover + .ss-errtip__balloon {
display: block;
}
.ss-errtip__menu {
position: absolute;
left: 0;
top: 22px;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 200px;
max-width: 320px;
padding: var(--spacing-4);
font-size: 13px;
color: var(--color-text-body);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.ss-errtip__head {
padding: var(--spacing-4);
font-weight: 600;
}
.ss-errtip__item {
padding: var(--spacing-4);
text-align: left;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
.ss-errtip__item:hover {
background: var(--color-mist-violet);
}
@@ -0,0 +1,61 @@
/* =============================================================================
* spreadsheet_autofit.ts (2단계 · 주인 sub_laptop_1)
* 열 머리 경계를 두 번 눌러 폭 자동 맞춤 — 그 열 안 글자 수로 어림(엑셀 「글자 단위」).
* 진짜 픽셀 값은 C(격자)가 쓰는 글꼴로 재야 더 정확 — `measure` 자리에 C 의 실제 재기 함수를
* 나중에 꽂을 수 있게 열어 둠(안 주면 아래 어림 규칙을 씀).
*
* 잇는 법(D · sub7 · C 격자 머리) — 열 머리 경계에서 두 번 누르면(dblclick)
* `autofitColumn(ctx, col)` 을 부름. C 가 완성되면 `measure` 자리에 캔버스 `measureText` 를 넘겨 더 정밀하게.
*
* ── 이미 계약에 있어 새 파일이 필요 없는 것(붙일 자리만 확인) ──────────────────────
* · 여러 범위(Ctrl+누르기) — `spreadsheet_view_types.ts` `Selection.범위` 가 이미 배열(D `spreadsheet_selection.ts` 가 씀).
* · 눈금선 끄기 — `Command`(`종류:"눈금선"`) · `Sheet.보기?.눈금선` 이미 있음(A 가 처리).
* · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖.
* ========================================================================== */
import type { Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export type TextMeasurer = (text: string) => number;
const PADDING = 2;
const MIN_WIDTH = 4;
/** 글꼴을 모를 때 어림 — 한글 · 한자 · 전각은 1.9 배 폭 · 나머지는 1 배. */
const defaultMeasure: TextMeasurer = (text) => {
let width = 0;
for (const ch of text) width += ch.codePointAt(0)! >= 0x1100 ? 1.9 : 1;
return width;
};
const colFromKey = (key: string): number => {
const m = /^([A-Z]+)\d+$/.exec(key);
if (!m) return -1;
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return col - 1;
};
/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */
export function estimateColumnWidth(
book: Workbook,
sheetId: string,
col: number,
measure: TextMeasurer = defaultMeasure,
): number {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return MIN_WIDTH;
let widest = 0;
for (const [key, cell] of Object.entries(sheet.칸)) {
if (colFromKey(key) !== col) continue;
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
if (text) widest = Math.max(widest, measure(text));
}
return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING));
}
/** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */
export function autofitColumn(ctx: SpreadsheetContext, col: number, measure?: TextMeasurer): void {
const width = estimateColumnWidth(ctx.book, ctx.selection.시트, col, measure);
ctx.dispatch({ 종류: "열폭", 시트: ctx.selection.시트, 열: [col], 폭: width });
}
@@ -0,0 +1,102 @@
/* =============================================================================
* spreadsheet_autosum.ts (3단계 · 주인 sub_laptop_4)
* 자동 합계(Alt+= · 도구 단추 Σ) — 엑셀 규칙: 활성 칸 바로 위가 숫자면 그 위 연속 숫자 칸을
* 위로 죽 잡고, 아니면 바로 왼쪽이 숫자면 왼쪽으로 죽 잡음. 여러 칸을 고른 채면 그 고름
* 안에서(활성 칸 위 또는 왼쪽만) 잡음. 아무것도 못 찾으면 빈 `=SUM()`.
*
* 잇는 법(D · E · sub7 — 지금은 하나도 안 이어짐):
* 1) `spreadsheet_keys.ts` 에 Alt+= 를 매핑해 `applyAutoSum(ctx)` 를 부름.
* 2) `spreadsheet_toolbar.ts` 에 Σ 단추를 두고 눌리면 `applyAutoSum(ctx)` 를 부름.
* 3) 지금은 칸에 식 글만 바로 넣음(편집기를 안 거침) — 사용자가 범위를 눈으로 고쳐 잡게 하려면
* `ctx.editor.begin("=SUM(...)")` 로 편집 상태로 열고 커밋은 사용자가 Enter.
* ========================================================================== */
import { rangeToA1, toA1 } from "./spreadsheet_address";
import { isFrac } from "./spreadsheet_functions_math";
import type { Cell, CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
function isNumericCell(ctx: SpreadsheetContext, sheet: string, r: number, c: number): boolean {
if (r < 0 || c < 0) return false;
return isFrac(ctx.engine.value(sheet, r, c));
}
function verticalRun(
ctx: SpreadsheetContext,
sheet: string,
r: number,
c: number,
top: number,
): CellRange | null {
if (r - 1 < top || !isNumericCell(ctx, sheet, r - 1, c)) return null;
let start = r - 1;
while (start - 1 >= top && isNumericCell(ctx, sheet, start - 1, c)) start--;
return { r0: start, c0: c, r1: r - 1, c1: c };
}
function horizontalRun(
ctx: SpreadsheetContext,
sheet: string,
r: number,
c: number,
left: number,
): CellRange | null {
if (c - 1 < left || !isNumericCell(ctx, sheet, r, c - 1)) return null;
let start = c - 1;
while (start - 1 >= left && isNumericCell(ctx, sheet, r, start - 1)) start--;
return { r0: r, c0: start, r1: r, c1: c - 1 };
}
/** 활성 칸 기준 합산 범위를 찾음 — 없으면 null(빈 `=SUM()`). */
export function autoSumRange(ctx: SpreadsheetContext): CellRange | null {
const sheet = ctx.selection.시트;
const { r, c } = ctx.selection.활성;
const sel = ctx.selection.범위[0];
if (sel && (sel.r1 > sel.r0 || sel.c1 > sel.c0)) {
if (r > sel.r0) return { r0: sel.r0, c0: c, r1: r - 1, c1: c };
if (c > sel.c0) return { r0: r, c0: sel.c0, r1: r, c1: c - 1 };
}
return verticalRun(ctx, sheet, r, c, 0) ?? horizontalRun(ctx, sheet, r, c, 0);
}
/** 여러 칸을 고른 채 Σ — 엑셀처럼 열마다(한 행만 골랐으면 행 끝) 고름 끝 빈 칸 또는 바로 다음 빈 칸에
* 합계 식을 바로 넣을 칸들. 고른 칸이 하나거나 넣을 빈 칸이 없으면 null. */
export function autoSumCells(ctx: SpreadsheetContext): Record<string, Cell> | null {
const sel = ctx.selection.범위[0];
if (!sel || (sel.r0 === sel.r1 && sel.c0 === sel.c1)) return null;
const sheet = ctx.sheet();
const empty = (r: number, c: number): boolean => {
const x = sheet.칸[toA1(r, c)];
return !x || (x.식 === undefined && (x.값 === undefined || x.값 === ""));
};
const across = sel.r0 === sel.r1;
const out: Record<string, Cell> = {};
const lines = across
? [sel.r0]
: Array.from({ length: sel.c1 - sel.c0 + 1 }, (_, i) => sel.c0 + i);
for (const k of lines) {
const at = (i: number): [number, number] => (across ? [k, i] : [i, k]);
const first = across ? sel.c0 : sel.r0;
const last = across ? sel.c1 : sel.r1;
const end = empty(...at(last)) ? last : last + 1;
if (!empty(...at(end))) continue;
const [r, c] = at(end);
const [r0, c0] = at(first);
const [r1, c1] = at(end - 1);
const key = toA1(r, c);
const 식 = `SUM(${rangeToA1({ r0, c0, r1, c1 })})`;
const 서식 = sheet.칸[key]?.서식;
out[key] = 서식 === undefined ? { 식 } : { 식, 서식 };
}
return Object.keys(out).length ? out : null;
}
/** 활성 칸에 `=SUM(...)` 을 바로 넣음(칸 명령 — 편집기를 안 거침). */
export function applyAutoSum(ctx: SpreadsheetContext): void {
const range = autoSumRange(ctx);
const { r, c } = ctx.selection.활성;
const 식 = range ? `SUM(${rangeToA1(range)})` : "SUM()";
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [toA1(r, c)]: { 식 } } });
}
@@ -0,0 +1,204 @@
/* =============================================================================
* spreadsheet_cellstyle.ts (주인 C)
* 서식 → CSS — 글꼴 · 정렬(균등 분할 · 선택 영역 가운데 · 세로 · 줄 바꿈) · 채움색 ·
* 테두리(hair · thin · medium · thick · double · dotted · dashed · 색) · 겹친 테두리 고르기.
* 칸 하나가 위 · 왼 이웃과 테두리를 나눠 가짐(표 `border-collapse` 방식) — 겹쳐 그리지 않게
* 위 · 왼만 두 이웃 중 굵은 쪽으로 그리고 오른 · 아래는 시트 끝 칸만 그림(`resolveEdgeBorders`).
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
* ========================================================================== */
import { colName, toA1 } from "./spreadsheet_address";
import type {
BorderLine,
BorderSide,
CellStyle,
HAlign,
Sheet,
VAlign,
Workbook,
} from "./spreadsheet_types";
/** 선 굵기 차례 — 겹치면 굵은 쪽이 이김(엑셀과 완전히 같지 않음 · 근사). */
const BORDER_WEIGHT: Record<BorderLine, number> = {
hair: 0,
dotted: 1,
dashed: 2,
thin: 3,
double: 4,
medium: 5,
thick: 6,
};
/** 두 이웃 칸이 한 자리에 그리려는 테두리 중 굵은 쪽. 없으면 있는 쪽 · 둘 다 없으면 undefined. */
export function strongerBorder(a?: BorderSide, b?: BorderSide): BorderSide | undefined {
if (!a) return b;
if (!b) return a;
return BORDER_WEIGHT[b.선] > BORDER_WEIGHT[a.선] ? b : a;
}
/** 칸 하나가 실제로 그릴 네 변 — 위 · 왼은 이웃과 겹쳐 고름 · 오른 · 아래는 시트 끝(그 다음 칸이 없음)일 때만. */
export interface ResolvedBorders {
위?: BorderSide;
아래?: BorderSide;
왼?: BorderSide;
오른?: BorderSide;
}
/** `서식` 표 번호 — 칸 → 행 → 열 → 시트 `기본.서식` → 0(기본) 차례(칸 안의 계약 주석과 같음). */
export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number {
const cell = sheet.칸[key];
if (cell?.서식 !== undefined) return cell.서식;
const rowFmt = sheet.행?.[String(r + 1)]?.서식;
if (rowFmt !== undefined) return rowFmt;
const colFmt = sheet.열?.[colName(c)]?.서식;
if (colFmt !== undefined) return colFmt;
if (sheet.기본?.서식 !== undefined) return sheet.기본.서식;
return 0;
}
/** (r, c) 칸이 쓸 서식 — 서식 표 밖 번호는 기본(빈 `{}`)으로. */
export function cellStyleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
const key = toA1(r, c);
const idx = styleIndexAt(sheet, r, c, key);
return book.서식[idx] ?? {};
}
/** 칸 하나 · 병합 칸 한 덩이(box)의 테두리 — 네 변마다 안쪽 이웃과 겹쳐 굵은 쪽으로 고름.
* 병합이면 왼위(r0,c0) · 오른아래(r1,c1) 이 다른 칸이라 네 변을 따로 봄(테두리는 칸마다 있음). */
export function resolveBoxBorders(
book: Workbook,
sheet: Sheet,
r0: number,
c0: number,
r1: number,
c1: number,
lastRow: number,
lastCol: number,
): ResolvedBorders {
const top = cellStyleAt(book, sheet, r0, c0).테두리?.위;
const aboveOf = r0 > 0 ? cellStyleAt(book, sheet, r0 - 1, c0).테두리?.아래 : undefined;
const left = cellStyleAt(book, sheet, r0, c0).테두리?.왼;
const leftOf = c0 > 0 ? cellStyleAt(book, sheet, r0, c0 - 1).테두리?.오른 : undefined;
const bottomOwn = cellStyleAt(book, sheet, r1, c0).테두리?.아래;
const belowOf = r1 < lastRow ? cellStyleAt(book, sheet, r1 + 1, c0).테두리?.위 : undefined;
const rightOwn = cellStyleAt(book, sheet, r0, c1).테두리?.오른;
const rightOf = c1 < lastCol ? cellStyleAt(book, sheet, r0, c1 + 1).테두리?.왼 : undefined;
return {
위: strongerBorder(top, aboveOf),
왼: strongerBorder(left, leftOf),
아래: r1 >= lastRow ? bottomOwn : strongerBorder(bottomOwn, belowOf),
오른: c1 >= lastCol ? rightOwn : strongerBorder(rightOwn, rightOf),
};
}
/** 색 글을 비교용으로 정리 — 공백 · 대소문자 · `#` 3자리 · ARGB(`#FF000000`) · `rgb(0,0,0)` 를 `#rrggbb` 로. */
function normalizeColor(c: string): string {
const t = c.trim().toLowerCase().replace(/\s+/g, "");
const rgb = /^rgba?\((\d+),(\d+),(\d+)(?:,1(?:\.0+)?)?\)$/.exec(t);
if (rgb)
return `#${[rgb[1], rgb[2], rgb[3]].map((n) => Number(n).toString(16).padStart(2, "0")).join("")}`;
if (/^#[0-9a-f]{3}$/.test(t)) return `#${t[1]}${t[1]}${t[2]}${t[2]}${t[3]}${t[3]}`;
if (/^#ff[0-9a-f]{6}$/.test(t)) return `#${t.slice(3)}`;
return t;
}
/** 엑셀 「자동」 글자색 · 테두리색 — 검정 · windowtext · auto 는 테마 글자색을 따름(저장 값은 그대로 · 그릴 때만). */
export function isAutoInk(c: string | undefined): boolean {
if (!c) return true;
const n = normalizeColor(c);
return n === "#000000" || n === "black" || n === "windowtext" || n === "auto";
}
/** 엑셀 「자동」 채움 — 흰색 · window 는 채움 없음처럼(테마 바탕이 비침). */
export function isAutoPaper(c: string | undefined): boolean {
if (!c) return true;
const n = normalizeColor(c);
return n === "#ffffff" || n === "white" || n === "window";
}
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */
function borderCss(side: BorderSide | undefined, scale: number): string {
if (!side) return "";
const color = isAutoInk(side.색) ? "var(--aislo-grid-fg)" : (side.색 as string);
const w = (px: number): number => Math.max(1, Math.round(px * scale));
switch (side.선) {
case "hair":
return `${w(1)}px dotted ${color}`;
case "thin":
return `${w(1)}px solid ${color}`;
case "medium":
return `${w(2)}px solid ${color}`;
case "thick":
return `${w(3)}px solid ${color}`;
case "double":
return `${w(3)}px double ${color}`;
case "dotted":
return `${w(1)}px dotted ${color}`;
case "dashed":
return `${w(1)}px dashed ${color}`;
}
}
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
const HALIGN_CSS: Record<HAlign, string> = {
general: "left",
left: "left",
right: "right",
center: "center",
distributed: "justify",
centerContinuous: "center",
};
const VALIGN_CSS: Record<VAlign, string> = {
top: "flex-start",
center: "center",
bottom: "flex-end",
};
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle(
cell: HTMLElement,
style: CellStyle,
borders: ResolvedBorders,
hAlign: HAlign,
scale = 1,
): void {
const s = cell.style;
s.fontFamily = style.글꼴 ?? "";
s.fontSize = style.크기 ? `${style.크기 * scale}pt` : "";
s.fontWeight = style.굵게 ? "700" : "400";
s.fontStyle = style.기울임 ? "italic" : "normal";
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
.filter(Boolean)
.join(" ");
s.color = isAutoInk(style.글자색) ? "" : (style.글자색 as string);
s.backgroundColor = isAutoPaper(style.채움) ? "" : (style.채움 as string);
s.borderTop = borderCss(borders.위, scale);
s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
let text = cell.firstElementChild as HTMLElement | null;
if (!text || !text.classList.contains("aislo-cell__text")) {
text = document.createElement("div");
text.className = "aislo-cell__text";
cell.replaceChildren(text);
}
text.style.textAlign = HALIGN_CSS[hAlign];
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
}
/** 칸 글 — 서식 CSS 와 따로 두어 `invalidate()` 가 값만 바꿀 때 서식을 다시 잴 필요가 없게. */
export function setCellText(cell: HTMLElement, text: string, title?: string): void {
const node = cell.firstElementChild as HTMLElement | null;
if (node && node.classList.contains("aislo-cell__text")) {
node.textContent = text;
} else {
cell.textContent = text;
}
if (title) cell.title = title;
else cell.removeAttribute("title");
}
@@ -0,0 +1,624 @@
/* =============================================================================
* spreadsheet_clipboard.ts (주인 E)
* 복사 · 잘라내기 · 붙여넣기 — `copy` · `cut` · `paste` 사건만(허락 창 없음 · navigator.clipboard 안 씀).
* 형 · 수식 살리기 규칙은 `spreadsheet_types.ts` `ClipBlock` 머리. 잘라내기 = 복사 + `내용지움`(누른 순간
* 바로 지움) · 붙여넣기 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 서식 · 병합 명령
* 묶음(칸 하나하나). 잘라낸 것을 같은 문서에 처음 붙이면(클립보드 HTML 의 `aislo-cut` 표 · 그 사이 잘라낸
* 자리에 새로 안 씀) 지운 칸을 되살려 `옮기기` 명령으로 — 그 칸을 가리키던 다른 식도 따라감(엑셀).
* 다른 앱 · 두 번째 붙여넣기는 보통 붙여넣기. 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김.
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `attachClipboard(ctx)` 한 번 부름(반환 `root` 없음 · 안 붙여도 됨).
* `ctx.root` 의 copy/cut/paste 사건을 직접 받으므로 그 요소가 포커스를 받을 수 있어야 함
* (D 의 숨은 textarea 가 늘 그 안에 있어야 함).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
import { st } from "./spreadsheet_text";
import type {
BorderLine,
BorderSide,
Cell,
CellAddress,
CellInput,
CellRange,
CellStyle,
ClipBlock,
ClipCell,
Command,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
/* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */
const BORDER_CSS: Record<BorderLine, string> = {
hair: "dotted",
thin: "solid",
medium: "solid",
thick: "solid",
double: "double",
dotted: "dotted",
dashed: "dashed",
};
function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
const cell = sheet.칸[toA1(r, c)];
const idx =
cell?.서식 ??
sheet.열?.[colName(c)]?.서식 ??
sheet.행?.[String(r + 1)]?.서식 ??
sheet.기본?.서식 ??
0;
return book.서식[idx] ?? {};
}
function styleToCss(style: CellStyle): string {
const parts: string[] = [];
if (style.글꼴) parts.push(`font-family:${style.글꼴}`);
if (style.크기) parts.push(`font-size:${style.크기}pt`);
if (style.굵게) parts.push("font-weight:bold");
if (style.기울임) parts.push("font-style:italic");
const deco = [style.밑줄 && "underline", style.취소선 && "line-through"]
.filter(Boolean)
.join(" ");
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") {
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
}
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
const s = style.테두리?.[side];
if (!s) return;
const prop = {
위: "border-top",
아래: "border-bottom",
왼: "border-left",
오른: "border-right",
}[side];
parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`);
});
}
return parts.join(";");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
const { r0, c0, r1, c1 } = range;
const 칸: Record<string, ClipCell> = {};
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const cell = sheet.칸[toA1(r, c)];
if (!cell) continue;
const clip: ClipCell = {};
if (cell.값 !== undefined) clip.값 = cell.값;
if (cell.식 !== undefined) clip.식 = cell.식;
if (cell.서식 !== undefined) clip.서식 = book.서식[cell.서식];
if (Object.keys(clip).length) 칸[`${r - r0},${c - c0}`] = clip;
}
}
const 병합 = (sheet.병합 ?? [])
.map((m) => parseRange(m))
.filter((m): m is CellRange => !!m && m.r0 >= r0 && m.c0 >= c0 && m.r1 <= r1 && m.c1 <= c1)
.map((m) => ({ r0: m.r0 - r0, c0: m.c0 - c0, r1: m.r1 - r0, c1: m.c1 - c0 }));
return { rows: r1 - r0 + 1, cols: c1 - c0 + 1, 칸, 병합, 원점: { r: r0, c: c0 }, 출처: "aislo" };
}
/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON). */
export function blockToClipboard(
book: Workbook,
sheetId: string,
range: CellRange,
valueAt: (r: number, c: number) => Scalar,
): { html: string; text: string } {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return { html: "", text: "" };
const { r0, c0, r1, c1 } = range;
const aisloBlock = buildAisloBlock(book, sheet, range);
const rowsHtml: string[] = [];
const tsvRows: string[] = [];
const skip = new Set<string>();
for (let r = r0; r <= r1; r++) {
const cellsHtml: string[] = [];
const tsvCells: string[] = [];
for (let c = c0; c <= c1; c++) {
const key = `${r},${c}`;
if (skip.has(key)) continue;
const style = styleAt(book, sheet, r, c);
const text = formatValue(valueAt(r, c), style.형식).글;
tsvCells.push(text.replace(/\t/g, " "));
let span = "";
const merge = (sheet.병합 ?? [])
.map((m) => parseRange(m))
.find((m): m is CellRange => !!m && m.r0 === r && m.c0 === c && m.r1 <= r1 && m.c1 <= c1);
if (merge) {
const rs = merge.r1 - merge.r0 + 1;
const cs = merge.c1 - merge.c0 + 1;
if (rs > 1) span += ` rowspan="${rs}"`;
if (cs > 1) span += ` colspan="${cs}"`;
for (let dr = 0; dr < rs; dr++)
for (let dc = 0; dc < cs; dc++) skip.add(`${r + dr},${c + dc}`);
}
cellsHtml.push(
`<td${span} style="${escapeHtml(styleToCss(style))}">${escapeHtml(text)}</td>`,
);
}
rowsHtml.push(`<tr>${cellsHtml.join("")}</tr>`);
tsvRows.push(tsvCells.join("\t"));
}
const table = `<table>${rowsHtml.join("")}</table>`;
const html = `<!--aislo:${encodeURIComponent(JSON.stringify(aisloBlock))}-->${table}`;
return { html, text: tsvRows.join("\r\n") };
}
/* ── 밖 → 안 ─────────────────────────────────────────────────────────────── */
/** 「1,234.56」 · 「TRUE」 는 값으로 · 나머지는 글로. */
function coerceValue(text: string): CellInput {
const t = text.trim();
if (/^[+-]?\d[\d,]*(\.\d+)?$/.test(t)) {
const n = Number(t.replace(/,/g, ""));
if (Number.isFinite(n)) return n;
}
if (/^(TRUE|FALSE)$/i.test(t)) return t.toUpperCase() === "TRUE";
return text;
}
/** 「=」 뒤에 글이 있고 식으로 보이면 식 — 엑셀 「수식 표시」 복사 대응(「=」 한 글자는 글). */
function cellFromText(text: string): ClipCell {
if (text.startsWith("=") && text.length > 1) return { 식: text.slice(1) };
return { 값: coerceValue(text) };
}
interface TableWalk {
rows: number;
cols: number;
owner: Map<string, HTMLTableCellElement>;
merges: CellRange[];
}
function walkTable(table: HTMLTableElement): TableWalk {
const occupied = new Set<string>();
const owner = new Map<string, HTMLTableCellElement>();
const merges: CellRange[] = [];
const rows = Array.from(table.rows);
rows.forEach((row, r) => {
let c = 0;
Array.from(row.cells).forEach((cellEl) => {
while (occupied.has(`${r},${c}`)) c++;
const rowspan = cellEl.rowSpan || 1;
const colspan = cellEl.colSpan || 1;
owner.set(`${r},${c}`, cellEl);
for (let dr = 0; dr < rowspan; dr++) {
for (let dc = 0; dc < colspan; dc++) occupied.add(`${r + dr},${c + dc}`);
}
if (rowspan > 1 || colspan > 1) {
merges.push({ r0: r, c0: c, r1: r + rowspan - 1, c1: c + colspan - 1 });
}
c += colspan;
});
});
let cols = 0;
for (const key of occupied) {
const c = Number(key.split(",")[1]);
if (c + 1 > cols) cols = c + 1;
}
return { rows: rows.length, cols, owner, merges };
}
/** 선언 나눔 — `mso-number-format:"...\;..."` 안 이스케이프된 `\;`(구역 나눔 자리표시)는 진짜
* 선언 끝이 아님(엑셀 회계 형식처럼 구역이 여럿인 값을 여기서 잘라 버리면 값 끝에 떨어진 `\` 만
* 남아 형식이 깨짐) — `\` 뒤 글자는 통째로 건너뛰어 그 안 `;` 는 안 자름. */
function splitDecls(s: string): string[] {
return s.split(/(?<!\\);/);
}
/** 엑셀 · 구글이 `td style="..."` 에 싣는 인라인 서식 → `CellStyle`(읽을 것 없으면 undefined). */
function parseStyleAttr(styleAttr: string): Record<string, string> {
const out: Record<string, string> = {};
for (const decl of splitDecls(styleAttr)) {
const i = decl.indexOf(":");
if (i < 0) continue;
const key = decl.slice(0, i).trim().toLowerCase();
const value = decl.slice(i + 1).trim();
if (key) out[key] = value;
}
return out;
}
function cssColorToHex(v: string | undefined): string | undefined {
if (!v) return undefined;
const s = v.trim().toLowerCase();
if (!s || s === "windowtext" || s === "transparent" || s === "none" || s === "auto")
return undefined;
if (/^#[0-9a-f]{3}$/.test(s)) return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
if (/^#[0-9a-f]{6}$/.test(s)) return s;
const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
if (rgb) {
const hex = (n: string) => Number(n).toString(16).padStart(2, "0");
return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`;
}
return undefined;
}
/** 엑셀 `mso-number-format:"\#\,\#\#0\.00"` — 따옴표 떼고 `\x` 이스케이프 풀기(`\0022` 처럼 진짜
* CSS 유니코드 이스케이프(`\` + 16진 1~6 자리, 회계 형식 글자 구역 따옴표 `"` 를 이렇게 실음)도
* 먼저 풀지 않으면 따옴표 대신 글자 "0022" 가 그대로 남아 형식이 깨짐 — sub5 numfmt 가 받는
* 형식 코드에는 이 CSS 껍데기가 없어야 함). */
function unescapeMsoNumFmt(v: string): string {
return v
.replace(/^"|"$/g, "")
.replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_, hex, ch) =>
hex !== undefined ? String.fromCodePoint(parseInt(hex, 16)) : ch,
);
}
/** pt 굵기 → 우리 선 종류(엑셀 내보내기 흔한 값 기준 — hairline ≈ .5pt 이하 · thin ≈ 1pt · medium ≈
* 1.5~2pt · thick ≈ 2pt 넘음). `solid` 아닌 것은 굵기와 상관없이 그 모양. */
function ptToBorderLine(pt: number, cssStyle: string): BorderLine {
if (cssStyle === "dashed") return "dashed";
if (cssStyle === "dotted") return "dotted";
if (cssStyle === "double") return "double";
if (pt <= 0.5) return "hair";
if (pt <= 1.25) return "thin";
if (pt <= 2) return "medium";
return "thick";
}
function parseBorderShorthand(v: string): BorderSide | undefined {
const m = /([\d.]+)\s*(?:pt|px)\s+(solid|dashed|dotted|double)\s*([^;]*)/i.exec(v);
if (!m) return undefined;
const 선 = ptToBorderLine(Number(m[1]), m[2].toLowerCase());
const 색 = cssColorToHex(m[3]);
return 색 ? { 선, 색 } : { 선 };
}
/** `<style>...</style>` 안 `.xl65 {...}` · `.xl65, .xl70 {...}`(묶음 선택자) 짜임(엑셀이 인라인 대신
* 클래스로 서식을 실을 때) → 클래스별 선언(같은 클래스가 여러 규칙에 나오면 뒤엣것이 이김). */
function parseClassRules(doc: Document): Record<string, Record<string, string>> {
const css = Array.from(doc.querySelectorAll("style"))
.map((s) => s.textContent ?? "")
.join("\n");
const rules: Record<string, Record<string, string>> = {};
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
const decls = parseStyleAttr(m[2]);
for (const selectorPart of m[1].split(",")) {
const cls = /\.([a-zA-Z0-9_-]+)\s*$/.exec(selectorPart.trim());
if (cls) Object.assign((rules[cls[1]] ??= {}), decls);
}
}
return rules;
}
function styleFromTd(
cellEl: HTMLTableCellElement,
classRules: Record<string, Record<string, string>>,
): CellStyle | undefined {
const decls: Record<string, string> = {};
for (const cls of cellEl.classList) Object.assign(decls, classRules[cls]);
const raw = cellEl.getAttribute("style");
if (raw) Object.assign(decls, parseStyleAttr(raw));
if (!Object.keys(decls).length) return undefined;
const style: CellStyle = {};
const weight = decls["font-weight"];
if (weight === "bold" || Number(weight) >= 700) style.굵게 = true;
if (decls["font-style"] === "italic") style.기울임 = true;
const color = cssColorToHex(decls["color"]);
if (color) style.글자색 = color;
const bg = cssColorToHex(decls["background-color"] ?? decls["background"]);
if (bg) style.채움 = bg;
const align = decls["text-align"];
if (align === "left" || align === "center" || align === "right") style.가로 = align;
if (decls["mso-number-format"]) style.형식 = unescapeMsoNumFmt(decls["mso-number-format"]);
const 테두리: NonNullable<CellStyle["테두리"]> = {};
const sideMap: [string, "위" | "아래" | "왼" | "오른"][] = [
["border-top", "위"],
["border-bottom", "아래"],
["border-left", "왼"],
["border-right", "오른"],
];
for (const [prop, side] of sideMap) {
const b = parseBorderShorthand(decls[prop] ?? decls["border"] ?? "");
if (b) 테두리[side] = b;
}
if (Object.keys(테두리).length) style.테두리 = 테두리;
return Object.keys(style).length ? style : undefined;
}
function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null {
const doc = new DOMParser().parseFromString(html, "text/html");
const table = doc.querySelector("table");
if (!table) return null;
const walk = walkTable(table);
const sheetsMode = /data-sheets-formula/.test(html);
const classRules = parseClassRules(doc);
const 칸: Record<string, ClipCell> = {};
for (const [key, cellEl] of walk.owner) {
const [r, c] = key.split(",").map(Number);
const text = cellEl.textContent ?? "";
const formula = sheetsMode ? cellEl.getAttribute("data-sheets-formula") : null;
if (formula) {
const target = { r: at.r + r, c: at.c + c };
칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) };
continue;
}
const style = styleFromTd(cellEl, classRules);
if (!text) {
if (style) 칸[key] = { 서식: style };
continue;
}
const clip = cellFromText(text);
if (style) clip.서식 = style;
칸[key] = clip;
}
return {
rows: walk.rows,
cols: walk.cols,
칸,
병합: walk.merges,
원점: null,
출처: sheetsMode ? "google" : "excel",
};
}
function parseTsv(text: string): ClipBlock {
const lines = text.replace(/\r\n/g, "\n").split("\n");
while (lines.length && lines[lines.length - 1] === "") lines.pop();
const grid = lines.map((line) => line.split("\t"));
let cols = 0;
for (const row of grid) if (row.length > cols) cols = row.length;
const 칸: Record<string, ClipCell> = {};
grid.forEach((row, r) => {
row.forEach((text, c) => {
if (text === "") return;
칸[`${r},${c}`] = cellFromText(text);
});
});
return { rows: grid.length, cols, 칸, 병합: [], 원점: null, 출처: "text" };
}
/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이). */
export function parseClipboard(html: string, text: string, at: CellAddress): ClipBlock | null {
const aislo = /<!--aislo:([^>]*)-->/.exec(html);
if (aislo) {
try {
return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock;
} catch {
// 손상됐으면 아래 일반 경로로 폴백.
}
}
if (/<table/i.test(html)) return parseHtmlTable(html, at);
if (text) return parseTsv(text);
return null;
}
const NO_FORMULA_NOTIFIED_KEY = "aislo-ss-notified-no-formula";
/** 붙인 뭉치 어디에도 수식(`식`)이 없음 — 엑셀 HTML 클립보드는 「수식 표시」(Ctrl+`) 없이 복사하면
* 계산값만 실림(수식 자체는 절대 안 실림). */
function hasNoFormula(block: ClipBlock): boolean {
return !Object.values(block.칸).some((cell) => cell.식 !== undefined);
}
/** 세션에 한 번만 — 엑셀에서 값만 온 붙여넣기마다 매번 뜨면 성가심. */
function notifyNoFormulaOnce(): void {
try {
if (sessionStorage.getItem(NO_FORMULA_NOTIFIED_KEY)) return;
sessionStorage.setItem(NO_FORMULA_NOTIFIED_KEY, "1");
} catch {
// 세션 저장 안 되면 매번 뜰 수 있으나 치명적이지 않음.
}
showToast(st("ClipboardExcelNoFormula"));
}
/* ── 화면 붙임 — copy · cut · paste 사건 ────────────────────────────────── */
function activeRange(ctx: SpreadsheetContext): CellRange {
return (
ctx.selection.범위[0] ?? {
r0: ctx.selection.활성.r,
c0: ctx.selection.활성.c,
r1: ctx.selection.활성.r,
c1: ctx.selection.활성.c,
}
);
}
function rangesOverlap(a: CellRange, b: CellRange): boolean {
return a.r0 <= b.r1 && a.r1 >= b.r0 && a.c0 <= b.c1 && a.c1 >= b.c0;
}
function rangeContains(outer: CellRange, inner: CellRange): boolean {
return (
outer.r0 <= inner.r0 && outer.r1 >= inner.r1 && outer.c0 <= inner.c0 && outer.c1 >= inner.c1
);
}
/** 붙일 자리가 기존 병합의 일부만 걸치면 막음(엑셀 「병합된 셀의 일부를 바꿀 수 없습니다」) — 병합을
* 통째로 덮거나(허용) · 병합 한 칸에 값 하나만 얹는 것(허용)은 됨. */
function findBlockingMerge(sheet: Sheet, target: CellRange): boolean {
const isSingleCell = target.r0 === target.r1 && target.c0 === target.c1;
for (const m of sheet.병합 ?? []) {
const mr = parseRange(m);
if (!mr || !rangesOverlap(target, mr)) continue;
if (rangeContains(target, mr)) continue;
if (isSingleCell && rangeContains(mr, target)) continue;
return true;
}
return false;
}
function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
const target: CellRange = {
r0: at.r,
c0: at.c,
r1: at.r + block.rows - 1,
c1: at.c + block.cols - 1,
};
const sheet = ctx.sheet();
if (findBlockingMerge(sheet, target)) {
showToast(st("ClipboardMergeBlocked"), "error");
return;
}
// 붙일 자리가 기존 병합을 통째로 덮으면 먼저 풂(엑셀도 큰 덩어리를 얹으면 병합이 풀리고 칸마다 값이 옴) —
// 안 풀면 A `applyCommand` 가 병합 속 왼위 아닌 칸에 값을 넣으려 할 때 막음.
const unmergeCmds: Command[] = (sheet.병합 ?? [])
.map((m) => parseRange(m))
.filter((m): m is CellRange => !!m && rangeContains(target, m))
.map((범위) => ({ 종류: "병합풀기", 시트: ctx.selection.시트, 범위 }));
const shift = block.원점 ? { dr: at.r - block.원점.r, dc: at.c - block.원점.c } : null;
const 칸: Record<string, Cell | null> = {};
const styleCmds: Command[] = [];
for (const [key, clip] of Object.entries(block.칸)) {
const [lr, lc] = key.split(",").map(Number);
const r = at.r + lr;
const c = at.c + lc;
const cell: Cell = {};
if (clip.값 !== undefined) cell.값 = clip.값;
if (clip.식 !== undefined) cell.식 = shift ? moveFormula(clip.식, shift.dr, shift.dc) : clip.식;
칸[toA1(r, c)] = Object.keys(cell).length ? cell : null;
if (clip.서식) {
styleCmds.push({
종류: "서식",
시트: ctx.selection.시트,
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: clip.서식,
});
}
}
const commands: Command[] = [
...unmergeCmds,
{ 종류: "칸", 시트: ctx.selection.시트, 칸 },
...styleCmds,
];
for (const m of block.병합) {
commands.push({
종류: "병합",
시트: ctx.selection.시트,
범위: { r0: at.r + m.r0, c0: at.c + m.c0, r1: at.r + m.r1, c1: at.c + m.c1 },
});
}
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
}
/** A1 칸이 범위 안인가 */
function inside(rg: CellRange, a1: string): boolean {
const p = parseA1(a1);
return !!p && p.r >= rg.r0 && p.r <= rg.r1 && p.c >= rg.c0 && p.c <= rg.c1;
}
interface PendingCut {
token: string;
sheet: string;
range: CellRange;
/** 지우기 전 칸 — 같은 문서에 붙일 때 되살려 `옮기기` 로 넘김 */
cells: Record<string, Cell>;
}
/** 병합을 가르는지(통째로 든 병합은 됨) — `옮기기` 명령과 같은 규칙 */
function cutsMerge(sheet: Sheet, rg: CellRange): boolean {
return (sheet.병합 ?? [])
.map((m) => parseRange(m))
.some((m) => !!m && rangesOverlap(m, rg) && !rangeContains(rg, m));
}
/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */
export function attachClipboard(ctx: SpreadsheetContext): PartHandle {
/** 마지막 잘라내기 — 같은 문서에 붙이면 칸을 옮김(그 칸을 가리키던 식도 따라감 · 엑셀) */
let cut: PendingCut | null = null;
function write(ev: ClipboardEvent, prefix = ""): void {
const range = activeRange(ctx);
const { html, text } = blockToClipboard(ctx.book, ctx.selection.시트, range, (r, c) =>
ctx.engine.value(ctx.selection.시트, r, c),
);
ev.clipboardData?.setData("text/html", prefix + html);
ev.clipboardData?.setData("text/plain", text);
ev.preventDefault();
}
function onCopy(ev: ClipboardEvent): void {
if (ctx.editor.editing()) return;
cut = null;
write(ev);
}
function onCut(ev: ClipboardEvent): void {
if (ctx.editor.editing() || ctx.readOnly) return;
const range = activeRange(ctx);
const sheet = ctx.sheet();
const cells: Record<string, Cell> = {};
for (const [a1, cell] of Object.entries(sheet.칸))
if (inside(range, a1)) cells[a1] = structuredClone(cell);
const token = Math.random().toString(36).slice(2);
write(ev, `<!--aislo-cut:${token}-->`);
cut = { token, sheet: sheet.id, range, cells };
ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: [range] });
}
/** 잘라낸 칸을 같은 문서 안으로 — 되살린 뒤 `옮기기`(한 번에 되돌림) · 못 하면 false(보통 붙여넣기) */
function pasteCut(pending: PendingCut, at: CellAddress): boolean {
const src = ctx.book.시트.find((s) => s.id === pending.sheet);
if (!src) return false;
const { r0, c0, r1, c1 } = pending.range;
for (const [a1, cell] of Object.entries(src.칸))
if (inside(pending.range, a1) && (cell.값 !== undefined || cell.식 !== undefined))
return false; // 잘라낸 뒤 그 자리에 새로 씀
const dst = { r0: at.r, c0: at.c, r1: at.r + r1 - r0, c1: at.c + c1 - c0 };
if (cutsMerge(src, pending.range) || cutsMerge(ctx.sheet(), dst)) return false;
ctx.dispatch({
종류: "묶음",
명령: [
{ 종류: "칸", 시트: src.id, 칸: pending.cells },
{ 종류: "옮기기", 시트: src.id, 범위: pending.range, 대상시트: ctx.selection.시트, ...at },
],
});
return true;
}
function onPaste(ev: ClipboardEvent): void {
if (ctx.editor.editing() || ctx.readOnly) return;
const html = ev.clipboardData?.getData("text/html") ?? "";
const text = ev.clipboardData?.getData("text/plain") ?? "";
const at = ctx.selection.활성;
const pending = cut;
if (pending && html.includes(`<!--aislo-cut:${pending.token}-->`)) {
cut = null; // 엑셀처럼 한 번만 옮김 — 다음부터는 보통 붙여넣기
if (pasteCut(pending, at)) return void ev.preventDefault();
}
const block = parseClipboard(html, text, at);
if (!block) {
showToast(st("ClipboardPasteFailed"), "error");
return;
}
ev.preventDefault();
if (block.출처 === "excel" && hasNoFormula(block)) notifyNoFormulaOnce();
pasteBlock(ctx, block, at);
}
ctx.root.addEventListener("copy", onCopy);
ctx.root.addEventListener("cut", onCut);
ctx.root.addEventListener("paste", onPaste);
return {
root: null,
refresh() {},
destroy() {
ctx.root.removeEventListener("copy", onCopy);
ctx.root.removeEventListener("cut", onCut);
ctx.root.removeEventListener("paste", onPaste);
},
};
}
@@ -0,0 +1,665 @@
/* =============================================================================
* spreadsheet_commands.ts (주인 A)
* 명령 적용 — 문서를 그 자리에서 바꾸고 되돌림 짝 · 바뀐 칸 · 통째 다시 여부를 돌려줌.
* 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) ·
* 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김.
* 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`).
* 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에.
* ========================================================================== */
import {
colName,
inRange,
normRange,
parseA1,
parseRange,
rangeInside,
rangesOverlap,
rangeToA1,
toA1,
} from "./spreadsheet_address";
import {
addSheet,
moveSheet,
removeSheet,
renameSheet,
replaceSheet,
} from "./spreadsheet_commands_sheet";
import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift";
import { applyFilter, applySort, shiftFilter } from "./spreadsheet_sortfilter";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
Cell,
CellRange,
CellStyle,
Command,
CommandEffect,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
/** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */
export class CommandError extends Error {}
export type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
export const clone = <T>(x: T): T => structuredClone(x);
export function sheetOf(book: Workbook, id: string): Sheet {
const s = book.시트.find((x) => x.id === id);
if (!s) throw new CommandError(`없는 시트: ${id}`);
return s;
}
const mergesOf = (s: Sheet): CellRange[] =>
(s.병합 ?? []).map((t) => parseRange(t)).filter((r): r is CellRange => r !== null);
function setMerges(s: Sheet, list: CellRange[]) {
if (list.length) s.병합 = list.map(rangeToA1);
else delete s.병합;
}
/** 칸 열쇠 중 범위 안 것 */
function cellsIn(s: Sheet, ranges: CellRange[]): [string, number, number][] {
const out: [string, number, number][] = [];
for (const a1 of Object.keys(s.칸)) {
const at = parseA1(a1);
if (at && ranges.some((rg) => inRange(rg, at.r, at.c))) out.push([a1, at.r, at.c]);
}
return out;
}
const isFull = (rg: CellRange, rows: boolean) =>
rows ? rg.c0 === 0 && rg.c1 === MAX_COLS - 1 : rg.r0 === 0 && rg.r1 === MAX_ROWS - 1;
function sortKeys(v: unknown): unknown {
if (!v || typeof v !== "object" || Array.isArray(v)) return v;
const o = v as Record<string, unknown>;
return Object.fromEntries(
Object.keys(o)
.sort()
.map((k) => [k, sortKeys(o[k])]),
);
}
const styleKey = (s: CellStyle) => JSON.stringify(sortKeys(s));
function internStyle(book: Workbook, style: CellStyle): number {
const key = styleKey(style);
const i = book.서식.findIndex((s) => styleKey(s) === key);
if (i >= 0) return i;
book.서식.push(style);
return book.서식.length - 1;
}
function setComments(s: Sheet, notes: Record<string, string>) {
if (Object.keys(notes).length) s.comments = notes;
else delete s.comments;
}
/** 시트 통째 서식 */
const sheetStyle = (s: Sheet) => s.기본?.서식 ?? 0;
/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 시트 · 0 */
const inherited = (s: Sheet, r: number, c: number) =>
s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? sheetStyle(s);
/** 시트 `기본` 한 칸 — undefined 면 지움 · 빈 `기본` 도 지움 */
function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value: number | undefined) {
const d = (s.기본 ??= {});
if (value === undefined) delete d[key];
else d[key] = value;
if (!Object.keys(d).length) delete s.기본;
}
const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c });
/** 바뀐 시트만 통째 되돌림 짝 */
export function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
const out: Command[] = [];
for (const o of old) {
const now = book.시트.find((x) => x.id === o.id);
if (now && JSON.stringify(now) !== JSON.stringify(o)) out.push({ 종류: "시트통째", 시트: o });
}
return out;
}
/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */
export function rewriteAll(book: Workbook, command: Command) {
for (const s of book.시트)
for (const cell of Object.values(s.칸))
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book);
}
// ── 칸 ────────────────────────────────────────────────────────────────────
function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const merges = mergesOf(s);
const plan: [string, number, number, Cell | null][] = [];
for (const [key, cell] of Object.entries(cmd.칸)) {
const at = parseA1(key);
if (!at) throw new CommandError(`칸 주소 틀림: ${key}`);
if (cell && (cell.값 !== undefined || cell.식 !== undefined))
if (merges.some((m) => inRange(m, at.r, at.c) && (m.r0 !== at.r || m.c0 !== at.c)))
throw new CommandError("병합한 칸은 왼위 칸에만 값을 넣음");
plan.push([toA1(at.r, at.c), at.r, at.c, cell]);
}
const prev: Record<string, Cell | null> = {};
for (const [a1, , , cell] of plan) {
prev[a1] = s.칸[a1] ? clone(s.칸[a1]) : null;
if (cell) s.칸[a1] = clone(cell);
else delete s.칸[a1];
}
return {
undo: { 종류: "칸", 시트: s.id, 칸: prev },
cells: plan.map(([, r, c]) => addr(s.id, r, c)),
rebuild: false,
};
}
function clearContents(book: Workbook, cmd: Of<"내용지움">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
for (const [a1, r, c] of cellsIn(s, cmd.범위)) {
const cell = s.칸[a1];
if (cell.값 === undefined && cell.식 === undefined) continue;
prev[a1] = clone(cell);
if (cell.서식 === undefined) delete s.칸[a1];
else s.칸[a1] = { 서식: cell.서식 };
cells.push(addr(s.id, r, c));
}
return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
}
// ── 서식 ────────────────────────────────────────────────────────────────────
/** 범위 서식 바꿈 — `change(지금 번호) → 새 번호`. 행 · 열 전체는 행 · 열 서식 + 든 칸. */
function restyle(
book: Workbook,
sheet: string,
ranges: CellRange[],
change: (i: number) => number,
): CommandEffect {
const s = sheetOf(book, sheet);
const before = clone(s);
const memo = new Map<number, number>();
const f = (i: number) => {
if (!memo.has(i)) memo.set(i, change(i));
return memo.get(i)!;
};
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
let whole = false;
const setInfo = (axis: "행" | "열", key: string) => {
const table = (s[axis] ??= {});
const info = (table[key] ??= {});
const idx = f(info.서식 ?? sheetStyle(s));
if (idx !== sheetStyle(s)) info.서식 = idx;
else delete info.서식;
if (!Object.keys(info).length) delete table[key];
};
const touch = (r: number, c: number) => {
const a1 = toA1(r, c);
if (a1 in prev) return;
const cell = s.칸[a1];
prev[a1] = cell ? clone(cell) : null;
const next: Cell = { ...cell };
const idx = f(cell?.서식 ?? inherited(s, r, c));
if (idx === inherited(s, r, c)) delete next.서식;
else next.서식 = idx;
if (Object.keys(next).length) s.칸[a1] = next;
else delete s.칸[a1];
cells.push(addr(s.id, r, c));
};
for (const rg of ranges) {
const fullRows = isFull(rg, true);
const fullCols = isFull(rg, false);
if (fullRows && fullCols) {
whole = true;
const idx = f(sheetStyle(s));
setSheetDefault(s, "서식", idx || undefined);
for (const k of Object.keys(s.행 ?? {})) setInfo("행", k);
for (const k of Object.keys(s.열 ?? {})) setInfo("열", k);
} else if (fullRows || fullCols) {
whole = true;
for (let i = fullRows ? rg.r0 : rg.c0; i <= (fullRows ? rg.r1 : rg.c1); i++)
setInfo(fullRows ? "행" : "열", fullRows ? String(i + 1) : colName(i));
} else {
if ((rg.r1 - rg.r0 + 1) * (rg.c1 - rg.c0 + 1) > 1_000_000)
throw new CommandError("범위가 너무 큼");
for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) touch(r, c);
continue;
}
for (const [, r, c] of cellsIn(s, [rg])) touch(r, c);
}
if (s.행 && !Object.keys(s.행).length) delete s.행;
if (s.열 && !Object.keys(s.열).length) delete s.열;
return whole
? { undo: { 종류: "시트통째", 시트: before }, cells, rebuild: true }
: { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
}
function patchStyle(book: Workbook, cmd: Of<"서식">): CommandEffect {
return restyle(book, cmd.시트, cmd.범위, (i) => {
const next: Record<string, unknown> = { ...book.서식[i] };
for (const [k, v] of Object.entries(cmd.바꿀)) {
if (v === null) delete next[k];
else if (v !== undefined) next[k] = clone(v);
}
return internStyle(book, next as CellStyle);
});
}
// ── 행열 넣기 · 지우기 ──────────────────────────────────────────────────────
function insertDelete(
book: Workbook,
cmd: Of<"행넣기" | "행지우기" | "열넣기" | "열지우기">,
): CommandEffect {
const s = sheetOf(book, cmd.시트);
const rows = cmd.종류.startsWith("행");
const insert = cmd.종류.endsWith("넣기");
const max = rows ? MAX_ROWS : MAX_COLS;
const { at, 수: n } = cmd;
if (n < 1 || at < 0 || at >= max || (!insert && at + n > max))
throw new CommandError("넣기 · 지우기 자리가 틀림");
const pos = (a1: string) => {
const p = parseA1(a1)!;
return rows ? p.r : p.c;
};
if (insert && Object.keys(s.칸).some((a1) => pos(a1) + n >= max))
throw new CommandError("격자 끝 칸이 밀려 나감 — 끝 쪽 칸을 먼저 지움");
const old = book.시트.map(clone);
rewriteAll(book, cmd);
const span = (i: number) => shiftSpan(i, i, insert, at, n, max)?.[0];
const cells: Record<string, Cell> = {};
for (const [a1, cell] of Object.entries(s.칸)) {
const p = parseA1(a1)!;
const i = span(rows ? p.r : p.c);
if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell;
}
s.칸 = cells;
if (s.comments) {
const notes: Record<string, string> = {};
for (const [a1, text] of Object.entries(s.comments)) {
const p = parseA1(a1);
const i = p && span(rows ? p.r : p.c);
if (p && i !== undefined && i !== null) notes[rows ? toA1(i, p.c) : toA1(p.r, i)] = text;
}
setComments(s, notes);
}
const shiftRange = (m: CellRange) => {
const sp = rows
? shiftSpan(m.r0, m.r1, insert, at, n, max)
: shiftSpan(m.c0, m.c1, insert, at, n, max);
return sp && (rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] });
};
const merges: CellRange[] = [];
for (const m of mergesOf(s)) {
const next = shiftRange(m);
if (next && (next.r0 !== next.r1 || next.c0 !== next.c1)) merges.push(next);
}
setMerges(s, merges);
// 이름 범위도 따라감 · 범위가 통째로 지워진 이름은 뺌(엑셀은 `#REF!` 이름으로 남김 — 여기선 식이 `#NAME?`)
if (s.이름정의)
s.이름정의 = s.이름정의.flatMap((d) => {
const 범위 = shiftRange(d.범위);
return 범위 ? [{ ...d, 범위 }] : [];
});
const axis = rows ? "행" : "열";
const table = s[axis];
if (table) {
const next: Record<string, (typeof table)[string]> = {};
for (const [k, v] of Object.entries(table)) {
const i = span(rows ? Number(k) - 1 : parseA1(`${k}1`)!.c);
if (i !== undefined) next[rows ? String(i + 1) : colName(i)] = v;
}
if (Object.keys(next).length) s[axis] = next;
else delete s[axis];
}
// 넣은 줄은 위(왼) 줄 서식 · 폭 · 높이를 따라감(엑셀 「위와 같은 서식」) — 첫 줄 앞 넣기는 안 따라감
if (insert && at > 0) {
for (const [a1, cell] of Object.entries(s.칸)) {
const p = parseA1(a1)!;
if ((rows ? p.r : p.c) !== at - 1 || cell.서식 === undefined) continue;
for (let k = 0; k < n; k++)
s.칸[rows ? toA1(at + k, p.c) : toA1(p.r, at + k)] = { 서식: cell.서식 };
}
const info = s[axis]?.[rows ? String(at) : colName(at - 1)];
if (info) {
const { 숨김: _hidden, ...look } = info;
if (Object.keys(look).length)
for (let k = 0; k < n; k++)
s[axis]![rows ? String(at + k + 1) : colName(at + k)] = { ...look };
}
}
shiftFilter(s, rows, insert, at, n);
const key = rows ? "행" : "열";
const frozen = s.틀고정?.[key];
if (frozen && at < frozen)
s.틀고정![key] = insert ? frozen + n : frozen - (Math.min(frozen, at + n) - at);
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
}
// ── 옮기기 (잘라 붙이기) ─────────────────────────────────────────────────────
function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const t = sheetOf(book, cmd.대상시트);
const src = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
const dr = cmd.r - src.r0;
const dc = cmd.c - src.c0;
const dst: CellRange = { r0: cmd.r, c0: cmd.c, r1: src.r1 + dr, c1: src.c1 + dc };
if (dst.r0 < 0 || dst.c0 < 0 || dst.r1 >= MAX_ROWS || dst.c1 >= MAX_COLS)
throw new CommandError("옮길 자리가 격자 밖");
const cuts = (list: CellRange[], rg: CellRange) =>
list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg));
if (cuts(mergesOf(s), src) || cuts(mergesOf(t), dst))
throw new CommandError("병합 일부를 가르는 옮기기는 못 함");
if (s === t && dr === 0 && dc === 0)
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
const old = book.시트.map(clone);
const moved: [number, number, Cell][] = [];
for (const [a1, r, c] of cellsIn(s, [src])) {
const cell = s.칸[a1];
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, cmd, book, t.id);
moved.push([r + dr, c + dc, cell]);
delete s.칸[a1];
}
rewriteAll(book, cmd);
for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1];
for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell;
// 메모도 칸과 같이 — 덮은 자리 메모는 지움
const carriedNotes: [string, string][] = [];
const srcNotes = { ...s.comments };
for (const [a1, text] of Object.entries(srcNotes)) {
const p = parseA1(a1);
if (p && inRange(src, p.r, p.c)) {
carriedNotes.push([toA1(p.r + dr, p.c + dc), text]);
delete srcNotes[a1];
}
}
setComments(s, srcNotes);
const dstNotes = { ...t.comments };
for (const a1 of Object.keys(dstNotes)) {
const p = parseA1(a1);
if (p && inRange(dst, p.r, p.c)) delete dstNotes[a1];
}
setComments(t, { ...dstNotes, ...Object.fromEntries(carriedNotes) });
const sm = mergesOf(s);
const carried = sm.filter((m) => rangeInside(m, src));
setMerges(
s,
sm.filter((m) => !rangeInside(m, src)),
);
setMerges(t, [
...mergesOf(t).filter((m) => !rangeInside(m, dst)),
...carried.map((m) => ({ r0: m.r0 + dr, c0: m.c0 + dc, r1: m.r1 + dr, c1: m.c1 + dc })),
]);
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
}
// ── 병합 ────────────────────────────────────────────────────────────────────
function merge(book: Workbook, cmd: Of<"병합">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const rg = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
if (rg.r0 === rg.r1 && rg.c0 === rg.c1)
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
const list = mergesOf(s);
if (list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg)))
throw new CommandError("다른 병합과 걸침");
const absorbed = list.filter((m) => rangeInside(m, rg));
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
for (const [a1, r, c] of cellsIn(s, [rg])) {
const cell = s.칸[a1];
if ((r === rg.r0 && c === rg.c0) || (cell.값 === undefined && cell.식 === undefined)) continue;
prev[a1] = clone(cell);
if (cell.서식 === undefined) delete s.칸[a1];
else s.칸[a1] = { 서식: cell.서식 };
cells.push(addr(s.id, r, c));
}
setMerges(s, [...list.filter((m) => !rangeInside(m, rg)), rg]);
const undo: Command[] = [
{ 종류: "병합풀기", 시트: s.id, 범위: rg },
...absorbed.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })),
{ 종류: "칸", 시트: s.id, 칸: prev },
];
return { undo: { 종류: "묶음", 명령: undo }, cells, rebuild: false };
}
function unmerge(book: Workbook, cmd: Of<"병합풀기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const list = mergesOf(s);
const gone = list.filter((m) => rangesOverlap(m, cmd.범위));
setMerges(
s,
list.filter((m) => !rangesOverlap(m, cmd.범위)),
);
const undo = gone.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m }));
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: false };
}
// ── 행 · 열 모양 ─────────────────────────────────────────────────────────────
/** 행 · 열 정보 한 속성 바꿈 → 원래 값끼리 묶은 되돌림 짝 */
function setAxis<V>(
s: Sheet,
axis: "행" | "열",
idx: number[],
prop: "폭" | "높이" | "숨김" | "묶음",
value: V | null,
undoOf: (idx: number[], prev: V | null) => Command,
): Command {
const table = ((s as unknown as Record<string, Record<string, Record<string, unknown>>>)[axis] ??=
{});
const groups = new Map<string, number[]>();
for (const i of idx) {
const k = axis === "열" ? colName(i) : String(i + 1);
const prev = table[k]?.[prop];
const g = JSON.stringify(prev ?? null);
groups.set(g, [...(groups.get(g) ?? []), i]);
const info = (table[k] ??= {});
if (value === null || value === false) delete info[prop];
else info[prop] = value;
if (!Object.keys(info).length) delete table[k];
}
if (!Object.keys(table).length) delete s[axis];
return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) };
}
/** 모두 고르기 폭 · 높이 — 시트 `기본` 에 두고 줄마다 값은 지움(엑셀 기본 폭 · 높이) */
function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): CommandEffect {
const before = clone(s);
const prop = axis === "열" ? "폭" : "높이";
setSheetDefault(s, axis === "열" ? "열폭" : "행높이", value ?? undefined);
const table = s[axis] as Record<string, Record<string, unknown>> | undefined;
for (const [k, info] of Object.entries(table ?? {})) {
delete info[prop];
if (!Object.keys(info).length) delete table![k];
}
if (table && !Object.keys(table).length) delete s[axis];
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
}
function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect {
const done: CommandEffect[] = [];
try {
for (const c of cmd.명령) done.push(applyCommand(book, c));
} catch (e) {
for (const d of done.reverse()) applyCommand(book, d.undo);
throw e;
}
return {
undo: { 종류: "묶음", 명령: done.map((d) => d.undo).reverse() },
cells: done.flatMap((d) => d.cells),
rebuild: done.some((d) => d.rebuild),
};
}
// ── 들머리 ──────────────────────────────────────────────────────────────────
export function applyCommand(book: Workbook, command: Command): CommandEffect {
const plain = (undo: Command): CommandEffect => ({ undo, cells: [], rebuild: false });
switch (command.종류) {
case "칸":
return setCells(book, command);
case "내용지움":
return clearContents(book, command);
case "서식":
return patchStyle(book, command);
case "서식지움":
return restyle(book, command.시트, command.범위, () => 0);
case "행넣기":
case "행지우기":
case "열넣기":
case "열지우기":
return insertDelete(book, command);
case "옮기기":
return move(book, command);
case "병합":
return merge(book, command);
case "병합풀기":
return unmerge(book, command);
case "열폭": {
const s = sheetOf(book, command.시트);
if (new Set(command.열).size >= MAX_COLS) return sheetSize(s, "열", command.폭);
return plain(
setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({
종류: "열폭",
시트: s.id,
열,
폭,
})),
);
}
case "행높이": {
const s = sheetOf(book, command.시트);
if (new Set(command.행).size >= MAX_ROWS) return sheetSize(s, "행", command.높이);
return plain(
setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({
종류: "행높이",
시트: s.id,
행,
높이,
})),
);
}
case "숨김": {
const s = sheetOf(book, command.시트);
const { 축 } = command;
return plain(
setAxis(s, 축, command.번호, "숨김", command.숨김, (번호, prev) => ({
종류: "숨김",
시트: s.id,
축,
번호,
숨김: prev ?? false,
})),
);
}
case "묶음설정": {
const s = sheetOf(book, command.시트);
const { 축 } = command;
return plain(
setAxis(s, 축, command.번호, "묶음", command.묶음, (번호, prev) => ({
종류: "묶음설정",
시트: s.id,
축,
번호,
묶음: prev,
})),
);
}
case "틀고정": {
const s = sheetOf(book, command.시트);
const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 };
const next: Sheet["틀고정"] = {};
if (command.행 > 0) next.행 = command.행;
if (command.열 > 0) next.열 = command.열;
if (Object.keys(next).length) s.틀고정 = next;
else delete s.틀고정;
return plain({ 종류: "틀고정", 시트: s.id, ...prev });
}
case "눈금선": {
const s = sheetOf(book, command.시트);
const prev = s.보기?.눈금선 ?? true;
if (command.보임) {
delete s.보기?.눈금선;
if (s.보기 && !Object.keys(s.보기).length) delete s.보기;
} else (s.보기 ??= {}).눈금선 = false;
return plain({ 종류: "눈금선", 시트: s.id, 보임: prev });
}
case "시트더하기":
return addSheet(book, command);
case "시트지우기":
return removeSheet(book, command);
case "시트이름":
return renameSheet(book, command);
case "시트옮기기":
return moveSheet(book, command);
case "시트통째":
return replaceSheet(book, command);
case "정렬":
return applySort(book, command);
case "필터":
return applyFilter(book, command);
case "묶음":
return batch(book, command);
}
}
/** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */
export function emptyWorkbook(column: string, sheetName: string): Workbook {
return {
종류: "통합문서",
판: 1,
열: column,
서식: [{}],
시트: [emptySheet("s1", sheetName)],
활성: "s1",
};
}
export function emptySheet(id: string, name: string): Sheet {
return { id, 이름: name, 칸: {} };
}
/** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */
export function compactStyles(book: Workbook): void {
const next: CellStyle[] = [];
const byKey = new Map<string, number>();
const remap = new Map<number, number>();
const map = (i: number) => {
let j = remap.get(i);
if (j === undefined) {
const style = book.서식[i] ?? {};
const k = styleKey(style);
j = byKey.get(k);
if (j === undefined) {
j = next.push(style) - 1;
byKey.set(k, j);
}
remap.set(i, j);
}
return j;
};
map(0);
for (const s of book.시트) {
for (const cell of Object.values(s.칸)) if (cell.서식 !== undefined) cell.서식 = map(cell.서식);
for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {}), s.기본 ?? {}])
if (info.서식 !== undefined) info.서식 = map(info.서식);
}
book.서식 = next;
}
@@ -0,0 +1,69 @@
/* =============================================================================
* spreadsheet_commands_sheet.ts (주인 A)
* 시트 명령 — 더하기 · 지우기 · 이름 · 옮기기 · 통째 바꿔 넣기. 들머리는 `applyCommand`(commands).
* ========================================================================== */
import { clone, CommandError, restoreChanged, rewriteAll, sheetOf } from "./spreadsheet_commands";
import type { Of } from "./spreadsheet_commands";
import type { Command, CommandEffect, Workbook } from "./spreadsheet_types";
function checkSheetName(book: Workbook, name: string, self?: string) {
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
}
export function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
if (book.시트.some((s) => s.id === cmd.시트.id))
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
checkSheetName(book, cmd.시트.이름);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
}
export function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
const at = book.시트.indexOf(s);
const old = book.시트.map(clone);
rewriteAll(book, cmd);
book.시트.splice(at, 1);
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
const undo: Command[] = [
{ 종류: "시트더하기", 시트: old[at], 자리: at },
...restoreChanged(book, old),
];
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
}
export function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
const s = sheetOf(book, cmd.시트);
checkSheetName(book, cmd.이름, s.id);
const prev = s.이름;
rewriteAll(book, cmd);
s.이름 = cmd.이름;
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
}
export function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const from = book.시트.indexOf(s);
book.시트.splice(from, 1);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
}
export function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
const prev = book.시트[at];
book.시트[at] = clone(cmd.시트);
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
if (cmd.서식) {
undo.서식 = book.서식;
book.서식 = clone(cmd.서식);
}
return { undo, cells: [], rebuild: true };
}
@@ -0,0 +1,139 @@
/* =============================================================================
* spreadsheet_comments.ts (2단계 · 주인 sub_laptop_1)
* 메모(풍선 설명) · 칸 위쪽 빨간 세모 표시. 저장 자리 — 브레인 답(2026-09-27):
* `Sheet.comments?: Record<string, string>` (열쇠 = A1 주소 · 값 = 메모 글 · 빈 글이면 지움).
* 이 칸은 sub3 가 0 계약(spreadsheet_types.ts)에 보태는 중 — 여기서는 고치지 않고 모양만 가정
* (`SheetWithComments` 로 좁혀 씀 · sub3 커밋이 오면 구조가 그대로 맞음).
*
* 문서를 바꾸는 길은 명령뿐(view_types.ts 머리) — 「메모」 전용 명령이 아직 없어 `시트통째`
* (있는 시트를 통째로 바꿔 넣는 명령)로 얹음. 메모 전용 명령이 생기면 이 파일의 `applyComments`
* 한 곳만 고치면 됨.
*
* 행 · 열 넣기 · 지우기 때 메모 주소 옮김 — 지금은 안 함(사각지대로 남김). A 가
* `spreadsheet_refshift.ts` 를 완성하면 그 파일의 참조 옮김 함수(예: `shiftAddress` 류)를
* 이 파일 `remapComments` 자리에 불러 A1 주소들을 옮기게 잇기(지우는 행 · 열에 있던 메모는
* 참조 지움과 같은 규칙으로 버림). 지금은 `remapComments` 를 D · A 가 채울 자리로 비워 둠.
*
* 잇는 법(D · sub7 · C 격자) — `attachComments(ctx)` 를 한 번 붙이고:
* 1) C 가 칸을 그릴 때 `hasComment(sheet, r, c)` 가 참이면 칸 위 오른쪽에 빨간 세모(4~6px)를 그림.
* 2) 그 칸에 마우스가 올라가면(hover) `showBalloon(r, c)` · 벗어나면 `hideBalloon()`.
* 3) 우클릭 메뉴 [메모 삽입 · 메모 편집 · 메모 삭제] 는 `setComment` · `deleteComment` 를 부름.
* ========================================================================== */
import type { Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import { el } from "@ui/ui_template_elements";
/** 0 계약이 아직 안 실은 칸 — sub3 커밋 뒤엔 진짜 `Sheet` 가 이 모양을 그대로 가짐. */
export interface SheetWithComments extends Sheet {
comments?: Record<string, string>;
}
const a1 = (r: number, c: number): string => {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
};
/** 자리 이동(행열 넣기 · 지우기) 뒤 메모 주소를 옮김 — A `spreadsheet_refshift` 완성 뒤 채울 자리.
* `shift` 는 옛 주소 → 새 주소(지워진 자리면 null). 지금은 호출부가 없어 안 씀(사각지대 기록용). */
export function remapComments(
comments: Record<string, string>,
shift: (old: { r: number; c: number }) => { r: number; c: number } | null,
): Record<string, string> {
const next: Record<string, string> = {};
for (const [key, text] of Object.entries(comments)) {
const at = parseA1(key);
const moved = shift(at);
if (moved) next[a1(moved.r, moved.c)] = text;
}
return next;
}
function parseA1(key: string): { r: number; c: number } {
const m = /^([A-Z]+)(\d+)$/.exec(key);
if (!m) return { r: 0, c: 0 };
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { r: Number(m[2]) - 1, c: col - 1 };
}
export function getComment(sheet: Sheet, r: number, c: number): string | null {
const comments = (sheet as SheetWithComments).comments;
return comments?.[a1(r, c)] ?? null;
}
export function hasComment(sheet: Sheet, r: number, c: number): boolean {
return getComment(sheet, r, c) !== null;
}
export function listComments(sheet: Sheet): { r: number; c: number; 글: string }[] {
const comments = (sheet as SheetWithComments).comments ?? {};
return Object.entries(comments).map(([key, 글]) => ({ ...parseA1(key), 글 }));
}
/** `시트통째` 명령으로 메모 칸만 바꿔 넣음 — 그 밖 시트 내용은 그대로. */
function applyComments(ctx: SpreadsheetContext, next: Record<string, string>): void {
const sheet = ctx.sheet() as SheetWithComments;
const cleaned: Record<string, string> = {};
for (const [key, text] of Object.entries(next)) if (text) cleaned[key] = text;
const nextSheet: SheetWithComments = { ...sheet, comments: cleaned };
ctx.dispatch({ 종류: "시트통째", 시트: nextSheet });
}
/** 글이 비면 메모를 지움(값과 같은 규칙 — `spreadsheet_types.ts` 「빈 칸 안 적음」). */
export function setComment(ctx: SpreadsheetContext, r: number, c: number, text: string): void {
const sheet = ctx.sheet() as SheetWithComments;
const next = { ...(sheet.comments ?? {}) };
if (text) next[a1(r, c)] = text;
else delete next[a1(r, c)];
applyComments(ctx, next);
}
export function deleteComment(ctx: SpreadsheetContext, r: number, c: number): void {
setComment(ctx, r, c, "");
}
export interface CommentsHandle extends PartHandle {
showBalloon(r: number, c: number): void;
hideBalloon(): void;
}
/** 풍선 뿌리 하나를 만들어 `ctx.root` 에 붙임 — 세모 그리기 자체는 C(격자)가 `hasComment` 로 판단해서 함. */
export function attachComments(ctx: SpreadsheetContext): CommentsHandle {
const balloon = el("div", { className: "spreadsheet-comment-balloon", attrs: { hidden: "" } });
ctx.root.append(balloon);
return {
root: balloon,
showBalloon(r: number, c: number): void {
const text = getComment(ctx.sheet(), r, c);
if (!text) return void this.hideBalloon();
balloon.textContent = text;
balloon.removeAttribute("hidden");
const box = ctx.grid.cellBox(r, c);
balloon.style.left = `${box.x + box.w}px`;
balloon.style.top = `${box.y}px`;
},
hideBalloon(): void {
balloon.setAttribute("hidden", "");
},
refresh(): void {
/* 문서가 바뀌면 지금 보이는 풍선 글도 새로 — 열린 풍선이 없으면 할 일 없음 */
},
destroy(): void {
balloon.remove();
},
};
}
/** 시트 전체 메모 개수(시험 · 실무 대조용) */
export function countComments(book: Workbook, sheetId: string): number {
const sheet = book.시트.find((s) => s.id === sheetId);
return sheet ? Object.keys((sheet as SheetWithComments).comments ?? {}).length : 0;
}
@@ -0,0 +1,285 @@
/* =============================================================================
* spreadsheet_condfmt.ts (3단계 · 주인 sub_laptop_4)
* 조건부 서식 — 칸 값이 규칙에 맞으면 채움색 · 글자색을 덮어씀. 저장 자리 `Sheet.조건부서식`
* (0 계약 `spreadsheet_types.ts` 에 방금 더함 · 계약 더함).
*
* 규칙 차례 = 우선순위(배열 앞쪽이 먼저 이김 · 엑셀 「규칙 관리」 위쪽과 같음). 스타일은 `서식` 표
* 번호를 안 씀 — 규칙마다 채움 · 글자색을 직접 가져 표 번호 조율(A 몫)이 필요 없음. 「수식이 참」
* 조건은 임의 수식 글을 파싱·계산해야 해서(엔진 A) 이 파일은 `evalFormula` 콜백만 받고 엔진을 모름.
*
* 잇는 법(A · C · D · sub7 — 지금은 하나도 안 이어짐):
* 1) 격자(C)가 칸을 그릴 때 `resolveCondFmtStyle(sheet, r, c, env)` 로 덮어쓸 스타일을 얻어
* 지금 셀 스타일 위에 얹음(env.value 는 `ctx.engine.value` 를 수 · 글 · 참거짓 · null 로 바꿔 줌).
* 2) `env.evalFormula(식, r, c)` 구현 — `spreadsheet_parser.parse(식)` 뒤 `spreadsheet_eval` 로
* 풀어 참거짓으로(A 가 만들 자리 · 지금 없음).
* 3) 행 · 열 넣기 · 지우기 뒤 `remapCondFmtRanges` 를 그 시트 `조건부서식` 에 적용해 범위를 옮김
* (`spreadsheet_commands.insertDelete` 가 지금 이 규칙을 모름 — refshift 참조 옮김과 같은 자리에
* 걸어야 함 · A 가 채울 자리).
* 4) 도구 모음 · 우클릭 메뉴에 [조건부 서식] 항목을 두고 `createCondFmtPanel(ctx)` 를 띄움.
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellRange,
CondFmtCompareOp,
CondFmtCondition,
CondFmtRule,
CondFmtStyle,
Sheet,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { CondFmtCompareOp, CondFmtCondition, CondFmtRule, CondFmtStyle };
/** 칸 값을 이미 수 · 글 · 참거짓 · 빈칸으로 바꿔 넘김(Frac → 수는 호출부 몫 — 이 파일은 엔진을 모름). */
export type CondFmtValue = number | string | boolean | null;
function testCondition(
condition: CondFmtCondition,
value: CondFmtValue,
evalFormula: (식: string) => boolean,
): boolean {
switch (condition.종류) {
case "비교": {
if (typeof value !== "number") return false;
switch (condition.연산) {
case "보다큼":
return value > condition.값;
case "보다작음":
return value < condition.값;
case "크거나같음":
return value >= condition.값;
case "작거나같음":
return value <= condition.값;
case "같음":
return value === condition.값;
case "다름":
return value !== condition.값;
}
break;
}
case "사이":
return typeof value === "number" && value >= condition.아래 && value <= condition.위;
case "글포함":
return typeof value === "string" && value.includes(condition.글);
case "수식":
return evalFormula(condition.식);
}
return false;
}
export interface CondFmtEnv {
value(r: number, c: number): CondFmtValue;
/** 없으면 「수식이 참」 조건은 늘 거짓 */
evalFormula?(formula: string, r: number, c: number): boolean;
}
/** 칸 하나에 먹일 조건부 서식 — 처음 맞는 규칙(우선순위 위) · 없으면 null. */
export function resolveCondFmtStyle(
sheet: Sheet,
r: number,
c: number,
env: CondFmtEnv,
): CondFmtStyle | null {
for (const rule of sheet.조건부서식 ?? []) {
if (!rule.범위.some((range) => inRange(range, r, c))) continue;
const value = env.value(r, c);
const hit = testCondition(rule.조건, value, (식) => env.evalFormula?.(식, r, c) ?? false);
if (hit) return rule.스타일;
}
return null;
}
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
export function remapCondFmtRanges(
rules: CondFmtRule[],
shift: (range: CellRange) => CellRange | null,
): CondFmtRule[] {
const out: CondFmtRule[] = [];
for (const rule of rules) {
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
if (moved.length) out.push({ ...rule, 범위: moved });
}
return out;
}
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
let seq = 0;
const newId = (): string => `cf${Date.now().toString(36)}${seq++}`;
export function addCondFmtRule(ctx: SpreadsheetContext, rule: Omit<CondFmtRule, "id">): void {
const sheet = ctx.sheet();
applySheet(ctx, {
...sheet,
조건부서식: [...(sheet.조건부서식 ?? []), { ...rule, id: newId() }],
});
}
export function removeCondFmtRule(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 조건부서식: (sheet.조건부서식 ?? []).filter((r) => r.id !== id) });
}
/** 우선순위 한 칸 옮김(엑셀 「규칙 관리」 위 · 아래 화살표와 같음). */
export function moveCondFmtRule(ctx: SpreadsheetContext, id: string, dir: -1 | 1): void {
const sheet = ctx.sheet();
const list = [...(sheet.조건부서식 ?? [])];
const idx = list.findIndex((r) => r.id === id);
const swapWith = idx + dir;
if (idx < 0 || swapWith < 0 || swapWith >= list.length) return;
[list[idx], list[swapWith]] = [list[swapWith], list[idx]];
applySheet(ctx, { ...sheet, 조건부서식: list });
}
const conditionText = (c: CondFmtCondition): string => {
switch (c.종류) {
case "비교":
return `${c.연산} ${c.값}`;
case "사이":
return `${c.아래} ~ ${c.위} 사이`;
case "글포함":
return `「${c.글}」 포함`;
case "수식":
return `수식 =${c.식}`;
}
};
/** 규칙 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. 잇는 법 머리 주석 참고. */
export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-condfmt" });
const list = el("ul", { className: "ss-condfmt__list" });
const compareOps: CondFmtCompareOp[] = [
"보다큼",
"보다작음",
"크거나같음",
"작거나같음",
"같음",
"다름",
];
const kindSelect = createSelectField({
label: "조건",
options: [
{ value: "비교", text: "값 비교" },
{ value: "사이", text: "사이" },
{ value: "글포함", text: "글 포함" },
{ value: "수식", text: "수식이 참" },
],
});
const opSelect = createSelectField({
label: "연산",
options: compareOps.map((op) => ({ value: op, text: op })),
});
const val1 = createInputField({ label: "값(또는 아래 · 글 · 수식)" });
const val2 = createInputField({ label: "위(사이일 때만)" });
const fill = createInputField({ label: "채움색", type: "text", placeholder: "#RRGGBB" });
const fontColor = createInputField({ label: "글자색", type: "text", placeholder: "#RRGGBB" });
const addBtn = createButton({ label: "규칙 추가" });
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 조건 = buildCondition(kindSelect.select.value, opSelect.select.value, val1, val2);
if (!조건) return showToast("조건 값을 넣을 것", "warning");
const colors = [fill.input.value.trim(), fontColor.input.value.trim()];
if (colors.some((c) => c && !/^#[0-9a-f]{6}$/i.test(c)))
return showToast("색은 #RRGGBB 꼴로", "warning");
if (!colors[0] && !colors[1]) return showToast("채움색이나 글자색을 넣을 것", "warning");
const 스타일: CondFmtStyle = {};
if (colors[0]) 스타일.채움 = colors[0];
if (colors[1]) 스타일.글자색 = colors[1];
addCondFmtRule(ctx, { 범위: [...범위], 조건, 스타일 });
render();
});
function buildCondition(
kind: string,
op: string,
a: ReturnType<typeof createInputField>,
b: ReturnType<typeof createInputField>,
): CondFmtCondition | null {
const blank = (f: typeof a): boolean => f.input.value.trim() === "";
if (kind === "비교") {
if (blank(a)) return null;
const n = Number(a.input.value);
return Number.isFinite(n) ? { 종류: "비교", 연산: op as CondFmtCompareOp, 값: n } : null;
}
if (kind === "사이") {
if (blank(a) || blank(b)) return null;
const 아래 = Number(a.input.value);
const 위 = Number(b.input.value);
return Number.isFinite(아래) && Number.isFinite(위) ? { 종류: "사이", 아래, 위 } : null;
}
if (kind === "글포함") return a.input.value ? { 종류: "글포함", 글: a.input.value } : null;
if (kind === "수식") return a.input.value ? { 종류: "수식", 식: a.input.value } : null;
return null;
}
function render(): void {
const rules = ctx.sheet().조건부서식 ?? [];
list.replaceChildren(
...rules.map((rule, idx) => {
const li = el("li", { className: "ss-condfmt__row" });
const swatch = el("span", { className: "ss-condfmt__swatch" });
if (rule.스타일.채움) swatch.style.background = rule.스타일.채움;
const label = el("span", {
text: `${rule.범위.map(rangeToA1).join(", ")} — ${conditionText(rule.조건)}`,
});
const up = createButton({ label: "↑", variant: "ghost" });
up.disabled = idx === 0;
up.addEventListener("click", () => {
moveCondFmtRule(ctx, rule.id, -1);
render();
});
const down = createButton({ label: "↓", variant: "ghost" });
down.disabled = idx === rules.length - 1;
down.addEventListener("click", () => {
moveCondFmtRule(ctx, rule.id, 1);
render();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
removeCondFmtRule(ctx, rule.id);
render();
});
li.append(swatch, label, up, down, del);
return li;
}),
);
}
const form = el("div", {
className: "ss-condfmt__form",
children: [
kindSelect.root,
opSelect.root,
val1.root,
val2.root,
fill.root,
fontColor.root,
addBtn,
],
});
root.append(el("h4", { text: "조건부 서식 규칙" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,329 @@
/* =============================================================================
* spreadsheet_data_menu.ts (주인 D)
* 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾].
* 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) ·
* 조건부 서식 · 데이터 유효성 · 이름 관리 · 시트 숨김/탭 색(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 ·
* 수식 분석(sub3 — 선행 · 종속 추적 화살표 · 화살표 지우기 · 수식 계산 · 오류 ⚠ 풍선) · 자동 합계(Σ · Alt+=) ·
* 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽(`spreadsheet_actions.ts`).
* 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저).
* 누를 때만 쓰는 창(정렬 · 조건부 서식 · 시트)은 `import()` 로 늦게 — 크기 예산(열 때 받는 조각).
* ========================================================================== */
import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임)
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { autoSumCells, autoSumRange } from "./spreadsheet_autosum";
import { isError } from "./spreadsheet_eval";
import type { FilterHandle } from "./spreadsheet_filter";
import { createCondFmtPanel } from "./spreadsheet_condfmt";
import { createSheetExtraPanel } from "./spreadsheet_sheet_extra";
import { mountActions } from "./spreadsheet_actions";
import type { ErrorTipHandle } from "./spreadsheet_errcheck";
import type { TraceHandle } from "./spreadsheet_trace";
import {
createValidationDropdown,
createValidationPanel,
findValidation,
} from "./spreadsheet_validation";
import type { KeyHooks } from "./spreadsheet_keys";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
asc: "오름차순 정렬(활성 칸 열 기준)",
desc: "내림차순 정렬(활성 칸 열 기준)",
filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)",
menu: "데이터 ▾",
sort: "정렬…",
filterItem: "자동 필터 켜기 · 끄기",
condfmt: "조건부 서식…",
validation: "데이터 유효성…",
names: "이름 관리…",
grid: "눈금선 보이기 · 숨기기",
close: "닫기",
list: "값 목록(Alt+↓)",
sheets: "시트 숨기기 · 탭 색…",
audit: "수식 분석",
prec: "선행 참조 추적",
dep: "종속 참조 추적",
clear: "화살표 지우기",
steps: "수식 계산…",
noMore: "더 추적할 칸 없음",
sum: "자동 합계(Alt+=)",
};
export interface DataMenu {
/** 수식 입력줄 앞에 둘 단추들 */
buttons: HTMLElement[];
keys: Pick<KeyHooks, "filter" | "dropdown" | "autosum" | "more">;
part: PartHandle;
}
export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu {
// 자동 필터는 시트에 필터가 있거나 켤 때 불러옴(크기 예산)
let filter: FilterHandle | null = null;
let filterMod: Promise<typeof import("./spreadsheet_filter")> | null = null;
const loadFilter = () =>
(filterMod ??= import("./spreadsheet_filter").then((m) => {
filter = m.attachFilter(ctx);
filter.refresh();
return m;
}));
const toggleFilter = (): void => void loadFilter().then((m) => m.toggleFilter(ctx));
// 수식 분석(추적 · ⚠ 풍선 · 수식 계산)은 처음 쓸 때 — 메뉴 · 오류 칸 · 글 숫자 칸을 고르면
type Audit = { trace: TraceHandle; tip: ErrorTipHandle };
let audit: Audit | null = null;
let loading: Promise<Audit> | null = null;
const loadAudit = () =>
(loading ??= Promise.all([
import("./spreadsheet_trace"),
import("./spreadsheet_errcheck"),
]).then(([t, e]) => {
const trace = t.attachTrace(ctx);
audit = { trace, tip: e.attachErrorTip(ctx, trace) };
audit.tip.refresh();
return audit;
}));
const warnAt = (): boolean => {
const { r, c } = ctx.selection.활성;
const cell = ctx.sheet().칸[toA1(r, c)];
if (!cell) return false;
if (cell.식 !== undefined) return isError(ctx.engine.value(ctx.selection.시트, r, c));
return typeof cell.값 === "string" && /\d/.test(cell.값);
};
let closeSteps: (() => void) | null = null;
const traced = (more: boolean): void => {
if (!more) showToast(TEXT.noMore, "info");
back();
};
const act = mountActions(ctx, back);
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
let closeSort: (() => void) | null = null;
const button = (text: string, title: string, run: (b: HTMLElement) => void, keep = false) => {
const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } });
b.addEventListener("click", () => {
run(b);
if (!keep) back();
});
return b;
};
// ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ─────────────────────
const title = el("span", { className: "ss-dialog__title" });
const body = el("div", { className: "ss-dialog__body" });
const shut = el("button", {
className: "ss-find__btn",
text: "×",
attrs: { type: "button", title: TEXT.close },
});
const dialog = el("div", {
className: "ss-dialog",
attrs: { hidden: "" },
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
});
ctx.root.append(dialog);
let panel: PartHandle | null = null;
const closePanel = (): void => {
panel?.destroy();
panel = null;
body.replaceChildren();
dialog.setAttribute("hidden", "");
};
shut.addEventListener("click", () => (closePanel(), back()));
dialog.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.stopPropagation();
closePanel();
back();
});
async function openPanel(
name: string,
load: () => Promise<(c: SpreadsheetContext) => PartHandle>,
): Promise<void> {
const make = await load();
closePanel();
panel = make(ctx);
title.textContent = name.replace("…", "");
if (panel.root) body.append(panel.root);
dialog.removeAttribute("hidden");
}
const sorting = () => import("./spreadsheet_sort_dialog");
async function sortQuick(desc: boolean): Promise<void> {
(await sorting()).sortSelection(ctx, desc);
back();
}
async function sortDialog(): Promise<void> {
const m = await sorting();
closeSort?.();
closeSort = m.openSortDialog(ctx);
}
async function evalSteps(): Promise<void> {
const m = await import("./spreadsheet_eval_steps");
closeSteps?.();
closeSteps = m.openEvalSteps(ctx);
}
function openMenu(anchor: HTMLElement): void {
const a = anchor.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 뿌리 기준 자리(칸 우클릭 메뉴와 같음)
const r = { left: a.left - o.left, bottom: a.bottom - o.top };
const items: MapContextMenuItem[] = [
[TEXT.sort, () => void sortDialog()],
[TEXT.filterItem, () => (toggleFilter(), back())],
[TEXT.condfmt, () => void openPanel(TEXT.condfmt, async () => createCondFmtPanel)],
[TEXT.validation, () => void openPanel(TEXT.validation, async () => createValidationPanel)],
[
TEXT.names,
() =>
void openPanel(
TEXT.names,
async () => (await import("./spreadsheet_names")).createNameManagerPanel,
),
],
...(ctx.root.classList.contains("ss--notabs") // 시트 탭 없음(산출근거) — 시트 창도 없음
? []
: [
[
TEXT.sheets,
() => void openPanel(TEXT.sheets, async () => createSheetExtraPanel),
] as MapContextMenuItem,
]),
[TEXT.grid, () => (toggleGridlines(), back())],
...act.items,
{
label: TEXT.audit,
children: [
[TEXT.prec, () => void loadAudit().then((a) => traced(a.trace.precedents()))],
[TEXT.dep, () => void loadAudit().then((a) => traced(a.trace.dependents()))],
[TEXT.clear, () => (audit?.trace.clear(), back())],
[TEXT.steps, () => void evalSteps()],
],
},
];
if (!ctx.readOnly) menu.open(r.left, r.bottom, items);
}
function toggleGridlines(): void {
const s = ctx.sheet();
ctx.dispatch({ 종류: "눈금선", 시트: s.id, 보임: s.보기?.눈금선 === false });
}
// ── 유효성 목록 — ▼ 단추 · Alt+↓ ─────────────────────────────────────────
let drop: HTMLElement | null = null;
const closeDrop = (): void => {
drop?.remove();
drop = null;
};
const arrow = el("button", {
className: "ss-list-arrow",
text: "▼",
attrs: { type: "button", hidden: "", title: TEXT.list },
});
arrow.addEventListener("mousedown", (e) => e.preventDefault());
arrow.addEventListener("click", () => openList());
ctx.root.append(arrow);
/** 칸 자리(부품 뿌리 기준) */
const at = (r: number, c: number) => {
const { box } = ctx.grid.editorSlot(r, c);
const g = ctx.grid.root.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
return { x: box.x + g.left - o.left, y: box.y + g.top - o.top, w: box.w, h: box.h };
};
function openList(): boolean {
const { r, c } = ctx.selection.활성;
const rule = findValidation(ctx.sheet(), r, c);
if (ctx.readOnly || !rule) return false;
closeDrop();
drop = createValidationDropdown(ctx, r, c, rule, (value) => {
closeDrop();
const key = toA1(r, c);
const n = Number(value);
const 값 = value !== "" && Number.isFinite(n) ? n : value;
const 서식 = ctx.sheet().칸[key]?.서식;
const cell = 서식 === undefined ? { 값 } : { 값, 서식 };
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [key]: cell } });
back();
});
if (!drop) return false;
const p = at(r, c);
drop.style.left = `${p.x}px`; // cellBox(스크롤 전 내용 좌표) 자리를 화면 자리로 고침
drop.style.top = `${p.y + p.h}px`;
ctx.root.append(drop);
return true;
}
const onOutside = (e: MouseEvent): void => {
if (drop && !drop.contains(e.target as Node) && e.target !== arrow) closeDrop();
};
document.addEventListener("mousedown", onOutside, true);
/** 자동 합계 — 엑셀처럼 =SUM(잡은 범위) 로 편집을 열고 Enter 는 사용자 몫 */
function autosum(): void {
const cells = autoSumCells(ctx);
if (cells) return ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
const g = autoSumRange(ctx);
ctx.editor.begin(g ? `=SUM(${rangeToA1(g)})` : "=SUM()");
}
const buttons = ctx.readOnly
? []
: [
button("↑", TEXT.asc, () => void sortQuick(false), true),
button("↓", TEXT.desc, () => void sortQuick(true), true),
button("필터", TEXT.filter, toggleFilter),
button("Σ", TEXT.sum, autosum, true),
button(TEXT.menu, TEXT.menu, openMenu, true),
];
return {
buttons,
keys: {
filter: toggleFilter,
autosum,
more: act.key,
dropdown() {
const f = ctx.sheet().필터;
const g = f && parseRange(f.범위);
const { r, c } = ctx.selection.활성;
if (g && filter && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true);
return openList();
},
},
part: {
root: null,
refresh() {
filter?.refresh();
if (!filter && ctx.sheet().필터) void loadFilter();
audit?.trace.refresh();
audit?.tip.refresh();
if (!audit && warnAt()) void loadAudit();
panel?.refresh();
closeDrop();
const { r, c } = ctx.selection.활성;
const list = !ctx.readOnly && findValidation(ctx.sheet(), r, c)?.규칙.종류 === "목록";
arrow.hidden = !list || ctx.editor.editing();
if (!list) return;
const p = at(r, c);
arrow.style.left = `${p.x + p.w + 1}px`;
arrow.style.top = `${p.y}px`;
arrow.style.height = `${p.h}px`;
},
destroy() {
document.removeEventListener("mousedown", onOutside, true);
closeSort?.();
closePanel();
closeDrop();
filter?.destroy();
closeSteps?.();
audit?.trace.destroy();
audit?.tip.destroy();
menu.element.remove();
dialog.remove();
arrow.remove();
buttons.forEach((b) => b.remove());
},
},
};
}
@@ -0,0 +1,433 @@
/* =============================================================================
* spreadsheet_data_tools.ts (2단계 · 주인 sub_laptop_1)
* 데이터 도구 셋 — ① 텍스트 나누기(구분 기호 · 고정 폭 · 미리보기) ② 중복된 항목 제거
* (열 고르기 · 지운 수 알림) ③ 채우기 연속 데이터(단계 값 · 끝 값 · 행/열).
* 문서를 바꾸는 길은 「칸」 명령 하나뿐 — 세 도구 모두 이것만 씀.
*
* 잇는 법(D · sub7) — 메뉴 [데이터] 아래 세 항목을 만들어 `openTextToColumnsDialog(ctx)` ·
* `openRemoveDuplicatesDialog(ctx)` · `openFillSeriesDialog(ctx)` 를 각각 부름
* (`spreadsheet_menu.ts` 우클릭 메뉴에도 [중복된 항목 제거]는 달아 둘 만함).
*
* 고정 폭 나누기는 실제 엑셀처럼 눈금자를 끌어 자르지 않고 자를 자리(문자 수)를
* 쉼표로 적는 입력 한 줄로 단순화함(미리보기로 바로 확인 가능 — 자리 표시만 다름).
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
import { dialogKeys } from "./spreadsheet_actions";
import { colName, toA1 } from "./spreadsheet_address";
import type { Cell, CellAddress, CellInput, CellRange, Scalar } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
textToColumns: "텍스트 나누기",
byDelimiter: "구분 기호로",
byFixedWidth: "고정 폭으로",
delimiters: "구분 기호",
tab: "탭",
semicolon: "세미콜론",
comma: "쉼표",
space: "공백",
custom: "사용자 지정",
breaks: "자를 자리(문자 수, 쉼표로 구분)",
preview: "미리보기",
run: "실행",
cancel: "취소",
removeDuplicates: "중복된 항목 제거",
hasHeader: "머리글 포함",
keyColumns: "비교할 열",
removed: (n: number) => `중복 ${n}개 지움`,
fillSeries: "연속 데이터 채우기",
direction: "방향",
down: "아래로(열)",
right: "오른쪽으로(행)",
step: "단계 값",
stop: "끝 값(선택)",
};
// ═════════════════════════════════════════════════════════════════════════════
// 공용 — 값 ↔ 글, 작은 대화상자 뼈대
// ═════════════════════════════════════════════════════════════════════════════
function scalarText(v: Scalar): string {
if (v == null) return "";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (typeof v === "string") return v;
if (typeof v === "object" && "error" in v) return v.error;
return fracToString(v);
}
function cellInputFrom(text: string): CellInput {
const n = Number(text);
return text !== "" && !Number.isNaN(n) ? n : text;
}
function row(label: string, ...controls: HTMLElement[]): HTMLElement {
const r = el("div", {
children: [el("span", { text: label }), ...controls],
});
r.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:6px;";
return r;
}
/** `.ui-confirm` 배경 위에 제목 · 본문 · [실행]/[취소] 를 올린 작은 대화상자. */
function modal(title: string, body: HTMLElement[], onRun: () => void, runLabel = TEXT.run): void {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
panel.style.minWidth = "360px";
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
panel.append(el("h3", { text: title }), ...body);
panel.append(
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({
label: runLabel,
variant: "filled",
onClick: () => {
onRun();
close();
},
}),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, close, () => (onRun(), close()));
}
function readRange(ctx: SpreadsheetContext): CellRange | null {
return ctx.selection.범위[0] ?? null;
}
// ═════════════════════════════════════════════════════════════════════════════
// 1. 텍스트 나누기
// ═════════════════════════════════════════════════════════════════════════════
export type DelimiterKind = "탭" | "세미콜론" | "쉼표" | "공백" | "사용자";
const DELIM_CHARS: Record<Exclude<DelimiterKind, "사용자">, string> = {
탭: "\t",
세미콜론: ";",
쉼표: ",",
공백: " ",
};
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 한 줄을 고른 구분 기호로 나눔 — 기호가 하나도 없으면 원문 그대로 한 칸. */
export function splitDelimited(line: string, kinds: DelimiterKind[], custom: string): string[] {
const chars = kinds
.map((k) => (k === "사용자" ? custom : DELIM_CHARS[k]))
.filter((c): c is string => !!c);
if (chars.length === 0) return [line];
return line.split(new RegExp(chars.map(escapeRegExp).join("|")));
}
/** 한 줄을 자를 자리(문자 수, 오름차순)로 나눔. */
export function splitFixedWidth(line: string, breaks: number[]): string[] {
const points = [0, ...breaks.filter((b) => b > 0 && b < line.length), line.length];
const sorted = [...new Set(points)].sort((a, b) => a - b);
const out: string[] = [];
for (let i = 0; i < sorted.length - 1; i++) out.push(line.slice(sorted[i], sorted[i + 1]));
return out;
}
/** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 값만 지움 · 칸 서식은 그대로). */
export function applyTextToColumns(
ctx: SpreadsheetContext,
anchor: CellAddress,
rows: string[][],
): void {
const cells: Record<string, Cell | null> = {};
rows.forEach((cols, ri) => {
cols.forEach((text, ci) => {
const key = toA1(anchor.r + ri, anchor.c + ci);
const 서식 = ctx.sheet().칸[key]?.서식;
if (text !== "")
cells[key] =
서식 === undefined ? { 값: cellInputFrom(text) } : { 값: cellInputFrom(text), 서식 };
else cells[key] = 서식 === undefined ? null : { 서식 };
});
});
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function openTextToColumnsDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const lines: string[] = [];
for (let r = range.r0; r <= range.r1; r++) {
lines.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0)));
}
let mode: "구분" | "고정" = "구분";
let kinds: DelimiterKind[] = ["쉼표"];
let custom = "";
let breaksText = "";
const modeSelect = el("select", {
className: "ss-toolbar__select",
children: [
el("option", { attrs: { value: "구분" }, text: TEXT.byDelimiter }),
el("option", { attrs: { value: "고정" }, text: TEXT.byFixedWidth }),
],
}) as HTMLSelectElement;
const delimBox = el("div");
const kindChecks = (["탭", "세미콜론", "쉼표", "공백", "사용자"] as DelimiterKind[]).map((k) => {
const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
check.checked = kinds.includes(k);
check.addEventListener("change", () => {
kinds = check.checked ? [...kinds, k] : kinds.filter((x) => x !== k);
refreshPreview();
});
return row(
k === "탭"
? TEXT.tab
: k === "세미콜론"
? TEXT.semicolon
: k === "쉼표"
? TEXT.comma
: k === "공백"
? TEXT.space
: TEXT.custom,
check,
);
});
const customInput = createInputField({
label: TEXT.custom,
onInput: (v) => {
custom = v;
refreshPreview();
},
});
delimBox.append(...kindChecks, customInput.root);
const breaksField = createInputField({
label: TEXT.breaks,
placeholder: "5,10,20",
onInput: (v) => {
breaksText = v;
refreshPreview();
},
});
breaksField.root.hidden = true;
const previewBox = el("pre");
previewBox.style.cssText =
"max-height:160px;overflow:auto;background:var(--color-canvas);padding:6px;border-radius:4px;font-size:0.8rem;";
function splitAll(): string[][] {
if (mode === "고정") {
const breaks = breaksText
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
return lines.map((l) => splitFixedWidth(l, breaks));
}
return lines.map((l) => splitDelimited(l, kinds, custom));
}
function refreshPreview(): void {
previewBox.textContent = splitAll()
.slice(0, 8)
.map((cols) => cols.join(" | "))
.join("\n");
}
modeSelect.addEventListener("change", () => {
mode = modeSelect.value as "구분" | "고정";
delimBox.hidden = mode !== "구분";
breaksField.root.hidden = mode !== "고정";
refreshPreview();
});
refreshPreview();
modal(
TEXT.textToColumns,
[
row(TEXT.byDelimiter + "/" + TEXT.byFixedWidth, modeSelect),
delimBox,
breaksField.root,
el("div", { text: TEXT.preview }),
previewBox,
],
() => applyTextToColumns(ctx, { r: range.r0, c: range.c0 }, splitAll()),
);
}
// ═════════════════════════════════════════════════════════════════════════════
// 2. 중복된 항목 제거
// ═════════════════════════════════════════════════════════════════════════════
/** `keyCols`(범위 안 0 부터 상대 열)로 값이 같은 뒤쪽 행들의 자리(0 부터 상대 행)를 돌려줌. */
export function findDuplicateRowIndexes(rows: string[][], keyCols: number[]): number[] {
const seen = new Set<string>();
const dup: number[] = [];
rows.forEach((r, i) => {
const key = keyCols.map((c) => r[c] ?? "").join("\u0001");
if (seen.has(key)) dup.push(i);
else seen.add(key);
});
return dup;
}
/** 범위 안 값을 읽어 중복 행을 지우고 위로 당김(범위 밖 칸은 안 건드림) — 지운 행 수를 돌려줌. */
export function removeDuplicateRows(
ctx: SpreadsheetContext,
range: CellRange,
keyCols: number[],
skipHeader: boolean,
): number {
const width = range.c1 - range.c0 + 1;
const bodyR0 = range.r0 + (skipHeader ? 1 : 0);
const rows: string[][] = [];
for (let r = bodyR0; r <= range.r1; r++) {
const line: string[] = [];
for (let c = 0; c < width; c++)
line.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0 + c)));
rows.push(line);
}
const dupIdx = new Set(findDuplicateRowIndexes(rows, keyCols));
const kept = rows.filter((_, i) => !dupIdx.has(i));
const cells: Record<string, Cell | null> = {};
for (let i = 0; i < rows.length; i++) {
const r = bodyR0 + i;
for (let c = 0; c < width; c++) {
const text = i < kept.length ? kept[i][c] : "";
cells[toA1(r, range.c0 + c)] = text === "" ? null : { 값: cellInputFrom(text) };
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
return dupIdx.size;
}
export function openRemoveDuplicatesDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const width = range.c1 - range.c0 + 1;
let hasHeader = width > 1 || range.r1 > range.r0;
const headerCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
headerCheck.checked = hasHeader;
const keyCols = new Set<number>(Array.from({ length: width }, (_, i) => i));
const colChecks = Array.from({ length: width }, (_, i) => {
const c = range.c0 + i;
const label = hasHeader
? scalarText(ctx.engine.value(ctx.selection.시트, range.r0, c)) || colName(c)
: colName(c);
const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
check.checked = true;
check.addEventListener("change", () => {
if (check.checked) keyCols.add(i);
else keyCols.delete(i);
});
return row(label, check);
});
headerCheck.addEventListener("change", () => {
hasHeader = headerCheck.checked;
});
modal(
TEXT.removeDuplicates,
[row(TEXT.hasHeader, headerCheck), el("div", { text: TEXT.keyColumns }), ...colChecks],
() => {
const removed = removeDuplicateRows(ctx, range, [...keyCols], hasHeader);
showToast(TEXT.removed(removed), "success");
},
);
}
// ═════════════════════════════════════════════════════════════════════════════
// 3. 채우기 연속 데이터
// ═════════════════════════════════════════════════════════════════════════════
export type SeriesDirection = "행" | "열";
/** 시작값 · 단계 · 끝값(넘으면 멈춤, 없으면 count 까지) · 개수로 등차수열을 지음. */
export function buildLinearSeries(
start: number,
step: number,
stop: number | null,
count: number,
): number[] {
const out: number[] = [];
for (let i = 0; i < count; i++) {
const v = start + step * i;
if (stop != null && ((step >= 0 && v > stop) || (step < 0 && v < stop))) break;
out.push(v);
}
return out;
}
export function applyFillSeries(
ctx: SpreadsheetContext,
anchor: CellAddress,
direction: SeriesDirection,
values: number[],
): void {
const cells: Record<string, Cell | null> = {};
values.forEach((v, i) => {
const r = direction === "열" ? anchor.r + i : anchor.r;
const c = direction === "행" ? anchor.c + i : anchor.c;
const key = toA1(r, c);
const 서식 = ctx.sheet().칸[key]?.서식;
cells[key] = 서식 === undefined ? { 값: v } : { 값: v, 서식 };
});
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function openFillSeriesDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const at = ctx.selection.활성;
const startText = scalarText(ctx.engine.value(ctx.selection.시트, at.r, at.c));
const start = Number(startText) || 0;
let direction: SeriesDirection = range.r1 > range.r0 ? "열" : "행";
let step = 1;
let stop: number | null = null;
const dirSelect = el("select", {
className: "ss-toolbar__select",
children: [
el("option", { attrs: { value: "열" }, text: TEXT.down }),
el("option", { attrs: { value: "행" }, text: TEXT.right }),
],
}) as HTMLSelectElement;
dirSelect.value = direction;
dirSelect.addEventListener("change", () => (direction = dirSelect.value as SeriesDirection));
const stepField = createInputField({
label: TEXT.step,
type: "number",
value: "1",
onInput: (v) => (step = Number(v) || 0),
});
const stopField = createInputField({
label: TEXT.stop,
type: "number",
onInput: (v) => (stop = v === "" ? null : Number(v)),
});
modal(TEXT.fillSeries, [row(TEXT.direction, dirSelect), stepField.root, stopField.root], () => {
const extent = direction === "열" ? range.r1 - range.r0 + 1 : range.c1 - range.c0 + 1;
const values = buildLinearSeries(start, step, stop, extent);
applyFillSeries(ctx, at, direction, values);
});
}
@@ -0,0 +1,628 @@
/* =============================================================================
* spreadsheet_editor.ts (주인 D)
* 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기(식 편집 중 칸 · 범위를 눌러 넣기) ·
* 참조 칸 색 테두리.
* 한글: 숨은 textarea(`ta`) 가 늘 활성 칸 자리에서 초점을 쥠 → compositionstart · input 에서 편집을
* 시작하고 같은 textarea 가 그대로 칸 편집기가 됨(첫 글자 조합이 안 끊김 · keydown 글자 방식 금지).
* 방식(엑셀): enter = 바로 쳐서 시작(방향키가 확정 · 식이면 가리키기) · edit = F2 · 두 번 누르기 ·
* 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈.
* ========================================================================== */
import { el, showToast } from "@ui/ui_template_elements";
import { moveFormula } from "./spreadsheet_refshift";
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import type { FuncHelp } from "./spreadsheet_func_help";
import { parseFormula, tokenize } from "./spreadsheet_parser";
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
import { checkValue, findValidation } from "./spreadsheet_validation";
import { cellStyleAt } from "./spreadsheet_cellstyle";
import type { Cell, CellAddress, CellStyle, Command, Sheet } from "./spreadsheet_types";
import type {
CellBox,
EditorHandle,
RefHighlight,
Selection,
SpreadsheetContext,
} from "./spreadsheet_view_types";
export interface Editor extends EditorHandle {
/** 숨은 textarea = 칸 편집기(늘 초점) */
ta: HTMLTextAreaElement;
/** 주소 상자 + 수식 입력줄 한 줄 */
bar: HTMLElement;
formula: HTMLTextAreaElement;
mode(): "enter" | "edit";
toggleMode(): void;
/** 지금 글자 자리에 참조를 넣을 수 있나(식 · 연산자 뒤) */
pointable(): boolean;
/** 가리킨 고름을 식 글에 넣음(같은 가리키기면 바꿔 적음) */
point(sel: Selection): void;
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
/** 함수 자동 완성 목록이 떠 있으면 고른 이름을 넣음(Tab) */
accept(): boolean;
/** 자동 완성 목록 안 위아래(목록이 없으면 false) */
suggestMove(d: number): boolean;
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
commitAll(): void;
/** 초점 되찾기(스크롤 없이) */
focus(): void;
/** 편집 중이면 칸 자리 다시(스크롤 · 폭 바뀜) */
place(): void;
destroy(): void;
}
interface EditState {
sheet: string;
at: CellAddress;
mode: "enter" | "edit";
original: string;
/** 가리키기 중 — 식 글 [start, end) 가 가리킨 참조 */
point: { start: number; end: number; sel: Selection } | null;
}
/** 수 입력 — 1,234 · -1.5e3 · 50% (엑셀 입력 규칙) · 아니면 null */
export function parseNumber(text: string): number | null {
let t = text.trim();
const pct = t.endsWith("%");
if (pct) t = t.slice(0, -1);
if (!/\d/.test(t) || !/^[+-]?(\d{1,3}(,\d{3})+|\d*)(\.\d*)?([eE][+-]?\d+)?$/.test(t)) return null;
const n = Number(t.replace(/,/g, ""));
if (!Number.isFinite(n)) return null;
return pct ? n / 100 : n;
}
const looksTyped = (s: string): boolean =>
(s.length > 1 && s[0] === "=") ||
s[0] === "'" ||
parseNumber(s) !== null ||
/^(true|false)$/i.test(s);
/** 친 글 → 칸(서식은 두고) · 지울 칸이면 null */
export function textToCell(text: string, old?: Cell): Cell | null {
const base: Cell = old?.서식 !== undefined ? { 서식: old.서식 } : {};
if (text === "") return old?.서식 !== undefined ? base : null;
if (text.length > 1 && text[0] === "=") return { ...base, 식: text.slice(1) };
if (text[0] === "'") return { ...base, 값: text.slice(1) };
const n = parseNumber(text);
if (n !== null) return { ...base, 값: n };
if (/^(true|false)$/i.test(text)) return { ...base, 값: text.toUpperCase() === "TRUE" };
return { ...base, 값: text };
}
/** 친 모양대로 숫자 형식(엑셀) — `50%` → `0%` · `12.5%` → `0.0%` · `1,234.5` → `#,##0.0` · 아니면 null */
export function typedFormat(text: string): string | null {
const t = text.trim();
if (parseNumber(t) === null || /e/i.test(t)) return null;
const dec = /\.(\d+)/.exec(t)?.[1].length ?? 0;
const frac = dec ? "." + "0".repeat(dec) : "";
if (t.endsWith("%")) return `0${frac}%`;
if (t.includes(",")) return `#,##0${frac}`;
return null;
}
/** 식 글(앞 `=` 뺌) — 함수 · 참조 · 참거짓을 대문자로(엑셀) · 시트 이름 · 글 · 정의된 이름은 그대로 */
export function upperFormula(body: string): string {
return tokenize(body)
.map((t) => {
if (t.kind === "func" || t.kind === "bool") return t.text.toUpperCase();
if (t.kind !== "ref") return t.text;
const bang = t.text.lastIndexOf("!") + 1;
return t.text.slice(0, bang) + t.text.slice(bang).toUpperCase();
})
.join("");
}
/** 닫는 괄호만 모자란 식이면 채운 식(엑셀 자동 고침) · 아니면 null */
export function closeParens(value: string): string | null {
let depth = 0;
for (const t of tokenize(value.slice(1)))
if (t.kind === "paren") depth += t.text === "(" ? 1 : -1;
if (depth <= 0) return null;
const fixed = value + ")".repeat(depth);
return parseFormula(fixed).ok ? fixed : null;
}
/** 칸 → 편집 글(식은 `=` 붙임 · 수처럼 읽힐 글은 `'` 붙임) */
export function cellToText(cell: Cell | undefined): string {
if (!cell) return "";
if (cell.식 !== undefined) return "=" + cell.식;
const v = cell.값;
if (v === undefined) return "";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (typeof v === "number") return String(v);
return looksTyped(v) ? "'" + v : v;
}
/** 참조 넣을 자리 — 식이고 caret 앞(빈칸 건너)이 연산자 · 괄호 · 쉼표 · `=` */
export function insertableAt(text: string, pos: number): boolean {
if (text[0] !== "=") return false;
if (pos < text.length && /[A-Za-z0-9$_.!']/.test(text[pos])) return false;
let i = pos - 1;
while (i >= 0 && text[i] === " ") i--;
return i >= 0 && "=(,+-*/^&<>:;".includes(text[i]);
}
/** F4 — caret 에 걸친 참조의 $ 를 A1 → $A$1 → A$1 → $A1 → A1 로(범위면 두 끝 같이) · caret 은 참조 끝 */
export function cycleAbs(text: string, pos: number): { text: string; caret: number } | null {
if (text[0] !== "=") return null;
const t = tokenize(text.slice(1)).find(
(t) => t.kind === "ref" && t.start < pos && pos <= t.end + 1,
);
if (!t) return null;
const bang = t.text.lastIndexOf("!") + 1;
const part = /(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g;
const first = part.exec(t.text.slice(bang));
if (!first) return null;
const state = (first[1] ? 1 : 0) + (first[3] ? 2 : 0); // 0 없음 · 3 둘 다 · 2 행만 · 1 열만
const next = { 0: 3, 3: 2, 2: 1, 1: 0 }[state]!;
const ref =
t.text.slice(0, bang) +
t.text
.slice(bang)
.replace(
part,
(_m, _a, col, _b, row) => (next & 1 ? "$" : "") + col + (next & 2 ? "$" : "") + row,
);
const end = t.start + 1 + ref.length;
return { text: text.slice(0, t.start + 1) + ref + text.slice(t.end + 1), caret: end };
}
/** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */
export function refHighlights(
text: string,
host: string,
sheetId: (name: string) => string | null,
): RefHighlight[] {
if (text[0] !== "=") return [];
const out: RefHighlight[] = [];
const colors = new Map<string, number>();
for (const t of tokenize(text.slice(1))) {
if (t.kind !== "ref") continue;
const bang = t.text.lastIndexOf("!");
let sheet: string | null = host;
if (bang >= 0) {
const raw = t.text.slice(0, bang);
const name = raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw;
sheet = sheetId(name);
}
const range = parseRange(t.text.slice(bang + 1).replace(/\$/g, ""));
if (!sheet || !range) continue;
const key = t.text.toUpperCase();
if (!colors.has(key)) colors.set(key, colors.size);
out.push({ 시트: sheet, 범위: range, 색번호: colors.get(key)! });
}
return out;
}
/** 칸 서식(칸 → 행 → 열 → 기본) */
function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): CellStyle {
const idx =
sheet.칸[toA1(r, c)]?.서식 ??
sheet.행?.[String(r + 1)]?.서식 ??
sheet.열?.[colName(c)]?.서식 ??
0;
return ctx.book.서식[idx] ?? {};
}
/** 식 글 caret 앞 → 함수 이름 치는 중(prefix) · 함수 인자 안(func · 몇 번째 인자 0 부터) · 아님 null */
export function formulaHint(
pre: string,
): { prefix: string } | { func: string; arg: number } | null {
if (pre[0] !== "=") return null;
const m = /(?:^=|[(,+\-*/^&<>:;= ])([A-Za-z][A-Za-z0-9.]*)$/.exec(pre);
if (m && !/^\$?[A-Za-z]{1,3}\$?\d+$/.test(m[1])) return { prefix: m[1] };
let depth = 0;
let arg = 0;
let inStr = false;
for (let i = pre.length - 1; i > 0; i--) {
const ch = pre[i];
if (ch === '"') inStr = !inStr;
if (inStr) continue;
if (ch === ")") depth++;
else if (ch === "," && depth === 0) arg++;
else if (ch === "(") {
if (depth-- > 0) continue;
const n = /([A-Za-z][A-Za-z0-9.]*)$/.exec(pre.slice(0, i));
return n ? { func: n[1].toUpperCase(), arg } : null;
}
}
return null;
}
/** 편집기 · 미리보기 자리 — C `editorSlot` 의 box 는 격자 뿌리 기준이라 층(layer)이 뿌리 안에서
* 비켜 놓인 만큼 빼서 층 안 좌표로 맞춤. */
export function slotOf(
ctx: SpreadsheetContext,
r: number,
c: number,
): { layer: HTMLElement; box: CellBox } {
const { layer, box } = ctx.grid.editorSlot(r, c);
const a = layer.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { layer, box: { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) } };
}
/** 칸 자리 → 층 안 자리 */
export function boxStyle(node: HTMLElement, box: CellBox): void {
node.style.left = `${box.x}px`;
node.style.top = `${box.y}px`;
node.style.minWidth = `${box.w}px`;
node.style.minHeight = `${box.h}px`;
}
export function createEditor(ctx: SpreadsheetContext): Editor {
const ta = el("textarea", {
className: "ss-input ss-input--idle",
attrs: { spellcheck: "false", autocomplete: "off", rows: "1", "aria-label": "칸 입력" },
});
const addr = el("input", {
className: "ss-addr",
attrs: { spellcheck: "false", "aria-label": "주소 상자" },
});
const formula = el("textarea", {
className: "ss-formula",
attrs: { spellcheck: "false", rows: "1", "aria-label": "수식 입력줄" },
});
const bar = el("div", {
className: "ss-bar",
children: [addr, el("span", { className: "ss-fx", text: "fx" }), formula],
});
formula.readOnly = ctx.readOnly;
ta.readOnly = ctx.readOnly;
let st: EditState | null = null;
const src = (): HTMLTextAreaElement => (document.activeElement === formula ? formula : ta);
const text = (): string => src().value;
const sheetOf = (id: string): Sheet => ctx.book.시트.find((s) => s.id === id) ?? ctx.sheet();
const sheetId = (name: string): string | null =>
ctx.book.시트.find((s) => s.이름.toUpperCase() === name.toUpperCase())?.id ?? null;
function place(): void {
const at = st?.at ?? ctx.selection.활성;
const { layer, box } = slotOf(ctx, at.r, at.c);
if (ta.parentElement !== layer) {
const had = document.activeElement === ta;
layer.append(ta);
if (had) focus();
}
boxStyle(ta, box);
if (!st) return;
const s = styleAt(ctx, sheetOf(st.sheet), at.r, at.c);
ta.style.fontFamily = s.글꼴 ?? "";
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
ta.style.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : "";
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
ta.style.width = "0";
ta.style.height = "0";
ta.style.width = `${Math.max(box.w, ta.scrollWidth + 4)}px`;
ta.style.height = `${Math.max(box.h, ta.scrollHeight)}px`;
}
function focus(): void {
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
}
function highlight(): void {
const t = st ? text() : "";
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
showHint();
}
// ── 함수 자동 완성 · 인자 도움말 (spreadsheet_func_help · sub1) ────────────────
const hint = el("div", { className: "ss-hint", attrs: { hidden: "" } });
let sugg: FuncHelp[] = [];
let pick = 0;
let typed = "";
let fh: typeof import("./spreadsheet_func_help") | null = null; // 식을 처음 칠 때 불러옴 — 크기 예산
function showHint(): void {
const node = src();
const h = st ? formulaHint(node.value.slice(0, node.selectionEnd)) : null;
if (h && !fh) return void import("./spreadsheet_func_help").then((m) => ((fh = m), showHint()));
sugg = h && "prefix" in h ? fh!.suggestFunctions(h.prefix).slice(0, 8) : [];
typed = h && "prefix" in h ? h.prefix : "";
const help = h && "func" in h ? fh!.funcHelp(h.func) : null;
if (!sugg.length && !help) return void hint.setAttribute("hidden", "");
pick = Math.min(pick, Math.max(0, sugg.length - 1));
hint.replaceChildren(
...(sugg.length
? sugg.map((f, i) => {
const row = el("div", {
className: i === pick ? "ss-hint__item is-on" : "ss-hint__item",
children: [el("b", { text: f.이름 }), ` ${f.뜻}`],
});
row.addEventListener("mousedown", (e) => {
e.preventDefault();
pick = i;
accept();
});
return row;
})
: [el("div", { className: "ss-hint__help", text: `${help!.꼴} — ${help!.뜻}` })]),
);
if (hint.parentElement !== ta.parentElement) ta.parentElement?.append(hint);
hint.style.left = ta.style.left;
hint.style.top = `${parseFloat(ta.style.top || "0") + ta.offsetHeight}px`;
hint.removeAttribute("hidden");
}
function accept(): boolean {
if (!st || !sugg.length) return false;
const node = src();
const pos = node.selectionEnd;
const name = `${sugg[pick].이름}(`;
node.setRangeText(name, pos - typed.length, pos, "end");
(node === ta ? formula : ta).value = node.value;
pick = 0;
place();
highlight();
return true;
}
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
function start(mode: "enter" | "edit", value?: string, from: HTMLTextAreaElement = ta): void {
if (ctx.readOnly || st) return;
const sel = ctx.selection;
const sheet = ctx.sheet();
const original = cellToText(sheet.칸[toA1(sel.활성.r, sel.활성.c)]);
st = { sheet: sheet.id, at: { ...sel.활성 }, mode, original, point: null };
if (value !== undefined) from.value = value;
(from === ta ? formula : ta).value = from.value;
ta.classList.remove("ss-input--idle");
ctx.root.classList.add("ss--editing");
place();
highlight();
if (from === ta && value !== undefined) ta.setSelectionRange(ta.value.length, ta.value.length);
}
function end(): void {
st = null;
ta.value = "";
ta.classList.add("ss-input--idle");
ta.removeAttribute("style");
ctx.root.classList.remove("ss--editing");
ctx.grid.setRefHighlights([]);
hint.setAttribute("hidden", "");
sync();
focus();
}
/** 칸에 적음 — 식이 틀렸거나(인자 수 등) 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */
function write(cells: CellAddress[], value: string, from: CellAddress): boolean {
if (!st) return true;
const isFormula = value.length > 1 && value[0] === "=";
if (isFormula && !parseFormula(value).ok) value = closeParens(value) ?? value;
const parsed = isFormula ? parseFormula(value) : null;
if (parsed?.ok) value = "=" + upperFormula(value.slice(1));
if (parsed && !parsed.ok) {
showToast(parsed.message, "error");
const node = src();
const at = Math.min(parsed.at + 1, node.value.length);
st.mode = "edit"; // 방향키가 글자 사이를 움직이게
st.point = null;
node.setSelectionRange(at, at);
return false;
}
const sheet = sheetOf(st.sheet);
const out: Record<string, Cell | null> = {};
const warn = new Set<string>();
for (const a of cells) {
let v = value;
if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c))
v = "=" + moveFormula(v.slice(1), a.r - from.r, a.c - from.c);
const key = toA1(a.r, a.c);
const next = textToCell(v, sheet.칸[key]);
if (next === null && !sheet.칸[key]) continue;
const rule = next?.값 !== undefined ? findValidation(sheet, a.r, a.c) : undefined;
const check = rule && checkValue(rule, next!.값!);
if (check && !check.ok) {
if (check.어기면 === "막음") return (showToast(check.메시지, "error"), false);
warn.add(check.메시지);
}
out[key] = next;
}
for (const m of warn) showToast(m, "warning");
if (!Object.keys(out).length) return true;
const cellCmd: Command = { 종류: "칸", 시트: sheet.id, 칸: out };
const fmt = isFormula ? null : typedFormat(value);
// `%` 는 글 형식(`@`) · 이미 % 형식만 빼고 바꿈 · 천 단위 쉼표는 일반 형식일 때만(엑셀)
const takes = (code?: string): boolean =>
!code ||
/^(general|g\/표준)$/i.test(code) ||
(fmt!.endsWith("%") && !code.includes("%") && code.trim() !== "@");
const fmtAt = fmt
? cells.filter((a) => takes(cellStyleAt(ctx.book, sheet, a.r, a.c).형식))
: [];
ctx.dispatch(
fmtAt.length
? {
종류: "묶음",
명령: [
cellCmd,
{
종류: "서식",
시트: sheet.id,
범위: fmtAt.map((a) => ({ r0: a.r, c0: a.c, r1: a.r, c1: a.c })),
바꿀: { 형식: fmt! },
},
],
}
: cellCmd,
);
return true;
}
function commit(): void {
if (!st) return;
const value = text();
if (value !== st.original && !write([st.at], value, st.at)) return;
end();
}
function commitAll(): void {
if (!st) return;
if (!write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at)) return;
end();
}
function cancel(): void {
if (!st) return;
end();
}
function sync(): void {
const sel = ctx.selection;
addr.value = toA1(sel.활성.r, sel.활성.c);
if (st) return;
formula.value = cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]);
place();
}
function pointable(): boolean {
if (!st) return false;
const node = src();
if (node.selectionStart !== node.selectionEnd) return false;
const pos = node.selectionEnd;
if (st.point && st.point.end === pos) return true;
st.point = null;
return insertableAt(node.value, pos);
}
function point(sel: Selection): void {
if (!st) return;
const node = src();
const other = node === ta ? formula : ta;
const pos = node.selectionEnd;
const p = st.point ?? { start: pos, end: pos, sel };
const g = sel.범위[0];
const ref = rangeToA1(g);
node.value = node.value.slice(0, p.start) + ref + node.value.slice(p.end);
other.value = node.value;
st.point = { start: p.start, end: p.start + ref.length, sel };
node.setSelectionRange(st.point.end, st.point.end);
place();
highlight();
ctx.grid.reveal(sel.활성.r === g.r0 ? g.r1 : g.r0, sel.활성.c === g.c0 ? g.c1 : g.c0);
}
function pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void {
if (!st) return;
const sheet = sheetOf(st.sheet);
const from = st.point?.sel ?? selectCell(sheet, st.at.r, st.at.c);
point(move(sheet, from, dr, dc, { extend, jump }));
}
// ── 한글 조합 · 바로 치기 → 편집 시작 ─────────────────────────────────────
ta.addEventListener("compositionstart", () => {
if (!st) start("enter");
});
ta.addEventListener("input", () => {
if (!st) return start("enter");
formula.value = ta.value;
if (st.point && ta.selectionEnd !== st.point.end) st.point = null;
place();
highlight();
});
// 수식 입력줄 — 누르면 edit 방식으로 시작
formula.addEventListener("focus", () => {
if (!st && !ctx.readOnly) start("edit", formula.value, formula);
});
formula.addEventListener("input", () => {
if (!st) return start("edit", undefined, formula);
ta.value = formula.value;
if (st.point && formula.selectionEnd !== st.point.end) st.point = null;
place();
highlight();
});
// 주소 상자 — `B5` · `A1:C3` · `시트!B5` 로 가기
addr.addEventListener("focus", () => addr.select());
addr.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
sync();
focus();
}
if (e.key !== "Enter") return;
e.preventDefault();
goTo(addr.value.trim());
focus();
});
/** 이름 상자(sub4) — 있는 이름이면 그 범위로 · 없으면 지금 고름에 이름을 붙임(엑셀) */
async function nameBox(name: string): Promise<void> {
const { defineName, resolveName } = await import("./spreadsheet_names"); // 늦게 — 크기 예산
const hit = resolveName(ctx.book, ctx.selection.시트, name);
if (hit) {
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
const at = { r: hit.범위.r0, c: hit.범위.c0 };
return ctx.select({ 시트: hit.시트, 범위: [hit.범위], 활성: at, 기준: { ...at } }, true);
}
const why = ctx.readOnly ? null : defineName(ctx, name, ctx.selection.범위[0]);
if (why) showToast(why, "error");
sync();
}
function goTo(value: string): void {
const bang = value.lastIndexOf("!");
let sheet = ctx.sheet();
if (bang >= 0) {
const raw = value.slice(0, bang);
const id = sheetId(raw[0] === "'" ? raw.slice(1, -1).replace(/''/g, "'") : raw);
if (!id) return sync();
if (id !== sheet.id) ctx.showSheet(id);
sheet = ctx.sheet();
}
const body = value.slice(bang + 1).replace(/\$/g, "");
const one = parseA1(body);
const g = parseRange(body);
if (!g) return void nameBox(value.trim());
if (one) return ctx.select(selectCell(sheet, one.r, one.c), true);
const at = { r: g.r0, c: g.c0 };
ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true);
}
return {
ta,
bar,
formula,
editing: () => st !== null,
begin(initial?: string) {
if (initial === undefined) {
const sel = ctx.selection;
start("edit", cellToText(ctx.sheet().칸[toA1(sel.활성.r, sel.활성.c)]));
} else start("enter", initial);
focus();
},
commit,
commitAll,
cancel,
sync,
mode: () => st?.mode ?? "enter",
toggleMode() {
if (st) ((st.mode = st.mode === "enter" ? "edit" : "enter"), (st.point = null));
},
pointable,
point,
pointMove,
accept,
suggestMove(d: number) {
if (!st || !sugg.length) return false;
pick = (pick + d + sugg.length) % sugg.length;
showHint();
return true;
},
focus,
place() {
if (st) place();
},
destroy() {
hint.remove();
ta.remove();
bar.remove();
},
};
}
@@ -0,0 +1,193 @@
/* =============================================================================
* spreadsheet_errcheck.ts (주인 A)
* 오류 검사(엑셀 초록 세모) — 칸마다 까닭을 냄: 순환 참조 · 식 결과가 오류(#DIV/0! 등) · 숫자처럼 보이는 글.
* 활성 칸에 까닭이 있으면 칸 왼쪽에 ⚠ 단추 · 올리면 까닭 풍선 · 누르면 메뉴(숫자로 변환 · 계산 단계 보기 ·
* 선행 참조 추적 · 오류 무시). 「오류 무시」는 이 화면 동안만(문서에 안 적음).
*
* 잇는 법(C · sub6 · D · sub7):
* · 부품 붙이기: `const tip = attachErrorTip(ctx, trace?)` — ⚠ 단추를 격자 overlay(editorSlot 층)에 얹음.
* grid.render() · 고름 바뀜 뒤마다 `tip.refresh()`. `trace`(spreadsheet_trace 의 손잡이)를 주면 메뉴에 「선행 참조 추적」.
* · 격자(C): 보이는 칸마다 `tip.warning(r, c)` 가 null 이 아니면 칸 왼쪽 위에 초록 세모(6px · `--color-success`).
* 시트 전체를 훑지 말고 그리는 칸만 물음(값 하나 · 칸 하나 읽는 값싼 부름).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { toA1 } from "./spreadsheet_address";
import { isError } from "./spreadsheet_eval";
import { openEvalSteps } from "./spreadsheet_eval_steps";
import type { TraceHandle } from "./spreadsheet_trace";
import type { CalcEngine, Cell, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_analysis.css";
export type WarningKind = "순환" | "오류" | "글숫자";
export interface CellWarning {
종류: WarningKind;
까닭: string;
}
/** 엑셀 오류 값 뜻(엔진 까닭이 없을 때) */
const MEANING: Record<string, [string, string]> = {
"#DIV/0!": ["0 으로 나눔", "Divide by zero"],
"#VALUE!": ["값의 종류가 맞지 않음", "Wrong type of value"],
"#REF!": ["지워진 칸을 가리킴", "Invalid cell reference"],
"#NAME?": ["모르는 이름 · 함수", "Unrecognized name"],
"#N/A": ["찾는 값이 없음", "Value not available"],
"#NUM!": ["수가 맞지 않음", "Invalid number"],
"#NULL!": ["겹치지 않는 범위", "Ranges do not intersect"],
};
const TEXT = {
Cycle: ["순환 참조", "Circular reference"],
TextNumber: ["숫자가 글로 저장됨", "Number stored as text"],
Convert: ["숫자로 변환", "Convert to number"],
Steps: ["계산 단계 보기", "Show calculation steps"],
Trace: ["선행 참조 추적", "Trace precedents"],
Ignore: ["오류 무시", "Ignore error"],
};
const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0];
/** 숫자로 읽히는 글(앞뒤 빈칸 · 천 단위 쉼표 · 지수 허용) */
const NUMBER_LIKE = /^[+-]?(\d{1,3}(,\d{3})+|\d+)?(\.\d+)?(e[+-]?\d+)?$/i;
export function numberLike(text: string): number | null {
const s = text.trim();
if (!/\d/.test(s) || !NUMBER_LIKE.test(s)) return null;
const n = Number(s.replace(/,/g, ""));
return Number.isFinite(n) ? n : null;
}
/** 칸 하나 까닭 — 없으면 null */
export function cellWarning(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): CellWarning | null {
const cell: Cell | undefined = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)];
if (!cell) return null;
if (cell.식 !== undefined) {
const v = engine.value(sheet, r, c);
if (!isError(v)) return null;
if (v.error === "#CYCLE!") return { 종류: "순환", 까닭: v.why ?? t("Cycle") };
const m = MEANING[v.error];
const head = m ? `${v.error} ${m[currentLanguageIndex] ?? m[0]}` : v.error;
return { 종류: "오류", 까닭: v.why ? `${v.error} ${v.why}` : head };
}
if (typeof cell.값 === "string" && numberLike(cell.값) !== null)
return { 종류: "글숫자", 까닭: t("TextNumber") };
return null;
}
export interface ErrorTipHandle extends PartHandle {
/** 격자 세모용 — 「오류 무시」한 칸은 null */
warning(r: number, c: number): CellWarning | null;
}
export function attachErrorTip(ctx: SpreadsheetContext, trace?: TraceHandle): ErrorTipHandle {
const layer = el("div", { className: "ss-errtip" });
const ignored = new Set<string>();
let menu: HTMLElement | null = null;
const warning = (r: number, c: number) =>
ignored.has(`${ctx.sheet().id}!${toA1(r, c)}`)
? null
: cellWarning(ctx.book, ctx.engine, ctx.sheet().id, r, c);
function closeMenu() {
menu?.remove();
menu = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (ev: MouseEvent) => {
if (menu && !layer.contains(ev.target as Node)) closeMenu();
};
/** 고른 칸 중 글숫자를 모두 수로(서식은 그대로) */
function convert() {
const s = ctx.sheet();
const cells: Record<string, Cell> = {};
for (const g of ctx.selection.범위)
for (let r = g.r0; r <= g.r1; r++)
for (let c = g.c0; c <= g.c1; c++) {
const a1 = toA1(r, c);
const cell = s.칸[a1];
const n =
typeof cell?.값 === "string" && cell.식 === undefined ? numberLike(cell.값) : null;
if (n !== null) cells[a1] = { ...cell, 값: n };
}
if (Object.keys(cells).length) ctx.dispatch({ 종류: "칸", 시트: s.id, 칸: cells });
}
function place() {
closeMenu();
const { r, c } = ctx.selection.활성;
const { layer: host, box } = ctx.grid.editorSlot(r, c);
if (layer.parentElement !== host) host.append(layer);
const w = warning(r, c);
const v = ctx.grid.visibleRange();
if (!w || !box.w || r < v.r0 || r > v.r1 || c < v.c0 || c > v.c1) {
layer.replaceChildren();
return;
}
const a = host.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
layer.style.left = `${box.x - (a.left - g.left) - 20}px`;
layer.style.top = `${box.y - (a.top - g.top)}px`;
const btn = el("button", {
className: "ss-errtip__btn",
text: "⚠",
attrs: { type: "button", "aria-label": w.까닭 },
});
const balloon = el("div", { className: "ss-errtip__balloon", text: w.까닭 });
btn.addEventListener("mousedown", (ev) => ev.stopPropagation());
btn.addEventListener("click", () => (menu ? closeMenu() : openMenu(w, r, c)));
layer.replaceChildren(btn, balloon);
}
function openMenu(w: CellWarning, r: number, c: number) {
const item = (label: string, run: () => void) => {
const b = el("button", {
className: "ss-errtip__item",
text: label,
attrs: { type: "button" },
});
b.addEventListener("click", () => {
closeMenu();
run();
});
return b;
};
const items: HTMLElement[] = [el("div", { className: "ss-errtip__head", text: w.까닭 })];
if (w.종류 === "글숫자" && !ctx.readOnly) items.push(item(t("Convert"), convert));
if (w.종류 !== "글숫자") items.push(item(t("Steps"), () => openEvalSteps(ctx)));
if (w.종류 !== "글숫자" && trace) items.push(item(t("Trace"), () => trace.precedents()));
items.push(
item(t("Ignore"), () => {
ignored.add(`${ctx.sheet().id}!${toA1(r, c)}`);
ctx.grid.render();
place();
}),
);
menu = el("div", { className: "ss-errtip__menu", attrs: { role: "menu" }, children: items });
layer.append(menu);
document.addEventListener("mousedown", onOutside, true);
}
const onScroll = () => place();
ctx.grid.root.addEventListener("scroll", onScroll, true);
place();
return {
root: layer,
refresh: place,
warning,
destroy() {
closeMenu();
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}
+300
View File
@@ -0,0 +1,300 @@
/* =============================================================================
* spreadsheet_eval.ts (주인 A)
* 나무 풀기 · 오류 값 전파 · 형 바꾸기(엑셀 규칙). B 의 함수들이 형 바꾸기 도우미를 씀.
* 빈 칸 = 수에서 0 · 글에서 "" · `"12"+1 = 13` · `TRUE+1 = 2` · 글이 수로 안 읽히면 `#VALUE!`.
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수(B · A 같이 `fromDouble`).
* 오류 전파 = 왼쪽부터 처음 만난 오류. 비교 = 수 < 글 < 참거짓 · 글은 대소문자 가리지 않음.
* ========================================================================== */
import {
add,
cmp,
div,
frac,
fracToString,
mul,
parseDecimal,
roundAt,
sub,
toFrac,
ZERO,
} from "@ui/sheet/ui_template_sheet_frac";
import { normRange } from "./spreadsheet_address";
import { getFunction } from "./spreadsheet_functions";
import type {
CalcValue,
ErrorCode,
ErrorValue,
EvalContext,
EvalResult,
FormulaNode,
Frac,
RangeValue,
Scalar,
} from "./spreadsheet_types";
export function err(code: ErrorCode, why?: string): ErrorValue {
return why === undefined ? { error: code } : { error: code, why };
}
export function isError(value: unknown): value is ErrorValue {
return typeof value === "object" && value !== null && "error" in value;
}
export function isFrac(value: unknown): value is Frac {
return typeof value === "object" && value !== null && "n" in value && "d" in value;
}
export function isRange(value: unknown): value is RangeValue {
return typeof value === "object" && value !== null && (value as RangeValue).kind === "range";
}
const ONE = frac(1n);
const HUNDRED = frac(100n);
const BIG_D = 10n ** 40n;
/** 분모가 너무 커지면(나눗셈 사슬) 30 자리 십진으로 굳힘 */
export function tame(x: Frac): Frac {
return x.d > BIG_D ? parseDecimal(fracToString(x)) : x;
}
/** 범위 → 칸 하나(1×1 일 때만) · 아니면 `#VALUE!` */
function single(value: EvalResult): Scalar {
if (!isRange(value)) return value;
return value.rows === 1 && value.cols === 1
? value.at(0, 0)
: err("#VALUE!", "범위를 값 하나로 못 씀");
}
/** 엔진이 만든 범위 — 적힌 자리(끝 자름 전) · 암묵 교차에 씀 */
export interface PlacedRange extends RangeValue {
r0: number;
c0: number;
r1: number;
c1: number;
}
/** 범위 → 칸 하나 — 1×1 이면 그 칸 · 아니면 식 든 행(한 열 범위) · 열(한 행 범위)과 겹친 칸(엑셀 암묵 교차) */
export function intersect(value: EvalResult, at: { r: number; c: number }): Scalar {
if (!isRange(value) || (value.rows === 1 && value.cols === 1)) return single(value);
const p = value as Partial<PlacedRange>;
if (p.r0 !== undefined && p.c0 !== undefined && p.r1 !== undefined && p.c1 !== undefined) {
if (p.c0 === p.c1 && at.r >= p.r0 && at.r <= p.r1) return value.at(at.r - p.r0, 0);
if (p.r0 === p.r1 && at.c >= p.c0 && at.c <= p.c1) return value.at(0, at.c - p.c0);
}
return err("#VALUE!", "범위가 식 든 행 · 열과 안 겹침");
}
/** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */
export function asNumber(value: EvalResult): Frac | ErrorValue {
const v = single(value);
if (v === null) return ZERO;
if (typeof v === "boolean") return v ? ONE : ZERO;
if (typeof v === "string") {
const t = v.trim();
const pct = t.endsWith("%");
try {
const n = parseDecimal(pct ? t.slice(0, -1) : t);
return pct ? div(n, HUNDRED) : n;
} catch {
return err("#VALUE!", `수로 못 읽는 글: "${v}"`);
}
}
return v;
}
/** 일반 형식 글 — 유효 15 자리 · 1e15 넘거나 1e-9 밑이면 `1.5E+18` 꼴(엑셀 `&` 와 같게) */
export function numberText(x: Frac): string {
if (x.n === 0n) return "0";
const d = parseFloat(fracToString(x));
const e = Math.floor(Math.log10(Math.abs(d)));
if (e >= 15 || e < -9) {
const [m, ex] = d.toExponential(14).split("e");
const mant = m.replace(/\.?0+$/, "");
const n = Number(ex);
return `${mant}E${n < 0 ? "-" : "+"}${String(Math.abs(n)).padStart(2, "0")}`;
}
return fracToString(roundAt(x, 14 - e, "round"));
}
/** 글로 — 수는 일반 형식 글(엑셀 `&` 와 같게) · 참거짓 `TRUE`/`FALSE` */
export function asText(value: EvalResult): string | ErrorValue {
const v = single(value);
if (v === null) return "";
if (typeof v === "string" || isError(v)) return v;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return numberText(v);
}
export function asBool(value: EvalResult): boolean | ErrorValue {
const v = single(value);
if (v === null) return false;
if (typeof v === "boolean" || isError(v)) return v;
if (typeof v === "string") {
const t = v.toUpperCase();
return t === "TRUE"
? true
: t === "FALSE"
? false
: err("#VALUE!", `참거짓으로 못 읽는 글: "${v}"`);
}
return v.n !== 0n;
}
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
export function fromDouble(x: number): Frac | ErrorValue {
if (!Number.isFinite(x)) return err("#NUM!", "수가 너무 크거나 뜻이 없음");
return x === 0 ? ZERO : parseDecimal(x.toPrecision(15));
}
export const toDouble = (x: Frac): number => parseFloat(fracToString(x));
/** 저장 모양 — 수는 십진 글(30 자리까지) */
export function toCalcValue(value: Scalar): CalcValue {
if (value === null) return { 수: "0" };
if (typeof value === "string") return { 글: value };
if (typeof value === "boolean") return { 참: value };
if (isError(value))
return value.why ? { 오류: value.error, 까닭: value.why } : { 오류: value.error };
return { 수: fracToString(value) };
}
// ── 연산 ────────────────────────────────────────────────────────────────────
function power(a: Frac, b: Frac): Frac | ErrorValue {
if (a.n === 0n) {
if (b.n === 0n) return err("#NUM!", "0^0");
return b.n < 0n ? err("#DIV/0!", "0 의 음수 거듭제곱") : ZERO;
}
if (b.d === 1n) {
const e = b.n < 0n ? -b.n : b.n;
const bits = BigInt(a.n.toString(2).length + a.d.toString(2).length);
// ponytail: 4,000 비트 넘는 정확 거듭제곱은 double 로 — 실무 식엔 없음
if (bits * e <= 4000n) {
const r = frac(a.n ** e, a.d ** e);
return tame(b.n < 0n ? div(ONE, r) : r);
}
} else if (a.n < 0n) return err("#NUM!", "음수의 소수 거듭제곱");
return fromDouble(Math.pow(toDouble(a), toDouble(b)));
}
function arith(op: string, a: Frac, b: Frac): Frac | ErrorValue {
switch (op) {
case "+":
return tame(add(a, b));
case "-":
return tame(sub(a, b));
case "*":
return tame(mul(a, b));
case "/":
return b.n === 0n ? err("#DIV/0!", "0 으로 나눔") : tame(div(a, b));
default:
return power(a, b);
}
}
const RANK = (v: Scalar) => (isFrac(v) ? 0 : typeof v === "string" ? 1 : 2);
/** 엑셀 비교 — 빈 칸은 맞은편 형의 빈 값(0 · "" · FALSE) · 형이 다르면 수 < 글 < 참거짓 */
export function compareValues(a: Scalar, b: Scalar): number {
const blank = (other: Scalar): Scalar =>
isFrac(other)
? ZERO
: typeof other === "string"
? ""
: typeof other === "boolean"
? false
: ZERO;
if (a === null) a = blank(b);
if (b === null) b = blank(a);
const ra = RANK(a);
const rb = RANK(b);
if (ra !== rb) return ra < rb ? -1 : 1;
if (isFrac(a)) return cmp(a, b as Frac);
if (typeof a === "string") {
const x = a.toLowerCase();
const y = (b as string).toLowerCase();
return x === y ? 0 : x < y ? -1 : 1;
}
return a === b ? 0 : a ? 1 : -1;
}
function compareOp(op: string, a: Scalar, b: Scalar): boolean {
const c = compareValues(a, b);
return op === "="
? c === 0
: op === "<>"
? c !== 0
: op === "<"
? c < 0
: op === "<="
? c <= 0
: op === ">"
? c > 0
: c >= 0;
}
// ── 풀기 ────────────────────────────────────────────────────────────────────
export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult {
switch (node.type) {
case "number":
return node.value;
case "string":
return node.value;
case "bool":
return node.value;
case "error":
return err(node.code);
case "name":
return err("#NAME?", `모르는 이름: ${node.name}`);
case "cell":
case "range": {
const sheet = node.sheet === null ? ctx.sheet : ctx.sheetId(node.sheet);
if (sheet === null) return err("#REF!", `없는 시트: ${node.sheet}`);
if (node.type === "cell") return ctx.cell(sheet, node.ref.r, node.ref.c);
return ctx.range(sheet, normRange(node.from.r, node.from.c, node.to.r, node.to.c));
}
case "unary": {
const v = intersect(evaluate(node.arg, ctx), ctx);
if (node.op === "+") return v;
const n = asNumber(v);
if (isError(n)) return n;
return node.op === "-" ? frac(-n.n, n.d) : div(n, HUNDRED);
}
case "binary": {
const left = intersect(evaluate(node.left, ctx), ctx);
const right = intersect(evaluate(node.right, ctx), ctx);
if (node.op === "&") {
const a = asText(left);
if (isError(a)) return a;
const b = asText(right);
return isError(b) ? b : a + b;
}
if (PLAIN_OPS.has(node.op)) {
const a = asNumber(left);
if (isError(a)) return a;
const b = asNumber(right);
return isError(b) ? b : arith(node.op, a, b);
}
if (isError(left)) return left;
return isError(right) ? right : compareOp(node.op, left, right);
}
case "call": {
const fn = getFunction(node.name);
if (!fn) return err("#NAME?", `모르는 함수: ${node.name}`);
// 인자 수는 파서가 막음(spreadsheet_parser)
return fn.call(
node.args.map((a) => () => evaluate(a, ctx)),
ctx,
);
}
}
}
const PLAIN_OPS = new Set(["+", "-", "*", "/", "^"]);
/** 수로 온 입력 값 → 풀이 값 */
export function inputValue(v: number | string | boolean): Scalar {
return typeof v === "number" ? toFrac(v) : v;
}
@@ -0,0 +1,261 @@
/* =============================================================================
* spreadsheet_eval_steps.ts (주인 A)
* 수식 계산 단계 보기(엑셀 [수식 계산] 창) — 식을 왼쪽부터 한 마디씩 값으로 바꿔 보여 줌.
* 엑셀 규칙: 칸 참조 → 값(빈 칸은 0 · 글은 따옴표) · 연산 · 함수는 인자가 다 값이 된 뒤 · 범위는 함수가 통째로 풂 ·
* IF · IFERROR · CHOOSE 류는 첫 인자만 먼저 풀고 고른 쪽은 함수 한 걸음으로 · ROW(A1) 같은 참조 인자는 그대로.
* 마디 값은 늘 엔진 풀이(`evalSafe`)로 냄 — 보이는 글은 보여 주기만.
*
* 잇는 법(D · sub7):
* · 도구 모음 [수식 분석] 「수식 계산」 → `openEvalSteps(ctx)` — 활성 칸 식의 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
* · 식 없는 칸이면 토스트로 알리고 창을 안 띄움. 들어가기(참조 칸 식 속으로)는 아직 없음.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { isError, isRange, numberText } from "./spreadsheet_eval";
import type { PlacedRange } from "./spreadsheet_eval";
import { bindNames, evalSafe } from "./spreadsheet_graph";
import { formulaToText, parseFormula } from "./spreadsheet_parser";
import type {
CalcEngine,
CellRange,
EvalContext,
FormulaNode,
Scalar,
Workbook,
} from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_analysis.css";
/** 한 걸음 — 글과 다음에 풀 곳(밑줄 · 글 안 [시작, 끝)) */
export interface EvalStep {
글: string;
밑줄: [number, number] | null;
}
/** 첫 인자만 먼저 풂 */
const LAZY = new Set(["IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH"]);
/** 칸 참조 인자를 값으로 안 바꿈 */
const REF_ARGS = new Set([
"ROW",
"COLUMN",
"ROWS",
"COLUMNS",
"OFFSET",
"INDEX",
"ISREF",
"ISBLANK",
"CELL",
"ISFORMULA",
"FORMULATEXT",
"SUMIF",
"SUMIFS",
"COUNTIF",
"COUNTIFS",
"AVERAGEIF",
"AVERAGEIFS",
"COUNTBLANK",
]);
/** 푸는 차례(왼쪽 먼저 · 아래 먼저) */
function order(node: FormulaNode, out: FormulaNode[], refArg = false): void {
switch (node.type) {
case "cell":
if (!refArg) out.push(node);
return;
case "name":
out.push(node);
return;
case "unary":
if (node.op !== "%" && node.arg.type === "number") return; // -5 는 글 그대로
order(node.arg, out);
out.push(node);
return;
case "binary":
order(node.left, out);
order(node.right, out);
out.push(node);
return;
case "call": {
const args = LAZY.has(node.name) ? node.args.slice(0, 1) : node.args;
for (const a of args) order(a, out, REF_ARGS.has(node.name));
out.push(node);
}
}
}
export function valueText(v: Scalar): string {
if (v === null) return "0";
if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (isError(v)) return v.error;
return numberText(v);
}
/** 엔진 없이 쓸 풀이 문맥 — 칸 값은 엔진에서 · 범위는 값 든 끝까지(엔진과 같게) */
function contextFor(book: Workbook, engine: CalcEngine, sheet: string, r: number, c: number) {
const ext = new Map<string, { r: number; c: number }>();
const extent = (id: string) => {
let e = ext.get(id);
if (!e) {
e = { r: 0, c: 0 };
for (const [a1, cell] of Object.entries(book.시트.find((s) => s.id === id)?.칸 ?? {})) {
const at = cell.식 !== undefined || cell.값 !== undefined ? parseA1(a1) : null;
if (at) e = { r: Math.max(e.r, at.r), c: Math.max(e.c, at.c) };
}
ext.set(id, e);
}
return e;
};
const ctx: EvalContext = {
sheet,
r,
c,
cell: (s, rr, cc) => engine.value(s, rr, cc),
range(s, rg: CellRange): PlacedRange {
const e = extent(s);
const r1 = Math.min(rg.r1, Math.max(rg.r0, e.r));
const c1 = Math.min(rg.c1, Math.max(rg.c0, e.c));
return {
kind: "range",
rows: r1 - rg.r0 + 1,
cols: c1 - rg.c0 + 1,
at: (i, j) => engine.value(s, rg.r0 + i, rg.c0 + j),
...rg,
};
},
sheetId: (name) =>
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null,
};
return ctx;
}
/** 칸 식의 모든 걸음(첫 글 → … → 값) · 식이 없으면 null */
export function evalSteps(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): EvalStep[] | null {
const text = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)]?.식;
if (text === undefined) return null;
const p = parseFormula(text);
if (!p.ok)
return [
{ 글: text, 밑줄: null },
{ 글: valueText(engine.value(sheet, r, c)), 밑줄: null },
];
const ctx = contextFor(book, engine, sheet, r, c);
const nodes: FormulaNode[] = [];
order(p.node, nodes);
// 식 전체가 범위(`=A1:A3`)면 마지막 걸음으로 엔진 값(암묵 교차)
if (nodes[nodes.length - 1] !== p.node && p.node.type === "range") nodes.push(p.node);
const vals = new Map<FormulaNode, Scalar>();
const steps: FormulaNode[] = [];
for (const n of nodes) {
if (n === p.node) vals.set(n, engine.value(sheet, r, c));
else {
const v = evalSafe(bindNames(n, book, sheet), ctx);
if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로
vals.set(n, v);
}
steps.push(n);
}
const done = new Map<FormulaNode, string>();
const out: EvalStep[] = [];
for (let i = 0; i <= steps.length; i++) {
const raw = formulaToText(p.node, (n) => done.get(n), steps[i]);
const a = raw.indexOf("\u0001");
const b = raw.indexOf("\u0002");
out.push(
a < 0
? { 글: raw, 밑줄: null }
: { 글: raw.replace(/[\u0001\u0002]/g, ""), 밑줄: [a, b - 1] },
);
if (i < steps.length) done.set(steps[i], valueText(vals.get(steps[i]) ?? null));
}
return out;
}
// ── 창 ──────────────────────────────────────────────────────────────────────
const TEXT = {
Title: ["수식 계산", "Evaluate Formula"],
Ref: ["참조", "Reference"],
Hint: [
"밑줄 친 식의 값을 보려면 [계산] 을 누름",
"To show the result of the underlined expression, click Evaluate.",
],
Eval: ["계산", "Evaluate"],
Restart: ["다시 시작", "Restart"],
Close: ["닫기", "Close"],
NoFormula: ["식이 없는 칸", "The cell has no formula"],
};
const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0];
export function openEvalSteps(ctx: SpreadsheetContext): () => void {
const sheet = ctx.sheet();
const { r, c } = ctx.selection.활성;
const steps = evalSteps(ctx.book, ctx.engine, sheet.id, r, c);
if (!steps) {
showToast(t("NoFormula"), "info");
return () => {};
}
let i = 0;
const view = el("div", { className: "ss-steps__text", attrs: { "aria-live": "polite" } });
const next = createButton({
label: t("Eval"),
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
});
const box = el("div", {
className: "ss-steps__box",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-steps", children: [box] });
function go(to: number) {
i = to;
const s = steps![i];
if (!s.밑줄) view.replaceChildren(s.글);
else {
const [a, b] = s.밑줄;
view.replaceChildren(s.글.slice(0, a), el("u", { text: s.글.slice(a, b) }), s.글.slice(b));
}
next.textContent = i + 1 < steps!.length ? t("Eval") : t("Restart");
}
const close = () => {
shade.remove();
document.removeEventListener("keydown", onKey, true);
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") close();
else if (ev.key === "Enter") next.click();
else return;
ev.preventDefault();
ev.stopPropagation();
};
box.append(
el("div", { className: "ss-steps__title", text: t("Title") }),
el("div", {
className: "ss-steps__meta",
text: `${t("Ref")}: ${ctx.book.시트.length > 1 ? `${quoteSheet(sheet.이름)}!` : ""}${toA1(r, c)}`,
}),
view,
el("div", { className: "ss-steps__meta", text: t("Hint") }),
el("div", {
className: "ss-steps__foot",
children: [next, createButton({ label: t("Close"), variant: "ghost", onClick: close })],
}),
);
shade.addEventListener("mousedown", (ev) => {
if (ev.target === shade) close();
});
document.addEventListener("keydown", onKey, true);
go(0);
ctx.root.append(shade);
next.focus();
return close;
}
@@ -0,0 +1,221 @@
/* =============================================================================
* spreadsheet_extras.ts (주인 D)
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
* 셀 서식 창(Ctrl+1) · 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 데이터 단추 · 메뉴는 `spreadsheet_data_menu.ts`.
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { watchClose } from "./spreadsheet_actions";
import { parseClipboard } from "./spreadsheet_clipboard";
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
import { mountDataMenu } from "./spreadsheet_data_menu";
import type { Editor } from "./spreadsheet_editor";
import type { FindPanelHandle } from "./spreadsheet_find_panel";
import type { FormatPainterHandle } from "./spreadsheet_format_painter";
import type { KeyHooks } from "./spreadsheet_keys";
import type { Memo } from "./spreadsheet_memo";
import type { MouseHooks } from "./spreadsheet_mouse";
import type { PasteSpecialMode } from "./spreadsheet_paste_special";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
format: "셀 서식(Ctrl+1)",
painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속",
pasteTitle: "골라 붙여넣기",
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
};
export interface Extras {
keys: KeyHooks;
mouse: MouseHooks;
parts: PartHandle[];
}
export function attachExtras(
ctx: SpreadsheetContext,
ed: Editor,
toolbarRoot: HTMLElement | null,
): Extras {
const back = (): void => ed.focus();
/** 칸 오른쪽 자리(부품 뿌리 기준 px) — C editorSlot box 는 격자 뿌리 기준 */
const beside = (r: number, c: number): { x: number; y: number } => {
const { box } = ctx.grid.editorSlot(r, c);
const g = ctx.grid.root.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
return { x: box.x + box.w + g.left - o.left + 4, y: box.y + g.top - o.top };
};
const put = (node: HTMLElement, r: number, c: number): void => {
const p = beside(r, c);
node.style.left = `${p.x}px`;
node.style.top = `${p.y}px`;
};
// ── 찾기(처음 열 때 불러옴 — 크기 예산) ─────────────────────────────────────
let find: FindPanelHandle | null = null;
async function openFind(replace: boolean): Promise<void> {
find ??= (await import("./spreadsheet_find_panel")).mountFindPanel(ctx, back);
find.open(replace);
}
// ── 메모 — 풍선 · 편집 ─────────────────────────────────────────────────
const comments = attachComments(ctx);
let hovered = "";
let memo: Memo | null = null; // 처음 쓸 때 불러옴 — 크기 예산
async function editMemo(): Promise<void> {
if (ctx.readOnly) return;
const { r, c } = ctx.selection.활성;
memo ??= (await import("./spreadsheet_memo")).createMemo(ctx, back, {
get: getComment,
set: setComment,
});
comments.hideBalloon();
memo.open(r, c, (node) => put(node, r, c));
}
// ── 서식 붓 ─────────────────────────────────────────────────────────────
let painter: FormatPainterHandle | null = null; // 처음 누를 때 불러옴 — 크기 예산
let sticky = false;
const brush = el("button", {
className: "ss-brush",
text: "붓",
attrs: { type: "button", title: TEXT.painter },
});
const showBrush = (): void => {
const on = !!painter?.active();
brush.classList.toggle("is-on", on);
ctx.root.classList.toggle("ss--painting", on);
};
brush.addEventListener("click", async (e) => {
painter ??= (await import("./spreadsheet_format_painter")).createFormatPainter(ctx);
if (e.detail === 2) sticky = true;
else if (painter.active()) (painter.clear(), (sticky = false));
else (painter.pick(), (sticky = false));
showBrush();
back();
});
// 셀 서식 창 — Ctrl+1 을 브라우저 · ORCA 가 가로챌 때 누를 자리
const fmt = el("button", {
className: "ss-brush",
text: "서식…",
attrs: { type: "button", title: TEXT.format },
});
/** 창이 닫히면(확인 · 취소) 초점을 격자로 */
async function openFormat(): Promise<void> {
if (ctx.readOnly) return;
(await import("./spreadsheet_format_dialog")).openFormatDialog(ctx); // 늦게 — 크기 예산
watchClose(back);
}
fmt.addEventListener("click", () => void openFormat());
if (!ctx.readOnly) ed.bar.prepend(fmt, brush);
// ── 데이터 단추 · 메뉴(정렬 · 필터 · 조건부 서식 · 유효성 · 이름 · 눈금선) ─────────
const data = mountDataMenu(ctx, back);
brush.after(...data.buttons);
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
let armed = false;
const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } });
ctx.root.append(picker);
const onPaste = (e: ClipboardEvent): void => {
if (!armed || ed.editing()) return;
armed = false;
e.preventDefault();
e.stopPropagation(); // E 클립보드의 보통 붙여넣기를 막음
const at = { ...ctx.selection.활성 };
const data = e.clipboardData;
const block = parseClipboard(
data?.getData("text/html") ?? "",
data?.getData("text/plain") ?? "",
at,
);
if (!block) return;
// ponytail: 우리 복사면 원본을 같은 시트로 봄(ClipBlock 에 시트가 없음) — 값만 때 식 칸 계산값용
const source =
block.출처 === "aislo" && block.원점
? { 시트: ctx.selection.시트, ...block.원점 }
: undefined;
picker.replaceChildren(
...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => {
const b = el("button", {
text: TEXT.modes[mode],
attrs: { type: "button", "data-mode": mode },
});
b.addEventListener("click", () => {
picker.setAttribute("hidden", "");
// 늦게 — 크기 예산
void import("./spreadsheet_paste_special").then((m) => {
m.pasteSpecial(ctx, block, at, mode, source);
back();
});
});
return b;
}),
);
picker.removeAttribute("hidden");
put(picker, at.r, at.c);
(picker.firstElementChild as HTMLElement).focus();
};
picker.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.preventDefault();
picker.setAttribute("hidden", "");
back();
});
ctx.root.addEventListener("paste", onPaste, true);
// ── 도구 모음 뒤 초점 ───────────────────────────────────────────────────
const onToolbarClick = (e: Event): void => {
if ((e.target as HTMLElement).closest("button")) back();
};
toolbarRoot?.addEventListener("click", onToolbarClick);
toolbarRoot?.addEventListener("change", back);
const self: PartHandle = {
root: null,
refresh: () => showBrush(),
destroy() {
ctx.root.removeEventListener("paste", onPaste, true);
toolbarRoot?.removeEventListener("click", onToolbarClick);
toolbarRoot?.removeEventListener("change", back);
find?.destroy();
memo?.destroy();
picker.remove();
brush.remove();
fmt.remove();
},
};
return {
keys: {
find: (replace) => void openFind(replace),
pasteSpecial: () => (armed = true),
comment: () => void editMemo(),
formatDialog: () => void openFormat(),
...data.keys,
escape() {
painter?.clear();
sticky = false;
showBrush();
},
},
mouse: {
selected() {
if (!painter?.active()) return;
painter.paint(ctx.selection.범위[0], sticky);
showBrush();
},
hover(hit) {
const key = hit?.kind === "cell" ? `${hit.r},${hit.c}` : "";
if (key === hovered) return;
hovered = key;
if (!hit || !key || memo?.busy() || !getComment(ctx.sheet(), hit.r, hit.c))
return comments.hideBalloon();
comments.showBalloon(hit.r, hit.c);
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
},
},
parts: [comments, data.part, self],
};
}
@@ -0,0 +1,90 @@
/* =============================================================================
* spreadsheet_fill.ts (주인 B)
* 채우기 핸들 — 원본 범위를 대상 범위로 늘림: 수 하나 = 복사 · 수 둘 이상 = 등차 연속 ·
* 글 끝 번호(`구-01` → `구-02`) · 식 = 상대 참조 옮김(A `moveFormula`) · 서식 같이.
* 결과는 `{ 종류: "칸" }` 명령에 넣을 칸 뭉치.
* ========================================================================== */
import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types";
import { moveFormula } from "./spreadsheet_refshift";
import { toA1 } from "./spreadsheet_address";
function detectDirection(source: CellRange, target: CellRange): FillDirection {
if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down";
if (target.r0 < source.r0 && target.c0 === source.c0 && target.c1 === source.c1) return "up";
if (target.c1 > source.c1 && target.r0 === source.r0 && target.r1 === source.r1) return "right";
return "left";
}
/** 글 끝 숫자를 옮김(`구-01`,step 1 → `구-02`) · 끝 숫자가 없으면 그대로 */
function incrementTrailingNumber(text: string, step: number): string {
const m = /^(.*?)(\d+)$/.exec(text);
if (!m) return text;
const [, prefix, digits] = m;
const next = BigInt(digits) + BigInt(step);
const clamped = next < 0n ? 0n : next;
return prefix + clamped.toString().padStart(digits.length, "0");
}
/** `target` 은 `source` 를 한 방향으로 늘린 범위(방향은 둘을 견줘 앎) · A1 → 칸(null = 비움) */
export function fillCells(
book: Workbook,
sheet: string,
source: CellRange,
target: CellRange,
): Record<string, Cell | null> {
const sheetObj = book.시트.find((s) => s.id === sheet);
const out: Record<string, Cell | null> = {};
if (!sheetObj) return out;
const direction = detectDirection(source, target);
const vertical = direction === "down" || direction === "up";
const lineCount = vertical ? source.c1 - source.c0 + 1 : source.r1 - source.r0 + 1;
for (let li = 0; li < lineCount; li++) {
const fixed = vertical ? source.c0 + li : source.r0 + li;
const sourceStart = vertical ? source.r0 : source.c0;
const sourceEnd = vertical ? source.r1 : source.c1;
const targetStart = vertical ? target.r0 : target.c0;
const targetEnd = vertical ? target.r1 : target.c1;
const lineLen = sourceEnd - sourceStart + 1;
const key = (pos: number): string => (vertical ? toA1(pos, fixed) : toA1(fixed, pos));
const sourceCells: (Cell | undefined)[] = [];
for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]);
const allNumeric = sourceCells.every(
(c) => c !== undefined && c.식 === undefined && typeof c.값 === "number",
);
const nums = allNumeric ? sourceCells.map((c) => c!.값 as number) : [];
const step = nums.length > 1 ? (nums[nums.length - 1] - nums[0]) / (nums.length - 1) : 0;
for (let p = targetStart; p <= targetEnd; p++) {
if (p >= sourceStart && p <= sourceEnd) continue;
const rel = p - sourceStart;
const slot = ((rel % lineLen) + lineLen) % lineLen;
const period = (rel - slot) / lineLen;
const template = sourceCells[slot];
const outKey = key(p);
if (template === undefined) {
out[outKey] = null;
continue;
}
const cell: Cell = {};
if (template.서식 !== undefined) cell.서식 = template.서식;
if (allNumeric) {
cell.값 = nums[0] + step * rel;
} else if (template.식 !== undefined) {
const dr = vertical ? period * lineLen : 0;
const dc = vertical ? 0 : period * lineLen;
cell.식 = moveFormula(template.식, dr, dc);
} else if (typeof template.값 === "string" && lineLen === 1) {
cell.값 = incrementTrailingNumber(template.값, rel);
} else if (template.값 !== undefined) {
cell.값 = template.값;
}
out[outKey] = cell;
}
}
return out;
}
@@ -0,0 +1,238 @@
/* =============================================================================
* spreadsheet_filter.ts (주인 A · 화면)
* 자동 필터 — 머리 행 칸마다 ▼ 단추 · 값 목록 창(모두 선택 · 값마다 체크 · 이 열 정렬 · 이 열 필터 지움).
* 걸러진 행은 엔진이 `행.걸러짐` 으로 적음(사용자 숨김과 따로) · 명령은 `필터`(spreadsheet_sortfilter.ts).
*
* 잇는 법(D · sub7 · C · sub6):
* · 부품 붙이기: `const filter = attachFilter(ctx)` — 단추 층을 격자 overlay(editorSlot 층)에 얹음.
* grid.render() 뒤마다 `filter.refresh()`(단추 자리 · 켜짐 표시) · 스크롤은 스스로 따라감.
* · 도구 단추 「필터」 · Ctrl+Shift+L → `toggleFilter(ctx)`(켜기 = 고른 범위 · 칸 하나면 현재 영역 / 끄기).
* · Alt+↓(활성 칸이 머리 행이면) → `filter.open(활성 열)`.
* · 격자(C): 행 `걸러짐` 을 `숨김` 처럼 높이 0 으로 — 머리의 숨김 표시(두 줄)는 사용자 `숨김` 만.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName, parseRange, rangeToA1 } from "./spreadsheet_address";
import { CommandError } from "./spreadsheet_commands";
import { currentRegion, filterCommand, filterItems, sortCommand } from "./spreadsheet_sortfilter";
import type { AutoFilter, Command } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_sortfilter.css";
const TEXT = {
Button: ["필터", "Filter"],
All: ["(모두 선택)", "(Select all)"],
Blank: ["(빈 칸)", "(Blanks)"],
SortAsc: ["오름차순 정렬", "Sort ascending"],
SortDesc: ["내림차순 정렬", "Sort descending"],
Clear: ["이 열 필터 지우기", "Clear filter from column"],
Ok: ["확인", "OK"],
Cancel: ["취소", "Cancel"],
};
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
function send(ctx: SpreadsheetContext, make: () => Command): void {
try {
ctx.dispatch(make());
} catch (e) {
if (!(e instanceof CommandError)) throw e;
showToast(e.message, "error");
}
}
/** 필터 켜기 · 끄기 */
export function toggleFilter(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const s = ctx.sheet();
if (s.필터) return send(ctx, () => filterCommand(ctx.book, ctx.engine, s.id, null));
const g = ctx.selection.범위[0];
const rg = g.r0 === g.r1 && g.c0 === g.c1 ? currentRegion(ctx.book, s.id, g.r0, g.c0) : g;
send(ctx, () => ({ 종류: "필터", 시트: s.id, 필터: { 범위: rangeToA1(rg) }, 걸러짐: [] }));
}
export interface FilterHandle extends PartHandle {
/** 열 `c` 값 목록 창 열기(필터 범위 안일 때) */
open(c: number): void;
}
export function attachFilter(ctx: SpreadsheetContext): FilterHandle {
const layer = el("div", { className: "ss-filter__layer" });
let panel: HTMLElement | null = null;
const filterOf = () => {
const f = ctx.sheet().필터;
const rg = f && parseRange(f.범위);
return f && rg ? { f, rg } : null;
};
/** 칸 자리 → 단추 층 안 자리(editorSlot 상자는 격자 뿌리 기준 — 층 원점과 다를 수 있어 실제 자리로 맞춤) */
function boxIn(r: number, c: number) {
const { layer: host, box } = ctx.grid.editorSlot(r, c);
const a = host.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) };
}
/** 머리 칸이 지금 보이나(틀고정 판이면 늘 보임) */
function shown(r: number, c: number): boolean {
const v = ctx.grid.visibleRange();
const fz = ctx.sheet().틀고정 ?? {};
const rowOk = r < (fz.행 ?? 0) || (r >= v.r0 && r <= v.r1);
const colOk = c < (fz.열 ?? 0) || (c >= v.c0 && c <= v.c1);
return rowOk && colOk;
}
function place(): void {
const { layer: host } = ctx.grid.editorSlot(0, 0);
if (layer.parentElement !== host) host.append(layer);
const cur = filterOf();
const btns: HTMLElement[] = [];
if (cur)
for (let c = cur.rg.c0; c <= cur.rg.c1; c++) {
const box = boxIn(cur.rg.r0, c);
if (!box.w || !box.h || !shown(cur.rg.r0, c)) continue;
const on = !!cur.f.조건?.[colName(c)];
const b = el("button", {
className: on ? "ss-filter__btn is-on" : "ss-filter__btn",
text: "▼",
attrs: {
type: "button",
"aria-label": `${t("Button")} ${colName(c)}`,
"data-col": String(c),
},
});
b.style.left = `${box.x + box.w - 17}px`;
b.style.top = `${box.y + box.h - 17}px`;
b.addEventListener("mousedown", (ev) => ev.stopPropagation());
b.addEventListener("click", () => open(c));
btns.push(b);
}
layer.replaceChildren(...btns, ...(panel ? [panel] : []));
}
function close(): void {
panel?.remove();
panel = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (ev: MouseEvent) => {
if (panel && !panel.contains(ev.target as Node)) close();
};
function apply(next: AutoFilter): void {
close();
send(ctx, () => filterCommand(ctx.book, ctx.engine, ctx.sheet().id, next));
}
function open(c: number): void {
const cur = filterOf();
if (!cur || c < cur.rg.c0 || c > cur.rg.c1 || ctx.readOnly) return;
close();
const sheetId = ctx.sheet().id;
const items = filterItems(ctx.book, ctx.engine, sheetId, c);
const boxes = items.map((it) => {
const cb = el("input", { attrs: { type: "checkbox" } });
cb.checked = it.켬;
return cb;
});
const all = el("input", { attrs: { type: "checkbox" } });
const ok = createButton({
label: t("Ok"),
onClick: () => {
const conds = { ...cur.f.조건 };
if (boxes.every((b) => b.checked)) delete conds[colName(c)];
else conds[colName(c)] = items.filter((_, i) => boxes[i].checked).map((it) => it.글);
apply({ ...cur.f, 조건: conds });
},
});
const sync = () => {
all.checked = boxes.every((b) => b.checked);
all.indeterminate = !all.checked && boxes.some((b) => b.checked);
ok.disabled = !boxes.some((b) => b.checked);
};
all.addEventListener("change", () => {
for (const b of boxes) b.checked = all.checked;
sync();
});
for (const b of boxes) b.addEventListener("change", sync);
const link = (label: string, run: () => void) => {
const a = el("button", {
className: "ss-filter__link",
text: label,
attrs: { type: "button" },
});
a.addEventListener("click", run);
return a;
};
const sortBy = (desc: boolean) => () => {
close();
send(ctx, () =>
sortCommand(ctx.book, ctx.engine, sheetId, cur.rg, {
머리: true,
기준: [{ 열: c, 내림: desc }],
}),
);
};
const rest = { ...cur.f.조건 };
delete rest[colName(c)];
panel = el("div", {
className: "ss-filter__panel",
attrs: { role: "dialog", "aria-label": `${t("Button")} ${colName(c)}` },
children: [
link(t("SortAsc"), sortBy(false)),
link(t("SortDesc"), sortBy(true)),
link(t("Clear"), () => apply({ ...cur.f, 조건: rest })),
el("div", {
className: "ss-filter__list",
children: [
el("label", { children: [all, t("All")] }),
...items.map((it, i) =>
el("label", { children: [boxes[i], it.글 === "" ? t("Blank") : it.글] }),
),
],
}),
el("div", {
className: "ss-filter__foot",
children: [createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), ok],
}),
],
});
panel.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") close();
ev.stopPropagation();
});
const box = boxIn(cur.rg.r0, c);
panel.style.left = `${box.x}px`;
panel.style.top = `${box.y + box.h}px`;
sync();
layer.append(panel);
// 격자 아래 · 오른쪽 끝에 걸리면 위 · 왼쪽으로 뒤집음(잘려서 [확인] 이 안 보이지 않게)
const p = panel.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
if (p.bottom > g.bottom && p.top - box.h - p.height >= g.top)
panel.style.top = `${box.y - p.height}px`;
if (p.right > g.right) panel.style.left = `${Math.max(0, box.x + box.w - p.width)}px`;
document.addEventListener("mousedown", onOutside, true);
}
const onScroll = () => place();
ctx.grid.root.addEventListener("scroll", onScroll, true);
place();
return {
root: layer,
refresh() {
close();
place();
},
open,
destroy() {
close();
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}
+179
View File
@@ -0,0 +1,179 @@
/* =============================================================================
* spreadsheet_find.ts (2단계 · 주인 sub_laptop_1)
* 찾기 · 바꾸기 — 대소문자 구분 · 전체 칸 일치 · 「수식 안」(식 글 속 글자도 뒤짐) 옵션.
* 값 칸은 `값`(수 · 참거짓은 글로 바꿔 견줌) · 식 칸은 기본으로 식 글(`=` 뺀 것)을 뒤짐(계산값은
* 이 계층에서 모름 — CalcEngine 은 C·D 쪽에만 있음). 바꾸기는 `dispatch({종류:"칸", …})` 하나로만.
*
* 잇는 법(D · sub7) — 1단계 격자 · 편집기가 서면:
* 1) 도구 모음이나 Ctrl+F 단축키로 작은 패널을 띄우고 `createFindPanel(ctx)` 를 그 안에 붙임.
* 2) 패널의 「다음 찾기」 는 `findNext` 결과로 `ctx.select` + `ctx.grid.reveal` 호출.
* 3) 「모두 바꾸기」 버튼은 `replaceAll` 을 부르고 끝나면 바뀐 수를 토스트로.
* ========================================================================== */
import type { CellInput, SheetCellAddress, Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export interface FindOptions {
대소문자?: boolean;
전체칸?: boolean;
/** 식 칸은 식 글(수식) 속에서도 찾음 — 꺼지면 값 칸만 뒤짐(식 칸은 건너뜀) */
수식안?: boolean;
}
const norm = (text: string, opts: FindOptions | undefined): string =>
opts?.대소문자 ? text : text.toLowerCase();
/** 칸 하나를 글로 — 식 칸은 `수식안` 이 있어야 식 글, 없으면 후보에서 빠짐(null). */
function cellText(
book: Workbook,
sheetId: string,
r: number,
c: number,
opts?: FindOptions,
): string | null {
const sheet = book.시트.find((s) => s.id === sheetId);
const cell = sheet?.칸[a1(r, c)];
if (!cell) return null;
if (cell.식 !== undefined) return opts?.수식안 ? cell.식 : null;
const v = cell.값;
if (v === undefined) return null;
return typeof v === "boolean" ? (v ? "TRUE" : "FALSE") : String(v);
}
function a1(r: number, c: number): string {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
}
const matches = (haystack: string, query: string, opts: FindOptions | undefined): boolean => {
const h = norm(haystack, opts);
const q = norm(query, opts);
return opts?.전체칸 ? h === q : h.includes(q);
};
/** 시트 하나 안 찾는 순서 — 왼위부터 오른아래로(행 우선). */
function* cellsInOrder(book: Workbook, sheetId: string): Generator<SheetCellAddress> {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return;
const addrs = Object.keys(sheet.칸)
.map((key) => parseA1(key))
.sort((x, y) => x.r - y.r || x.c - y.c);
for (const { r, c } of addrs) yield { 시트: sheetId, r, c };
}
function parseA1(key: string): { r: number; c: number } {
const m = /^([A-Z]+)(\d+)$/.exec(key);
if (!m) return { r: 0, c: 0 };
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { r: Number(m[2]) - 1, c: col - 1 };
}
/** 통합문서 전체(활성 시트 먼저 뒤지고 싶으면 `sheetOrder` 로 순서를 줌)에서 다 찾음. */
export function findAll(
book: Workbook,
query: string,
opts?: FindOptions,
sheetOrder?: string[],
): SheetCellAddress[] {
if (!query) return [];
const ids = sheetOrder ?? book.시트.map((s) => s.id);
const out: SheetCellAddress[] = [];
for (const id of ids) {
for (const at of cellsInOrder(book, id)) {
const text = cellText(book, at.시트, at.r, at.c, opts);
if (text !== null && matches(text, query, opts)) out.push(at);
}
}
return out;
}
/** `from` 다음 칸부터 한 바퀴 돌아 처음 걸리는 것(순환) · 없으면 null. */
export function findNext(
book: Workbook,
from: SheetCellAddress,
query: string,
opts?: FindOptions,
): SheetCellAddress | null {
const all = findAll(book, query, opts, [
from.시트,
...book.시트.map((s) => s.id).filter((id) => id !== from.시트),
]);
if (all.length === 0) return null;
const idx = all.findIndex((m) => m.시트 === from.시트 && m.r === from.r && m.c === from.c);
return all[(idx + 1) % all.length];
}
/** 한 칸 바꾸기 — 식 칸(`수식안`)이면 식 글 속 글자를 바꿈 · 값 칸이면 값 전체(전체칸)나 부분(글만)을 바꿈. */
export function replaceOne(
ctx: SpreadsheetContext,
at: SheetCellAddress,
query: string,
replacement: string,
opts?: FindOptions,
): void {
const sheet = ctx.book.시트.find((s) => s.id === at.시트);
const cell = sheet?.칸[a1(at.r, at.c)];
if (!sheet || !cell) return;
const next: { 값?: CellInput; 식?: string; 서식?: number } = { 서식: cell.서식 };
if (cell.식 !== undefined && opts?.수식안) {
next.식 = replaceText(cell.식, query, replacement, opts);
} else if (cell.값 !== undefined) {
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
next.값 = replaceText(text, query, replacement, opts);
} else {
return;
}
ctx.dispatch({ 종류: "칸", 시트: at.시트, 칸: { [a1(at.r, at.c)]: next } });
}
function replaceText(
text: string,
query: string,
replacement: string,
opts: FindOptions | undefined,
): string {
if (opts?.전체칸) return matches(text, query, opts) ? replacement : text;
if (opts?.대소문자) return text.split(query).join(replacement);
const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
return text.replace(re, replacement);
}
/** 모두 바꾸기 — 한 번의 되돌리기로 묶어 적용 · 바뀐 칸 수를 돌려줌. */
export function replaceAll(
ctx: SpreadsheetContext,
query: string,
replacement: string,
opts?: FindOptions,
): number {
const hits = findAll(ctx.book, query, opts);
if (hits.length === 0) return 0;
const bySheet = new Map<string, SheetCellAddress[]>();
for (const at of hits) bySheet.set(at.시트, [...(bySheet.get(at.시트) ?? []), at]);
const commands = [...bySheet.entries()].map(([sheetId, ats]) => {
const sheet = ctx.book.시트.find((s) => s.id === sheetId)!;
const 칸: Record<string, { 값?: CellInput; 식?: string; 서식?: number }> = {};
for (const at of ats) {
const cell = sheet.칸[a1(at.r, at.c)];
if (!cell) continue;
if (cell.식 !== undefined && opts?.수식안) {
칸[a1(at.r, at.c)] = {
식: replaceText(cell.식, query, replacement, opts),
서식: cell.서식,
};
} else if (cell.값 !== undefined) {
const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값);
칸[a1(at.r, at.c)] = { 값: replaceText(text, query, replacement, opts), 서식: cell.서식 };
}
}
return { 종류: "칸" as const, 시트: sheetId, 칸 };
});
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
return hits.length;
}
@@ -0,0 +1,131 @@
/* =============================================================================
* spreadsheet_find_panel.ts (주인 D)
* 찾기(Ctrl+F) · 바꾸기(Ctrl+H) 작은 패널 — 부품 오른위에 뜸. 찾기 규칙은 `spreadsheet_find.ts`(sub1).
* Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find";
import { selectCell } from "./spreadsheet_selection";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
find: "찾을 내용",
replace: "바꿀 내용",
next: "다음",
one: "바꾸기",
all: "모두 바꾸기",
close: "닫기",
case: "대소문자",
whole: "전체 칸",
formula: "수식 안",
none: "찾을 수 없음",
replaced: "{n} 개 바꿈",
};
export interface FindPanelHandle extends PartHandle {
open(replace: boolean): void;
close(): void;
}
export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle {
const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } });
const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } });
const status = el("span", { className: "ss-find__status" });
const button = (text: string, onClick: () => void): HTMLButtonElement => {
const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } });
b.addEventListener("click", onClick);
return b;
};
const check = (text: string): HTMLInputElement => {
const box = el("input", { attrs: { type: "checkbox" } });
options.append(el("label", { className: "ss-find__opt", children: [box, text] }));
return box;
};
const closeBtn = button("×", close);
closeBtn.title = TEXT.close;
const options = el("div", { className: "ss-find__row" });
const caseBox = check(TEXT.case);
const wholeBox = check(TEXT.whole);
const formulaBox = check(TEXT.formula);
const replaceRow = el("div", {
className: "ss-find__row",
children: [repl, button(TEXT.one, one), button(TEXT.all, all)],
});
const root = el("div", {
className: "ss-find",
attrs: { hidden: "" },
children: [
el("div", {
className: "ss-find__row",
children: [query, button(TEXT.next, next), closeBtn],
}),
replaceRow,
options,
status,
],
});
ctx.root.append(root);
const opts = (): FindOptions => ({
대소문자: caseBox.checked,
전체칸: wholeBox.checked,
수식안: formulaBox.checked,
});
const here = () => ({ 시트: ctx.selection.시트, ...ctx.selection.활성 });
function next(): void {
status.textContent = "";
if (!query.value) return;
const hit = findNext(ctx.book, here(), query.value, opts());
if (!hit) return void (status.textContent = TEXT.none);
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
ctx.select(selectCell(ctx.sheet(), hit.r, hit.c), true);
}
function one(): void {
if (ctx.readOnly || !query.value) return;
const at = here();
const onIt = findAll(ctx.book, query.value, opts(), [at.시트]).some(
(h) => h.r === at.r && h.c === at.c,
);
if (onIt) replaceOne(ctx, at, query.value, repl.value, opts());
next();
}
function all(): void {
if (ctx.readOnly || !query.value) return;
const n = replaceAll(ctx, query.value, repl.value, opts());
status.textContent = n ? TEXT.replaced.replace("{n}", String(n)) : TEXT.none;
}
function open(replace: boolean): void {
replaceRow.hidden = !replace || ctx.readOnly;
root.removeAttribute("hidden");
status.textContent = "";
query.focus();
query.select();
}
function close(): void {
root.setAttribute("hidden", "");
back();
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") close();
else if (e.key === "Enter") e.target === repl ? one() : next();
else return;
e.preventDefault();
e.stopPropagation();
};
root.addEventListener("keydown", onKey);
return {
root,
open,
close,
refresh() {},
destroy: () => root.remove(),
};
}
@@ -0,0 +1,58 @@
/* =============================================================================
* spreadsheet_format_code.ts (2단계 · 주인 sub_laptop_1)
* 셀 서식 대화상자(표시 형식 탭)의 범주 → 형식 코드 짓기 — DOM · CSS 없음(시험이 바로 부름).
* 실무 자주 쓰는 모양만(일반 · 숫자 · 통화 · 회계 · 백분율 · 텍스트 · 사용자 지정).
* ========================================================================== */
export const CATEGORIES = [
"일반",
"숫자",
"통화",
"회계",
"백분율",
"텍스트",
"사용자 지정",
] as const;
export type Category = (typeof CATEGORIES)[number];
export type NegStyle = "minus" | "paren" | "red-minus" | "red-paren";
/** 범주 · 소수 자릿수 · 천단위 · 음수 표시로 형식 코드를 지음. */
export function buildCode(
cat: Category,
decimals: number,
thousands: boolean,
neg: NegStyle,
): string {
if (cat === "일반") return "";
if (cat === "텍스트") return "@";
const digits = decimals > 0 ? `0.${"0".repeat(decimals)}` : "0";
const grouped = thousands ? `#,##${digits}` : digits;
if (cat === "백분율") return `${grouped}%`;
const prefix = cat === "통화" ? '"₩"' : "";
const positive = `${prefix}${grouped}`;
if (cat === "회계") {
const acc = decimals > 0 ? "0.00" : "0";
return `_-* #,##${acc}_-;-* #,##${acc}_-;_-* "-"_-;_-@_-`;
}
switch (neg) {
case "minus":
return positive;
case "paren":
return `${positive};(${positive})`;
case "red-minus":
return `${positive};[Red]-${positive}`;
case "red-paren":
return `${positive};[Red](${positive})`;
}
}
/** 있는 형식 코드를 보고 범주를 어림(대화상자를 열 때 초기 범주 고르기). */
export function guessCategory(code: string | undefined): Category {
if (!code) return "일반";
if (code === "@") return "텍스트";
if (code.includes("%")) return "백분율";
if (code.includes("_-*")) return "회계";
if (code.includes("₩")) return "통화";
if (/[0#]/.test(code)) return "숫자";
return "사용자 지정";
}
@@ -0,0 +1,147 @@
/* =============================================================================
* spreadsheet_format_dialog.css (2단계 · 주인 sub_laptop_1)
* 셀 서식 대화상자 — 뿌리 배경(`.ui-confirm`)은 공용 확인 모달과 같은 것을 그대로 씀.
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
* ========================================================================== */
.ss-fmtdlg__panel {
display: flex;
flex-direction: column;
width: min(560px, 92vw);
max-height: 84vh;
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-fmtdlg__title {
margin: 0 0 var(--spacing-8);
font-size: 1rem;
color: var(--color-text-body);
}
.ss-fmtdlg__tabs {
display: flex;
flex-wrap: wrap;
gap: 2px;
border-bottom: 1px solid var(--color-border);
}
.ss-fmtdlg__tabbtn {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--color-paper);
color: var(--color-text-secondary);
font-size: 0.82rem;
cursor: pointer;
}
.ss-fmtdlg__tabbtn.is-active {
background: var(--color-canvas);
color: var(--color-text-body);
font-weight: 600;
}
.ss-fmtdlg__body {
flex: 1;
min-height: 300px;
overflow-y: auto;
padding: var(--spacing-12) 2px;
}
.ss-fmtdlg__tab {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.ss-fmtdlg__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.ss-fmtdlg__label {
width: 140px;
flex-shrink: 0;
color: var(--color-text-secondary);
font-size: 0.82rem;
}
.ss-fmtdlg__row select,
.ss-fmtdlg__row input[type="text"],
.ss-fmtdlg__row input:not([type]),
.ss-fmtdlg__row input[type="number"],
.ss-fmtdlg__code {
height: 28px;
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.82rem;
}
.ss-fmtdlg__code {
flex: 1;
}
.ss-fmtdlg__subrows {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding-left: var(--spacing-16);
}
.ss-fmtdlg__preview-text {
padding: 4px 8px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
font-family: monospace;
}
.ss-fmtdlg__font-sample {
margin-top: var(--spacing-8);
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
}
.ss-fmtdlg__border-presets {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
.ss-fmtdlg__border-preview {
width: 96px;
height: 64px;
margin-top: var(--spacing-8);
background: var(--color-canvas);
}
.ss-fmtdlg__fill-swatch {
width: 96px;
height: 40px;
margin-top: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-fmtdlg__note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 0.8rem;
}
.ss-fmtdlg__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
padding-top: var(--spacing-12);
border-top: 1px solid var(--color-border);
}
@@ -0,0 +1,509 @@
/* =============================================================================
* spreadsheet_format_dialog.ts (2단계 · 주인 sub_laptop_1)
* 셀 서식 대화상자(Ctrl+1) — 엑셀과 같은 탭 여섯: 표시 형식 · 맞춤 · 글꼴 · 테두리 · 채우기 · 보호.
* [확인] 을 누를 때까지 아무 것도 안 바꿈(도구 모음처럼 칸마다 바로 안 바꿈) — 눌러야
* 「서식」 명령 하나(+ 병합이 바뀌었으면 「병합」·「병합풀기」)로 한 번에 나감.
*
* 들여쓰기 · 보호(잠금·숨김)는 화면 자리만 — 0 계약 `CellStyle`·`Command` 에 그 칸이 없어
* 실제로는 안 바뀜(보호는 브레인 지시대로 「자리만」).
*
* 잇는 법(D · sub7) — Ctrl+1(`spreadsheet_shortcuts.ts` 의 `openFormatDialog`) 을
* `spreadsheet_keys.ts` navKey 에 새 case 로 더해 `openFormatDialog(ctx)` 를 부름.
* 우클릭 메뉴 [셀 서식] 도 같은 함수를 부르면 됨(`spreadsheet_menu.ts`).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import {
buildCode,
CATEGORIES,
guessCategory,
type Category,
type NegStyle,
} from "./spreadsheet_format_code";
import { dialogKeys } from "./spreadsheet_actions";
import { isAutoInk } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_format_dialog.css";
const TEXT = {
title: "셀 서식",
tabNumber: "표시 형식",
tabAlign: "맞춤",
tabFont: "글꼴",
tabBorder: "테두리",
tabFill: "채우기",
tabProtect: "보호",
ok: "확인",
cancel: "취소",
category: "범주",
decimals: "소수 자릿수",
thousands: "천 단위 구분 기호 사용",
negative: "음수 표시 형식",
customCode: "사용자 지정 형식 코드",
preview: "보기",
hAlign: "가로",
vAlign: "세로",
wrap: "줄 바꿈",
merge: "셀 병합",
indent: "들여쓰기",
fontName: "글꼴",
fontSize: "크기",
bold: "굵게",
italic: "기울임",
underline: "밑줄",
strike: "취소선",
fontColor: "글자색",
lineStyle: "선 모양",
lineColor: "선 색",
none: "없음",
all: "모두",
top: "위",
bottom: "아래",
left: "왼",
right: "오른",
fillColor: "채움색",
clear: "지움",
protectNote: "칸 잠금 · 숨김은 자리만 둠(시트 보호 자체가 아직 없음)",
lock: "잠금",
hidden: "수식 숨김",
};
/** 활성 칸의 실제 서식 — 칸 → 행 → 열 → 시트 기본 → 0(계약 주석과 같은 차례). */
function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle {
const sheet = ctx.sheet();
const cell = sheet.칸[toA1(r, c)];
const idx = cell?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? 0;
return ctx.book.서식[idx] ?? {};
}
function row(label: string, ...controls: HTMLElement[]): HTMLElement {
return el("div", {
className: "ss-fmtdlg__row",
children: [el("span", { className: "ss-fmtdlg__label", text: label }), ...controls],
});
}
function borderCss(side?: BorderSide): string {
if (!side) return "none";
const color = isAutoInk(side.색) ? "var(--color-text-body)" : (side.색 as string);
const width = side.선 === "medium" ? 2 : side.선 === "thick" || side.선 === "double" ? 3 : 1;
const style = side.선 === "hair" ? "dotted" : side.선 === "double" ? "double" : side.선;
return `${width}px ${style} ${color}`;
}
export function openFormatDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = ctx.selection.범위[0];
if (!range) return;
const at = ctx.selection.활성;
const base = styleAt(ctx, at.r, at.c);
const draft: CellStyle = structuredClone(base);
const spansMany = range.r0 !== range.r1 || range.c0 !== range.c1;
let mergeWanted = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
const overlay = el("div", { className: "ui-confirm ss-fmtdlg" });
const panel = el("div", { className: "ss-fmtdlg__panel" });
const tabsBar = el("div", { className: "ss-fmtdlg__tabs" });
const body = el("div", { className: "ss-fmtdlg__body" });
overlay.append(panel);
panel.append(el("h2", { className: "ss-fmtdlg__title", text: TEXT.title }), tabsBar, body);
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
// ── 표시 형식 ────────────────────────────────────────────────────────
let category: Category = guessCategory(draft.형식);
let decimals = (draft.형식?.match(/\.0+/)?.[0].length ?? 1) - 1;
let thousands = draft.형식?.includes(",") ?? false;
let negStyle: NegStyle = draft.형식?.includes("[Red]")
? draft.형식.includes("(")
? "red-paren"
: "red-minus"
: draft.형식?.includes(";(")
? "paren"
: "minus";
let customCode = draft.형식 ?? "";
let useCustom = category === "사용자 지정";
function numberTab(): HTMLElement {
const catSelect = el("select", {
children: CATEGORIES.map((c) => el("option", { attrs: { value: c }, text: c })),
}) as HTMLSelectElement;
catSelect.value = category;
const decimalsInput = el("input", {
attrs: { type: "number", min: "0", max: "10" },
}) as HTMLInputElement;
decimalsInput.value = String(Math.max(0, decimals));
const thousandsCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
thousandsCheck.checked = thousands;
const negSelect = el("select", {
children: [
el("option", { attrs: { value: "minus" }, text: "-1,234.00" }),
el("option", { attrs: { value: "red-minus" }, text: "-1,234.00(빨강)" }),
el("option", { attrs: { value: "paren" }, text: "(1,234.00)" }),
el("option", { attrs: { value: "red-paren" }, text: "(1,234.00)(빨강)" }),
],
}) as HTMLSelectElement;
negSelect.value = negStyle;
const customInput = el("input", { className: "ss-fmtdlg__code" }) as HTMLInputElement;
customInput.value = customCode;
const previewText = el("span", { className: "ss-fmtdlg__preview-text" });
const numberRows = el("div", { className: "ss-fmtdlg__subrows" });
const customRow = row(TEXT.customCode, customInput);
function syncControls(): void {
const showsNumberControls =
category === "숫자" || category === "통화" || category === "백분율";
numberRows.hidden = !showsNumberControls;
customRow.hidden = category !== "사용자 지정";
decimalsInput.disabled = category === "회계" ? false : !showsNumberControls;
}
function refreshPreview(): void {
const code = useCustom ? customCode : buildCode(category, decimals, thousands, negStyle);
draft.형식 = code || undefined;
const cellValue = ctx.engine.value(ctx.selection.시트, at.r, at.c);
const sample = cellValue ?? toFrac(-1234.5);
previewText.textContent = formatValue(sample, code).글;
}
catSelect.addEventListener("change", () => {
category = catSelect.value as Category;
useCustom = category === "사용자 지정";
syncControls();
refreshPreview();
});
decimalsInput.addEventListener("input", () => {
decimals = Math.max(0, Math.min(10, Number(decimalsInput.value) || 0));
refreshPreview();
});
thousandsCheck.addEventListener("change", () => {
thousands = thousandsCheck.checked;
refreshPreview();
});
negSelect.addEventListener("change", () => {
negStyle = negSelect.value as NegStyle;
refreshPreview();
});
customInput.addEventListener("input", () => {
customCode = customInput.value;
refreshPreview();
});
numberRows.append(
row(TEXT.decimals, decimalsInput),
row(TEXT.thousands, thousandsCheck),
row(TEXT.negative, negSelect),
);
syncControls();
refreshPreview();
return el("div", {
className: "ss-fmtdlg__tab",
children: [
row(TEXT.category, catSelect),
numberRows,
customRow,
row(TEXT.preview, previewText),
],
});
}
// ── 맞춤 ────────────────────────────────────────────────────────────
function alignTab(): HTMLElement {
const hSelect = el("select", {
children: [
["general", "일반"],
["left", "왼쪽"],
["center", "가운데"],
["right", "오른쪽"],
["distributed", "균등 분할"],
["centerContinuous", "선택 영역 가운데"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
hSelect.value = draft.가로 ?? "general";
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
const vSelect = el("select", {
children: [
["top", "위"],
["center", "가운데"],
["bottom", "아래"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
vSelect.value = draft.세로 ?? "bottom";
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
wrapCheck.checked = !!draft.줄바꿈;
wrapCheck.addEventListener("change", () => (draft.줄바꿈 = wrapCheck.checked));
const mergeCheck = el("input", {
attrs: { type: "checkbox", ...(spansMany ? {} : { disabled: "true" }) },
}) as HTMLInputElement;
mergeCheck.checked = mergeWanted;
mergeCheck.addEventListener("change", () => (mergeWanted = mergeCheck.checked));
const indentInput = el("input", {
attrs: { type: "number", min: "0", max: "15", disabled: "true", title: "0 계약 밖 — 자리만" },
}) as HTMLInputElement;
indentInput.value = "0";
return el("div", {
className: "ss-fmtdlg__tab",
children: [
row(TEXT.hAlign, hSelect),
row(TEXT.vAlign, vSelect),
row(TEXT.wrap, wrapCheck),
row(TEXT.merge, mergeCheck),
row(TEXT.indent, indentInput),
],
});
}
// ── 글꼴 ────────────────────────────────────────────────────────────
function fontTab(): HTMLElement {
const nameInput = el("input") as HTMLInputElement;
nameInput.value = draft.글꼴 ?? "";
const sizeInput = el("input", {
attrs: { type: "number", min: "1", max: "409", step: "0.5" },
}) as HTMLInputElement;
sizeInput.value = draft.크기 != null ? String(draft.크기) : "";
const boldCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
boldCheck.checked = !!draft.굵게;
const italicCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
italicCheck.checked = !!draft.기울임;
const underlineCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
underlineCheck.checked = !!draft.밑줄;
const strikeCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
strikeCheck.checked = !!draft.취소선;
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
colorInput.value = draft.글자색 ?? "#000000";
const sample = el("div", { className: "ss-fmtdlg__font-sample", text: "가나다 ABC 123" });
function refreshSample(): void {
sample.style.fontFamily = nameInput.value || "";
sample.style.fontSize = sizeInput.value ? `${sizeInput.value}pt` : "";
sample.style.fontWeight = boldCheck.checked ? "700" : "400";
sample.style.fontStyle = italicCheck.checked ? "italic" : "normal";
sample.style.textDecorationLine = [
underlineCheck.checked && "underline",
strikeCheck.checked && "line-through",
]
.filter(Boolean)
.join(" ");
sample.style.color = isAutoInk(colorInput.value) ? "" : colorInput.value;
}
nameInput.addEventListener("input", () => {
draft.글꼴 = nameInput.value || undefined;
refreshSample();
});
sizeInput.addEventListener("input", () => {
const n = Number(sizeInput.value);
draft.크기 = sizeInput.value && n > 0 ? n : undefined;
refreshSample();
});
boldCheck.addEventListener("change", () => {
draft.굵게 = boldCheck.checked || undefined;
refreshSample();
});
italicCheck.addEventListener("change", () => {
draft.기울임 = italicCheck.checked || undefined;
refreshSample();
});
underlineCheck.addEventListener("change", () => {
draft.밑줄 = underlineCheck.checked || undefined;
refreshSample();
});
strikeCheck.addEventListener("change", () => {
draft.취소선 = strikeCheck.checked || undefined;
refreshSample();
});
colorInput.addEventListener("input", () => {
draft.글자색 = colorInput.value;
refreshSample();
});
refreshSample();
return el("div", {
className: "ss-fmtdlg__tab",
children: [
row(TEXT.fontName, nameInput),
row(TEXT.fontSize, sizeInput),
row(TEXT.bold, boldCheck),
row(TEXT.italic, italicCheck),
row(TEXT.underline, underlineCheck),
row(TEXT.strike, strikeCheck),
row(TEXT.fontColor, colorInput),
sample,
],
});
}
// ── 테두리 ────────────────────────────────────────────────────────────
function borderTab(): HTMLElement {
const lineSelect = el("select", {
children: (
["hair", "thin", "medium", "thick", "double", "dotted", "dashed"] as BorderLine[]
).map((v) => el("option", { attrs: { value: v }, text: v })),
}) as HTMLSelectElement;
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
colorInput.value = "#000000";
const preview = el("div", { className: "ss-fmtdlg__border-preview" });
function refreshPreview(): void {
const t = draft.테두리 ?? {};
preview.style.borderTop = borderCss(t.위);
preview.style.borderBottom = borderCss(t.아래);
preview.style.borderLeft = borderCss(t.왼);
preview.style.borderRight = borderCss(t.오른);
}
refreshPreview();
const set = (sides: ("위" | "아래" | "왼" | "오른")[] | null): void => {
if (sides === null) {
draft.테두리 = undefined;
} else {
const cur = { ...(draft.테두리 ?? {}) };
const side: BorderSide = { 선: lineSelect.value as BorderLine, 색: colorInput.value };
for (const s of sides) cur[s] = side;
draft.테두리 = cur;
}
refreshPreview();
};
// 「바깥(outline)」은 범위 가장자리 칸만 따로 골라야 해서(토대는 toolbar.ts cellsFor) 여기선 뺌 —
// 여러 칸을 한 서식 값으로 통째로 바꾸는 이 대화상자는 위·아래·왼·오른·모두만 (모두 = 칸마다 네 변).
const presets: [string, ("위" | "아래" | "왼" | "오른")[] | null][] = [
[TEXT.none, null],
[TEXT.top, ["위"]],
[TEXT.bottom, ["아래"]],
[TEXT.left, ["왼"]],
[TEXT.right, ["오른"]],
[TEXT.all, ["위", "아래", "왼", "오른"]],
];
const presetRow = el("div", {
className: "ss-fmtdlg__border-presets",
children: presets.map(([label, sides]) =>
createButton({ label, variant: "ghost", onClick: () => set(sides) }),
),
});
return el("div", {
className: "ss-fmtdlg__tab",
children: [
row(TEXT.lineStyle, lineSelect),
row(TEXT.lineColor, colorInput),
presetRow,
preview,
],
});
}
// ── 채우기 ────────────────────────────────────────────────────────────
function fillTab(): HTMLElement {
const colorInput = el("input", { attrs: { type: "color" } }) as HTMLInputElement;
colorInput.value = draft.채움 ?? "#ffffff";
const swatch = el("div", { className: "ss-fmtdlg__fill-swatch" });
function refresh(): void {
swatch.style.background = draft.채움 ?? "transparent";
}
colorInput.addEventListener("input", () => {
draft.채움 = colorInput.value;
refresh();
});
const clearBtn = createButton({
label: TEXT.clear,
variant: "ghost",
onClick: () => {
draft.채움 = undefined;
refresh();
},
});
refresh();
return el("div", {
className: "ss-fmtdlg__tab",
children: [row(TEXT.fillColor, colorInput, clearBtn), swatch],
});
}
// ── 보호(자리만) ────────────────────────────────────────────────────
function protectTab(): HTMLElement {
const lockCheck = el("input", {
attrs: { type: "checkbox", checked: "true", disabled: "true" },
});
const hiddenCheck = el("input", { attrs: { type: "checkbox", disabled: "true" } });
return el("div", {
className: "ss-fmtdlg__tab",
children: [
el("p", { className: "ss-fmtdlg__note", text: TEXT.protectNote }),
row(TEXT.lock, lockCheck),
row(TEXT.hidden, hiddenCheck),
],
});
}
const tabs: [string, () => HTMLElement][] = [
[TEXT.tabNumber, numberTab],
[TEXT.tabAlign, alignTab],
[TEXT.tabFont, fontTab],
[TEXT.tabBorder, borderTab],
[TEXT.tabFill, fillTab],
[TEXT.tabProtect, protectTab],
];
function showTab(index: number): void {
body.replaceChildren(tabs[index][1]());
tabsBar.querySelectorAll(".ss-fmtdlg__tabbtn").forEach((btn, i) => {
btn.classList.toggle("is-active", i === index);
});
}
tabs.forEach(([label], i) => {
const btn = el("button", {
className: "ss-fmtdlg__tabbtn",
text: label,
attrs: { type: "button" },
});
btn.addEventListener("click", () => showTab(i));
tabsBar.append(btn);
});
function apply(): void {
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: ctx.selection.범위, 바꿀: draft });
const isMerged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
if (mergeWanted !== isMerged) {
ctx.dispatch({
종류: mergeWanted ? "병합" : "병합풀기",
시트: ctx.selection.시트,
범위: range,
});
}
close();
}
const actions = el("div", {
className: "ss-fmtdlg__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({ label: TEXT.ok, variant: "filled", onClick: apply }),
],
});
panel.append(actions);
showTab(0);
document.body.append(overlay);
dialogKeys(overlay, close, apply);
}
@@ -0,0 +1,95 @@
/* =============================================================================
* spreadsheet_format_painter.ts (2단계 · 주인 sub_laptop_1)
* 서식 붓 — 활성 칸의 서식을 묻혀 다른 범위에 바름. 한 번 누르면 한 번만 · 두 번 누르면(sticky)
* 끌 때까지 계속. 서식 찾기는 칸 → 행 → 열 → 기본(0) 차례(`spreadsheet_types.ts` `Cell.서식` 규칙).
*
* 잇는 법(D · sub7 · E 도구 모음) — 도구 모음에 버튼을 두고 `createFormatPainter(ctx)` 하나를 붙임.
* 한 번 누르면 `pick()` → 커서를 붓 모양으로 → 범위를 고르면(mouseup) `paint(range)`.
* 두 번 누르면 `pick()` 뒤 `sticky=true` 로 두고 Esc 나 버튼을 다시 누르면 꺼짐(`active()` 로 표시).
* ========================================================================== */
import type { CellRange, CellStyle, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const EMPTY_STYLE: CellStyle = {};
/** 칸 하나의 실제 서식 — 칸 자기 서식 없으면 행 서식 · 없으면 열 서식 · 없으면 기본(0). */
export function resolveCellStyle(book: Workbook, sheetId: string, r: number, c: number): CellStyle {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return EMPTY_STYLE;
const idx = cellStyleIndex(sheet, r, c);
return book.서식[idx] ?? EMPTY_STYLE;
}
function cellStyleIndex(
sheet: {
칸: Record<string, { 서식?: number }>;
열?: Record<string, { 서식?: number }>;
행?: Record<string, { 서식?: number }>;
},
r: number,
c: number,
): number {
const key = a1(r, c);
const cellIdx = sheet.칸[key]?.서식;
if (cellIdx !== undefined) return cellIdx;
const rowIdx = sheet.행?.[String(r + 1)]?.서식;
if (rowIdx !== undefined) return rowIdx;
const colIdx = sheet.열?.[colLetters(c)]?.서식;
if (colIdx !== undefined) return colIdx;
return 0;
}
function colLetters(c: number): string {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return letters;
}
const a1 = (r: number, c: number): string => `${colLetters(c)}${r + 1}`;
export interface FormatPainterHandle extends PartHandle {
/** 지금 활성 칸의 서식을 붓에 묻힘 */
pick(): void;
/** 묻힌 서식이 있으면 범위에 바름 · `sticky` 가 아니면 한 번 쓰고 스스로 끔 */
paint(range: CellRange, sticky?: boolean): void;
active(): boolean;
clear(): void;
}
export function createFormatPainter(ctx: SpreadsheetContext): FormatPainterHandle {
let picked: CellStyle | null = null;
let sticky = false;
return {
root: null,
pick(): void {
const at = ctx.selection.활성;
picked = resolveCellStyle(ctx.book, ctx.selection.시트, at.r, at.c);
},
paint(range: CellRange, keepOn = false): void {
if (!picked) return;
sticky = keepOn;
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: [range], 바꿀: { ...picked } });
if (!sticky) picked = null;
},
active(): boolean {
return picked !== null;
},
clear(): void {
picked = null;
sticky = false;
},
refresh(): void {
/* 상태 없는 표시만 씀 — 버튼 눌림은 active() 로 E 가 그림 */
},
destroy(): void {
picked = null;
},
};
}
@@ -0,0 +1,99 @@
/* =============================================================================
* spreadsheet_func_help.ts (2단계 · 주인 sub_laptop_1 · 2단계 묶음은 B 가 대신 고침 2026-09-27)
* 함수 자동 완성 목록 · 인자 도움말 — 1단계 함수 48 개(`spreadsheet_types.ts` 머리 「1단계 함수」) +
* B 의 2단계 묶음(`spreadsheet_functions.ts` 의 `FUNC_HELP_BATCH2` · `FUNC_HELP_BATCH3`)을 이어 붙임.
* 이름 · 인자 모양 · 한 줄 뜻. B `spreadsheet_functions.ts`(실제 풀이)와는 따로 — 이 표는 화면 도움말 전용
* 정적 자료라 B 가 아직 없어도 씀. B 가 완성돼도 이름 목록이 갈리지 않게 이 표를 그대로 둠.
*
* 잇는 법(D · sub7 · 편집기) — 식 입력 중 `=` 뒤 낱말을 `suggestFunctions` 로 목록을 보여주고
* `(` 를 친 뒤엔 `funcHelp(name)` 로 인자 도움말 풍선을 씀(지금 몇 번째 인자인지는 D 가 콤마를 세어 넘김).
* ========================================================================== */
import { FUNC_HELP_BATCH2, FUNC_HELP_BATCH3 } from "./spreadsheet_functions";
export interface FuncHelp {
이름: string;
/** 인자 모양 — 대괄호는 생략 가능 */
꼴: string;
뜻: string;
}
const TABLE: FuncHelp[] = [
{ 이름: "SUM", 꼴: "SUM(수1, [수2], …)", 뜻: "수를 더함" },
{ 이름: "PRODUCT", 꼴: "PRODUCT(수1, [수2], …)", 뜻: "수를 곱함" },
{ 이름: "SUMPRODUCT", 꼴: "SUMPRODUCT(범위1, [범위2], …)", 뜻: "같은 자리끼리 곱한 뒤 더함" },
{ 이름: "ROUND", 꼴: "ROUND(수, 자릿수)", 뜻: "반올림" },
{ 이름: "ROUNDUP", 꼴: "ROUNDUP(수, 자릿수)", 뜻: "올림" },
{ 이름: "ROUNDDOWN", 꼴: "ROUNDDOWN(수, 자릿수)", 뜻: "내림" },
{ 이름: "INT", 꼴: "INT(수)", 뜻: "정수로 내림(음수는 작은 쪽)" },
{ 이름: "TRUNC", 꼴: "TRUNC(수, [자릿수])", 뜻: "소수점을 버림(0 쪽으로)" },
{ 이름: "CEILING", 꼴: "CEILING(수, 배수)", 뜻: "배수 단위로 올림" },
{ 이름: "FLOOR", 꼴: "FLOOR(수, 배수)", 뜻: "배수 단위로 내림" },
{ 이름: "ABS", 꼴: "ABS(수)", 뜻: "절댓값" },
{ 이름: "MOD", 꼴: "MOD(수, 나눌수)", 뜻: "나눈 나머지" },
{ 이름: "POWER", 꼴: "POWER(수, 지수)", 뜻: "거듭제곱" },
{ 이름: "SQRT", 꼴: "SQRT(수)", 뜻: "제곱근" },
{ 이름: "PI", 꼴: "PI()", 뜻: "원주율" },
{ 이름: "SIN", 꼴: "SIN(각도값)", 뜻: "사인(라디안)" },
{ 이름: "COS", 꼴: "COS(각도값)", 뜻: "코사인(라디안)" },
{ 이름: "TAN", 꼴: "TAN(각도값)", 뜻: "탄젠트(라디안)" },
{ 이름: "ASIN", 꼴: "ASIN(수)", 뜻: "아크사인(라디안)" },
{ 이름: "ACOS", 꼴: "ACOS(수)", 뜻: "아크코사인(라디안)" },
{ 이름: "ATAN", 꼴: "ATAN(수)", 뜻: "아크탄젠트(라디안)" },
{ 이름: "RADIANS", 꼴: "RADIANS(각도)", 뜻: "도 → 라디안" },
{ 이름: "DEGREES", 꼴: "DEGREES(라디안)", 뜻: "라디안 → 도" },
{ 이름: "MIN", 꼴: "MIN(수1, [수2], …)", 뜻: "가장 작은 수" },
{ 이름: "MAX", 꼴: "MAX(수1, [수2], …)", 뜻: "가장 큰 수" },
{ 이름: "AVERAGE", 꼴: "AVERAGE(수1, [수2], …)", 뜻: "평균" },
{ 이름: "IF", 꼴: "IF(조건, 참일때, [거짓일때])", 뜻: "조건에 따라 값을 고름" },
{ 이름: "AND", 꼴: "AND(조건1, [조건2], …)", 뜻: "모두 참이면 참" },
{ 이름: "OR", 꼴: "OR(조건1, [조건2], …)", 뜻: "하나라도 참이면 참" },
{ 이름: "NOT", 꼴: "NOT(조건)", 뜻: "참 · 거짓을 뒤집음" },
{ 이름: "IFERROR", 꼴: "IFERROR(식, 오류일때)", 뜻: "오류면 대신 값을 씀" },
{ 이름: "CONCATENATE", 꼴: "CONCATENATE(글1, [글2], …)", 뜻: "글을 이어 붙임" },
{ 이름: "FIXED", 꼴: "FIXED(수, [소수자리], [쉼표뺌])", 뜻: "소수 자리를 정해 글로 바꿈" },
{ 이름: "TEXT", 꼴: "TEXT(값, 형식)", 뜻: "형식 코드로 값을 글로 바꿈" },
{ 이름: "LEN", 꼴: "LEN(글)", 뜻: "글자 수" },
{ 이름: "LEFT", 꼴: "LEFT(글, [글자수])", 뜻: "왼쪽부터 글자를 뗌" },
{ 이름: "RIGHT", 꼴: "RIGHT(글, [글자수])", 뜻: "오른쪽부터 글자를 뗌" },
{ 이름: "MID", 꼴: "MID(글, 시작, 글자수)", 뜻: "가운데 글자를 뗌" },
{ 이름: "VALUE", 꼴: "VALUE(글)", 뜻: "숫자 모양 글을 수로 바꿈" },
{
이름: "VLOOKUP",
꼴: "VLOOKUP(찾을값, 표범위, 열번호, [정확히])",
뜻: "표 왼쪽에서 찾아 그 행의 값",
},
{
이름: "HLOOKUP",
꼴: "HLOOKUP(찾을값, 표범위, 행번호, [정확히])",
뜻: "표 위쪽에서 찾아 그 열의 값",
},
{ 이름: "INDEX", 꼴: "INDEX(범위, 행번호, [열번호])", 뜻: "범위 안 자리 값" },
{ 이름: "MATCH", 꼴: "MATCH(찾을값, 범위, [맞춤꼴])", 뜻: "범위 안 자리 번호" },
{ 이름: "CHOOSE", 꼴: "CHOOSE(순번, 값1, [값2], …)", 뜻: "순번에 맞는 값을 고름" },
{ 이름: "COUNT", 꼴: "COUNT(값1, [값2], …)", 뜻: "수가 든 칸 개수" },
{ 이름: "COUNTA", 꼴: "COUNTA(값1, [값2], …)", 뜻: "비어 있지 않은 칸 개수" },
{ 이름: "COUNTIF", 꼴: "COUNTIF(범위, 조건)", 뜻: "조건에 맞는 칸 개수" },
{ 이름: "SUMIF", 꼴: "SUMIF(범위, 조건, [더할범위])", 뜻: "조건에 맞는 칸(또는 짝 칸)의 합" },
...FUNC_HELP_BATCH2,
...FUNC_HELP_BATCH3,
];
const BY_NAME = new Map(TABLE.map((f) => [f.이름, f]));
/** `=` 뒤 지금 치는 낱말(대소문자 안 가림)로 시작하는 함수 이름 — 가나다(알파벳)순. */
export function suggestFunctions(prefix: string): FuncHelp[] {
const p = prefix.toUpperCase();
if (!p) return [];
return TABLE.filter((f) => f.이름.startsWith(p));
}
/** 함수 하나의 도움말(없으면 null) — 이름은 대소문자 안 가림. */
export function funcHelp(name: string): FuncHelp | null {
return BY_NAME.get(name.toUpperCase()) ?? null;
}
/** 아는 함수 전부 개수(1단계 48 + 2단계 묶음 · 시험용) */
export function knownFunctionCount(): number {
return TABLE.length;
}
@@ -0,0 +1,154 @@
/* =============================================================================
* spreadsheet_functions.ts (주인 B)
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` ·
* `_stat.ts` 에 두고 여기서 모음. 1단계 약 45 + 2단계 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2` ·
* `FUNC_HELP_BATCH3`(sub1 의 `spreadsheet_func_help.ts` 가 읽어 이름 목록에 둘 다 더할 것 —
* 그 파일은 sub1 몫이라 안 고침).
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
* ========================================================================== */
import type { SheetFunction } from "./spreadsheet_types";
import { MATH_FUNCTIONS } from "./spreadsheet_functions_math";
import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc";
import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup";
import { STAT_FUNCTIONS } from "./spreadsheet_functions_stat";
const TABLE: Record<string, SheetFunction> = {
...MATH_FUNCTIONS,
...MISC_FUNCTIONS,
...LOOKUP_FUNCTIONS,
...STAT_FUNCTIONS,
};
export function getFunction(name: string): SheetFunction | undefined {
return TABLE[name.toUpperCase()];
}
/** 아는 함수 이름 전부(가나다 · 자동 완성은 2단계) */
export function functionNames(): string[] {
return Object.keys(TABLE).sort();
}
/** 2단계 첫 묶음(2026-09-27) — sub1 `spreadsheet_func_help.ts` 의 `TABLE` 에 이 모양대로 더할 것.
* `이름` · `꼴`(인자 모양 · 대괄호 = 생략 가능) · `뜻`(한 줄). */
export interface FuncHelpEntry {
이름: string;
꼴: string;
뜻: string;
}
export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
{ 이름: "MROUND", 꼴: "MROUND(수, 배수)", 뜻: "배수의 가장 가까운 정수 배로 반올림" },
{ 이름: "ODD", 꼴: "ODD(수)", 뜻: "홀수로 올림(0 에서 먼 쪽)" },
{ 이름: "EVEN", 꼴: "EVEN(수)", 뜻: "짝수로 올림(0 에서 먼 쪽)" },
{ 이름: "LOG", 꼴: "LOG(수, [밑=10])", 뜻: "로그(밑 지정)" },
{ 이름: "LOG10", 꼴: "LOG10(수)", 뜻: "상용로그" },
{ 이름: "LN", 꼴: "LN(수)", 뜻: "자연로그" },
{ 이름: "EXP", 꼴: "EXP(수)", 뜻: "e 의 거듭제곱" },
{
이름: "SUMIFS",
꼴: "SUMIFS(합할범위, 조건범위1, 조건1, [조건범위2, 조건2], …)",
뜻: "여러 조건에 다 맞는 칸의 합",
},
{
이름: "COUNTIFS",
꼴: "COUNTIFS(조건범위1, 조건1, [조건범위2, 조건2], …)",
뜻: "여러 조건에 다 맞는 칸 개수",
},
{
이름: "AVERAGEIF",
꼴: "AVERAGEIF(범위, 조건, [평균범위])",
뜻: "조건에 맞는 칸(또는 짝 칸)의 평균",
},
{
이름: "AVERAGEIFS",
꼴: "AVERAGEIFS(평균범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 평균",
},
{
이름: "MAXIFS",
꼴: "MAXIFS(최대범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 최댓값",
},
{
이름: "MINIFS",
꼴: "MINIFS(최소범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 최솟값",
},
{
이름: "IFS",
꼴: "IFS(조건1, 값1, [조건2, 값2], …)",
뜻: "처음으로 참인 조건의 값",
},
{
이름: "SWITCH",
꼴: "SWITCH(식, 값1, 결과1, [값2, 결과2], …, [기본값])",
뜻: "식과 같은 값을 찾아 그 결과",
},
{
이름: "XLOOKUP",
꼴: "XLOOKUP(찾을값, 찾을범위, 돌려줄범위, [못찾으면], [맞춤꼴], [찾는방향])",
뜻: "찾을범위에서 찾아 돌려줄범위의 같은 자리 값",
},
{
이름: "TEXTJOIN",
꼴: "TEXTJOIN(구분자, 빈칸건너뜀, 글1, [글2], …)",
뜻: "구분자로 여러 글을 이어 붙임",
},
{ 이름: "TRIM", 꼴: "TRIM(글)", 뜻: "앞뒤 · 겹친 빈칸을 뺌" },
{ 이름: "UPPER", 꼴: "UPPER(글)", 뜻: "대문자로" },
{ 이름: "LOWER", 꼴: "LOWER(글)", 뜻: "소문자로" },
{
이름: "SUBSTITUTE",
꼴: "SUBSTITUTE(글, 옛글, 새글, [몇번째])",
뜻: "글 안 옛글을 새글로 바꿈",
},
{ 이름: "REPLACE", 꼴: "REPLACE(글, 시작, 글자수, 새글)", 뜻: "자리로 콕 집어 바꿈" },
{ 이름: "FIND", 꼴: "FIND(찾을글, 안글, [시작])", 뜻: "글자 그대로 찾음(대소문자 가림)" },
{
이름: "SEARCH",
꼴: "SEARCH(찾을글, 안글, [시작])",
뜻: "찾음(대소문자 안 가림 · * ? 자리표시)",
},
{ 이름: "REPT", 꼴: "REPT(글, 번)", 뜻: "글을 번 만큼 되풀이" },
{ 이름: "ISNUMBER", 꼴: "ISNUMBER(값)", 뜻: "수면 참" },
{ 이름: "ISTEXT", 꼴: "ISTEXT(값)", 뜻: "글이면 참" },
{ 이름: "ISBLANK", 꼴: "ISBLANK(값)", 뜻: "빈 칸이면 참" },
{ 이름: "ISERROR", 꼴: "ISERROR(값)", 뜻: "오류 값이면 참" },
{ 이름: "NA", 꼴: "NA()", 뜻: "#N/A 오류를 만듦" },
{ 이름: "TODAY", 꼴: "TODAY()", 뜻: "오늘 날짜(일련번호)" },
{ 이름: "DATE", 꼴: "DATE(년, 월, 일)", 뜻: "날짜 일련번호를 만듦" },
{ 이름: "YEAR", 꼴: "YEAR(일련번호)", 뜻: "연도" },
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
];
/** 2단계 셋째 묶음(2026-09-27) — 위 `FUNC_HELP_BATCH2` 와 같은 모양 · 같이 sub1 표에 더할 것. */
export const FUNC_HELP_BATCH3: FuncHelpEntry[] = [
{ 이름: "MEDIAN", 꼴: "MEDIAN(수1, [수2], …)", 뜻: "가운데 값" },
{ 이름: "MODE", 꼴: "MODE(수1, [수2], …)", 뜻: "가장 자주 나오는 값" },
{ 이름: "STDEV", 꼴: "STDEV(수1, [수2], …)", 뜻: "표본 표준편차" },
{ 이름: "STDEV.S", 꼴: "STDEV.S(수1, [수2], …)", 뜻: "표본 표준편차" },
{ 이름: "STDEV.P", 꼴: "STDEV.P(수1, [수2], …)", 뜻: "모집단 표준편차" },
{ 이름: "VAR", 꼴: "VAR(수1, [수2], …)", 뜻: "표본 분산" },
{ 이름: "RANK", 꼴: "RANK(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
{ 이름: "RANK.EQ", 꼴: "RANK.EQ(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
{ 이름: "LARGE", 꼴: "LARGE(범위, k)", 뜻: "k 번째로 큰 값" },
{ 이름: "SMALL", 꼴: "SMALL(범위, k)", 뜻: "k 번째로 작은 값" },
{ 이름: "PERCENTILE", 꼴: "PERCENTILE(범위, k)", 뜻: "k(0~1) 자리 백분위 값" },
{ 이름: "QUARTILE", 꼴: "QUARTILE(범위, 사분위수)", 뜻: "사분위수(0~4)" },
{ 이름: "COUNTBLANK", 꼴: "COUNTBLANK(범위)", 뜻: "빈 칸 개수" },
{ 이름: "NOW", 꼴: "NOW()", 뜻: "지금 날짜 · 시각(일련번호)" },
{ 이름: "WEEKDAY", 꼴: "WEEKDAY(일련번호, [유형=1])", 뜻: "요일 번호" },
{ 이름: "EDATE", 꼴: "EDATE(시작일, 개월수)", 뜻: "개월 뒤(앞) 같은 날" },
{ 이름: "EOMONTH", 꼴: "EOMONTH(시작일, 개월수)", 뜻: "개월 뒤(앞) 달의 말일" },
{ 이름: "DATEDIF", 꼴: "DATEDIF(시작일, 종료일, 단위)", 뜻: "두 날짜 차(단위 Y·M·D·YM·MD·YD)" },
{
이름: "NETWORKDAYS",
꼴: "NETWORKDAYS(시작일, 종료일, [휴일범위])",
뜻: "평일 일수(토·일 · 휴일 뺌)",
},
{ 이름: "HOUR", 꼴: "HOUR(일련번호)", 뜻: "시" },
{ 이름: "MINUTE", 꼴: "MINUTE(일련번호)", 뜻: "분" },
{ 이름: "TIME", 꼴: "TIME(시, 분, 초)", 뜻: "시각 일련번호를 만듦" },
];
@@ -0,0 +1,536 @@
/* =============================================================================
* spreadsheet_functions_lookup.ts (주인 B · misc 700 줄 넘어 2026-09-27 쪼갬)
* 찾기 · 조건부 세기·합·평균·최대·최소 함수. `spreadsheet_functions_misc.ts` 에서
* VLOOKUP · HLOOKUP · INDEX · MATCH · CHOOSE · COUNT · COUNTA · COUNTIF · SUMIF ·
* SUMIFS · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS · IFS · SWITCH · XLOOKUP 를 옮겨 옴.
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import {
ZERO,
add,
cmp,
div,
frac,
parseDecimal,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
import {
bool,
collectNumbers,
fracToDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
toNumber,
toScalar,
} from "./spreadsheet_functions_math";
const ONE = frac(1n);
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
function typeRank(v: Scalar): number {
if (v === null) return -1;
if (isFrac(v)) return 0;
if (typeof v === "string") return 1;
if (typeof v === "boolean") return 2;
return 3;
}
function compareScalars(a: Scalar, b: Scalar): number {
if (isFrac(a) && isFrac(b)) return cmp(a, b);
if (typeof a === "string" && typeof b === "string") {
const la = a.toUpperCase();
const lb = b.toUpperCase();
return la < lb ? -1 : la > lb ? 1 : 0;
}
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
return 0;
}
function equalScalars(a: Scalar, b: Scalar): boolean {
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
}
function wrapScalar(v: Scalar): RangeValue {
return { kind: "range", rows: 1, cols: 1, at: () => v };
}
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
function wildcardToRegex(text: string): RegExp {
const escaped = text
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`, "i");
}
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
if (crit === null) return (cell) => cell === null;
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
if (isErr(crit)) return () => false;
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
const op = m?.[1] ?? "";
const rest = m?.[2] ?? crit;
let numRest: Frac | null = null;
try {
numRest = rest.trim() === "" ? null : parseDecimal(rest);
} catch {
numRest = null;
}
if (numRest !== null) {
const nr = numRest;
return (cell) => {
if (!isFrac(cell)) return false;
const c = cmp(cell, nr);
if (op === "<=") return c <= 0;
if (op === ">=") return c >= 0;
if (op === "<>") return c !== 0;
if (op === "<") return c < 0;
if (op === ">") return c > 0;
return c === 0;
};
}
const pat = wildcardToRegex(rest);
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
return (cell) => typeof cell === "string" && pat.test(cell);
}
/** `args[from]` 부터 (범위,조건) 짝을 이어가며 모두 맞는 자리만 참 — *IFS 함수 공용.
* `shape` 범위와 크기가 다른 조건 범위가 있으면 `#VALUE!`. */
function criteriaMask(args: LazyArg[], from: number, shape: RangeValue): boolean[] | ErrorValue {
const mask = new Array<boolean>(shape.rows * shape.cols).fill(true);
for (let i = from; i + 1 < args.length; i += 2) {
const rangeArg = args[i]();
if (!isRange(rangeArg)) return mkErr("#VALUE!");
if (rangeArg.rows !== shape.rows || rangeArg.cols !== shape.cols) {
return mkErr("#VALUE!", "조건 범위 크기가 다름");
}
const crit = toScalar(args[i + 1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
let k = 0;
for (let r = 0; r < shape.rows; r++) {
for (let c = 0; c < shape.cols; c++) {
if (mask[k] && !pred(rangeArg.at(r, c))) mask[k] = false;
k++;
}
}
}
return mask;
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const LOOKUP_FUNCTIONS: Record<string, SheetFunction> = {
VLOOKUP: {
min: 3,
max: 4,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const table = args[1]();
if (!isRange(table)) return mkErr("#VALUE!");
const colN = num(args[2]());
if (isErr(colN)) return colN;
const col = Math.trunc(fracToDouble(colN)) - 1;
if (col < 0 || col >= table.cols) return mkErr("#REF!");
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
if (isErr(approxRaw)) return approxRaw;
if (approxRaw) {
let best = -1;
for (let r = 0; r < table.rows; r++) {
const key = table.at(r, 0);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
if (compareScalars(key, lookup) <= 0) best = r;
else break;
}
if (best < 0) return mkErr("#N/A");
return table.at(best, col);
}
for (let r = 0; r < table.rows; r++) {
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
}
return mkErr("#N/A");
},
},
HLOOKUP: {
min: 3,
max: 4,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const table = args[1]();
if (!isRange(table)) return mkErr("#VALUE!");
const rowN = num(args[2]());
if (isErr(rowN)) return rowN;
const row = Math.trunc(fracToDouble(rowN)) - 1;
if (row < 0 || row >= table.rows) return mkErr("#REF!");
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
if (isErr(approxRaw)) return approxRaw;
if (approxRaw) {
let best = -1;
for (let c = 0; c < table.cols; c++) {
const key = table.at(0, c);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
if (compareScalars(key, lookup) <= 0) best = c;
else break;
}
if (best < 0) return mkErr("#N/A");
return table.at(row, best);
}
for (let c = 0; c < table.cols; c++) {
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
}
return mkErr("#N/A");
},
},
INDEX: {
min: 2,
max: 3,
call: (args) => {
const rangeArg = args[0]();
if (isErr(rangeArg)) return rangeArg;
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
const rowN = num(args[1]());
if (isErr(rowN)) return rowN;
const row = Math.trunc(fracToDouble(rowN));
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
if (isErr(colN)) return colN;
const col = Math.trunc(fracToDouble(colN));
if (row === 0 && col === 0) return mkErr("#VALUE!");
if (row === 0) {
if (col < 1 || col > range.cols) return mkErr("#REF!");
const c = col - 1;
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
}
if (col === 0) {
if (row < 1 || row > range.rows) return mkErr("#REF!");
const r = row - 1;
return {
kind: "range",
rows: 1,
cols: range.cols,
at: (_r: number, c: number) => range.at(r, c),
};
}
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
return range.at(row - 1, col - 1);
},
},
MATCH: {
min: 2,
max: 3,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const rangeArg = args[1]();
if (!isRange(rangeArg)) return mkErr("#VALUE!");
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
}
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
const at = (i: number): Scalar =>
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
const modeN = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(modeN)) return modeN;
const mode = Math.trunc(fracToDouble(modeN));
if (mode === 0) {
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
return mkErr("#N/A");
}
let best = -1;
for (let i = 0; i < n; i++) {
const key = at(i);
if (typeRank(key) !== typeRank(lookup)) {
if (best >= 0) break;
continue;
}
const c = compareScalars(key, lookup);
if (mode > 0 ? c <= 0 : c >= 0) best = i;
else break;
}
if (best < 0) return mkErr("#N/A");
return frac(BigInt(best + 1));
},
},
CHOOSE: {
min: 2,
call: (args) => {
const idxN = num(args[0]());
if (isErr(idxN)) return idxN;
const idx = Math.trunc(fracToDouble(idxN));
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
return args[idx]();
},
},
COUNT: {
min: 0,
call: (args) => {
const values = collectNumbers(args, false, false);
return isErr(values) ? values : frac(BigInt(values.length));
},
},
COUNTA: {
min: 0,
call: (args) => {
let count = 0;
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++;
}
} else if (v !== null) {
count++;
}
}
return frac(BigInt(count));
},
},
COUNTIF: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
}
return frac(BigInt(count));
},
},
SUMIF: {
min: 2,
max: 3,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
const sumRange = args.length > 2 ? args[2]() : range;
if (!isRange(sumRange)) return mkErr("#VALUE!");
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
return mkErr("#VALUE!", "합 범위 크기가 다름");
}
let total = ZERO;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
if (!pred(range.at(r, c))) continue;
const n = toNumber(sumRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
}
}
return total;
},
},
SUMIFS: {
min: 3,
call: (args) => {
const sumRange = args[0]();
if (!isRange(sumRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, sumRange);
if (isErr(mask)) return mask;
let total = ZERO;
let k = 0;
for (let r = 0; r < sumRange.rows; r++) {
for (let c = 0; c < sumRange.cols; c++) {
if (mask[k]) {
const n = toNumber(sumRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
}
k++;
}
}
return total;
},
},
COUNTIFS: {
min: 2,
call: (args) => {
const first = args[0]();
if (!isRange(first)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 0, first);
if (isErr(mask)) return mask;
return frac(BigInt(mask.filter(Boolean).length));
},
},
AVERAGEIF: {
min: 2,
max: 3,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const crit = toScalar(args[1]());
if (isErr(crit)) return crit;
const pred = parseCriteria(crit);
const avgRange = args.length > 2 ? args[2]() : range;
if (!isRange(avgRange)) return mkErr("#VALUE!");
if (avgRange.rows !== range.rows || avgRange.cols !== range.cols) {
return mkErr("#VALUE!", "평균 범위 크기가 다름");
}
let total = ZERO;
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
if (!pred(range.at(r, c))) continue;
const n = toNumber(avgRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
count++;
}
}
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
},
},
AVERAGEIFS: {
min: 3,
call: (args) => {
const avgRange = args[0]();
if (!isRange(avgRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, avgRange);
if (isErr(mask)) return mask;
let total = ZERO;
let count = 0;
let k = 0;
for (let r = 0; r < avgRange.rows; r++) {
for (let c = 0; c < avgRange.cols; c++) {
if (mask[k]) {
const n = toNumber(avgRange.at(r, c));
if (isErr(n)) return n;
total = add(total, n);
count++;
}
k++;
}
}
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
},
},
MAXIFS: {
min: 3,
call: (args) => {
const targetRange = args[0]();
if (!isRange(targetRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, targetRange);
if (isErr(mask)) return mask;
let best: Frac | null = null;
let k = 0;
for (let r = 0; r < targetRange.rows; r++) {
for (let c = 0; c < targetRange.cols; c++) {
if (mask[k]) {
const cell = targetRange.at(r, c);
if (isFrac(cell) && (best === null || cmp(cell, best) > 0)) best = cell;
}
k++;
}
}
return best ?? ZERO;
},
},
MINIFS: {
min: 3,
call: (args) => {
const targetRange = args[0]();
if (!isRange(targetRange)) return mkErr("#VALUE!");
const mask = criteriaMask(args, 1, targetRange);
if (isErr(mask)) return mask;
let best: Frac | null = null;
let k = 0;
for (let r = 0; r < targetRange.rows; r++) {
for (let c = 0; c < targetRange.cols; c++) {
if (mask[k]) {
const cell = targetRange.at(r, c);
if (isFrac(cell) && (best === null || cmp(cell, best) < 0)) best = cell;
}
k++;
}
}
return best ?? ZERO;
},
},
IFS: {
min: 2,
call: (args) => {
for (let i = 0; i + 1 < args.length; i += 2) {
const c = bool(args[i]());
if (isErr(c)) return c;
if (c) return args[i + 1]();
}
return mkErr("#N/A");
},
},
SWITCH: {
min: 3,
call: (args) => {
const target = toScalar(args[0]());
if (isErr(target)) return target;
let i = 1;
for (; i + 1 < args.length; i += 2) {
const cand = toScalar(args[i]());
if (isErr(cand)) return cand;
if (equalScalars(target, cand)) return args[i + 1]();
}
return i < args.length ? args[i]() : mkErr("#N/A");
},
},
XLOOKUP: {
min: 3,
max: 6,
call: (args) => {
const lookup = toScalar(args[0]());
if (isErr(lookup)) return lookup;
const lookupRange = args[1]();
const returnRange = args[2]();
if (!isRange(lookupRange) || !isRange(returnRange)) return mkErr("#VALUE!");
const n = lookupRange.rows * lookupRange.cols;
if (n !== returnRange.rows * returnRange.cols) {
return mkErr("#VALUE!", "찾을 범위 · 돌려줄 범위 크기가 다름");
}
const at = (rv: RangeValue, i: number): Scalar => (rv.rows === 1 ? rv.at(0, i) : rv.at(i, 0));
const modeN = args.length > 4 ? num(args[4]()) : ZERO;
if (isErr(modeN)) return modeN;
const mode = Math.trunc(fracToDouble(modeN));
const searchN = args.length > 5 ? num(args[5]()) : ONE;
if (isErr(searchN)) return searchN;
const reverse = Math.trunc(fracToDouble(searchN)) < 0;
let best = -1;
for (let step = 0; step < n; step++) {
const i = reverse ? n - 1 - step : step;
const key = at(lookupRange, i);
if (typeRank(key) !== typeRank(lookup)) continue;
const c = compareScalars(key, lookup);
if (c === 0) {
best = i;
break;
}
if (mode === 1 && c > 0) {
if (best < 0 || compareScalars(key, at(lookupRange, best)) < 0) best = i;
} else if (mode === -1 && c < 0) {
if (best < 0 || compareScalars(key, at(lookupRange, best)) > 0) best = i;
}
}
if (best < 0) return args.length > 3 ? args[3]() : mkErr("#N/A");
return at(returnRange, best);
},
},
};
@@ -0,0 +1,490 @@
/* =============================================================================
* spreadsheet_functions_math.ts (주인 B)
* 수학 · 삼각 · 끝수 함수 + 함수 표 전체가 쓰는 형 바꾸기 도우미(공용은 여기서 export).
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만 씀 · 속에 안 넣음).
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수.
* ========================================================================== */
import {
ZERO,
add,
cmp,
div,
frac,
mul,
parseDecimal,
roundAt,
sub,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type {
ErrorCode,
ErrorValue,
EvalResult,
LazyArg,
RangeValue,
Scalar,
SheetFunction,
} from "./spreadsheet_types";
const ONE = frac(1n);
// ── 공용 도우미(math · misc 둘 다 씀) ────────────────────────────────────────
export function mkErr(code: ErrorCode, why?: string): ErrorValue {
return why === undefined ? { error: code } : { error: code, why };
}
export function isErr(v: unknown): v is ErrorValue {
return typeof v === "object" && v !== null && "error" in (v as Record<string, unknown>);
}
export function isFrac(v: unknown): v is Frac {
return typeof v === "object" && v !== null && "n" in (v as Record<string, unknown>);
}
export function isRange(v: EvalResult): v is RangeValue {
return typeof v === "object" && v !== null && (v as RangeValue).kind === "range";
}
/** 범위면 1×1 일 때만 칸 하나로 · 아니면 `#VALUE!` */
export function toScalar(v: EvalResult): Scalar | ErrorValue {
if (!isRange(v)) return v;
if (v.rows === 1 && v.cols === 1) return v.at(0, 0);
return mkErr("#VALUE!", "범위를 칸 하나 자리에 씀");
}
/** 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` */
export function toNumber(v: Scalar): Frac | ErrorValue {
if (v === null) return ZERO;
if (isErr(v)) return v;
if (typeof v === "boolean") return v ? ONE : ZERO;
if (typeof v === "string") {
try {
return parseDecimal(v);
} catch {
return mkErr("#VALUE!", `글을 수로 못 읽음: ${v}`);
}
}
return v;
}
/** 인자 하나(범위는 1×1 만) → 수 */
export function num(v: EvalResult): Frac | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toNumber(s);
}
/** 글로 — 수는 일반 형식(간단 십진) · 참거짓 TRUE/FALSE */
export function toText(v: Scalar): string | ErrorValue {
if (v === null) return "";
if (isErr(v)) return v;
if (typeof v === "string") return v;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return generalNumberText(v);
}
export function str(v: EvalResult): string | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toText(s);
}
export function toBool(v: Scalar): boolean | ErrorValue {
if (v === null) return false;
if (isErr(v)) return v;
if (typeof v === "boolean") return v;
if (typeof v === "string") {
const up = v.trim().toUpperCase();
if (up === "TRUE") return true;
if (up === "FALSE") return false;
return mkErr("#VALUE!", `글을 참거짓으로 못 읽음: ${v}`);
}
return cmp(v, ZERO) !== 0;
}
export function bool(v: EvalResult): boolean | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toBool(s);
}
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
export function fromDouble(x: number): Frac | ErrorValue {
if (!Number.isFinite(x)) return mkErr("#NUM!");
if (x === 0) return ZERO;
return parseDecimal(x.toPrecision(15));
}
export function fracToDouble(x: Frac): number {
return Number(x.n) / Number(x.d);
}
/** 엑셀 「일반」 형식과 같은 십진 글(수식 `&` · TEXT 안 형식 자리가 없을 때) */
export function generalNumberText(x: Frac): string {
const scaled = toInteger(mul(x, frac(10n ** 15n)), "round");
const negative = scaled < 0n;
const digits = (negative ? -scaled : scaled).toString().padStart(16, "0");
const whole = digits.slice(0, -15).replace(/^0+(?=\d)/, "");
const fraction = digits.slice(-15).replace(/0+$/, "");
const body = fraction ? `${whole}.${fraction}` : whole;
return negative && body !== "0" ? `-${body}` : body;
}
/** 인자 목록에서 수만 모음 — 범위는 수 아닌 값 조용히 건너뜀 · 낱값은 형 바꿈(실패시 strict 면 오류)
* `propagateErrors` = 범위 · 낱값 오류를 그대로 돌려줄지(COUNT 는 false) */
export function collectNumbers(
args: LazyArg[],
propagateErrors: boolean,
strictScalar: boolean,
): Frac[] | ErrorValue {
const out: Frac[] = [];
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const cell = v.at(r, c);
if (isErr(cell)) {
if (propagateErrors) return cell;
continue;
}
if (isFrac(cell)) out.push(cell);
}
}
} else if (isErr(v)) {
if (propagateErrors) return v;
} else if (v !== null) {
const n = toNumber(v);
if (isErr(n)) {
if (strictScalar) return n;
} else {
out.push(n);
}
}
}
return out;
}
function digitsArg(args: LazyArg[], index: number, fallback: number): number | ErrorValue {
if (index >= args.length) return fallback;
const n = num(args[index]());
return isErr(n) ? n : Math.trunc(fracToDouble(n));
}
function unaryDouble(fn: (x: number) => number): SheetFunction {
return {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return fromDouble(fn(fracToDouble(n)));
},
};
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
SUM: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.reduce(add, ZERO);
},
},
PRODUCT: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce(mul, ONE);
},
},
SUMPRODUCT: {
min: 1,
call: (args) => {
const arrays = args.map((a) => a());
const grids = arrays.map((v) =>
isRange(v) ? v : { rows: 1, cols: 1, at: () => (isErr(v) ? v : (v as Scalar)) },
);
const rows = grids[0].rows;
const cols = grids[0].cols;
for (const g of grids) {
if (g.rows !== rows || g.cols !== cols) return mkErr("#VALUE!", "배열 크기가 다름");
}
let sum = ZERO;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
let prod = ONE;
for (const g of grids) {
const n = toNumber(g.at(r, c));
if (isErr(n)) return n;
prod = mul(prod, n);
}
sum = add(sum, prod);
}
}
return sum;
},
},
ROUND: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "round");
},
},
ROUNDUP: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "away");
},
},
ROUNDDOWN: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "trunc");
},
},
INT: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return frac(toInteger(n, "floor"));
},
},
TRUNC: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "trunc");
},
},
CEILING: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const sig = num(args[1]());
if (isErr(sig)) return sig;
if (cmp(sig, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
}
return mul(frac(toInteger(div(n, sig), "away")), sig);
},
},
FLOOR: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const sig = num(args[1]());
if (isErr(sig)) return sig;
if (cmp(sig, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
}
return mul(frac(toInteger(div(n, sig), "trunc")), sig);
},
},
ABS: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return frac(n.n < 0n ? -n.n : n.n, n.d);
},
},
MOD: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = num(args[1]());
if (isErr(d)) return d;
if (cmp(d, ZERO) === 0) return mkErr("#DIV/0!");
const q = frac(toInteger(div(n, d), "floor"));
return sub(n, mul(d, q));
},
},
POWER: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const p = num(args[1]());
if (isErr(p)) return p;
if (p.d === 1n) {
const exp = p.n;
if (exp >= 0n) return frac(n.n ** exp, n.d ** exp);
if (n.n === 0n) return mkErr("#DIV/0!");
return frac(n.d ** -exp, n.n ** -exp);
}
return fromDouble(Math.pow(fracToDouble(n), fracToDouble(p)));
},
},
SQRT: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) < 0) return mkErr("#NUM!");
return fromDouble(Math.sqrt(fracToDouble(n)));
},
},
PI: { min: 0, max: 0, call: () => fromDouble(Math.PI) },
SIN: unaryDouble(Math.sin),
COS: unaryDouble(Math.cos),
TAN: unaryDouble(Math.tan),
ASIN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const x = fracToDouble(n);
if (x < -1 || x > 1) return mkErr("#NUM!");
return fromDouble(Math.asin(x));
},
},
ACOS: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const x = fracToDouble(n);
if (x < -1 || x > 1) return mkErr("#NUM!");
return fromDouble(Math.acos(x));
},
},
ATAN: unaryDouble(Math.atan),
RADIANS: unaryDouble((x) => (x * Math.PI) / 180),
DEGREES: unaryDouble((x) => (x * 180) / Math.PI),
MIN: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) <= 0 ? a : b));
},
},
MAX: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) >= 0 ? a : b));
},
},
AVERAGE: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
if (values.length === 0) return mkErr("#DIV/0!");
return div(values.reduce(add, ZERO), frac(BigInt(values.length)));
},
},
MROUND: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const m = num(args[1]());
if (isErr(m)) return m;
if (cmp(m, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(m, ZERO) > 0) {
return mkErr("#NUM!", "수 · 배수의 부호가 다름");
}
return mul(frac(toInteger(div(n, m), "round")), m);
},
},
ODD: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
let v = toInteger(n, "away");
if (v === 0n) v = 1n;
else if (v % 2n === 0n) v += v < 0n ? -1n : 1n;
return frac(v);
},
},
EVEN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
let v = toInteger(n, "away");
if (v % 2n !== 0n) v += v < 0n ? -1n : 1n;
return frac(v);
},
},
LOG: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
const base = args.length > 1 ? num(args[1]()) : frac(10n);
if (isErr(base)) return base;
if (cmp(base, ZERO) <= 0 || cmp(base, ONE) === 0) return mkErr("#NUM!");
return fromDouble(Math.log(fracToDouble(n)) / Math.log(fracToDouble(base)));
},
},
LOG10: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
return fromDouble(Math.log10(fracToDouble(n)));
},
},
LN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
return fromDouble(Math.log(fracToDouble(n)));
},
},
EXP: unaryDouble(Math.exp),
};
@@ -0,0 +1,656 @@
/* =============================================================================
* spreadsheet_functions_misc.ts (주인 B)
* 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는
* `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27).
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import {
ZERO,
cmp,
frac,
mul,
parseDecimal,
roundAt,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types";
import { formatValue } from "./spreadsheet_numfmt";
import {
bool,
fracToDouble,
fromDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
str,
toBool,
toScalar,
toText,
} from "./spreadsheet_functions_math";
const ONE = frac(1n);
/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */
function wildcardToSearchRegex(text: string): RegExp {
const escaped = text
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(escaped, "i");
}
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
function groupThousands(digits: string): string {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function formatFixed(n: Frac, decimals: number, commas: boolean): string {
const d = Math.max(0, decimals);
const rounded = roundAt(n, d, "round");
const negative = cmp(rounded, ZERO) < 0;
const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded;
const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round");
const digits = scaled.toString().padStart(d + 1, "0");
let whole = d > 0 ? digits.slice(0, -d) : digits;
const fraction = d > 0 ? digits.slice(-d) : "";
if (commas) whole = groupThousands(whole);
const body = fraction ? `${whole}.${fraction}` : whole;
return negative ? `-${body}` : body;
}
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
const out: boolean[] = [];
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const cell = v.at(r, c);
if (isErr(cell)) return cell;
if (typeof cell === "boolean") out.push(cell);
else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0);
}
}
} else if (isErr(v)) {
return v;
} else if (v !== null) {
const b = toBool(v);
if (isErr(b)) return b;
out.push(b);
}
}
return out;
}
// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ───────────────────────
// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) —
// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖).
const MS_PER_DAY = 86400000;
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
function serialToDate(serial: number): Date {
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
}
function dateToSerial(d: Date): number {
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
}
/** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */
function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue {
const start = serialToDate(startSerial);
const end = serialToDate(endSerial);
if (end.getTime() < start.getTime()) return mkErr("#NUM!");
const u = unit.toUpperCase();
const sy = start.getUTCFullYear();
const sm = start.getUTCMonth();
const sd = start.getUTCDate();
const ey = end.getUTCFullYear();
const em = end.getUTCMonth();
const ed = end.getUTCDate();
if (u === "Y") {
let y = ey - sy;
if (em < sm || (em === sm && ed < sd)) y--;
return y;
}
if (u === "M") {
let m = (ey - sy) * 12 + (em - sm);
if (ed < sd) m--;
return m;
}
if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY);
if (u === "YM") {
let m = em - sm;
if (ed < sd) m--;
return ((m % 12) + 12) % 12;
}
if (u === "MD") {
let d = ed - sd;
if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate();
return d;
}
if (u === "YD") {
let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY);
if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY);
return days;
}
return mkErr("#NUM!");
}
/** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */
function timeParts(serial: number): { h: number; mnt: number; s: number } {
const dayFrac = serial - Math.floor(serial);
const totalSeconds = Math.round(dayFrac * 86400);
return {
h: Math.floor(totalSeconds / 3600) % 24,
mnt: Math.floor(totalSeconds / 60) % 60,
s: totalSeconds % 60,
};
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
IF: {
min: 2,
max: 3,
call: (args) => {
const c = bool(args[0]());
if (isErr(c)) return c;
if (c) return args[1]();
return args.length > 2 ? args[2]() : false;
},
},
AND: {
min: 1,
call: (args) => {
const bs = collectBools(args);
if (isErr(bs)) return bs;
if (bs.length === 0) return mkErr("#VALUE!");
return bs.every(Boolean);
},
},
OR: {
min: 1,
call: (args) => {
const bs = collectBools(args);
if (isErr(bs)) return bs;
if (bs.length === 0) return mkErr("#VALUE!");
return bs.some(Boolean);
},
},
NOT: {
min: 1,
max: 1,
call: (args) => {
const b = bool(args[0]());
return isErr(b) ? b : !b;
},
},
IFERROR: {
min: 2,
max: 2,
call: (args) => {
const v = args[0]();
if (!isRange(v) && isErr(v)) return args[1]();
return v;
},
},
CONCATENATE: {
min: 1,
call: (args) => {
let out = "";
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const t = toText(v.at(r, c));
if (isErr(t)) return t;
out += t;
}
}
} else {
const t = str(v);
if (isErr(t)) return t;
out += t;
}
}
return out;
},
},
FIXED: {
min: 1,
max: 3,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const decArg = args.length > 1 ? num(args[1]()) : frac(2n);
if (isErr(decArg)) return decArg;
const decimals = Math.trunc(fracToDouble(decArg));
const noCommaArg = args.length > 2 ? bool(args[2]()) : false;
if (isErr(noCommaArg)) return noCommaArg;
return formatFixed(n, decimals, !noCommaArg);
},
},
TEXT: {
min: 2,
max: 2,
call: (args) => {
const v = toScalar(args[0]());
if (isErr(v)) return v;
const code = str(args[1]());
if (isErr(code)) return code;
return formatValue(v, code).글;
},
},
LEN: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : frac(BigInt(s.length));
},
},
LEFT: {
min: 1,
max: 2,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const nArg = args.length > 1 ? num(args[1]()) : ONE;
if (isErr(nArg)) return nArg;
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
return s.slice(0, n);
},
},
RIGHT: {
min: 1,
max: 2,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const nArg = args.length > 1 ? num(args[1]()) : ONE;
if (isErr(nArg)) return nArg;
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
return n === 0 ? "" : s.slice(-n);
},
},
MID: {
min: 3,
max: 3,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const startN = num(args[1]());
if (isErr(startN)) return startN;
const lenN = num(args[2]());
if (isErr(lenN)) return lenN;
const start = Math.trunc(fracToDouble(startN));
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
if (start < 1) return mkErr("#VALUE!");
return s.slice(start - 1, start - 1 + len);
},
},
VALUE: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const trimmed = s.trim();
const pct = trimmed.endsWith("%");
const body = pct ? trimmed.slice(0, -1) : trimmed;
try {
const v = parseDecimal(body);
return pct ? { n: v.n, d: v.d * 100n } : v;
} catch {
return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`);
}
},
},
TEXTJOIN: {
min: 3,
call: (args) => {
const delim = str(args[0]());
if (isErr(delim)) return delim;
const ignoreEmpty = bool(args[1]());
if (isErr(ignoreEmpty)) return ignoreEmpty;
const parts: string[] = [];
for (let i = 2; i < args.length; i++) {
const v = args[i]();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const t = toText(v.at(r, c));
if (isErr(t)) return t;
if (!(ignoreEmpty && t === "")) parts.push(t);
}
}
} else {
const t = str(v);
if (isErr(t)) return t;
if (!(ignoreEmpty && t === "")) parts.push(t);
}
}
return parts.join(delim);
},
},
TRIM: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.split(" ").filter(Boolean).join(" ");
},
},
UPPER: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.toUpperCase();
},
},
LOWER: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.toLowerCase();
},
},
SUBSTITUTE: {
min: 3,
max: 4,
call: (args) => {
const text = str(args[0]());
if (isErr(text)) return text;
const oldT = str(args[1]());
if (isErr(oldT)) return oldT;
const newT = str(args[2]());
if (isErr(newT)) return newT;
if (oldT === "") return text;
if (args.length <= 3) return text.split(oldT).join(newT);
const nthArg = num(args[3]());
if (isErr(nthArg)) return nthArg;
const nth = Math.trunc(fracToDouble(nthArg));
let idx = -1;
let count = 0;
let pos = 0;
for (;;) {
const found = text.indexOf(oldT, pos);
if (found < 0) break;
count++;
if (count === nth) {
idx = found;
break;
}
pos = found + oldT.length;
}
return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length);
},
},
REPLACE: {
min: 4,
max: 4,
call: (args) => {
const text = str(args[0]());
if (isErr(text)) return text;
const startN = num(args[1]());
if (isErr(startN)) return startN;
const lenN = num(args[2]());
if (isErr(lenN)) return lenN;
const newText = str(args[3]());
if (isErr(newText)) return newText;
const start = Math.trunc(fracToDouble(startN));
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
if (start < 1) return mkErr("#VALUE!");
return text.slice(0, start - 1) + newText + text.slice(start - 1 + len);
},
},
FIND: {
min: 2,
max: 3,
call: (args) => {
const findT = str(args[0]());
if (isErr(findT)) return findT;
const within = str(args[1]());
if (isErr(within)) return within;
const startArg = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(startArg)) return startArg;
const start = Math.trunc(fracToDouble(startArg));
if (start < 1) return mkErr("#VALUE!");
const idx = within.indexOf(findT, start - 1);
return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1));
},
},
SEARCH: {
min: 2,
max: 3,
call: (args) => {
const findT = str(args[0]());
if (isErr(findT)) return findT;
const within = str(args[1]());
if (isErr(within)) return within;
const startArg = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(startArg)) return startArg;
const start = Math.trunc(fracToDouble(startArg));
if (start < 1) return mkErr("#VALUE!");
const pat = wildcardToSearchRegex(findT);
const m = pat.exec(within.slice(start - 1));
return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!");
},
},
REPT: {
min: 2,
max: 2,
call: (args) => {
const s = str(args[0]());
if (isErr(s)) return s;
const nArg = num(args[1]());
if (isErr(nArg)) return nArg;
const n = Math.trunc(fracToDouble(nArg));
return n < 0 ? mkErr("#VALUE!") : s.repeat(n);
},
},
ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) },
ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" },
ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null },
ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) },
NA: { min: 0, max: 0, call: () => mkErr("#N/A") },
TODAY: {
min: 0,
max: 0,
call: () => {
const now = new Date();
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
return fromDouble(dateToSerial(new Date(midnight)));
},
},
DATE: {
min: 3,
max: 3,
call: (args) => {
const y = num(args[0]());
if (isErr(y)) return y;
const m = num(args[1]());
if (isErr(m)) return m;
const d = num(args[2]());
if (isErr(d)) return d;
const date = new Date(
Date.UTC(
Math.trunc(fracToDouble(y)),
Math.trunc(fracToDouble(m)) - 1,
Math.trunc(fracToDouble(d)),
),
);
return fromDouble(dateToSerial(date));
},
},
YEAR: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear()));
},
},
MONTH: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1));
},
},
DAY: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
},
},
NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) },
WEEKDAY: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const typeArg = args.length > 1 ? num(args[1]()) : ONE;
if (isErr(typeArg)) return typeArg;
const type = Math.trunc(fracToDouble(typeArg));
const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토
if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1));
if (type === 3) return frac(BigInt((dow + 6) % 7));
return frac(BigInt(dow + 1));
},
},
EDATE: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const months = Math.trunc(fracToDouble(mArg));
const d = serialToDate(fracToDouble(n));
const targetMonth = d.getUTCMonth() + months;
// 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음).
const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate();
const day = Math.min(d.getUTCDate(), lastDay);
const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day));
return fromDouble(dateToSerial(date));
},
},
EOMONTH: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const months = Math.trunc(fracToDouble(mArg));
const d = serialToDate(fracToDouble(n));
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0));
return fromDouble(dateToSerial(date));
},
},
DATEDIF: {
min: 3,
max: 3,
call: (args) => {
const s = num(args[0]());
if (isErr(s)) return s;
const e = num(args[1]());
if (isErr(e)) return e;
const unit = str(args[2]());
if (isErr(unit)) return unit;
const result = dateDiff(fracToDouble(s), fracToDouble(e), unit);
return isErr(result) ? result : frac(BigInt(result));
},
},
NETWORKDAYS: {
min: 2,
max: 3,
call: (args) => {
const s = num(args[0]());
if (isErr(s)) return s;
const e = num(args[1]());
if (isErr(e)) return e;
let startSerial = Math.round(fracToDouble(s));
let endSerial = Math.round(fracToDouble(e));
let sign = 1;
if (startSerial > endSerial) {
[startSerial, endSerial] = [endSerial, startSerial];
sign = -1;
}
const holidays = new Set<number>();
if (args.length > 2) {
const holidayArg = args[2]();
if (isRange(holidayArg)) {
for (let r = 0; r < holidayArg.rows; r++) {
for (let c = 0; c < holidayArg.cols; c++) {
const cell = holidayArg.at(r, c);
if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell)));
}
}
} else if (isFrac(holidayArg)) {
holidays.add(Math.round(fracToDouble(holidayArg)));
}
}
let count = 0;
for (let day = startSerial; day <= endSerial; day++) {
const dow = serialToDate(day).getUTCDay();
if (dow === 0 || dow === 6) continue;
if (holidays.has(day)) continue;
count++;
}
return frac(BigInt(sign * count));
},
},
HOUR: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h));
},
},
MINUTE: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt));
},
},
TIME: {
min: 3,
max: 3,
call: (args) => {
const hArg = num(args[0]());
if (isErr(hArg)) return hArg;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const sArg = num(args[2]());
if (isErr(sArg)) return sArg;
const totalSeconds =
Math.trunc(fracToDouble(hArg)) * 3600 +
Math.trunc(fracToDouble(mArg)) * 60 +
Math.trunc(fracToDouble(sArg));
const dayFraction = ((totalSeconds % 86400) + 86400) % 86400;
return fromDouble(dayFraction / 86400);
},
},
};
@@ -0,0 +1,228 @@
/* =============================================================================
* spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27)
* 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL ·
* PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac";
import type {
ErrorValue,
EvalContext,
EvalResult,
LazyArg,
RangeValue,
SheetFunction,
} from "./spreadsheet_types";
import {
collectNumbers,
fracToDouble,
fromDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
} from "./spreadsheet_functions_math";
function rangeNumbers(range: RangeValue): Frac[] | ErrorValue {
const out: Frac[] = [];
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
const cell = range.at(r, c);
if (isErr(cell)) return cell;
if (isFrac(cell)) out.push(cell);
}
}
return out;
}
function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } {
const n = frac(BigInt(values.length));
const mean = div(values.reduce(add, ZERO), n);
const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO);
return { mean, sumSq };
}
function sampleVariance(values: Frac[]): Frac | ErrorValue {
if (values.length < 2) return mkErr("#DIV/0!");
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1)));
}
function populationVariance(values: Frac[]): Frac | ErrorValue {
if (values.length < 1) return mkErr("#DIV/0!");
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length)));
}
function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue {
if (isErr(variance)) return variance;
return fromDouble(Math.sqrt(fracToDouble(variance)));
}
/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */
function percentileOf(values: Frac[], k: number): Frac | ErrorValue {
if (values.length === 0) return mkErr("#NUM!");
if (k < 0 || k > 1) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
const pos = k * (sorted.length - 1);
const lowIdx = Math.floor(pos);
const highIdx = Math.ceil(pos);
if (lowIdx === highIdx) return sorted[lowIdx];
const weight = fromDouble(pos - lowIdx);
if (isErr(weight)) return weight;
return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx])));
}
function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue {
const values = rangeNumbers(range);
if (isErr(values)) return values;
if (k < 1 || k > values.length) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
return sorted[largest ? sorted.length - k : k - 1];
}
function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult {
const target = num(args[0]());
if (isErr(target)) return target;
const range = args[1]();
if (!isRange(range)) return mkErr("#VALUE!");
const orderArg = args.length > 2 ? num(args[2]()) : ZERO;
if (isErr(orderArg)) return orderArg;
const ascending = cmp(orderArg, ZERO) !== 0;
let rank = 1;
let found = false;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
const cell = range.at(r, c);
if (!isFrac(cell)) continue;
if (cmp(cell, target) === 0) found = true;
if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++;
}
}
return found ? frac(BigInt(rank)) : mkErr("#N/A");
}
export const STAT_FUNCTIONS: Record<string, SheetFunction> = {
MEDIAN: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
if (values.length === 0) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[mid];
return div(add(sorted[mid - 1], sorted[mid]), frac(2n));
},
},
MODE: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
let bestIdx = -1;
let bestCount = 1;
for (let i = 0; i < values.length; i++) {
let count = 0;
for (const v of values) if (cmp(values[i], v) === 0) count++;
if (count > bestCount) {
bestCount = count;
bestIdx = i;
}
}
return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx];
},
},
STDEV: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(sampleVariance(values));
},
},
"STDEV.S": {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(sampleVariance(values));
},
},
"STDEV.P": {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(populationVariance(values));
},
},
VAR: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : sampleVariance(values);
},
},
RANK: { min: 2, max: 3, call: rankCall },
"RANK.EQ": { min: 2, max: 3, call: rankCall },
LARGE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true);
},
},
SMALL: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false);
},
},
PERCENTILE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
const values = rangeNumbers(range);
if (isErr(values)) return values;
return percentileOf(values, fracToDouble(kArg));
},
},
QUARTILE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const qArg = num(args[1]());
if (isErr(qArg)) return qArg;
const q = Math.trunc(fracToDouble(qArg));
if (q < 0 || q > 4) return mkErr("#NUM!");
const values = rangeNumbers(range);
if (isErr(values)) return values;
return percentileOf(values, q / 4);
},
},
COUNTBLANK: {
min: 1,
max: 1,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++;
}
return frac(BigInt(count));
},
},
};
+399
View File
@@ -0,0 +1,399 @@
/* =============================================================================
* spreadsheet_graph.ts (주인 A)
* 계산 엔진 — 식마다 가리키는 칸 · 범위로 의존 그래프 · 바뀐 칸에 딸린 칸만 차례로 다시 풂.
* 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂
* (고리를 읽는 칸은 오류 전파로 `#CYCLE!`). 식 나무는 식 글마다 캐시.
* 풀이는 필요할 때 끌어 풂(앞 칸이 안 풀렸으면 먼저) — 차례는 저절로 위상 순서.
* ========================================================================== */
import { FormulaError, ZERO } from "@ui/sheet/ui_template_sheet_frac";
import { cellId, fromCellId, inRange, parseA1, toA1 } from "./spreadsheet_address";
import { err, evaluate, inputValue, intersect, toCalcValue } from "./spreadsheet_eval";
import type { PlacedRange } from "./spreadsheet_eval";
import { parseFormula } from "./spreadsheet_parser";
import type {
CalcEngine,
CalcValues,
Cell,
CellRange,
EvalContext,
EvalResult,
FormulaNode,
ParseResult,
Scalar,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
interface SheetIndex {
id: string;
cells: Map<number, Cell>;
/** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */
maxR: number;
maxC: number;
}
interface Dep {
sheet: string;
range: CellRange;
}
const DONE = 2;
const ACTIVE = 1;
const keyOf = (sheet: string, id: number) => `${sheet}!${id}`;
function splitKey(key: string): SheetCellAddress {
const i = key.lastIndexOf("!");
return { 시트: key.slice(0, i), ...fromCellId(Number(key.slice(i + 1))) };
}
/** 나무 안 칸 · 범위 참조(시트 이름 → id · 없는 시트는 뺌) */
function collectRefs(
node: FormulaNode,
host: string,
sheetId: (n: string) => string | null,
out: Dep[],
) {
switch (node.type) {
case "cell":
case "range": {
const sheet = node.sheet === null ? host : sheetId(node.sheet);
if (sheet === null) return;
const [a, b] = node.type === "cell" ? [node.ref, node.ref] : [node.from, node.to];
out.push({
sheet,
range: {
r0: Math.min(a.r, b.r),
c0: Math.min(a.c, b.c),
r1: Math.max(a.r, b.r),
c1: Math.max(a.c, b.c),
},
});
return;
}
case "unary":
return collectRefs(node.arg, host, sheetId, out);
case "binary":
collectRefs(node.left, host, sheetId, out);
return collectRefs(node.right, host, sheetId, out);
case "call":
for (const a of node.args) collectRefs(a, host, sheetId, out);
}
}
/** 이름(`값들`) → 정의한 범위 참조(자기 시트 이름 먼저 · 없으면 다른 시트) · 없는 이름은 그대로(→ `#NAME?`) */
export function bindNames(node: FormulaNode, book: Workbook, host: string): FormulaNode {
const go = (n: FormulaNode): FormulaNode => {
switch (n.type) {
case "name": {
const low = n.name.toLowerCase();
const def = (s?: Sheet) => s?.이름정의?.find((d) => d.이름.toLowerCase() === low);
const s = [book.시트.find((x) => x.id === host), ...book.시트].find(def);
if (!s) return n;
const g = def(s)!.범위;
const sheet = s.id === host ? null : s.이름;
const from = { r: g.r0, c: g.c0, absR: true, absC: true };
const to = { r: g.r1, c: g.c1, absR: true, absC: true };
return g.r0 === g.r1 && g.c0 === g.c1
? { type: "cell", sheet, ref: from }
: { type: "range", sheet, from, to };
}
case "unary":
return { ...n, arg: go(n.arg) };
case "binary":
return { ...n, left: go(n.left), right: go(n.right) };
case "call":
return { ...n, args: n.args.map(go) };
}
return n;
};
return book.시트.some((s) => s.이름정의?.length) ? go(node) : node;
}
/** 나무 하나 풂 —던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */
export function evalSafe(node: FormulaNode, ctx: EvalContext): EvalResult {
try {
return evaluate(node, ctx);
} catch (e) {
if (e instanceof FormulaError && e.message.startsWith("0 으로"))
return err("#DIV/0!", e.message);
return err("#VALUE!", e instanceof Error ? e.message : String(e));
}
}
/** 문서를 참조로 쥠 — 만들 때 전부 풂. */
export function createCalcEngine(book: Workbook): CalcEngine {
const parsed = new Map<string, ParseResult>();
let sheets = new Map<string, SheetIndex>();
let names = new Map<string, string>();
/** 식 칸 → 가리키는 칸 · 범위 */
let precs = new Map<string, Dep[]>();
/** 칸 하나 → 그 칸을 가리키는 식 칸 */
let cellDeps = new Map<string, Set<string>>();
/** 시트 → 범위를 가리키는 식 칸 */
let rangeDeps = new Map<string, Map<string, CellRange[]>>();
let vals = new Map<string, Scalar>();
let state = new Map<string, number>();
const stack: string[] = [];
const cycle = new Map<string, string>();
const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null;
/** 식 나무(이름은 범위로 바꿈) — 시트마다 캐시 · rebuild 때 비움 */
const parse = (text: string, host: string) => {
const k = `${host}!${text}`;
let p = parsed.get(k);
if (!p) {
p = parseFormula(text);
if (p.ok) p.node = bindNames(p.node, book, host);
parsed.set(k, p);
}
return p;
};
function unlink(key: string) {
for (const d of precs.get(key) ?? []) {
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1)
cellDeps.get(keyOf(d.sheet, cellId(d.range.r0, d.range.c0)))?.delete(key);
else rangeDeps.get(d.sheet)?.delete(key);
}
precs.delete(key);
}
/** 칸 하나를 색인에 올림(식이면 가리키는 곳 잇기) */
function index(sheet: SheetIndex, id: number, cell: Cell | undefined) {
const key = keyOf(sheet.id, id);
unlink(key);
vals.delete(key);
state.delete(key);
cycle.delete(key);
if (!cell || (cell.식 === undefined && cell.값 === undefined)) {
sheet.cells.delete(id);
return;
}
sheet.cells.set(id, cell);
const { r, c } = fromCellId(id);
sheet.maxR = Math.max(sheet.maxR, r);
sheet.maxC = Math.max(sheet.maxC, c);
if (cell.식 === undefined) {
vals.set(key, inputValue(cell.값!));
return;
}
const p = parse(cell.식, sheet.id);
const deps: Dep[] = [];
if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps);
precs.set(key, deps);
for (const d of deps) {
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) {
const k = keyOf(d.sheet, cellId(d.range.r0, d.range.c0));
let set = cellDeps.get(k);
if (!set) cellDeps.set(k, (set = new Set()));
set.add(key);
} else {
let m = rangeDeps.get(d.sheet);
if (!m) rangeDeps.set(d.sheet, (m = new Map()));
let list = m.get(key);
if (!list) m.set(key, (list = []));
list.push(d.range);
}
}
}
function rebuild(next: Workbook) {
book = next;
parsed.clear();
sheets = new Map();
names = new Map();
precs = new Map();
cellDeps = new Map();
rangeDeps = new Map();
vals = new Map();
state = new Map();
cycle.clear();
for (const s of book.시트) {
sheets.set(s.id, { id: s.id, cells: new Map(), maxR: 0, maxC: 0 });
names.set(s.이름.toLowerCase(), s.id);
}
for (const s of book.시트) {
const idx = sheets.get(s.id)!;
for (const [a1, cell] of Object.entries(s.칸)) {
const at = parseA1(a1);
if (at) index(idx, cellId(at.r, at.c), cell);
}
}
for (const key of precs.keys()) ensure(key);
}
function ctxFor(sheet: string, r: number, c: number): EvalContext {
return { sheet, r, c, cell: read, range, sheetId };
}
function read(sheet: string, r: number, c: number): Scalar {
const key = keyOf(sheet, cellId(r, c));
if (precs.has(key)) return compute(key);
return vals.get(key) ?? null;
}
function range(sheet: string, rg: CellRange): PlacedRange {
const s = sheets.get(sheet);
// ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림
const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0));
const c1 = Math.min(rg.c1, Math.max(rg.c0, s?.maxC ?? 0));
return {
kind: "range",
rows: r1 - rg.r0 + 1,
cols: c1 - rg.c0 + 1,
at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j),
...rg,
};
}
/** 가리키는 식 칸들(범위는 넓이 · 든 칸 수 중 작은 쪽으로 훑음) */
function formulaPrecs(key: string): string[] {
const out: string[] = [];
for (const d of precs.get(key) ?? []) {
const s = sheets.get(d.sheet);
if (!s) continue;
const r1 = Math.min(d.range.r1, s.maxR);
const c1 = Math.min(d.range.c1, s.maxC);
if ((r1 - d.range.r0 + 1) * (c1 - d.range.c0 + 1) <= s.cells.size) {
for (let r = d.range.r0; r <= r1; r++)
for (let c = d.range.c0; c <= c1; c++) {
const k = keyOf(d.sheet, cellId(r, c));
if (precs.has(k)) out.push(k);
}
} else
for (const id of s.cells.keys()) {
const k = keyOf(d.sheet, id);
const { r, c } = fromCellId(id);
if (precs.has(k) && inRange(d.range, r, c)) out.push(k);
}
}
return out;
}
/** 앞 식 칸부터 반복으로 풂 — 깊은 사슬에도 스택이 안 넘침 · 고리는 compute 가 잡음 */
function ensure(key: string): Scalar {
const todo: [string, boolean][] = [[key, false]];
const seen = new Set<string>();
while (todo.length) {
const [k, ready] = todo.pop()!;
if (state.get(k) === DONE) continue;
if (ready) {
compute(k);
continue;
}
if (seen.has(k)) continue;
seen.add(k);
todo.push([k, true]);
for (const d of formulaPrecs(k))
if (!seen.has(d) && state.get(d) !== DONE) todo.push([d, false]);
}
return vals.get(key) ?? null;
}
/** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */
function compute(key: string): Scalar {
const st = state.get(key);
if (st === DONE) return vals.get(key) ?? null;
if (st === ACTIVE) {
const ring = stack.slice(stack.indexOf(key));
const why = `순환 참조: ${ring.map(label).join(" → ")} → ${label(key)}`;
for (const k of ring) if (!cycle.has(k)) cycle.set(k, why);
return err("#CYCLE!", why);
}
state.set(key, ACTIVE);
stack.push(key);
const { 시트, r, c } = splitKey(key);
const cell = sheets.get(시트)!.cells.get(cellId(r, c))!;
let v: Scalar;
try {
v = run(cell.식!, ctxFor(시트, r, c));
} catch (e) {
state.delete(key);
throw e;
} finally {
stack.pop();
}
const why = cycle.get(key);
if (why) v = err("#CYCLE!", why);
vals.set(key, v);
state.set(key, DONE);
return v;
}
function label(key: string) {
const { 시트, r, c } = splitKey(key);
const s = book.시트.find((x) => x.id === 시트);
return book.시트.length > 1 && s ? `${s.이름}!${toA1(r, c)}` : toA1(r, c);
}
function run(text: string, ctx: EvalContext): Scalar {
const p = parse(text, ctx.sheet);
if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`);
const one = intersect(evalSafe(p.node, ctx), ctx);
return one === null ? ZERO : one;
}
function dependents(key: string, out: Set<string>) {
for (const k of cellDeps.get(key) ?? []) out.add(k);
const { 시트, r, c } = splitKey(key);
for (const [k, list] of rangeDeps.get(시트) ?? [])
if (list.some((rg) => inRange(rg, r, c))) out.add(k);
}
rebuild(book);
return {
update(changed) {
const dirty = new Set<string>();
for (const a of changed) {
const s = sheets.get(a.시트);
const sheet = book.시트.find((x) => x.id === a.시트);
if (!s || !sheet) continue;
const id = cellId(a.r, a.c);
index(s, id, sheet.칸[toA1(a.r, a.c)]);
dirty.add(keyOf(a.시트, id));
}
// 딸린 칸을 끝까지 모음(고리였던 칸도 다시)
const queue = [...dirty];
while (queue.length) {
const next = new Set<string>();
dependents(queue.pop()!, next);
for (const k of next)
if (!dirty.has(k)) {
dirty.add(k);
queue.push(k);
}
}
for (const k of dirty)
if (precs.has(k)) {
state.delete(k);
cycle.delete(k);
}
for (const k of dirty) if (precs.has(k)) ensure(k);
return [...dirty].map(splitKey);
},
rebuild,
value(sheet, r, c) {
const key = keyOf(sheet, cellId(r, c));
return precs.has(key) ? ensure(key) : (vals.get(key) ?? null);
},
precedents(sheet, r, c) {
return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({
시트: d.sheet,
범위: d.range,
}));
},
snapshot() {
const out: CalcValues = {};
for (const key of precs.keys()) {
const { 시트, r, c } = splitKey(key);
(out[시트] ??= {})[toA1(r, c)] = toCalcValue(ensure(key));
}
return out;
},
};
}
+265
View File
@@ -0,0 +1,265 @@
/* =============================================================================
* spreadsheet_grid.css (주인 C)
* 격자 4 판 · 머리 · 칸 기본 모양. 색 · 간격은 여기 변수만(다른 파일은 이 클래스만 씀).
* ========================================================================== */
.aislo-grid {
/* 앱 전역 테마 토큰(ui_template_theme.css · :root[data-theme])을 그대로 물려받음 —
* 여기서 값을 다시 안 적음(다크 전환 시 앱 토큰이 바뀌면 같이 바뀜). */
--aislo-grid-bg: var(--color-surface-raised);
--aislo-grid-fg: var(--color-text-body);
--aislo-grid-line: var(--color-border);
--aislo-grid-head-bg: var(--color-surface);
--aislo-grid-head-fg: var(--color-text-secondary);
--aislo-grid-head-border: var(--color-border);
--aislo-grid-freeze-shadow: rgba(0, 0, 0, 0.12);
--aislo-grid-selected: var(--color-accent);
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
font:
10pt "굴림",
sans-serif;
background: var(--aislo-grid-bg);
color: var(--aislo-grid-fg);
user-select: none;
-webkit-user-select: none;
}
.aislo-grid__corner,
.aislo-grid__colhead-frozen,
.aislo-grid__colhead-scroll,
.aislo-grid__rowhead-frozen,
.aislo-grid__rowhead-scroll,
.aislo-grid__pane {
position: absolute;
overflow: hidden;
}
.aislo-grid__pane--br {
overflow: auto;
}
.aislo-grid__corner {
z-index: 5;
background: var(--aislo-grid-head-bg);
border-right: 1px solid var(--aislo-grid-head-border);
border-bottom: 1px solid var(--aislo-grid-head-border);
}
.aislo-grid__colhead-frozen,
.aislo-grid__colhead-scroll {
z-index: 4;
background: var(--aislo-grid-head-bg);
border-bottom: 1px solid var(--aislo-grid-head-border);
}
.aislo-grid__rowhead-frozen,
.aislo-grid__rowhead-scroll {
z-index: 4;
background: var(--aislo-grid-head-bg);
border-right: 1px solid var(--aislo-grid-head-border);
}
.aislo-grid__colhead-frozen,
.aislo-grid__rowhead-frozen {
z-index: 6;
box-shadow: 2px 2px 4px var(--aislo-grid-freeze-shadow);
}
.aislo-grid__colhead-inner,
.aislo-grid__rowhead-inner {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.aislo-grid__colhead,
.aislo-grid__rowhead {
position: absolute;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.82em;
font-weight: 600;
color: var(--aislo-grid-head-fg);
border-right: 1px solid var(--aislo-grid-head-border);
border-bottom: 1px solid var(--aislo-grid-head-border);
top: 0;
}
.aislo-grid__rowhead {
left: 0;
top: auto;
}
.aislo-grid__colhead--hidden-before,
.aislo-grid__rowhead--hidden-before {
border-left: 3px double var(--aislo-grid-head-fg);
}
.aislo-grid__head-label {
pointer-events: none;
overflow: hidden;
text-overflow: ellipsis;
}
.aislo-grid__resize {
position: absolute;
z-index: 2;
}
.aislo-grid__resize--col {
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
}
.aislo-grid__outline-btn {
position: absolute;
z-index: 2;
box-sizing: border-box;
padding: 0;
border: 1px solid var(--aislo-grid-head-border);
background: var(--aislo-grid-head-bg);
color: var(--aislo-grid-head-fg);
font-size: 0.7em;
line-height: 1;
cursor: pointer;
}
.aislo-grid__resize--row {
left: 0;
bottom: -3px;
width: 100%;
height: 6px;
cursor: row-resize;
}
.aislo-grid__pane-inner {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.aislo-grid__pane--br .aislo-grid__spacer {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.aislo-cell {
position: absolute;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 2px;
background: var(--aislo-grid-bg);
cursor: cell;
}
.aislo-grid--gridlines .aislo-cell {
border-right-style: solid;
border-bottom-style: solid;
border-right-width: var(--aislo-grid-gridline-w, 1px);
border-bottom-width: var(--aislo-grid-gridline-w, 1px);
border-right-color: var(--aislo-grid-line);
border-bottom-color: var(--aislo-grid-line);
}
.aislo-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: inherit;
}
.aislo-cell.is-error {
color: var(--color-danger, #c00);
}
/* 오류 칸 — 엑셀 오류 검사 초록 세모(왼 위 · sub3 오류 까닭은 title 풍선). */
.aislo-cell.is-error::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 6px 6px 0 0;
border-color: #2e7d32 transparent transparent transparent;
}
.aislo-cell.is-selected {
outline: 2px solid var(--aislo-grid-selected);
outline-offset: -1px;
z-index: 1;
}
/* 메모 칸 — 엑셀처럼 오른 위 모서리 빨간 세모. */
.aislo-cell.has-comment::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 6px 6px 0;
border-color: transparent #c00 transparent transparent;
}
.aislo-grid__ref-highlight {
position: absolute;
box-sizing: border-box;
pointer-events: none;
border: 1.5px solid;
z-index: 3;
}
.aislo-grid__fill-handle {
position: absolute;
width: 6px;
height: 6px;
background: var(--aislo-grid-selected);
border: 1px solid var(--aislo-grid-bg);
cursor: crosshair;
z-index: 3;
}
.aislo-grid__overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 7;
}
.aislo-grid__overlay > * {
pointer-events: auto;
}
/* 긴 글 넘침(`spreadsheet_spill.ts`) — 옆 빈 칸 위로 글을 폄 · 덮은 칸 사이 눈금선은 감춤(엑셀) ·
* 고치는 중에는 활성 칸 넘침을 감춤(편집기와 안 겹치게). */
.aislo-cell.is-spill {
overflow: visible;
z-index: 1;
}
.aislo-grid--gridlines .aislo-cell.is-spill-r {
border-right-color: transparent;
}
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
visibility: hidden;
}
+650
View File
@@ -0,0 +1,650 @@
/* =============================================================================
* spreadsheet_grid.ts (주인 C)
* 격자 — DOM(칸 = 절대 위치 div) · 틀고정 4 판(왼위 · 위 · 왼 · 본문) · 판마다 보이는 행열만 그림 ·
* 병합 칸 = div 하나 · 숨긴 행열 건너뜀 · 눈금선 · 고름 · 참조 색 테두리 · 채우기 손잡이.
* 칸 글은 엔진 값 → B `formatValue` → 서식 CSS(`spreadsheet_cellstyle.ts`). 머리 · 폭 끌기는 `spreadsheet_headers.ts`.
* 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`.
*
* 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) ·
* 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만).
* 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`).
* 칸 그리기 · 넘침은 `spreadsheet_grid_cells.ts` · 행열 머리 · 윤곽은 `spreadsheet_grid_heads.ts`.
* ========================================================================== */
import "./spreadsheet_grid.css";
import { colIndex, toA1 } from "./spreadsheet_address";
import {
buildColGeometry,
buildOutlineRuns,
buildRowGeometry,
visibleIndices,
type LineGeometry,
type OutlineRun,
} from "./spreadsheet_headers";
import {
buildMergeMap,
computeExtent,
createPool,
renderCells,
scaledGeometry,
type CellPool,
type GridView,
} from "./spreadsheet_grid_cells";
import { createHeads, HEAD_COL_W_BASE, HEAD_ROW_H_BASE } from "./spreadsheet_grid_heads";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellRange, SheetCellAddress } from "./spreadsheet_types";
import type {
CellBox,
GridHandle,
HitResult,
RefHighlight,
SpreadsheetContext,
} from "./spreadsheet_view_types";
const OUTLINE_GUTTER_BASE = 14;
const RESIZE_HOTZONE = 4;
const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"];
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 4;
export function createGrid(ctx: SpreadsheetContext): GridHandle {
const root = document.createElement("div");
root.className = "aislo-grid";
const corner = document.createElement("div");
corner.className = "aislo-grid__corner";
const colHeadFrozen = paneDiv("aislo-grid__colhead-frozen");
const colHeadScroll = paneDiv("aislo-grid__colhead-scroll");
const rowHeadFrozen = paneDiv("aislo-grid__rowhead-frozen");
const rowHeadScroll = paneDiv("aislo-grid__rowhead-scroll");
const paneTL = paneDiv("aislo-grid__pane aislo-grid__pane--tl");
const paneTR = paneDiv("aislo-grid__pane aislo-grid__pane--tr");
const paneBL = paneDiv("aislo-grid__pane aislo-grid__pane--bl");
const paneBR = paneDiv("aislo-grid__pane aislo-grid__pane--br");
const overlay = paneDiv("aislo-grid__overlay");
const colHeadInnerFrozen = innerDiv("aislo-grid__colhead-inner");
const colHeadInnerScroll = innerDiv("aislo-grid__colhead-inner");
const rowHeadInnerFrozen = innerDiv("aislo-grid__rowhead-inner");
const rowHeadInnerScroll = innerDiv("aislo-grid__rowhead-inner");
const paneTLInner = innerDiv("aislo-grid__pane-inner");
const paneTRInner = innerDiv("aislo-grid__pane-inner");
const paneBLInner = innerDiv("aislo-grid__pane-inner");
const paneBRInner = innerDiv("aislo-grid__pane-inner");
const spacer = document.createElement("div");
spacer.className = "aislo-grid__spacer";
colHeadFrozen.append(colHeadInnerFrozen);
colHeadScroll.append(colHeadInnerScroll);
rowHeadFrozen.append(rowHeadInnerFrozen);
rowHeadScroll.append(rowHeadInnerScroll);
paneTL.append(paneTLInner);
paneTR.append(paneTRInner);
paneBL.append(paneBLInner);
paneBR.append(paneBRInner, spacer);
root.append(
corner,
colHeadFrozen,
colHeadScroll,
rowHeadFrozen,
rowHeadScroll,
paneTL,
paneTR,
paneBL,
paneBR,
overlay,
);
const pools = { tl: createPool(), tr: createPool(), bl: createPool(), br: createPool() };
const colHeadPools = { frozen: createPool(), scroll: createPool() };
const rowHeadPools = { frozen: createPool(), scroll: createPool() };
let baseColGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
let baseRowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
let colGeo: LineGeometry = baseColGeo;
let rowGeo: LineGeometry = baseRowGeo;
let extent = computeExtent(ctx.sheet());
let merges = buildMergeMap(ctx.sheet());
let frozenRows = 0;
let frozenCols = 0;
let frozenW = 0;
let frozenH = 0;
let lastSheetId = "";
let zoom = 1;
let rowOutlineRuns: OutlineRun[] = [];
let colOutlineRuns: OutlineRun[] = [];
/** 행 묶음 윤곽 띠 — 행 머리 왼쪽(있을 때만) · 열 묶음 띠는 열 머리 위. */
const rowGutterW = (): number =>
rowOutlineRuns.length ? Math.round(OUTLINE_GUTTER_BASE * zoom) : 0;
const colGutterH = (): number =>
colOutlineRuns.length ? Math.round(OUTLINE_GUTTER_BASE * zoom) : 0;
const headRowH = (): number => Math.round(HEAD_ROW_H_BASE * zoom) + colGutterH();
const headColW = (): number => Math.round(HEAD_COL_W_BASE * zoom) + rowGutterW();
const view: GridView = {
get colGeo() {
return colGeo;
},
get rowGeo() {
return rowGeo;
},
get merges() {
return merges;
},
get extent() {
return extent;
},
get zoom() {
return zoom;
},
get frozenRows() {
return frozenRows;
},
get frozenCols() {
return frozenCols;
},
get rowOutlineRuns() {
return rowOutlineRuns;
},
get colOutlineRuns() {
return colOutlineRuns;
},
rowGutterW,
colGutterH,
};
const { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline } = createHeads(
ctx,
view,
{
colFrozen: colHeadInnerFrozen,
colScroll: colHeadInnerScroll,
rowFrozen: rowHeadInnerFrozen,
rowScroll: rowHeadInnerScroll,
},
);
const resize = new ResizeObserver(() => layoutPanes());
resize.observe(root);
/** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */
function layoutPanes(): void {
const w = root.clientWidth;
const h = root.clientHeight;
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기 * zoom}pt` : `${10 * zoom}pt`;
root.style.setProperty("--aislo-grid-gridline-w", `${Math.max(1, Math.round(zoom))}px`);
frozenW = colGeo.total(frozenCols);
frozenH = rowGeo.total(frozenRows);
corner.style.width = `${headColW()}px`;
corner.style.height = `${headRowH()}px`;
colHeadFrozen.style.left = `${headColW()}px`;
colHeadFrozen.style.top = "0";
colHeadFrozen.style.width = `${frozenW}px`;
colHeadFrozen.style.height = `${headRowH()}px`;
colHeadScroll.style.left = `${headColW() + frozenW}px`;
colHeadScroll.style.top = "0";
colHeadScroll.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
colHeadScroll.style.height = `${headRowH()}px`;
rowHeadFrozen.style.left = "0";
rowHeadFrozen.style.top = `${headRowH()}px`;
rowHeadFrozen.style.width = `${headColW()}px`;
rowHeadFrozen.style.height = `${frozenH}px`;
rowHeadScroll.style.left = "0";
rowHeadScroll.style.top = `${headRowH() + frozenH}px`;
rowHeadScroll.style.width = `${headColW()}px`;
rowHeadScroll.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
paneTL.style.left = `${headColW()}px`;
paneTL.style.top = `${headRowH()}px`;
paneTL.style.width = `${frozenW}px`;
paneTL.style.height = `${frozenH}px`;
paneTR.style.left = `${headColW() + frozenW}px`;
paneTR.style.top = `${headRowH()}px`;
paneTR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
paneTR.style.height = `${frozenH}px`;
paneBL.style.left = `${headColW()}px`;
paneBL.style.top = `${headRowH() + frozenH}px`;
paneBL.style.width = `${frozenW}px`;
paneBL.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
paneBR.style.left = `${headColW() + frozenW}px`;
paneBR.style.top = `${headRowH() + frozenH}px`;
paneBR.style.width = `${Math.max(0, w - headColW() - frozenW)}px`;
paneBR.style.height = `${Math.max(0, h - headRowH() - frozenH)}px`;
resizeSpacer();
overlay.style.left = `${headColW()}px`;
overlay.style.top = `${headRowH()}px`;
overlay.style.right = "0";
overlay.style.bottom = "0";
renderVisible();
}
/** `br` 스크롤 위치 → 세 판(머리 둘 · tr · bl)에 옮겨 그리고 보이는 칸을 다시. */
function resizeSpacer(): void {
spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`;
}
/** 이름 상자 · Ctrl+방향키 등으로 간 칸이 지금 스크롤 영역 밖이면 그만큼 늘림(엑셀처럼 · 빈 시트도 안 막힘). */
function ensureExtent(r: number, c: number): void {
let grown = false;
if (r >= extent.rows - 1) {
extent.rows = Math.min(MAX_ROWS, r + 100);
grown = true;
}
if (c >= extent.cols - 1) {
extent.cols = Math.min(MAX_COLS, c + 20);
grown = true;
}
if (grown) resizeSpacer();
}
/** 스크롤이 끝 가까이 닿으면 미리 늘림(휠 · 스크롤바로 끝까지 밀 때도 안 막힘). */
function growOnScrollEdge(): void {
const edge = 100;
if (
extent.rows < MAX_ROWS &&
paneBR.scrollTop + paneBR.clientHeight >= paneBR.scrollHeight - edge
) {
extent.rows = Math.min(MAX_ROWS, extent.rows + 100);
resizeSpacer();
}
if (
extent.cols < MAX_COLS &&
paneBR.scrollLeft + paneBR.clientWidth >= paneBR.scrollWidth - edge
) {
extent.cols = Math.min(MAX_COLS, extent.cols + 20);
resizeSpacer();
}
}
function onScroll(): void {
growOnScrollEdge();
const left = paneBR.scrollLeft;
const top = paneBR.scrollTop;
colHeadInnerScroll.style.transform = `translateX(${-left}px)`;
paneTRInner.style.transform = `translateX(${-left}px)`;
rowHeadInnerScroll.style.transform = `translateY(${-top}px)`;
paneBLInner.style.transform = `translateY(${-top}px)`;
renderVisible();
}
paneBR.addEventListener("scroll", onScroll);
/** Ctrl+휠 확대 · 축소(10~400%) — 칸 · 머리 · 선 굵기가 같이 커짐(`headRowH`·`headColW`·`applyCellStyle` scale). */
function onWheel(ev: WheelEvent): void {
if (!ev.ctrlKey) return;
ev.preventDefault();
const before = zoom;
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
if (next === before) return;
zoom = next;
colGeo = scaledGeometry(baseColGeo, zoom);
rowGeo = scaledGeometry(baseRowGeo, zoom);
paneBR.scrollLeft *= zoom / before;
paneBR.scrollTop *= zoom / before;
layoutPanes();
root.dispatchEvent(new CustomEvent("aislo-grid-zoom", { detail: { zoom }, bubbles: true }));
}
root.addEventListener("wheel", onWheel, { passive: false });
const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false;
function renderVisible(): void {
root.classList.toggle("aislo-grid--gridlines", gridlines());
const w = root.clientWidth;
const h = root.clientHeight;
const scrollW = Math.max(0, w - headColW() - frozenW);
const scrollH = Math.max(0, h - headRowH() - frozenH);
const frozenColsVisible = visibleIndices(colGeo, 0, frozenCols - 1);
// 본문 판 스크롤 0 = 틀고정 바로 다음 칸(`offset(frozen)`) — 그만큼 더해 재야 오른 · 아래 끝이 안 빔.
const left = paneBR.scrollLeft + frozenW;
const top = paneBR.scrollTop + frozenH;
const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(left));
const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW) + 1);
const scrollColsVisible = visibleIndices(
colGeo,
scrollColFrom,
Math.max(scrollColFrom, scrollColTo),
);
const frozenRowsVisible = visibleIndices(rowGeo, 0, frozenRows - 1);
const scrollRowFrom = Math.max(frozenRows, rowGeo.indexAt(top));
const scrollRowTo = Math.min(extent.rows - 1, rowGeo.indexAt(top + scrollH) + 1);
const scrollRowsVisible = visibleIndices(
rowGeo,
scrollRowFrom,
Math.max(scrollRowFrom, scrollRowTo),
);
layoutColHeaders(frozenColsVisible, colHeadPools.frozen, colHeadInnerFrozen, 0);
layoutColHeaders(
scrollColsVisible,
colHeadPools.scroll,
colHeadInnerScroll,
colGeo.offset(frozenCols),
);
layoutRowHeaders(frozenRowsVisible, rowHeadPools.frozen, rowHeadInnerFrozen, 0);
layoutRowHeaders(
scrollRowsVisible,
rowHeadPools.scroll,
rowHeadInnerScroll,
rowGeo.offset(frozenRows),
);
renderRowOutline();
renderColOutline();
renderCells(ctx, view, frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0);
renderCells(
ctx,
view,
frozenRowsVisible,
scrollColsVisible,
pools.tr,
paneTRInner,
colGeo.offset(frozenCols),
0,
);
renderCells(
ctx,
view,
scrollRowsVisible,
frozenColsVisible,
pools.bl,
paneBLInner,
0,
rowGeo.offset(frozenRows),
);
renderCells(
ctx,
view,
scrollRowsVisible,
scrollColsVisible,
pools.br,
paneBRInner,
colGeo.offset(frozenCols),
rowGeo.offset(frozenRows),
);
renderSelection();
}
function render(): void {
const sheet = ctx.sheet();
baseColGeo = buildColGeometry(ctx.book, sheet);
baseRowGeo = buildRowGeometry(ctx.book, sheet);
colGeo = scaledGeometry(baseColGeo, zoom);
rowGeo = scaledGeometry(baseRowGeo, zoom);
extent = computeExtent(sheet);
merges = buildMergeMap(sheet);
rowOutlineRuns = buildOutlineRuns(sheet.행, (k) => Number(k) - 1);
colOutlineRuns = buildOutlineRuns(sheet.열, colIndex);
frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows);
frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols);
spacer.style.width = "1px";
spacer.style.height = "1px";
if (sheet.id !== lastSheetId) {
lastSheetId = sheet.id;
paneBR.scrollLeft = 0;
paneBR.scrollTop = 0;
}
layoutPanes();
}
/** 값 · 서식이 바뀐 칸만 — 지금 그려진 칸이면 다시(구조는 안 바꿈 · `render()` 는 구조 바뀔 때만). */
function invalidate(cells: SheetCellAddress[]): void {
const sheet = ctx.sheet();
const dirty = new Set(cells.filter((c) => c.시트 === sheet.id).map((c) => `${c.r},${c.c}`));
if (dirty.size === 0) return;
const anyDirty = (pool: CellPool): boolean => {
for (const key of pool.used.keys()) if (dirty.has(key)) return true;
return false;
};
if (anyDirty(pools.tl) || anyDirty(pools.tr) || anyDirty(pools.bl) || anyDirty(pools.br))
renderVisible();
}
function boxOf(r: number, c: number): CellBox {
const merge = merges.get(`${r},${c}`);
const r0 = merge ? merge.r0 : r;
const c0 = merge ? merge.c0 : c;
const r1 = merge ? merge.r1 : r;
const c1 = merge ? merge.c1 : c;
return {
x: colGeo.offset(c0),
y: rowGeo.offset(r0),
w: colGeo.offset(c1 + 1) - colGeo.offset(c0),
h: rowGeo.offset(r1 + 1) - rowGeo.offset(r0),
};
}
function screenBoxOf(r: number, c: number): CellBox {
const box = boxOf(r, c);
const inFrozenCol = c < frozenCols;
const inFrozenRow = r < frozenRows;
const x = headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
const y = headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
return { x, y, w: box.w, h: box.h };
}
/** `overlay` 안 자리 — `overlay` 자신이 이미 머리 칸(headColW·headRowH)만큼 밀려 있어
* `screenBoxOf`(격자 뿌리 기준)를 그대로 쓰면 머리 칸만큼 두 번 밀림(찾은 버그 · 2026-09-27). */
function overlayBoxOf(r: number, c: number): CellBox {
const box = boxOf(r, c);
const inFrozenCol = c < frozenCols;
const inFrozenRow = r < frozenRows;
return {
x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft,
y: inFrozenRow ? box.y : box.y - paneBR.scrollTop,
w: box.w,
h: box.h,
};
}
function renderSelection(): void {
let selLayer = overlay.querySelector<HTMLElement>(".aislo-grid__sel-layer");
if (!selLayer) {
selLayer = document.createElement("div");
selLayer.className = "aislo-grid__sel-layer";
overlay.append(selLayer);
}
selLayer.replaceChildren();
const sel = ctx.selection;
if (!sel || sel.시트 !== ctx.sheet().id) return;
for (const [i, range] of sel.범위.entries()) {
const box: CellBox = {
x: colGeo.offset(range.c0),
y: rowGeo.offset(range.r0),
w: colGeo.offset(range.c1 + 1) - colGeo.offset(range.c0),
h: rowGeo.offset(range.r1 + 1) - rowGeo.offset(range.r0),
};
const inFrozenCol = range.c0 < frozenCols;
const inFrozenRow = range.r0 < frozenRows;
const screen: CellBox = {
x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft,
y: inFrozenRow ? box.y : box.y - paneBR.scrollTop,
w: box.w,
h: box.h,
};
const node = document.createElement("div");
node.className = "aislo-grid__ref-highlight";
node.style.left = `${screen.x}px`;
node.style.top = `${screen.y}px`;
node.style.width = `${screen.w}px`;
node.style.height = `${screen.h}px`;
node.style.borderColor = "var(--aislo-grid-selected)";
node.style.borderWidth = i === 0 ? "2px" : "1px";
selLayer!.append(node);
}
// 활성 칸 테두리 — 칸 다시 그리기(`renderRowOfCells`) 없이 고름만 바뀌어도 옛 칸에 남지 않게.
const activeKey = `${sel.활성.r},${sel.활성.c}`;
const anchor = merges.get(activeKey);
const activeAddr = toA1(anchor?.r0 ?? sel.활성.r, anchor?.c0 ?? sel.활성.c);
for (const el of root.querySelectorAll<HTMLElement>(".aislo-cell.is-selected"))
if (el.dataset.addr !== activeAddr) el.classList.remove("is-selected");
for (const el of root.querySelectorAll<HTMLElement>(`.aislo-cell[data-addr="${activeAddr}"]`))
el.classList.add("is-selected");
const handleAt = fillHandleCell();
if (!handleAt) return;
const corner = overlayBoxOf(handleAt.r, handleAt.c);
const handle = document.createElement("div");
handle.className = "aislo-grid__fill-handle";
handle.style.left = `${corner.x + corner.w - 4}px`;
handle.style.top = `${corner.y + corner.h - 4}px`;
selLayer!.append(handle);
}
/** 채우기 손잡이 자리 — 고른 범위 오른아래 칸(엑셀) · 범위가 여럿이거나 읽기 전용이면 없음. */
function fillHandleCell(): { r: number; c: number } | null {
const sel = ctx.selection;
if (ctx.readOnly || !sel || sel.범위.length !== 1) return null;
const g = sel.범위[0];
return { r: g.r1, c: g.c1 };
}
function setRefHighlights(list: RefHighlight[]): void {
let refLayer = overlay.querySelector<HTMLElement>(".aislo-grid__ref-layer");
if (!refLayer) {
refLayer = document.createElement("div");
refLayer.className = "aislo-grid__ref-layer";
overlay.append(refLayer);
}
refLayer.replaceChildren();
for (const ref of list) {
if (ref.시트 !== ctx.sheet().id) continue;
const box = boxOf(ref.범위.r0, ref.범위.c0);
const box2 = boxOf(ref.범위.r1, ref.범위.c1);
const merged: CellBox = {
x: box.x,
y: box.y,
w: box2.x + box2.w - box.x,
h: box2.y + box2.h - box.y,
};
const inFrozenCol = ref.범위.c0 < frozenCols;
const inFrozenRow = ref.범위.r0 < frozenRows;
const node = document.createElement("div");
node.className = "aislo-grid__ref-highlight";
node.style.left = `${inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft}px`;
node.style.top = `${inFrozenRow ? merged.y : merged.y - paneBR.scrollTop}px`;
node.style.width = `${merged.w}px`;
node.style.height = `${merged.h}px`;
node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length];
refLayer!.append(node);
}
}
function hitTest(clientX: number, clientY: number): HitResult | null {
const bounds = root.getBoundingClientRect();
const x = clientX - bounds.left;
const y = clientY - bounds.top;
if (x < 0 || y < 0 || x > root.clientWidth || y > root.clientHeight) return null;
if (x < headColW() && y < headRowH()) return { kind: "corner", r: 0, c: 0 };
const contentX =
x < headColW() ? -1 : x - headColW() + (x - headColW() > frozenW ? paneBR.scrollLeft : 0);
const contentY =
y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0);
if (y < headRowH()) {
const c = colGeo.indexAt(contentX);
const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
return { kind: edge ? "colEdge" : "colHead", r: 0, c };
}
if (x < headColW()) {
const r = rowGeo.indexAt(contentY);
const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE;
return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 };
}
const c = colGeo.indexAt(contentX);
const r = rowGeo.indexAt(contentY);
const sel = ctx.selection;
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
if (handleAt) {
const screen = screenBoxOf(handleAt.r, handleAt.c);
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
}
}
return { kind: "cell", r, c };
}
function reveal(r: number, c: number): void {
ensureExtent(r, c);
const [top0, left0] = [paneBR.scrollTop, paneBR.scrollLeft];
if (r >= frozenRows) {
const y0 = rowGeo.offset(r) - rowGeo.offset(frozenRows);
const y1 = y0 + rowGeo.size(r);
const viewTop = paneBR.scrollTop;
const viewBottom = viewTop + paneBR.clientHeight;
if (y0 < viewTop) paneBR.scrollTop = y0;
else if (y1 > viewBottom) paneBR.scrollTop = y1 - paneBR.clientHeight;
}
if (c >= frozenCols) {
const x0 = colGeo.offset(c) - colGeo.offset(frozenCols);
const x1 = x0 + colGeo.size(c);
const viewLeft = paneBR.scrollLeft;
const viewRight = viewLeft + paneBR.clientWidth;
if (x0 < viewLeft) paneBR.scrollLeft = x0;
else if (x1 > viewRight) paneBR.scrollLeft = x1 - paneBR.clientWidth;
}
// scroll 이벤트는 다음 그림 때 옴 — 먼 칸으로 뛸 때 한 틀 비어 보이지 않게 바로 그림
if (paneBR.scrollTop !== top0 || paneBR.scrollLeft !== left0) onScroll();
}
function visibleRange(): CellRange {
const scrollW = Math.max(0, root.clientWidth - headColW() - frozenW);
const scrollH = Math.max(0, root.clientHeight - headRowH() - frozenH);
const left = paneBR.scrollLeft + frozenW;
const top = paneBR.scrollTop + frozenH;
return {
r0: rowGeo.indexAt(top),
c0: colGeo.indexAt(left),
r1: Math.min(extent.rows - 1, rowGeo.indexAt(top + scrollH)),
c1: Math.min(extent.cols - 1, colGeo.indexAt(left + scrollW)),
};
}
/** 칸이 든 판(틀고정 4 판 중 하나)의 안쪽 층 — 어느 판인지는 이 반환 요소로 앎. */
function paneLayerOf(r: number, c: number): HTMLElement {
const inFrozenRow = r < frozenRows;
const inFrozenCol = c < frozenCols;
if (inFrozenRow) return inFrozenCol ? paneTLInner : paneTRInner;
return inFrozenCol ? paneBLInner : paneBRInner;
}
/** 편집기 자리 — `layer` = 칸이 든 판의 안쪽 층 · `box` = 격자 뿌리 기준 화면 좌표(D 가 층 차이를 빼서 맞춤). */
function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } {
return { layer: paneLayerOf(r, c), box: screenBoxOf(r, c) };
}
function getZoom(): number {
return zoom;
}
function destroy(): void {
resize.disconnect();
paneBR.removeEventListener("scroll", onScroll);
root.remove();
}
render();
return {
root,
render,
invalidate,
renderSelection,
setRefHighlights,
cellBox: boxOf,
hitTest,
reveal,
visibleRange,
editorSlot,
zoom: getZoom,
destroy,
};
}
function paneDiv(className: string): HTMLElement {
const node = document.createElement("div");
node.className = className;
return node;
}
function innerDiv(className: string): HTMLElement {
const node = document.createElement("div");
node.className = className;
return node;
}
@@ -0,0 +1,322 @@
/* =============================================================================
* spreadsheet_grid_cells.ts
* 격자 칸 그리기 — 판 하나(틀고정 4 판 중)의 보이는 칸을 div 로 그림(칸 재사용 `CellPool`) ·
* 값 → 글(`formatValue`) · 서식 CSS · 수 `#####` · 긴 글 넘침(`spreadsheet_spill.ts`) 붙이기.
* `spreadsheet_grid.ts` 가 판마다 부름 — 자리 재기 · 병합 · 확대는 `GridView` 로 받음.
* ========================================================================== */
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import {
applyCellStyle,
cellStyleAt,
isAutoInk,
resolveBoxBorders,
setCellText,
type ResolvedBorders,
} from "./spreadsheet_cellstyle";
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt";
import { formatValue } from "./spreadsheet_numfmt";
import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
CellRange,
CellStyle,
ErrorValue,
HAlign,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
/** 격자가 지금 쓰는 자리 재기 · 병합 · 틀고정 — `createGrid` 의 값을 읽기만(바뀌면 새 값이 보임). */
export interface GridView {
readonly colGeo: LineGeometry;
readonly rowGeo: LineGeometry;
readonly merges: Map<string, CellRange>;
readonly extent: { rows: number; cols: number };
readonly zoom: number;
readonly frozenRows: number;
readonly frozenCols: number;
readonly rowOutlineRuns: OutlineRun[];
readonly colOutlineRuns: OutlineRun[];
rowGutterW(): number;
colGutterH(): number;
}
/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */
export interface CellPool {
used: Map<string, HTMLElement>;
free: HTMLElement[];
}
export const createPool = (): CellPool => ({ used: new Map(), free: [] });
function poolGet(
pool: CellPool,
key: string,
container: HTMLElement,
className: string,
): HTMLElement {
const found = pool.used.get(key);
if (found) return found;
const node = pool.free.pop() ?? document.createElement("div");
node.className = className;
pool.used.set(key, node);
container.append(node);
return node;
}
export function poolSweep(pool: CellPool, keep: Set<string>): void {
for (const [key, node] of pool.used) {
if (keep.has(key)) continue;
pool.used.delete(key);
node.remove();
pool.free.push(node);
}
}
function toScalar(value: number | string | boolean): Scalar {
return typeof value === "number" ? toFrac(value) : value;
}
/** 시트에 실제 쓰인 자리 + 여유(엑셀 최대 자리까지 다 쓰지 않음 — 스크롤판 스페이서를 적당히 둠). */
export function computeExtent(sheet: Sheet): { rows: number; cols: number } {
let maxR = 0;
let maxC = 0;
for (const key of Object.keys(sheet.칸)) {
const addr = parseA1(key);
if (!addr) continue;
maxR = Math.max(maxR, addr.r);
maxC = Math.max(maxC, addr.c);
}
for (const key of Object.keys(sheet.행 ?? {})) maxR = Math.max(maxR, Number(key) - 1);
for (const key of Object.keys(sheet.열 ?? {})) maxC = Math.max(maxC, colIndex(key));
for (const m of sheet.병합 ?? []) {
const range = parseRange(m);
if (!range) continue;
maxR = Math.max(maxR, range.r1);
maxC = Math.max(maxC, range.c1);
}
return {
rows: Math.min(MAX_ROWS, Math.max(maxR + 100, 200)),
cols: Math.min(MAX_COLS, Math.max(maxC + 20, 60)),
};
}
export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
const map = new Map<string, CellRange>();
for (const m of sheet.병합 ?? []) {
const range = parseRange(m);
if (!range) continue;
for (let r = range.r0; r <= range.r1; r += 1) {
for (let c = range.c0; c <= range.c1; c += 1) map.set(`${r},${c}`, range);
}
}
return map;
}
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left",
left: "left",
right: "right",
center: "center",
};
function resetSpill(cell: HTMLElement): void {
cell.classList.remove("is-spill", "is-spill-r");
const t = cell.firstElementChild as HTMLElement | null;
if (t) t.style.width = t.style.marginLeft = t.style.clipPath = "";
}
let measureRuler: CanvasRenderingContext2D | null = null;
/** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */
function measureText(text: string, font: string): number {
measureRuler ??= document.createElement("canvas").getContext("2d");
if (!measureRuler) return text.length * 7;
measureRuler.font = font;
return measureRuler.measureText(text).width;
}
/** 칸 글꼴 CSS 글(캔버스 재기용) — `applyCellStyle` 이 칸에 입히는 글꼴과 같은 차례로 고름. */
function cellFont(style: CellStyle, book: Workbook, scale: number): string {
const family = style.글꼴 || book.기본?.글꼴 || "굴림";
const size = (style.크기 ?? book.기본?.크기 ?? 10) * scale;
const weight = style.굵게 ? "700" : "400";
const italic = style.기울임 ? "italic " : "";
return `${italic}${weight} ${size}pt ${family}`;
}
/** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */
export function scaledGeometry(base: LineGeometry, factor: number): LineGeometry {
if (factor === 1) return base;
return {
size: (i) => base.size(i) * factor,
offset: (i) => base.offset(i) * factor,
total: (i) => base.total(i) * factor,
indexAt: (px) => base.indexAt(px / factor),
};
}
interface CellDisplay {
text: string;
hAlign: HAlign;
title: string;
isError: boolean;
isNumeric: boolean;
color?: string;
}
/** 칸 하나 값 — 식이면 엔진 값 · 아니면 입력 값(수는 Frac로) · 둘 다 없으면 빈 칸. */
function scalarAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): Scalar {
const cell = sheet.칸[toA1(r, c)];
return cell?.식 !== undefined
? ctx.engine.value(sheet.id, r, c)
: cell?.값 !== undefined
? toScalar(cell.값)
: null;
}
/** 조건부 서식(sub4) 이 값을 보는 형 — Frac 는 근사 수로(조건 비교용 · 오류는 조건 대상 아님). */
function toCondFmtValue(scalar: Scalar): CondFmtValue {
if (scalar === null || typeof scalar === "boolean" || typeof scalar === "string") return scalar;
if ("error" in scalar) return null;
return Number(scalar.n) / Number(scalar.d);
}
function cellDisplay(
ctx: SpreadsheetContext,
sheet: Sheet,
r: number,
c: number,
hAlign0: HAlign,
code?: string,
): CellDisplay {
const scalar = scalarAt(ctx, sheet, r, c);
const formatted = formatValue(scalar, code);
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
const isNumeric =
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
return { text: formatted.글, hAlign, title, isError, isNumeric, color: formatted.색 };
}
/** 판 하나의 보이는 칸 — 병합 칸은 div 하나 · 안 보이게 된 칸은 떼어 둠(다음에 다시 씀). */
export function renderCells(
ctx: SpreadsheetContext,
view: GridView,
rowsVisible: number[],
colsVisible: number[],
pool: CellPool,
container: HTMLElement,
baseX: number,
baseY: number,
): void {
const { colGeo, rowGeo, merges, extent, zoom } = view;
const sheet = ctx.sheet();
const book = ctx.book;
const keep = new Set<string>();
// 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서)
const slots = new Map<number, Map<number, SpillSlot>>();
const longs: {
r: number;
c: number;
el: HTMLElement;
own: number;
need: number;
align: SpillAlign;
}[] = [];
for (const r of rowsVisible) {
for (const c of colsVisible) {
const merge = merges.get(`${r},${c}`);
if (merge && (merge.r0 !== r || merge.c0 !== c)) continue;
const r0 = merge ? merge.r0 : r;
const c0 = merge ? merge.c0 : c;
const r1 = merge ? merge.r1 : r;
const c1 = merge ? merge.c1 : c;
const key = `${r0},${c0}`;
if (keep.has(key)) continue;
keep.add(key);
const cellEl = poolGet(pool, key, container, "aislo-cell");
const x = colGeo.offset(c0) - baseX;
const y = rowGeo.offset(r0) - baseY;
const w = colGeo.offset(c1 + 1) - colGeo.offset(c0);
const h = rowGeo.offset(r1 + 1) - rowGeo.offset(r0);
cellEl.style.left = `${x}px`;
cellEl.style.top = `${y}px`;
cellEl.style.width = `${w}px`;
cellEl.style.height = `${h}px`;
cellEl.dataset.r = String(r0);
cellEl.dataset.c = String(c0);
const addr = toA1(r0, c0);
cellEl.dataset.addr = addr;
const baseStyle = cellStyleAt(book, sheet, r0, c0);
const condStyle = sheet.조건부서식?.length
? resolveCondFmtStyle(sheet, r0, c0, {
value: (rr, cc) => toCondFmtValue(scalarAt(ctx, sheet, rr, cc)),
})
: null;
const style = condStyle ? { ...baseStyle, ...condStyle } : baseStyle;
const borders: ResolvedBorders = resolveBoxBorders(
book,
sheet,
r0,
c0,
r1,
c1,
extent.rows - 1,
extent.cols - 1,
);
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
applyCellStyle(cellEl, style, borders, display.hAlign, zoom);
// 조건부 서식 글자색이 이기고(엑셀과 같음) · 없으면 숫자 형식([Red] 등)이 정한 색.
if (display.color && !condStyle?.글자색 && !isAutoInk(display.color))
cellEl.style.color = display.color;
const comment = sheet.comments?.[addr];
// 수는 칸 폭보다 길면 `#####`(엑셀과 같음) · 글은 지금처럼 줄임표.
let text = display.text;
if (display.isNumeric && !style.줄바꿈) {
const font = cellFont(style, book, zoom);
const avail = w - 4;
if (measureText(text, font) > avail) {
const hashW = measureText("#", font) || 1;
text = "#".repeat(Math.max(1, Math.floor(avail / hashW)));
}
}
setCellText(cellEl, text, display.title || comment || undefined);
resetSpill(cellEl);
if (!merge) {
(slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" });
const align = SPILL_ALIGN[display.hAlign];
if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) {
// 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로
const need = measureText(text, cellFont(style, book, zoom)) + 4;
if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align });
}
}
cellEl.classList.toggle("is-error", display.isError);
cellEl.classList.toggle("has-comment", !!comment);
cellEl.classList.toggle(
"is-selected",
ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0,
);
}
}
// 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산
for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5;
for (const l of longs) {
const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align);
if (!sp) continue;
l.el.classList.add("is-spill");
const t = l.el.firstElementChild as HTMLElement;
t.style.width = `calc(100% + ${sp.left + sp.right}px)`;
t.style.marginLeft = `${-sp.left}px`;
if (sp.clipLeft || sp.clipRight)
t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`;
for (let k = sp.from; k < sp.to; k++) pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r");
}
poolSweep(pool, keep);
}
@@ -0,0 +1,200 @@
/* =============================================================================
* spreadsheet_grid_heads.ts
* 격자 머리 — 행 · 열 머리 칸(보이는 것만 · 폭 · 높이 끌기) · 숨긴 자리 표시 · 묶음 윤곽 +/- 단추.
* `spreadsheet_grid.ts` 가 판을 그릴 때 부름 — 머리 칸 모양 · 끌기는 `spreadsheet_headers.ts`.
* ========================================================================== */
import { colName } from "./spreadsheet_address";
import {
commitColWidth,
commitRowHeight,
isExplicitHidden,
makeColHeaderCell,
makeRowHeaderCell,
startDragResize,
type OutlineRun,
} from "./spreadsheet_headers";
import { poolSweep, type CellPool, type GridView } from "./spreadsheet_grid_cells";
import type { Sheet } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export const HEAD_ROW_H_BASE = 24;
export const HEAD_COL_W_BASE = 46;
/** 머리 안쪽 층 넷 — 틀고정 쪽 · 스크롤 쪽. */
export interface HeadLayers {
colFrozen: HTMLElement;
colScroll: HTMLElement;
rowFrozen: HTMLElement;
rowScroll: HTMLElement;
}
export function createHeads(ctx: SpreadsheetContext, view: GridView, layers: HeadLayers) {
const {
colFrozen: colHeadInnerFrozen,
colScroll: colHeadInnerScroll,
rowFrozen: rowHeadInnerFrozen,
rowScroll: rowHeadInnerScroll,
} = layers;
/** 건너뛴 자리(prev+1..cur-1) 안에 사용자 숨김 · 필터 걸러짐이 있나(묶음 접힘은 빼고 — 화면 표시가 다름). */
function colHiddenGap(sheet: Sheet, prev: number, cur: number): boolean {
for (let i = prev + 1; i < cur; i += 1)
if (isExplicitHidden(sheet.열?.[colName(i)])) return true;
return false;
}
function rowHiddenGap(sheet: Sheet, prev: number, cur: number): boolean {
for (let i = prev + 1; i < cur; i += 1)
if (isExplicitHidden(sheet.행?.[String(i + 1)])) return true;
return false;
}
function layoutColHeaders(
cols: number[],
pool: CellPool,
container: HTMLElement,
base: number,
): void {
const sheet = ctx.sheet();
const keep = new Set<string>();
const top = view.colGutterH();
const height = Math.round(HEAD_ROW_H_BASE * view.zoom);
cols.forEach((c, i) => {
const key = String(c);
keep.add(key);
const prev = i > 0 ? cols[i - 1] : c - 1;
const hiddenBefore = c - prev > 1 && colHiddenGap(sheet, prev, c);
let cellEl = pool.used.get(key);
if (!cellEl) {
const built = makeColHeaderCell(c, 0, 0, hiddenBefore);
cellEl = built.el;
pool.used.set(key, cellEl);
container.append(cellEl);
const handle = cellEl.querySelector<HTMLElement>(".aislo-grid__resize--col")!;
startDragResize({
handle,
axis: "col",
currentPx: () => view.colGeo.size(c),
onPreview: (px) => {
cellEl!.style.width = `${px}px`;
},
onCommit: (px) => commitColWidth(ctx, c, px),
});
}
cellEl.style.left = `${view.colGeo.offset(c) - base}px`;
cellEl.style.top = `${top}px`;
cellEl.style.width = `${view.colGeo.size(c)}px`;
cellEl.style.height = `${height}px`;
cellEl.classList.toggle("aislo-grid__colhead--hidden-before", hiddenBefore);
});
poolSweep(pool, keep);
}
function layoutRowHeaders(
rows: number[],
pool: CellPool,
container: HTMLElement,
base: number,
): void {
const sheet = ctx.sheet();
const keep = new Set<string>();
const left = view.rowGutterW();
const width = Math.round(HEAD_COL_W_BASE * view.zoom);
rows.forEach((r, i) => {
const key = String(r);
keep.add(key);
const prev = i > 0 ? rows[i - 1] : r - 1;
const hiddenBefore = r - prev > 1 && rowHiddenGap(sheet, prev, r);
let cellEl = pool.used.get(key);
if (!cellEl) {
const built = makeRowHeaderCell(r, 0, 0, hiddenBefore);
cellEl = built.el;
pool.used.set(key, cellEl);
container.append(cellEl);
const handle = cellEl.querySelector<HTMLElement>(".aislo-grid__resize--row")!;
startDragResize({
handle,
axis: "row",
currentPx: () => view.rowGeo.size(r),
onPreview: (px) => {
cellEl!.style.height = `${px}px`;
},
onCommit: (px) => commitRowHeight(ctx, r, px),
});
}
cellEl.style.top = `${view.rowGeo.offset(r) - base}px`;
cellEl.style.left = `${left}px`;
cellEl.style.height = `${view.rowGeo.size(r)}px`;
cellEl.style.width = `${width}px`;
cellEl.classList.toggle("aislo-grid__rowhead--hidden-before", hiddenBefore);
});
poolSweep(pool, keep);
}
/** 묶음 접힘 뒤집기(같은 묶음 자리 모두) → `묶음설정` dispatch. */
function outlineToggle(axis: "행" | "열", run: OutlineRun): void {
const idx: number[] = [];
for (let i = run.from; i <= run.to; i += 1) idx.push(i);
ctx.dispatch({
종류: "묶음설정",
시트: ctx.sheet().id,
축: axis,
번호: idx,
묶음: { 층: 1, 접힘: !run.collapsed },
});
}
/** 윤곽 +/- 단추 — 행 묶음 마지막 다음 자리(머리 왼쪽 띠) · 열 묶음도 같은 이치(머리 위 띠).
* 여러 층(중첩)은 1단계 다음(지금은 늘 층 1). */
function renderRowOutline(): void {
rowHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
rowHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
if (!view.rowOutlineRuns.length) return;
const gw = view.rowGutterW();
for (const run of view.rowOutlineRuns) {
const btnRow = Math.min(run.to + 1, view.extent.rows - 1);
const container = btnRow < view.frozenRows ? rowHeadInnerFrozen : rowHeadInnerScroll;
const base = btnRow < view.frozenRows ? 0 : view.rowGeo.offset(view.frozenRows);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "aislo-grid__outline-btn";
btn.textContent = run.collapsed ? "+" : "−";
btn.style.top = `${view.rowGeo.offset(btnRow) - base}px`;
btn.style.left = "0";
btn.style.width = `${gw}px`;
btn.style.height = `${view.rowGeo.size(btnRow) || Math.round(HEAD_ROW_H_BASE * view.zoom)}px`;
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
outlineToggle("행", run);
});
container.append(btn);
}
}
function renderColOutline(): void {
colHeadInnerFrozen.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
colHeadInnerScroll.querySelectorAll(".aislo-grid__outline-btn").forEach((n) => n.remove());
if (!view.colOutlineRuns.length) return;
const gh = view.colGutterH();
for (const run of view.colOutlineRuns) {
const btnCol = Math.min(run.to + 1, view.extent.cols - 1);
const container = btnCol < view.frozenCols ? colHeadInnerFrozen : colHeadInnerScroll;
const base = btnCol < view.frozenCols ? 0 : view.colGeo.offset(view.frozenCols);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "aislo-grid__outline-btn";
btn.textContent = run.collapsed ? "+" : "−";
btn.style.left = `${view.colGeo.offset(btnCol) - base}px`;
btn.style.top = "0";
btn.style.width = `${view.colGeo.size(btnCol) || Math.round(HEAD_COL_W_BASE * view.zoom)}px`;
btn.style.height = `${gh}px`;
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
outlineToggle("열", run);
});
container.append(btn);
}
}
return { layoutColHeaders, layoutRowHeaders, renderRowOutline, renderColOutline };
}
@@ -0,0 +1,255 @@
/* =============================================================================
* spreadsheet_headers.ts (주인 C)
* 열 글자 · 행 번호 머리 · 폭 · 높이 재기(숨김 = 0) · 끌기 · 숨김 표시.
* `LineGeometry` = 자리 하나 재기 — 자리 대부분 기본값이라(성긴 `열`·`행` 표) 겹친 만큼만
* 어긋난 표로 쥐고 이분 탐색으로 자리 ↔ 픽셀을 오간다(1만 행이어도 가벼움).
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
* ========================================================================== */
import { colIndex, colName } from "./spreadsheet_address";
import type { RowInfo, Sheet, Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export const DEFAULT_COL_CHARS = 8.43;
export const DEFAULT_ROW_PT = 15;
/** 엑셀 글자 폭 → px 근사(굴림 10pt 기준 · Calibri 공식과 같은 꼴). */
const CHAR_PX = 7;
const CHAR_PAD_PX = 5;
const PT_PX = 4 / 3;
/** 엑셀 열 폭 상한(글자 단위) — 이 위로 재는 값이 들어와도 격자는 여기서 막음(자동 맞춤 폭주 방어). */
export const MAX_COL_CHARS = 255;
export const colCharsToPx = (chars: number): number =>
Math.round(Math.min(chars, MAX_COL_CHARS) * CHAR_PX + CHAR_PAD_PX);
export const pxToColChars = (px: number): number => Math.max(0, (px - CHAR_PAD_PX) / CHAR_PX);
export const ptToPx = (pt: number): number => Math.round(pt * PT_PX);
export const pxToPt = (px: number): number => px / PT_PX;
export const colLabel = (c: number): string => colName(c);
export const rowLabel = (r: number): string => String(r + 1);
/** 자리 하나 재기 — 성긴 기본값 어긋남 표(정렬된 색인)로 자리 ↔ 픽셀 변환. */
export interface LineGeometry {
/** 자리 `i` 폭(px) — 숨겼으면 0. */
size(i: number): number;
/** 자리 `i` 가 0 부터 시작하는 픽셀 자리(내용 좌표). */
offset(i: number): number;
/** 자리 `count` 개(0..count-1)를 합친 픽셀 길이. */
total(count: number): number;
/** 픽셀 자리 `px` 가 든 자리 번호(0 부터). */
indexAt(px: number): number;
}
class SparseGeometry implements LineGeometry {
private readonly idx: number[] = [];
private readonly prefix: number[] = [];
constructor(
private readonly base: number,
overrides: Map<number, number>,
) {
const keys = [...overrides.keys()].sort((a, b) => a - b);
let acc = 0;
for (const k of keys) {
acc += overrides.get(k)! - base;
this.idx.push(k);
this.prefix.push(acc);
}
}
/** 어긋난 표에서 `i` 가 들어갈 자리(첫 idx[k] >= i). */
private lowerBound(i: number): number {
let lo = 0;
let hi = this.idx.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.idx[mid] < i) lo = mid + 1;
else hi = mid;
}
return lo;
}
size(i: number): number {
const at = this.lowerBound(i);
if (this.idx[at] === i) {
const before = at > 0 ? this.prefix[at - 1] : 0;
return this.base + (this.prefix[at] - before);
}
return this.base;
}
offset(i: number): number {
const at = this.lowerBound(i);
const acc = at > 0 ? this.prefix[at - 1] : 0;
return i * this.base + acc;
}
total(count: number): number {
return this.offset(count);
}
indexAt(px: number): number {
if (px <= 0) return 0;
let hi = Math.max(1, Math.ceil(px / Math.max(this.base, 1)) + this.idx.length + 2);
while (this.offset(hi) <= px) hi *= 2;
let lo = 0;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.offset(mid) <= px) lo = mid;
else hi = mid - 1;
}
return lo;
}
}
export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
const base = colCharsToPx(sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS);
const overrides = new Map<number, number>();
for (const [key, info] of Object.entries(sheet.열 ?? {})) {
const c = colIndex(key);
if (c < 0) continue;
if (info.숨김 || info.묶음?.접힘) overrides.set(c, 0);
else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭));
}
return new SparseGeometry(base, overrides);
}
export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry {
const base = ptToPx(sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT);
const overrides = new Map<number, number>();
for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) {
const r = Number(key) - 1;
if (!Number.isInteger(r) || r < 0) continue;
if (info.숨김 || info.걸러짐 || info.묶음?.접힘) overrides.set(r, 0);
else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이));
}
return new SparseGeometry(base, overrides);
}
/** 접힌 윤곽 묶음(연속 자리) — 묶음 있는 자리를 모아 붙은 것끼리 한 덩이로(같은 접힘 값끼리만). */
export interface OutlineRun {
from: number;
to: number;
collapsed: boolean;
}
export function buildOutlineRuns<T extends { 묶음?: { 층: number; 접힘?: boolean } }>(
records: Record<string, T> | undefined,
indexOf: (key: string) => number,
): OutlineRun[] {
const items = Object.entries(records ?? {})
.map(([key, info]) => ({ i: indexOf(key), collapsed: !!info.묶음?.접힘, has: !!info.묶음 }))
.filter((x) => x.has && x.i >= 0)
.sort((a, b) => a.i - b.i);
const runs: OutlineRun[] = [];
for (const it of items) {
const last = runs[runs.length - 1];
if (last && last.to === it.i - 1 && last.collapsed === it.collapsed) last.to = it.i;
else runs.push({ from: it.i, to: it.i, collapsed: it.collapsed });
}
return runs;
}
/** 그 자리가 숨김 · 걸러짐(사용자 · 필터) 인가 — 묶음 접힘은 빼고(화면 표시가 다름). */
export function isExplicitHidden(info: { 숨김?: boolean; 걸러짐?: boolean } | undefined): boolean {
return !!info?.숨김 || !!info?.걸러짐;
}
/** `from..to` 안에서 숨기지 않은 자리만. */
export function visibleIndices(geo: LineGeometry, from: number, to: number): number[] {
const list: number[] = [];
for (let i = from; i <= to; i += 1) if (geo.size(i) > 0) list.push(i);
return list;
}
export interface HeaderCell {
el: HTMLElement;
index: number;
}
/** 열 머리 칸 — 글자 · 폭 끌기 손잡이 · 숨김 표시(왼쪽에 건너뛴 열이 있으면). */
export function makeColHeaderCell(
c: number,
x: number,
w: number,
hiddenBefore: boolean,
): HeaderCell {
const cellEl = document.createElement("div");
cellEl.className = "aislo-grid__colhead";
if (hiddenBefore) cellEl.classList.add("aislo-grid__colhead--hidden-before");
cellEl.style.left = `${x}px`;
cellEl.style.width = `${w}px`;
cellEl.dataset.col = String(c);
const label = document.createElement("span");
label.className = "aislo-grid__head-label";
label.textContent = colLabel(c);
cellEl.append(label);
const handle = document.createElement("div");
handle.className = "aislo-grid__resize aislo-grid__resize--col";
cellEl.append(handle);
return { el: cellEl, index: c };
}
/** 행 머리 칸 — 번호 · 높이 끌기 손잡이 · 숨김 표시(위에 건너뛴 행이 있으면). */
export function makeRowHeaderCell(
r: number,
y: number,
h: number,
hiddenBefore: boolean,
): HeaderCell {
const cellEl = document.createElement("div");
cellEl.className = "aislo-grid__rowhead";
if (hiddenBefore) cellEl.classList.add("aislo-grid__rowhead--hidden-before");
cellEl.style.top = `${y}px`;
cellEl.style.height = `${h}px`;
cellEl.dataset.row = String(r);
const label = document.createElement("span");
label.className = "aislo-grid__head-label";
label.textContent = rowLabel(r);
cellEl.append(label);
const handle = document.createElement("div");
handle.className = "aislo-grid__resize aislo-grid__resize--row";
cellEl.append(handle);
return { el: cellEl, index: r };
}
export interface ResizeDragOptions {
handle: HTMLElement;
axis: "col" | "row";
currentPx: () => number;
onPreview: (px: number) => void;
onCommit: (px: number) => void;
}
/** 폭 · 높이 끌기 — 누르는 순간 시작 픽셀을 쥐고 포인터가 옮긴 만큼 더함. */
export function startDragResize(opts: ResizeDragOptions): void {
opts.handle.addEventListener("pointerdown", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const startClient = opts.axis === "col" ? ev.clientX : ev.clientY;
const startPx = opts.currentPx();
opts.handle.setPointerCapture(ev.pointerId);
const next = (e: PointerEvent): number => {
const cur = opts.axis === "col" ? e.clientX : e.clientY;
return Math.max(4, startPx + (cur - startClient));
};
const move = (e: PointerEvent): void => opts.onPreview(next(e));
const up = (e: PointerEvent): void => {
opts.handle.removeEventListener("pointermove", move);
opts.handle.removeEventListener("pointerup", up);
opts.onCommit(next(e));
};
opts.handle.addEventListener("pointermove", move);
opts.handle.addEventListener("pointerup", up);
});
}
/** 폭 끌기 다 됨 → `열폭` 명령(글자 단위로 되돌려 보냄). */
export function commitColWidth(ctx: SpreadsheetContext, c: number, px: number): void {
ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: [c], 폭: pxToColChars(px) });
}
/** 높이 끌기 다 됨 → `행높이` 명령(pt 단위로 되돌려 보냄). */
export function commitRowHeight(ctx: SpreadsheetContext, r: number, px: number): void {
ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: [r], 높이: pxToPt(px) });
}
@@ -0,0 +1,38 @@
/* =============================================================================
* spreadsheet_history.ts (주인 A)
* 되돌리기 · 다시 — 명령과 되돌림 짝을 쌓음(메모리만 · 캐시에 안 넣음).
* undo() 는 되돌림 짝을 · redo() 는 원래 명령을 돌려줌 — 적용은 부른 쪽(`applyCommand`).
* ========================================================================== */
import type { Command, History } from "./spreadsheet_types";
/** `limit` = 쌓을 수(넘으면 오래된 것부터 버림) */
export function createHistory(limit = 100): History {
let done: [Command, Command][] = [];
let undone: [Command, Command][] = [];
return {
push(command, undo) {
done.push([command, undo]);
if (done.length > limit) done.shift();
undone = [];
},
undo() {
const pair = done.pop();
if (!pair) return null;
undone.push(pair);
return pair[1];
},
redo() {
const pair = undone.pop();
if (!pair) return null;
done.push(pair);
return pair[0];
},
canUndo: () => done.length > 0,
canRedo: () => undone.length > 0,
clear() {
done = [];
undone = [];
},
};
}
@@ -0,0 +1,242 @@
/* =============================================================================
* spreadsheet_insert_dialog.ts (2단계 · 주인 sub_laptop_1)
* 칸 삽입(Ctrl+Shift++) · 칸 삭제(Ctrl+-) 대화상자 — 엑셀처럼 네 갈래:
* 삽입 = 셀을 오른쪽으로 밀기 · 셀을 아래로 밀기 · 행 전체 · 열 전체
* 삭제 = 셀을 왼쪽으로 밀기 · 셀을 위로 밀기 · 행 전체 · 열 전체
* 「행 전체」·「열 전체」는 A 의 행넣기 · 행지우기 · 열넣기 · 열지우기 명령을 그대로 씀
* (참조 옮김까지 A 가 다 함). 「오른쪽 · 아래로 · 왼쪽 · 위로 밀기」는 0 계약에 전용 명령이
* 없어(부분 범위 이동 명령 없음) 「칸」 명령으로 손수 값 · 식 글 · 서식 번호를 옮김 —
* ⚠ 식의 상대 참조는 안 고쳐짐 · 행 · 열 단위 기본서식(`행`·`열` 레코드)도 안 옮김
* (부분 이동은 칸 레코드만). 온전한 참조 옮김이 필요하면 「행 전체」·「열 전체」를 쓰거나
* A 의 `spreadsheet_refshift.ts` 가 부분 범위까지 다루게 된 뒤 이 파일의 shift* 함수만 고치면 됨.
*
* 잇는 법(D · sub7) — Ctrl+Shift+= → `openInsertCellsDialog(ctx)` · Ctrl+- →
* `openDeleteCellsDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함. 우클릭 메뉴
* [삽입]·[삭제] 도 같은 함수를 부르면 됨.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { dialogKeys } from "./spreadsheet_actions";
import { parseA1, toA1 } from "./spreadsheet_address";
import type { Cell, CellRange, Sheet } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
insertTitle: "삽입",
deleteTitle: "삭제",
shiftRight: "셀을 오른쪽으로 밀기",
shiftDown: "셀을 아래로 밀기",
shiftLeft: "셀을 왼쪽으로 밀기",
shiftUp: "셀을 위로 밀기",
wholeRow: "행 전체",
wholeCol: "열 전체",
ok: "확인",
cancel: "취소",
};
function readRange(ctx: SpreadsheetContext): CellRange | null {
return ctx.selection.범위[0] ?? null;
}
/** 시트 `칸` 레코드 안에서 실제 값이 있는 가장 큰 행 · 열(없으면 -1). */
function sheetUsedBounds(sheet: Sheet): { maxR: number; maxC: number } {
let maxR = -1;
let maxC = -1;
for (const key of Object.keys(sheet.칸)) {
const at = parseA1(key);
if (!at) continue;
if (at.r > maxR) maxR = at.r;
if (at.c > maxC) maxC = at.c;
}
return { maxR, maxC };
}
// ═════════════════════════════════════════════════════════════════════════════
// 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고)
// ═════════════════════════════════════════════════════════════════════════════
export function insertShiftRight(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const width = range.c1 - range.c0 + 1;
const lastCol = Math.max(sheetUsedBounds(sheet).maxC + width, range.c1);
const cells: Record<string, Cell | null> = {};
for (let r = range.r0; r <= range.r1; r++) {
for (let c = lastCol; c >= range.c0; c--) {
const src = c - width;
cells[toA1(r, c)] = src >= range.c0 ? (sheet.칸[toA1(r, src)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const height = range.r1 - range.r0 + 1;
const lastRow = Math.max(sheetUsedBounds(sheet).maxR + height, range.r1);
const cells: Record<string, Cell | null> = {};
for (let c = range.c0; c <= range.c1; c++) {
for (let r = lastRow; r >= range.r0; r--) {
const src = r - height;
cells[toA1(r, c)] = src >= range.r0 ? (sheet.칸[toA1(src, c)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const width = range.c1 - range.c0 + 1;
const lastCol = Math.max(sheetUsedBounds(sheet).maxC, range.c1);
const cells: Record<string, Cell | null> = {};
for (let r = range.r0; r <= range.r1; r++) {
for (let c = range.c0; c <= lastCol; c++) {
const src = c + width;
cells[toA1(r, c)] = src <= lastCol ? (sheet.칸[toA1(r, src)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void {
const sheet = ctx.sheet();
const height = range.r1 - range.r0 + 1;
const lastRow = Math.max(sheetUsedBounds(sheet).maxR, range.r1);
const cells: Record<string, Cell | null> = {};
for (let c = range.c0; c <= range.c1; c++) {
for (let r = range.r0; r <= lastRow; r++) {
const src = r + height;
cells[toA1(r, c)] = src <= lastRow ? (sheet.칸[toA1(src, c)] ?? null) : null;
}
}
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
}
// ═════════════════════════════════════════════════════════════════════════════
// 대화상자
// ═════════════════════════════════════════════════════════════════════════════
function radioGroup(
name: string,
options: [string, string][],
): { root: HTMLElement; value(): string } {
const inputs: HTMLInputElement[] = [];
const root = el("div", {
children: options.map(([value, label], i) => {
const input = el("input", {
attrs: { type: "radio", name, value, ...(i === 0 ? { checked: "true" } : {}) },
}) as HTMLInputElement;
inputs.push(input);
const wrap = el("label", { children: [input, el("span", { text: label })] });
wrap.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:4px;";
return wrap;
}),
});
return { root, value: () => inputs.find((i) => i.checked)?.value ?? options[0][0] };
}
function modal(
title: string,
body: HTMLElement,
onOk: (choice: string) => void,
choice: () => string,
): void {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
panel.append(
el("h3", { text: title }),
body,
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({
label: TEXT.ok,
variant: "filled",
onClick: () => {
onOk(choice());
close();
},
}),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, close, () => (onOk(choice()), close()));
}
export function openInsertCellsDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const group = radioGroup("insertMode", [
["right", TEXT.shiftRight],
["down", TEXT.shiftDown],
["row", TEXT.wholeRow],
["col", TEXT.wholeCol],
]);
modal(
TEXT.insertTitle,
group.root,
(choice) => {
if (choice === "right") insertShiftRight(ctx, range);
else if (choice === "down") insertShiftDown(ctx, range);
else if (choice === "row") {
ctx.dispatch({
종류: "행넣기",
시트: ctx.selection.시트,
at: range.r0,
수: range.r1 - range.r0 + 1,
});
} else {
ctx.dispatch({
종류: "열넣기",
시트: ctx.selection.시트,
at: range.c0,
수: range.c1 - range.c0 + 1,
});
}
},
group.value,
);
}
export function openDeleteCellsDialog(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const range = readRange(ctx);
if (!range) return;
const group = radioGroup("deleteMode", [
["left", TEXT.shiftLeft],
["up", TEXT.shiftUp],
["row", TEXT.wholeRow],
["col", TEXT.wholeCol],
]);
modal(
TEXT.deleteTitle,
group.root,
(choice) => {
if (choice === "left") deleteShiftLeft(ctx, range);
else if (choice === "up") deleteShiftUp(ctx, range);
else if (choice === "row") {
ctx.dispatch({
종류: "행지우기",
시트: ctx.selection.시트,
at: range.r0,
수: range.r1 - range.r0 + 1,
});
} else {
ctx.dispatch({
종류: "열지우기",
시트: ctx.selection.시트,
at: range.c0,
수: range.c1 - range.c0 + 1,
});
}
},
group.value,
);
}
+251
View File
@@ -0,0 +1,251 @@
/* =============================================================================
* spreadsheet_keys.ts (주인 D)
* 엑셀 단축키 지도 — 숨은 textarea · 수식 입력줄 keydown 하나로 받음.
* 편집 중: Enter/Tab 확정 이동 · Alt+Enter 줄바꿈 · Ctrl+Enter 고름 전부 · Esc · F2 방식 바꿈 ·
* enter 방식 방향키 = 확정 이동(식 참조 자리면 가리키기).
* 고름 중: 방향(Shift 늘림 · Ctrl 끝) · Home · Ctrl+Home/End · PgUp/PgDn · Enter/Tab(고름 안 돌기) ·
* F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R · 그 밖은 hooks.more.
* 식 편집 중 F4 = 참조 $ 돌리기(A1 → $A$1 → A$1 → $A1).
* 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자).
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
* ========================================================================== */
import { cycleAbs } from "./spreadsheet_editor";
import { fillCells } from "./spreadsheet_fill";
import {
cycle,
LAST_R,
move,
selectAll,
selectCell,
selectCols,
selectRows,
selectSpan,
usedEnd,
} from "./spreadsheet_selection";
import type { Editor } from "./spreadsheet_editor";
import type { Selection, SpreadsheetContext } from "./spreadsheet_view_types";
const ARROWS: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
};
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
export interface KeyHooks {
/** Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 */
find?(replace: boolean): void;
/** Ctrl+Shift+V — 곧 올 paste 사건을 골라 붙여넣기로 */
pasteSpecial?(): void;
/** Shift+F2 메모 편집 */
comment?(): void;
/** 고름 중 Esc(서식 붓 끄기 등) */
escape?(): void;
/** Ctrl+1 셀 서식 창 */
formatDialog?(): void;
/** Alt+= 자동 합계 */
autosum?(): void;
/** Ctrl+Shift+L 필터 켜기 · 끄기 */
filter?(): void;
/** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */
dropdown?(): boolean;
/** 고름 중 그 밖 단축키(`spreadsheet_actions.ts`) — 받았으면 true */
more?(e: KeyboardEvent): boolean;
}
export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void {
const go = (sel: Selection): void => ctx.select(sel, true);
/** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */
function next(dr: number, dc: number): void {
const sheet = ctx.sheet();
go(cycle(sheet, ctx.selection, dr, dc) ?? move(sheet, ctx.selection, dr, dc));
}
function editingKey(e: KeyboardEvent, inBar: boolean): boolean {
const ctrl = e.ctrlKey || e.metaKey;
switch (e.key) {
case "Enter": {
const node = e.target as HTMLTextAreaElement;
if (e.altKey) {
node.setRangeText("\n", node.selectionStart, node.selectionEnd, "end");
node.dispatchEvent(new Event("input"));
return true;
}
if (ctrl) return (ed.commitAll(), true);
ed.commit();
if (ed.editing()) return true; // 유효성에 막힘
next(e.shiftKey ? -1 : 1, 0);
return true;
}
case "Tab":
if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성
ed.commit();
if (ed.editing()) return true;
next(0, e.shiftKey ? -1 : 1);
return true;
case "Escape":
ed.cancel();
return true;
case "F2":
ed.toggleMode();
return true;
case "F4": {
const node = e.target as HTMLTextAreaElement;
const got = cycleAbs(node.value, node.selectionStart);
if (!got) return false;
node.value = got.text;
node.setSelectionRange(got.caret, got.caret);
node.dispatchEvent(new Event("input"));
return true;
}
}
const arrow = ARROWS[e.key];
if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true;
if (!arrow || inBar || ed.mode() === "edit") return false;
if (ed.pointable()) {
ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl);
return true;
}
if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김
ed.commit();
if (!ed.editing()) go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
return true;
}
function navKey(e: KeyboardEvent): boolean {
const ctrl = e.ctrlKey || e.metaKey;
const sheet = ctx.sheet();
const sel = ctx.selection;
const arrow = ARROWS[e.key];
if (e.altKey && e.key === "ArrowDown" && hooks.dropdown?.()) return true;
if (hooks.more?.(e)) return true;
if (e.altKey && e.key === "=" && hooks.autosum && !ctx.readOnly) return (hooks.autosum(), true);
if (arrow && !e.altKey) {
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
return true;
}
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (ctrl && !e.altKey) {
switch (k) {
case "z":
e.shiftKey ? ctx.redo() : ctx.undo();
return true;
case "y":
ctx.redo();
return true;
case "a":
go(selectAll(sheet, sel.활성));
return true;
case "Home":
go(e.shiftKey ? selectSpan(sheet, sel.기준, { r: 0, c: 0 }) : selectCell(sheet, 0, 0));
return true;
case "End": {
const end = usedEnd(sheet);
go(e.shiftKey ? selectSpan(sheet, sel.기준, end) : selectCell(sheet, end.r, end.c));
return true;
}
case " ": {
const g = sel.범위[0];
go(selectCols(sheet, g.c0, g.c1, sel.활성.c));
return true;
}
case "d":
case "r":
fill(k === "d");
return true;
case "f":
case "h":
if (!hooks.find) return false;
hooks.find(k === "h");
return true;
case "1":
if (!hooks.formatDialog) return false;
hooks.formatDialog();
return true;
case "l":
if (!e.shiftKey || !hooks.filter) return false;
hooks.filter();
return true;
case "v":
if (e.shiftKey) hooks.pasteSpecial?.();
return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음
}
return false;
}
switch (e.key) {
case "Enter":
next(e.shiftKey ? -1 : 1, 0);
return true;
case "Tab":
next(0, e.shiftKey ? -1 : 1);
return true;
case "F5": // Shift+F5 = 찾기(엑셀 · Ctrl+F 를 브라우저가 가로챌 때)
if (!e.shiftKey || !hooks.find) return false;
hooks.find(false);
return true;
case "F2":
if (e.shiftKey) hooks.comment?.();
else ed.begin();
return true;
case "Escape":
hooks.escape?.();
return !!hooks.escape;
case "Delete":
if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 });
return true;
case "Backspace":
ed.begin("");
return true;
case "Home":
go(
e.shiftKey
? selectSpan(sheet, sel.기준, { r: sel.기준.r, c: 0 })
: selectCell(sheet, sel.활성.r, 0),
);
return true;
case "PageDown":
case "PageUp": {
const v = ctx.grid.visibleRange();
const n = Math.max(1, v.r1 - v.r0) * (e.key === "PageDown" ? 1 : -1);
const r = Math.min(LAST_R, Math.max(0, sel.활성.r + n));
go(selectCell(sheet, r, sel.활성.c));
return true;
}
case " ":
if (!e.shiftKey) return false;
go(selectRows(sheet, sel.범위[0].r0, sel.범위[0].r1, sel.활성.r));
return true;
}
return false;
}
/** Ctrl+D 아래로 · Ctrl+R 오른쪽으로 — 고름 첫 행(열)을 나머지에 */
function fill(down: boolean): void {
if (ctx.readOnly) return;
const sheet = ctx.sheet();
const g = ctx.selection.범위[0];
const src = down ? { ...g, r1: g.r0 } : { ...g, c1: g.c0 };
if ((down ? g.r1 : g.c1) === (down ? src.r1 : src.c1)) return;
ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: fillCells(ctx.book, sheet.id, src, g) });
}
function onKey(e: KeyboardEvent): void {
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫
const inBar = e.target === ed.formula;
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
if (done) {
e.preventDefault();
e.stopPropagation();
}
}
ed.ta.addEventListener("keydown", onKey);
ed.formula.addEventListener("keydown", onKey);
return () => {
ed.ta.removeEventListener("keydown", onKey);
ed.formula.removeEventListener("keydown", onKey);
};
}
@@ -0,0 +1,138 @@
/* spreadsheet_links.css (주인 D) — 아래 알약 막대 · 연결 칸 세모 · 자재 고르기 창 */
.ss-links {
display: flex;
flex: none;
flex-wrap: wrap;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.ss-link {
display: inline-flex;
align-items: baseline;
gap: var(--spacing-4);
padding: 2px 10px;
border: 1px solid var(--color-accent);
border-radius: 999px;
background: var(--color-surface-raised);
color: var(--color-text-body);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.ss-link small {
color: var(--color-text-secondary);
}
.ss-link code {
font-size: 11px;
color: var(--color-accent);
}
/* 연결됨 — 흐림(연결 현황) */
.ss-link.is-linked {
opacity: 0.5;
border-style: dashed;
}
.ss-link.is-linked:hover {
opacity: 0.8;
}
.ss-link--add {
border-style: dashed;
color: var(--color-text-secondary);
}
/* 연결 칸 왼아래 세모(초록 오류 세모는 왼위 · 빨강 메모는 오른위) */
.ss-link-dot {
position: absolute;
z-index: 3;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-left: 6px solid var(--color-accent);
pointer-events: none;
}
.ss-links__pick {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 320px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-links__search {
padding: 4px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
}
.ss-links__list {
display: flex;
flex-direction: column;
max-height: 240px;
overflow: auto;
}
.ss-links__row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: var(--spacing-8);
padding: 4px 6px;
border: none;
background: none;
color: var(--color-text-body);
font: inherit;
text-align: left;
cursor: pointer;
}
.ss-links__row:hover {
background: var(--color-mist-violet);
}
.ss-links__note {
padding: 4px 6px;
color: var(--color-text-muted);
}
/* 알약으로 이동한 칸 — 굵은 테두리 + 채움 깜빡임(1.6초 · 끝나면 스스로 지워짐) */
.ss-link-flash {
position: absolute;
z-index: 4;
box-sizing: border-box;
border: 3px solid var(--color-accent);
pointer-events: none;
animation: ss-link-flash 1.6s ease-out 1 both;
}
@keyframes ss-link-flash {
0%,
40%,
80% {
background: color-mix(in srgb, var(--color-accent) 40%, transparent);
}
20%,
60% {
background: transparent;
}
100% {
background: transparent;
opacity: 0;
}
}
+403
View File
@@ -0,0 +1,403 @@
/* =============================================================================
* spreadsheet_links.ts (주인 D)
* 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
* 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산).
* ========================================================================== */
import "./spreadsheet_links.css";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { applyCommand } from "./spreadsheet_commands";
import type { slotOf } from "./spreadsheet_editor";
import { shiftSpan } from "./spreadsheet_refshift";
import { selectCell } from "./spreadsheet_selection";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
CellLink,
Command,
CommandEffect,
LinkMaterial,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
/** 알약 한 개(입력값) — `키` 는 부른 쪽(일위대가 입력값 키) */
export interface LinkItem {
키: string;
이름: string;
단위?: string;
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
자재?: LinkMaterial;
/** 호스트가 정한 알약 — 자재 모드에서도 [목록에서 빼기]가 안 뜸(예: 선택 입력 카드가 자리를 고른 입력) */
고정?: boolean;
}
export interface LinkOptions {
항목: LinkItem[];
/** 입력 = 항목이 알약 · 자재 = 항목(자재 정보 포함 · 호스트 목록) + [자재 더하기] 로 더한 자재 */
모드: "입력" | "자재";
/** 자재 모드에서 목록이 바뀔 때(더하기 · 빼기) 새 목록을 알림 — 저장은 부른 쪽 몫 */
onItemsChange?: (항목: LinkItem[]) => void;
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
}
export interface Links extends PartHandle {
/** `applyCommand` + 연결 따라가기 */
apply(book: Workbook, command: Command): CommandEffect;
}
const TEXT = {
bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기",
free: "칸을 고른 채 누르면 이 칸에 연결",
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
unlink: "연결 풀기",
remove: "목록에서 빼기",
removed: "목록에서 뺌 — 연결도 풀림",
add: "+ 자재 추가",
search: "자재 이름 · 규격 · 코드",
searching: "찾는 중…",
none: "맞는 자재 없음",
fail: "자재 목록을 못 받음",
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
dup: "이미 목록에 있는 자재",
};
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
const isRowCol = (c: Command): c is RowCol =>
c.종류 === "행넣기" || c.종류 === "행지우기" || c.종류 === "열넣기" || c.종류 === "열지우기";
const flat = (c: Command): Command[] => (c.종류 === "묶음" ? c.명령.flatMap(flat) : [c]);
/** 행열 넣기 · 지우기 한 번에 맞춰 연결 칸을 옮김 — 지운 칸 연결은 뺌(순수 · 시험이 부름) */
export function shiftLinks(links: CellLink[], cmd: RowCol): CellLink[] {
const rows = cmd.종류.startsWith("행");
const insert = cmd.종류.endsWith("넣기");
return links.flatMap((link) => {
const p = parseA1(link.칸);
if (!p) return [link];
const i = rows ? p.r : p.c;
const to = shiftSpan(i, i, insert, cmd.at, cmd.수, rows ? MAX_ROWS : MAX_COLS)?.[0];
if (to === undefined) return [];
return [{ ...link, 칸: rows ? toA1(to, p.c) : toA1(p.r, to) }];
});
}
const setLinks = (s: Sheet, list: CellLink[]): void => {
if (list.length) s.연결 = list;
else delete s.연결;
};
/** 명령 적용 — 행열 명령이 연결 있는 시트를 건드리면 연결도 옮기고 되돌림 짝 끝에 명령 전 시트를 더함
* (A 의 되돌림은 바뀐 시트만 통째로 — 연결만 바뀐 시트도 돌아오게) */
export function applyLinked(book: Workbook, command: Command): CommandEffect {
const ops = flat(command).filter(isRowCol);
const hit = new Set(ops.map((o) => o.시트));
const before = book.시트
.filter((s) => hit.has(s.id) && s.연결?.length)
.map((s) => structuredClone(s));
const eff = applyCommand(book, command);
if (!before.length) return eff;
for (const op of ops) {
const s = book.시트.find((x) => x.id === op.시트);
if (s?.연결) setLinks(s, shiftLinks(s.연결, op));
}
const restore = before.map((시트): Command => ({ 종류: "시트통째", 시트 }));
return { ...eff, undo: { 종류: "묶음", 명령: [eff.undo, ...restore] } };
}
/** 자재 → 알약(키 = 자재 코드) */
const ofMaterial = (m: LinkMaterial): LinkItem => ({
키: m.코드,
이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름,
단위: m.단위,
자재: m,
});
export function mountLinks(
ctx: SpreadsheetContext,
opts: LinkOptions | undefined,
back: () => void,
/** 편집기 `slotOf` — 부른 쪽이 넘김(여기서 끌어오면 편집기가 따로 쪼개져 크기가 늚) */
slot: typeof slotOf,
): Links {
const bar = el("div", { className: "ss-links", attrs: { title: TEXT.bar } });
// 상태줄(합계 줄) 아래 맨 끝(표는 flex 라 막대 높이만큼 줄어듦 — 여러 줄이어도 페이지 스크롤 없음)
if (opts) ctx.root.querySelector(":scope > .ss-statusbar")?.after(bar);
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
const items: LinkItem[] = opts ? [...opts.항목] : [];
let dots: HTMLElement[] = [];
let pick: HTMLElement | null = null;
/** 키 → 연결(시트 · 줄) */
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
const out = new Map<string, { sheet: Sheet; link: CellLink }>();
for (const sheet of ctx.book.시트)
for (const link of sheet.연결 ?? []) out.set(link.키, { sheet, link });
return out;
}
/** 시트 연결을 바꿔 넣음 — `시트통째` 라 되돌리기 한 번 */
function put(sheet: Sheet, list: CellLink[]): void {
const next = structuredClone(sheet);
setLinks(next, list);
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
function link(item: LinkItem): void {
if (ctx.readOnly) return;
const sheet = ctx.sheet();
const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c);
const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키);
const one: CellLink = { 키: item.키, 이름: item.이름, 칸 };
if (item.단위) one.단위 = item.단위;
if (item.자재) one.자재 = item.자재;
put(sheet, [...rest, one]);
back();
}
/** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */
function goTo(sheet: Sheet, a1: string): void {
const p = parseA1(a1);
if (!p) return;
if (sheet.id !== ctx.selection.시트) ctx.showSheet(sheet.id);
ctx.select(selectCell(ctx.sheet(), p.r, p.c), true);
back();
requestAnimationFrame(() => {
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) return;
const f = el("div", { className: "ss-link-flash" });
f.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
f.addEventListener("animationend", () => f.remove());
layer.append(f);
});
}
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) {
const where = hit
? hit.sheet.id === ctx.selection.시트
? hit.link.칸
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
: "";
const b = el("button", {
className: hit ? "ss-link is-linked" : "ss-link",
attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 },
children: [
el("span", { text: item.이름 }),
...(item.단위 ? [el("small", { text: item.단위 })] : []),
...(where ? [el("code", { text: where })] : []),
],
});
b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item)));
const unlink = (): void => {
if (hit)
put(
hit.sheet,
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
);
};
b.addEventListener("contextmenu", (e) => {
e.preventDefault();
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
if (ctx.readOnly || !(hit || inList)) return;
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
menu.open(e.clientX - o.left, e.clientY - o.top, [
...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []),
...(inList
? [
[
TEXT.remove,
() => {
unlink();
items.splice(0, items.length, ...items.filter((i) => i.키 !== item.키));
commit();
renderBar();
if (hit) showToast(TEXT.removed, "info");
back();
},
] as [string, () => void],
]
: []),
]);
});
return b;
}
/** 옛 키(로직 키 등)로 저장된 연결 — 키가 알약 목록에 없으면 이름 + 단위가 같은 알약으로 이어
* 연결 키를 새 키로 바꿔 둠(다음 저장 때 새 키로 나감 · 안 맞으면 그대로 두어 늘 보임) */
function align(): void {
if (opts?.모드 !== "입력") return;
const keys = new Set(items.map((i) => i.키));
const taken = new Set<string>();
for (const sheet of ctx.book.시트)
for (const l of sheet.연결 ?? []) if (keys.has(l.키)) taken.add(l.키);
for (const sheet of ctx.book.시트)
for (const l of sheet.연결 ?? []) {
if (keys.has(l.키)) continue;
const hit = items.find(
(i) => !taken.has(i.키) && i.이름 === l.이름 && (i.단위 ?? "") === (l.단위 ?? ""),
);
if (!hit) continue;
l.키 = hit.키;
taken.add(hit.키);
}
}
function renderBar(): void {
if (!opts) return;
align();
const map = found();
const shown: LinkItem[] = [];
const seen = new Set<string>();
const add = (item: LinkItem) => {
if (seen.has(item.키)) return;
seen.add(item.키);
shown.push(item);
};
for (const item of items) add(item);
// 문서에 있는 연결(목록에서 빠진 것) — 풀 수 있게 늘 보임
for (const { link } of map.values())
add(
link.자재
? ofMaterial(link.자재)
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
);
const pills = shown.map((item) => pill(item, map.get(item.키)));
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
const more = el("button", {
className: "ss-link ss-link--add",
text: TEXT.add,
attrs: { type: "button" },
});
more.addEventListener("click", () => openPick(more));
pills.push(more);
}
bar.replaceChildren(...pills);
}
/** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */
function placeDots(): void {
for (const d of dots) d.remove();
dots = [];
for (const l of ctx.sheet().연결 ?? []) {
const p = parseA1(l.칸);
if (!p) continue;
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) continue; // 숨긴 행열
const dot = el("div", { className: "ss-link-dot" });
dot.style.left = `${box.x}px`;
dot.style.top = `${box.y + box.h - 7}px`;
layer.append(dot);
dots.push(dot);
}
}
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
function closePick(): void {
pick?.remove();
pick = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (e: MouseEvent): void => {
if (pick && !pick.contains(e.target as Node)) closePick();
};
function openPick(anchor: HTMLElement): void {
closePick();
const input = el("input", {
className: "ss-links__search",
attrs: { type: "search", placeholder: TEXT.search },
}) as HTMLInputElement;
const list = el("div", { className: "ss-links__list" });
const note = (text: string) => el("div", { className: "ss-links__note", text });
pick = el("div", { className: "ss-links__pick", children: [input, list] });
const a = anchor.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
pick.style.left = `${a.left - o.left}px`;
pick.style.bottom = `${o.bottom - bar.getBoundingClientRect().top + 4}px`; // 막대 위로 엶
ctx.root.append(pick);
document.addEventListener("mousedown", onOutside, true);
let seq = 0;
let timer = 0;
const choose = (m: LinkMaterial) => {
closePick();
if (items.some((i) => i.키 === m.코드)) showToast(TEXT.dup, "info");
else {
items.push(ofMaterial(m));
commit();
renderBar();
showToast(TEXT.picked, "info");
}
back();
};
const search = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(note(TEXT.searching));
try {
const got = await opts!.자재찾기!(input.value.trim());
if (mine !== seq) return;
list.replaceChildren(
...(got.length
? got.map((m) => {
const row = el("button", {
className: "ss-links__row",
attrs: { type: "button", title: m.코드 },
children: [
el("span", { text: m.이름 }),
el("span", { text: m.규격 }),
el("small", { text: m.단위 }),
],
});
row.addEventListener("click", () => choose(m));
return row;
})
: [note(TEXT.none)]),
);
} catch {
if (mine === seq) list.replaceChildren(note(TEXT.fail));
}
};
input.addEventListener("input", () => {
clearTimeout(timer);
timer = window.setTimeout(() => void search(), 250);
});
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") (e.stopPropagation(), closePick(), back());
if (e.key === "Enter") list.querySelector<HTMLElement>(".ss-links__row")?.click();
});
input.focus();
void search();
}
const onZoom = (): void => placeDots();
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
return {
root: opts ? bar : null,
apply: applyLinked,
refresh() {
renderBar();
placeDots();
},
destroy() {
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
closePick();
for (const d of dots) d.remove();
menu.element.remove();
bar.remove();
},
};
}
@@ -0,0 +1,59 @@
/* =============================================================================
* spreadsheet_memo.ts (주인 D)
* 메모 편집(Shift+F2) — 칸 옆 글상자 · Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소.
* 처음 쓸 때 `spreadsheet_extras.ts` 가 불러옴(크기 예산).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { getComment, setComment } from "./spreadsheet_comments";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TITLE = "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소";
export interface Memo {
/** (r, c) 메모를 열고 `place` 로 칸 옆에 둠 */
open(r: number, c: number, place: (node: HTMLElement) => void): void;
/** 편집 중인가 — 풍선을 띄우지 않음 */
busy(): boolean;
destroy(): void;
}
/** 메모 읽기 · 쓰기는 부른 쪽이 넘김 — 여기서 끌어오면 메모 조각이 따로 쪼개져 크기가 늘어남 */
export function createMemo(
ctx: SpreadsheetContext,
back: () => void,
io: { get: typeof getComment; set: typeof setComment },
): Memo {
const memo = el("textarea", { className: "ss-memo", attrs: { hidden: "", title: TITLE } });
ctx.root.append(memo);
let memoAt: { r: number; c: number } | null = null;
const close = (save: boolean): void => {
if (!memoAt) return;
const at = memoAt;
memoAt = null;
memo.setAttribute("hidden", "");
if (save && memo.value !== (io.get(ctx.sheet(), at.r, at.c) ?? ""))
io.set(ctx, at.r, at.c, memo.value.trim());
back();
};
memo.addEventListener("keydown", (e) => {
if (e.key === "Escape" || (e.key === "Enter" && e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
close(e.key === "Enter");
}
});
memo.addEventListener("blur", () => close(true));
return {
open(r, c, place) {
memoAt = { r, c };
memo.value = io.get(ctx.sheet(), r, c) ?? "";
memo.removeAttribute("hidden");
place(memo);
memo.focus();
},
busy: () => !!memoAt,
destroy: () => memo.remove(),
};
}
+266
View File
@@ -0,0 +1,266 @@
/* =============================================================================
* spreadsheet_menu.ts (주인 E)
* 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) ·
* 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기 · 메모 삽입/편집/삭제.
* `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김.
* 메모는 `spreadsheet_comments.ts`(sub1 · 브레인 답 2026-09-27)의 `setComment` · `deleteComment` ·
* `getComment` 를 부름 — 안에서 「시트통째」 명령 · 되돌리기 짝까지 만듦. 글 입력 창은 이 파일이
* 직접 띄움(작은 오버레이 · `showConfirmDialog` 와 같은 결의 자체 모달 · native prompt 안 씀).
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `attachMenu(ctx)` 한 번 부름(반환 `root` = 메뉴 껍데기 ·
* 안 붙여도 됨 · `ctx.root` 안에 스스로 넣음). `ctx.grid.hitTest` 가 준비된 뒤(C 격자 붙은 뒤) 불러야 함.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { confirmMerge, dialogKeys } from "./spreadsheet_actions";
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_toolbar.css";
function within(range: CellRange, r: number, c: number): boolean {
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
}
/** `ctx.root` 의 contextmenu 를 받음(root null) */
export function attachMenu(ctx: SpreadsheetContext): PartHandle {
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
function isMerged(range: CellRange): boolean {
return (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
}
function cutCopy(kind: "cut" | "copy"): void {
document.execCommand(kind);
}
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
* 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */
function openCommentEditor(initial: string, onSave: (text: string) => void): void {
const overlay = el("div", { className: "ss-comment-editor" });
const panel = el("div", { className: "ss-comment-editor__panel" });
const textarea = el("textarea", {
className: "ss-comment-editor__input",
}) as HTMLTextAreaElement;
textarea.value = initial;
const done = (): void => overlay.remove();
const actions = el("div", {
className: "ss-comment-editor__actions",
children: [
createButton({ label: "취소", variant: "ghost", onClick: done }),
createButton({
label: "저장",
variant: "filled",
onClick: () => {
onSave(textarea.value);
done();
},
}),
],
});
panel.append(textarea, actions);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, done);
textarea.focus();
textarea.select();
}
function cellMenuItems(range: CellRange, r: number, c: number): MapContextMenuItem[] {
const note = getComment(ctx.sheet(), r, c);
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuClearContent"),
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
],
[
st("MenuClearFormat"),
() => ctx.dispatch({ 종류: "서식지움", 시트: ctx.selection.시트, 범위: [range] }),
],
[
isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"),
async () => {
const merged = isMerged(range);
if (!merged && !(await confirmMerge(ctx, range))) return;
ctx.dispatch({
종류: merged ? "병합풀기" : "병합",
시트: ctx.selection.시트,
범위: range,
});
},
],
[
note ? st("MenuCommentEdit") : st("MenuCommentInsert"),
() => openCommentEditor(note ?? "", (text) => setComment(ctx, r, c, text)),
],
...(note
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
: []),
];
}
function rowMenuItems(rows: number[]): MapContextMenuItem[] {
const at = Math.min(...rows);
const 수 = rows.length;
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertRow"),
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuDeleteRow"),
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuHide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "행",
번호: rows,
숨김: true,
}),
],
[
st("MenuUnhide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "행",
번호: rows,
숨김: false,
}),
],
];
}
function colMenuItems(cols: number[]): MapContextMenuItem[] {
const at = Math.min(...cols);
const 수 = cols.length;
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertCol"),
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuDeleteCol"),
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
],
[
st("MenuHide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "열",
번호: cols,
숨김: true,
}),
],
[
st("MenuUnhide"),
() =>
ctx.dispatch({
종류: "숨김",
시트: ctx.selection.시트,
축: "열",
번호: cols,
숨김: false,
}),
],
];
}
function range(r0: number, c0: number, r1: number, c1: number): CellRange {
return { r0, c0, r1, c1 };
}
function onContextMenu(ev: MouseEvent): void {
const hit = ctx.grid.hitTest(ev.clientX, ev.clientY);
if (!hit) return;
ev.preventDefault();
const box = ctx.root.getBoundingClientRect();
const x = ev.clientX - box.left;
const y = ev.clientY - box.top;
if (hit.kind === "rowHead") {
const covered = ctx.selection.범위.find(
(rg) => rg.c0 === 0 && rg.c1 >= MAX_COLS - 1 && within(rg, hit.r, 0),
);
const rows = covered
? Array.from({ length: covered.r1 - covered.r0 + 1 }, (_, i) => covered.r0 + i)
: [hit.r];
if (!covered)
ctx.select({
시트: ctx.selection.시트,
범위: [range(hit.r, 0, hit.r, MAX_COLS - 1)],
활성: { r: hit.r, c: 0 },
기준: { r: hit.r, c: 0 },
});
menu.open(x, y, rowMenuItems(rows));
return;
}
if (hit.kind === "colHead") {
const covered = ctx.selection.범위.find(
(rg) => rg.r0 === 0 && rg.r1 >= MAX_ROWS - 1 && within(rg, 0, hit.c),
);
const cols = covered
? Array.from({ length: covered.c1 - covered.c0 + 1 }, (_, i) => covered.c0 + i)
: [hit.c];
if (!covered)
ctx.select({
시트: ctx.selection.시트,
범위: [range(0, hit.c, MAX_ROWS - 1, hit.c)],
활성: { r: 0, c: hit.c },
기준: { r: 0, c: hit.c },
});
menu.open(x, y, colMenuItems(cols));
return;
}
if (hit.kind === "cell") {
const covered = ctx.selection.범위.some((rg) => within(rg, hit.r, hit.c));
if (!covered) {
ctx.select({
시트: ctx.selection.시트,
범위: [range(hit.r, hit.c, hit.r, hit.c)],
활성: { r: hit.r, c: hit.c },
기준: { r: hit.r, c: hit.c },
});
}
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
}
}
ctx.root.addEventListener("contextmenu", onContextMenu);
return {
root: menu.element,
refresh() {},
destroy() {
ctx.root.removeEventListener("contextmenu", onContextMenu);
menu.close();
menu.element.remove();
},
};
}
+270
View File
@@ -0,0 +1,270 @@
/* =============================================================================
* spreadsheet_mouse.ts (주인 D)
* 마우스 — 칸 · 범위 끌기 · Shift 늘림 · 행열 머리 · 모서리(전체) · 식 편집 중 참조 가리키기 ·
* 채우기 핸들 끌기(늘림 · 안으로 끌면 지움) · 두 번 누르기(칸 편집 · 핸들이면 옆 열 끝까지 아래로) ·
* 우클릭 자리(고름 밖이면 그 칸을 고름 — 메뉴는 E).
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
* ========================================================================== */
import { fillCells } from "./spreadsheet_fill";
import {
hasValue,
inRange,
LAST_C,
LAST_R,
selectAll,
selectCell,
selectCols,
selectRows,
selectSpan,
} from "./spreadsheet_selection";
import { boxStyle, slotOf, type Editor } from "./spreadsheet_editor";
import type { CellAddress, CellRange } from "./spreadsheet_types";
import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types";
/** 채우기 끌기 목표 — 늘림(fill) · 안으로 줄임(clear = 지울 부분) */
export function fillTarget(
src: CellRange,
p: CellAddress,
): { target: CellRange; clear: CellRange | null } {
if (inRange(src, p.r, p.c)) {
const up = src.r1 - p.r;
const left = src.c1 - p.c;
if (up === 0 && left === 0) return { target: src, clear: null };
if (up >= left) return { target: { ...src, r1: p.r }, clear: { ...src, r0: p.r + 1 } };
return { target: { ...src, c1: p.c }, clear: { ...src, c0: p.c + 1 } };
}
const down = p.r - src.r1;
const upOut = src.r0 - p.r;
const right = p.c - src.c1;
const leftOut = src.c0 - p.c;
const best = Math.max(down, upOut, right, leftOut);
if (best === down) return { target: { ...src, r1: p.r }, clear: null };
if (best === upOut) return { target: { ...src, r0: p.r }, clear: null };
if (best === right) return { target: { ...src, c1: p.c }, clear: null };
return { target: { ...src, c0: p.c }, clear: null };
}
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
export interface MouseHooks {
/** 칸 끌어 고르기가 끝남(서식 붓 바르기) */
selected?(): void;
/** 누르지 않고 움직일 때 가리킨 것(메모 풍선) — 격자 밖이면 null */
hover?(hit: HitResult | null): void;
}
export function attachMouse(
ctx: SpreadsheetContext,
ed: Editor,
hooks: MouseHooks = {},
): () => void {
const root = ctx.grid.root;
const preview = document.createElement("div");
preview.className = "ss-fill-preview";
let stopDrag: (() => void) | null = null;
/** 끌기 — 누른 채 움직이면 `onMove(칸)` · 떼면 `onUp` · 격자 밖이면 가장자리 칸(스크롤 따라감) */
function drag(onMove: (p: HitResult) => void, onUp?: () => void): void {
const move = (e: MouseEvent): void => {
const box = root.getBoundingClientRect();
const x = Math.min(box.right - 2, Math.max(box.left + 2, e.clientX));
const y = Math.min(box.bottom - 2, Math.max(box.top + 2, e.clientY));
const hit = ctx.grid.hitTest(x, y);
if (!hit || hit.r < 0 || hit.c < 0) return;
onMove(hit);
if (x !== e.clientX || y !== e.clientY) ctx.grid.reveal(hit.r, hit.c);
};
const up = (): void => {
stopDrag?.();
onUp?.();
};
stopDrag = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
stopDrag = null;
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
const inSelection = (r: number, c: number): boolean =>
ctx.selection.범위.some((g) => inRange(g, r, c));
function showPreview(g: CellRange | null): void {
if (!g) return preview.remove();
const a = slotOf(ctx, g.r0, g.c0);
const b = slotOf(ctx, Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
if (preview.parentElement !== a.layer) a.layer.append(preview);
boxStyle(preview, {
x: a.box.x,
y: a.box.y,
w: b.box.x + b.box.w - a.box.x,
h: b.box.y + b.box.h - a.box.y,
});
}
function fillDrag(): void {
const sheet = ctx.sheet();
const src = ctx.selection.범위[ctx.selection.범위.length - 1];
let result = { target: src, clear: null as CellRange | null };
drag(
(p) => {
result = fillTarget(src, p);
showPreview(result.clear ?? result.target);
},
() => {
showPreview(null);
applyFill(sheet.id, src, result.target, result.clear);
},
);
}
function applyFill(
sheet: string,
src: CellRange,
target: CellRange,
clear: CellRange | null,
): void {
const at = ctx.selection.활성;
const sel = (g: CellRange): Selection => ({
시트: sheet,
범위: [g],
활성: at,
기준: { ...at },
});
if (clear) {
ctx.dispatch({ 종류: "내용지움", 시트: sheet, 범위: [clear] });
ctx.select(sel(target));
return;
}
if (
target.r0 === src.r0 &&
target.r1 === src.r1 &&
target.c0 === src.c0 &&
target.c1 === src.c1
)
return;
ctx.dispatch({ 종류: "칸", 시트: sheet, 칸: fillCells(ctx.book, sheet, src, target) });
ctx.select(sel(target));
}
/** 핸들 두 번 누르기 — 왼쪽(없으면 오른쪽) 열 값이 이어진 끝까지 아래로 */
function fillDown(): void {
const sheet = ctx.sheet();
const src = ctx.selection.범위[0];
const side = [src.c0 - 1, src.c1 + 1].find(
(c) => c >= 0 && c <= LAST_C && hasValue(sheet, src.r1 + 1, c),
);
if (side === undefined) return;
let r = src.r1 + 1;
while (r < LAST_R && hasValue(sheet, r + 1, side)) r++;
applyFill(sheet.id, src, { ...src, r1: r }, null);
}
function onDown(e: MouseEvent): void {
if (e.target === ed.ta) return; // 편집 칸 안 — 글자 자리 옮김
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit || hit.kind === "colEdge" || hit.kind === "rowEdge") return;
const sheet = ctx.sheet();
if (e.button === 2) {
if (ed.editing() || (hit.kind === "cell" && inSelection(hit.r, hit.c))) return;
if (hit.kind === "cell") ctx.select(selectCell(sheet, hit.r, hit.c));
else if (hit.kind === "rowHead" && !inSelection(hit.r, ctx.selection.활성.c))
ctx.select(selectRows(sheet, hit.r, hit.r));
else if (hit.kind === "colHead" && !inSelection(ctx.selection.활성.r, hit.c))
ctx.select(selectCols(sheet, hit.c, hit.c));
return;
}
if (e.button !== 0) return;
e.preventDefault(); // 초점은 편집기 · 수식 입력줄에 둠
if (ed.editing()) {
if (hit.kind === "cell" && ed.pointable()) {
const at = { r: hit.r, c: hit.c };
ed.point(selectCell(sheet, hit.r, hit.c));
drag((p) => ed.point(selectSpan(sheet, at, { r: p.r, c: p.c })));
return;
}
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
}
ed.focus();
if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell";
switch (hit.kind) {
case "fillHandle":
return fillDrag();
case "corner":
return ctx.select(selectAll(sheet));
case "rowHead": {
const a = e.shiftKey ? ctx.selection.기준.r : hit.r;
ctx.select(selectRows(sheet, a, hit.r, a));
return drag((p) => ctx.select(selectRows(sheet, a, p.r, a)));
}
case "colHead": {
const a = e.shiftKey ? ctx.selection.기준.c : hit.c;
ctx.select(selectCols(sheet, a, hit.c, a));
return drag((p) => ctx.select(selectCols(sheet, a, p.c, a)));
}
case "cell": {
// Ctrl = 범위 더함(여러 범위) · Shift = 마지막 범위를 늘림
const keep =
e.ctrlKey || e.metaKey
? ctx.selection.범위
: e.shiftKey
? ctx.selection.범위.slice(0, -1)
: [];
const one = e.shiftKey
? selectSpan(sheet, ctx.selection.기준, hit)
: selectCell(sheet, hit.r, hit.c);
const first = { ...one, 범위: [...keep, ...one.범위] };
ctx.select(first);
const anchor = first.기준;
let last = `${hit.r},${hit.c}`;
return drag(
(p) => {
if (`${p.r},${p.c}` === last) return;
last = `${p.r},${p.c}`;
const span = selectSpan(sheet, anchor, p);
ctx.select({ ...span, 범위: [...keep, ...span.범위] });
},
() => hooks.selected?.(),
);
}
}
}
function onDbl(e: MouseEvent): void {
if (e.target === ed.ta || ctx.readOnly) return;
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit) return;
if (hit.kind === "fillHandle") return fillDown();
if (hit.kind === "colEdge") {
// 고른 열 전체의 경계면 고른 열 모두(엑셀)
const g = ctx.selection.범위[0];
const whole = g.r0 === 0 && g.r1 === LAST_R && inRange(g, 0, hit.c);
void import("./spreadsheet_autofit").then((m) => {
for (let c = whole ? g.c0 : hit.c; c <= (whole ? g.c1 : hit.c); c++)
m.autofitColumn(ctx, c);
}); // 늦게 — 크기 예산
return;
}
if (hit.kind === "cell" && !ed.editing()) ed.begin();
}
const onHover = (e: MouseEvent): void => {
if (!stopDrag && !e.buttons) hooks.hover?.(ctx.grid.hitTest(e.clientX, e.clientY));
};
const onLeave = (): void => hooks.hover?.(null);
root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave);
return () => {
root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave);
stopDrag?.();
preview.remove();
root.removeEventListener("mousedown", onDown);
root.removeEventListener("dblclick", onDbl);
};
}
+215
View File
@@ -0,0 +1,215 @@
/* =============================================================================
* spreadsheet_names.ts (3단계 · 주인 sub_laptop_4)
* 이름 정의 — 범위에 이름을 붙이고 수식에서 `=단가*수량` 처럼 씀. 저장 자리 `Sheet.이름정의`(0 계약에
* 방금 더함) — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트 참조는 3단계 밖).
*
* 수식 나무는 이미 `{type:"name", name}` 갈래를 가짐(계약 `spreadsheet_types.ts` 3절 · 정의 없으면
* `#NAME?`). 이 파일은 이름을 저장 · 관리만 하고 실제로 식을 풀 때 이름을 찾는 일은 A 몫.
*
* 잇는 법(A · D · sub7 — 지금은 하나도 안 이어짐):
* 1) `spreadsheet_eval.ts` 가 `{type:"name"}` 나무를 만나면 `resolveName(book, 식이든시트, 이름)` 을
* 불러 범위를 얻고, 범위가 칸 하나면 그 칸 값 · 여러 칸이면 `RangeValue` 로 바꿔 씀(없으면 지금처럼 `#NAME?`).
* 2) 편집기 이름 상자(주소 상자)에서 범위를 고른 채 이름을 치고 Enter 하면 `defineName(ctx, 이름, 범위)`.
* 3) 도구 모음 · 메뉴에 [이름 관리] 항목을 두고 `createNameManagerPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import type { CellRange, NamedRange, Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { NamedRange };
const CELL_LIKE = /^[A-Za-z]{1,3}\d+$/;
const RESERVED = /^(TRUE|FALSE)$/i;
const NAME_RE = /^[\p{L}_][\p{L}\p{N}_.]*$/u;
/** 이름 규칙 — 글자 · 밑줄로 시작 · 칸 주소처럼 안 보임 · 겹치지 않음. 어기면 까닭 글 · 되면 null. */
export function invalidNameReason(name: string, existing: string[]): string | null {
if (!name) return "이름이 비었음";
if (name.length > 255) return "255 글자까지";
if (!NAME_RE.test(name)) return "글자 · 숫자 · 밑줄만(첫 글자는 글자 · 밑줄)";
if (CELL_LIKE.test(name)) return "칸 주소처럼 보이는 이름은 못 씀";
if (RESERVED.test(name)) return "TRUE · FALSE 는 못 씀";
if (existing.some((n) => n.toLowerCase() === name.toLowerCase())) return "이미 있는 이름";
return null;
}
export function listNames(book: Workbook): { 이름: string; 시트: string; 범위: CellRange }[] {
return book.시트.flatMap((sheet) =>
(sheet.이름정의 ?? []).map((n) => ({ 이름: n.이름, 시트: sheet.id, 범위: n.범위 })),
);
}
/** 식이 든 시트의 이름부터 찾고(자기 시트 우선) · 없으면 다른 시트 이름도(느슨한 전역 대체). */
export function resolveName(
book: Workbook,
sheetId: string,
name: string,
): { 시트: string; 범위: CellRange } | null {
const lower = name.toLowerCase();
const own = book.시트.find((s) => s.id === sheetId);
const local = own?.이름정의?.find((n) => n.이름.toLowerCase() === lower);
if (local) return { 시트: sheetId, 범위: local.범위 };
for (const sheet of book.시트) {
const found = sheet.이름정의?.find((n) => n.이름.toLowerCase() === lower);
if (found) return { 시트: sheet.id, 범위: found.범위 };
}
return null;
}
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
}
/** 지금 활성 시트에 이름을 더함 — 규칙 어기면 아무 일도 안 하고 까닭을 돌려줌(호출부가 토스트로). */
export function defineName(ctx: SpreadsheetContext, name: string, 범위: CellRange): string | null {
const reason = invalidNameReason(
name,
listNames(ctx.book).map((n) => n.이름),
);
if (reason) return reason;
const sheet = ctx.sheet();
applySheet(ctx, sheet.id, {
...sheet,
이름정의: [...(sheet.이름정의 ?? []), { 이름: name, 범위 }],
});
return null;
}
/** 이름을 지움 — 그 이름을 쓰던 식은 그대로 남아 다음 풀이 때 `#NAME?`(계약대로). */
export function deleteName(ctx: SpreadsheetContext, name: string): void {
const lower = name.toLowerCase();
for (const sheet of ctx.book.시트) {
if (!sheet.이름정의?.some((n) => n.이름.toLowerCase() === lower)) continue;
applySheet(ctx, sheet.id, {
...sheet,
이름정의: sheet.이름정의!.filter((n) => n.이름.toLowerCase() !== lower),
});
return;
}
}
/** 이름을 바꿈 — 정의뿐 아니라 모든 시트 식 글 속 이름 글자도 같이 바꿈(시트이름 바꾸기와 같은 규칙). */
export function renameName(
ctx: SpreadsheetContext,
oldName: string,
newName: string,
): string | null {
const reason = invalidNameReason(
newName,
listNames(ctx.book)
.map((n) => n.이름)
.filter((n) => n.toLowerCase() !== oldName.toLowerCase()),
);
if (reason) return reason;
const commands = ctx.book.시트
.map((sheet) => renamedSheet(sheet, oldName, newName))
.filter((s): s is Sheet => s !== null)
.map((sheet) => ({ 종류: "시트통째" as const, 시트: sheet }));
if (!commands.length) return null;
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
return null;
}
function renamedSheet(sheet: Sheet, oldName: string, newName: string): Sheet | null {
// 한글 이름은 `\w` 밖이라 `\b` 가 안 먹음 — 글자 · 숫자 · 밑줄이 아닌 자리를 경계로 직접 잡음.
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeReg(oldName)}(?![\\p{L}\\p{N}_])`, "giu");
let changed = false;
const 칸: Sheet["칸"] = { ...sheet.칸 };
for (const [key, cell] of Object.entries(칸)) {
if (cell.식 !== undefined && re.test(cell.식)) {
re.lastIndex = 0;
칸[key] = { ...cell, 식: cell.식.replace(re, newName) };
changed = true;
}
}
const 이름정의 = sheet.이름정의?.map((n) =>
n.이름.toLowerCase() === oldName.toLowerCase() ? { ...n, 이름: newName } : n,
);
if (이름정의 && JSON.stringify(이름정의) !== JSON.stringify(sheet.이름정의)) changed = true;
return changed ? { ...sheet, 칸, ...(이름정의 ? { 이름정의 } : {}) } : null;
}
function escapeReg(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 이름 관리 창 — 통합문서 전체 이름 목록 + 고른 범위에 새 이름. */
export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-names" });
const list = el("ul", { className: "ss-names__list" });
const nameInput = createInputField({ label: "새 이름", placeholder: "단가" });
const addBtn = createButton({ label: "이름 추가" });
let error: HTMLElement | null = null;
const showError = (msg: string | null): void => {
error?.remove();
error = null;
if (!msg) return;
error = el("p", { text: msg });
error.style.color = "var(--color-danger, red)";
root.append(error);
};
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위[0];
if (!범위) return;
const reason = defineName(ctx, nameInput.input.value.trim(), 범위);
showError(reason);
if (!reason) {
nameInput.input.value = "";
render();
}
});
function render(): void {
list.replaceChildren(
...listNames(ctx.book).map(({ 이름, 시트, 범위 }) => {
const li = el("li", { className: "ss-names__row" });
const sheetName = ctx.book.시트.find((s) => s.id === 시트)?.이름 ?? 시트;
const label = el("span", { text: `${이름} = ${sheetName}!${rangeToA1(범위)}` });
const rename = createButton({ label: "이름 바꾸기", variant: "ghost" });
// 창 안에서 바로 고침(브라우저 prompt 는 자동화 창에서 저절로 취소됨) — Enter 바꿈 · Esc 그만
rename.addEventListener("click", () => {
const input = el("input", { className: "ui-input" }) as HTMLInputElement;
input.value = 이름;
const finish = (commit: boolean): void => {
const next = input.value.trim();
if (commit && next && next !== 이름) showError(renameName(ctx, 이름, next));
render();
};
input.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== "Escape") return;
e.stopPropagation();
finish(e.key === "Enter");
});
label.replaceWith(input);
input.focus();
input.select();
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
deleteName(ctx, 이름);
render();
});
li.append(label, rename, del);
return li;
}),
);
}
const form = el("div", { className: "ss-names__form", children: [nameInput.root, addBtn] });
root.append(el("h4", { text: "이름 관리" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,634 @@
/* =============================================================================
* spreadsheet_numfmt.ts (주인 B)
* 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) ·
* 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`.
* 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함.
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄
* (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로).
* ========================================================================== */
import {
ZERO,
cmp,
div,
frac,
fracToString,
mul,
roundAt,
sub,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, FormattedText, Scalar } from "./spreadsheet_types";
function isErrorScalar(v: Scalar): v is ErrorValue {
return (
typeof v === "object" && v !== null && "error" in (v as unknown as Record<string, unknown>)
);
}
function groupThousands(digits: string): string {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// ── 구역 나누기(`;` · 따옴표 · `\` 뒤는 안 나눔) ─────────────────────────────
function splitSections(code: string): string[] {
const sections: string[] = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < code.length; i++) {
const ch = code[i];
if (ch === '"') {
inQuote = !inQuote;
cur += ch;
continue;
}
if (ch === "\\") {
// 형식 끝에 홀로 남은 역슬래시 · 구역 나눔(`;`) 바로 앞 역슬래시는 깨진 자투리로 보고 버림
// (엑셀 붙여넣기가 잘려 남긴 것 — 2026-09-28 실측 rv.json). 그 밖엔 원래대로 다음 글자를 이스케이프.
if (i + 1 >= code.length || code[i + 1] === ";") continue;
cur += ch + code[i + 1];
i++;
continue;
}
if (ch === ";" && !inQuote) {
sections.push(cur);
cur = "";
continue;
}
cur += ch;
}
sections.push(cur);
return sections;
}
const COLOR_NAMES = ["black", "white", "red", "green", "blue", "yellow", "magenta", "cyan"];
function extractColor(section: string): { rest: string; color?: string } {
let color: string | undefined;
const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => {
const lower = inner.toLowerCase();
if (COLOR_NAMES.includes(lower)) {
color = lower;
return "";
}
const currency = /^\$([^-\]]*)(-[0-9A-Fa-f]+)?$/.exec(inner);
if (currency) return currency[1]; // `[$₩-412]` 등 통화 태그 → 글자만 남김
return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시
});
return { rest, color };
}
// ── 낱말 나누기 ──────────────────────────────────────────────────────────
type Tok =
| { k: "digit"; ch: "0" | "#" | "?" }
| { k: "comma" }
| { k: "point" }
| { k: "percent" }
| { k: "text" }
| { k: "general" }
| { k: "lit"; ch: string };
function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
const toks: Tok[] = [];
let fillChar: string | undefined;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
// 코드 속에 섞인 맨 `General`(따옴표 없이) — 실무 흔한 `General"개"` 같은 단위 접미 버릇.
if (s.slice(i, i + 7).toLowerCase() === "general") {
toks.push({ k: "general" });
i += 6;
continue;
}
if (ch === '"') {
let j = i + 1;
while (j < s.length && s[j] !== '"') {
toks.push({ k: "lit", ch: s[j] });
j++;
}
i = j;
continue;
}
if (ch === "\\") {
// 홀로 남은 역슬래시(뒤에 이스케이프할 글자가 없음)는 굴림 등 글꼴에서 ₩ 모양으로 보여 버림.
if (i + 1 < s.length) toks.push({ k: "lit", ch: s[i + 1] });
i++;
continue;
}
if (ch === "_" && i + 1 < s.length) {
toks.push({ k: "lit", ch: " " });
i++;
continue;
}
if (ch === "*" && i + 1 < s.length) {
fillChar = s[i + 1];
i++;
continue;
}
if (ch === "0" || ch === "#" || ch === "?") {
toks.push({ k: "digit", ch });
continue;
}
if (ch === ",") {
toks.push({ k: "comma" });
continue;
}
if (ch === ".") {
toks.push({ k: "point" });
continue;
}
if (ch === "%") {
toks.push({ k: "percent" });
continue;
}
if (ch === "@") {
toks.push({ k: "text" });
continue;
}
toks.push({ k: "lit", ch });
}
return { toks, fillChar };
}
// ── 수 구역 그리기 ───────────────────────────────────────────────────────
function renderNumericSection(toks: Tok[], magnitude: Frac): string {
const pointIdx = toks.findIndex((t) => t.k === "point");
const hasPercent = toks.some((t) => t.k === "percent");
const scaledMag = hasPercent ? mul(magnitude, frac(100n)) : magnitude;
let lastDigit = -1;
toks.forEach((t, i) => {
if (t.k === "digit") lastDigit = i;
});
let scale = 0;
for (let i = lastDigit + 1; i < toks.length; i++) if (toks[i].k === "comma") scale++;
let hasGrouping = false;
const introEnd = pointIdx >= 0 ? pointIdx : toks.length;
for (let i = 0; i < introEnd && i <= lastDigit; i++)
if (toks[i].k === "comma") hasGrouping = true;
const intRun = pointIdx >= 0 ? toks.slice(0, pointIdx) : toks;
const fracRun = pointIdx >= 0 ? toks.slice(pointIdx + 1) : [];
const intDigitToks = intRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
const fracDigitToks = fracRun.filter((t): t is Extract<Tok, { k: "digit" }> => t.k === "digit");
const scaleDiv = scale > 0 ? 1000n ** BigInt(scale) : 1n;
const adjusted = scaleDiv === 1n ? scaledMag : div(scaledMag, frac(scaleDiv));
const rounded = roundAt(adjusted, fracDigitToks.length, "round");
const scaleUp = frac(10n ** BigInt(fracDigitToks.length));
const scaledInt = toInteger(mul(rounded, scaleUp), "round");
const allDigits = scaledInt.toString().padStart(fracDigitToks.length + 1, "0");
const rawIntStr =
fracDigitToks.length > 0 ? allDigits.slice(0, -fracDigitToks.length) : allDigits;
const rawFracStr = fracDigitToks.length > 0 ? allDigits.slice(-fracDigitToks.length) : "";
let intStr = rawIntStr;
// 정수부가 온통 `?`(회계 형식 0 구역의 `"-"??` 자리맞춤 버릇)뿐이고 값이 정말 0 이면
// 자리마다 빈칸(엑셀은 `?` 를 「있으면 숫자 · 없으면 빈칸」으로 봄 — `0` 처럼 0 을 안 채움).
if (intDigitToks.length > 0 && rawIntStr === "0" && intDigitToks.every((t) => t.ch === "?")) {
intStr = " ".repeat(intDigitToks.length);
} else if (intDigitToks.length > intStr.length) {
const need = intDigitToks.length - intStr.length;
let pad = "";
for (let i = 0; i < need; i++) pad += intDigitToks[i].ch === "0" ? "0" : "";
intStr = pad + intStr;
}
if (hasGrouping) intStr = groupThousands(intStr);
let keepUntil = fracDigitToks.length;
for (let i = fracDigitToks.length - 1; i >= 0; i--) {
if (fracDigitToks[i].ch === "0") break;
if (rawFracStr[i] !== "0") break;
keepUntil = i;
}
const fracStr = rawFracStr.slice(0, keepUntil);
let out = "";
let intEmitted = false;
let fracEmitted = false;
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.k === "digit" || t.k === "comma") {
const inFrac = pointIdx >= 0 && i > pointIdx;
if (inFrac) {
if (!fracEmitted) {
out += fracStr;
fracEmitted = true;
}
} else if (!intEmitted) {
out += intStr;
intEmitted = true;
}
continue;
}
if (t.k === "point") {
if (fracStr.length > 0) out += ".";
continue;
}
if (t.k === "percent") {
out += "%";
continue;
}
if (t.k === "text") continue; // 수 구역엔 의미 없음(방어)
if (t.k === "general") {
out += generalFormat(magnitude);
continue;
}
out += t.ch;
}
return out;
}
// ── 분수 형식(`0/0` · `# ?/?` · `# ??/??` · 분모 고정 `# ?/8`) ───────────────
interface FracPlan {
prefix: Tok[];
wholeDigits: number; // 0 = 정수부 자리 없음(가분수로 통째)
sepToks: Tok[]; // 정수부 · 분자 사이 리터럴(보통 칸 하나)
fixedDenom: bigint | null; // null = 분모 자리표시(자동 찾기) · 있으면 그 값 고정
denomDigits: number; // 자동일 때 자리표시 개수(최대 분모 = 10^자리 - 1)
suffix: Tok[];
}
/** 슬래시 앞뒤 자리표시(0/#/?)·고정 숫자 자리를 찾음 — 없으면 분수 형식이 아님(null). */
function detectFraction(toks: Tok[]): FracPlan | null {
if (toks.some((t) => t.k === "point")) return null;
const slashIdx = toks.findIndex((t) => t.k === "lit" && t.ch === "/");
if (slashIdx < 0) return null;
let i = slashIdx - 1;
while (i >= 0 && toks[i].k === "digit") i--;
const numeratorStart = i + 1;
if (numeratorStart === slashIdx) return null; // 분자 자리표시 없음
let j = slashIdx + 1;
let denomDigits = 0;
let fixedDigits = "";
let mode: "auto" | "fixed" | null = null;
while (j < toks.length) {
const t = toks[j];
if (t.k === "digit") {
if (mode === "fixed") break;
mode = "auto";
denomDigits++;
j++;
continue;
}
if (t.k === "lit" && /[0-9]/.test(t.ch)) {
if (mode === "auto") break;
mode = "fixed";
fixedDigits += t.ch;
j++;
continue;
}
break;
}
if (mode === null) return null;
const denomEnd = j;
const sepToks: Tok[] = [];
let k = numeratorStart - 1;
while (k >= 0 && toks[k].k !== "digit") {
sepToks.unshift(toks[k]);
k--;
}
let wholeDigits = 0;
while (k >= 0 && toks[k].k === "digit") {
wholeDigits++;
k--;
}
const prefix = wholeDigits > 0 ? toks.slice(0, k + 1) : toks.slice(0, numeratorStart);
if (wholeDigits === 0) sepToks.length = 0;
return {
prefix,
wholeDigits,
sepToks,
fixedDenom: mode === "fixed" ? BigInt(fixedDigits) : null,
denomDigits,
suffix: toks.slice(denomEnd),
};
}
/** 분모가 `maxDenom` 이하인 가장 가까운 분수(0 ≤ x) — 다 훑어봐도 999 번 안쪽(자리표시 최대 3 개). */
function bestFraction(x: Frac, maxDenom: bigint): { num: bigint; den: bigint } {
let bestNum = 0n;
let bestDen = 1n;
let bestDiff: Frac | null = null;
for (let den = 1n; den <= maxDenom; den++) {
const num = toInteger(mul(x, frac(den)), "round");
const gap = sub(x, frac(num, den));
const diff = frac(gap.n < 0n ? -gap.n : gap.n, gap.d);
if (bestDiff === null || cmp(diff, bestDiff) < 0) {
bestDiff = diff;
bestNum = num;
bestDen = den;
}
}
return { num: bestNum, den: bestDen };
}
function renderFractionSection(plan: FracPlan, magnitude: Frac): string {
let whole = 0n;
let remainder = magnitude;
if (plan.wholeDigits > 0) {
whole = toInteger(magnitude, "trunc");
remainder = sub(magnitude, frac(whole));
}
let num: bigint;
let den: bigint;
if (plan.fixedDenom !== null) {
den = plan.fixedDenom;
num = toInteger(mul(remainder, frac(den)), "round");
} else {
const maxDenom = 10n ** BigInt(plan.denomDigits) - 1n;
const best = bestFraction(remainder, maxDenom);
num = best.num;
den = best.den;
}
if (plan.wholeDigits > 0 && den > 0n && num >= den) {
whole += num / den;
num %= den;
}
const fracText = num !== 0n ? `${num}/${den}` : "";
let out = "";
for (const t of plan.prefix) if (t.k === "lit") out += t.ch;
if (plan.wholeDigits > 0) {
if (whole !== 0n || fracText === "") out += whole.toString();
if (fracText !== "") for (const t of plan.sepToks) if (t.k === "lit") out += t.ch;
}
out += fracText;
for (const t of plan.suffix) if (t.k === "lit") out += t.ch;
return out;
}
function generalFormat(value: Frac): string {
return fracToString(value);
}
// ── 지수(과학) 형식 `0.00E+00` ───────────────────────────────────────────────
interface SciPlan {
mantissaToks: Tok[]; // `E` 앞 가수 자리(자릿수·소수점은 수 구역과 같은 규칙)
expDigits: number; // `E+`·`E-` 뒤 자리표시 개수(모자라면 0 으로 채움)
expSign: "+" | "-"; // `E-00` 은 양의 지수에서 부호를 안 보임(엑셀과 같음)
}
/** `E`(부호 있음) 뒤 숫자 자리표시가 끝까지 이어질 때만 지수 형식 — 아니면 null. */
function detectScientific(toks: Tok[]): SciPlan | null {
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.k !== "lit" || (t.ch !== "E" && t.ch !== "e")) continue;
const signTok = toks[i + 1];
if (!signTok || signTok.k !== "lit" || (signTok.ch !== "+" && signTok.ch !== "-")) continue;
let j = i + 2;
let digits = 0;
while (j < toks.length && toks[j].k === "digit") {
digits++;
j++;
}
if (digits === 0 || j !== toks.length) continue;
return { mantissaToks: toks.slice(0, i), expDigits: digits, expSign: signTok.ch };
}
return null;
}
function renderScientificSection(plan: SciPlan, magnitude: Frac): string {
const value = toDouble(magnitude);
const pointIdx = plan.mantissaToks.findIndex((t) => t.k === "point");
const fracDigits = plan.mantissaToks
.slice(pointIdx >= 0 ? pointIdx + 1 : plan.mantissaToks.length)
.filter((t) => t.k === "digit").length;
let exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
let mantissaText = (value / Math.pow(10, exp)).toFixed(fracDigits);
if (Math.abs(Number(mantissaText)) >= 10) {
exp += 1;
mantissaText = (value / Math.pow(10, exp)).toFixed(fracDigits);
}
const expSign = exp < 0 ? "-" : plan.expSign === "+" ? "+" : "";
const expText = String(Math.abs(exp)).padStart(plan.expDigits, "0");
return `${mantissaText}E${expSign}${expText}`;
}
// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ───────────────────────
// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸.
const MS_PER_DAY = 86400000;
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
function toDouble(x: Frac): number {
return Number(x.n) / Number(x.d);
}
function serialToDateUTC(serial: number): Date {
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
}
type DatePart =
{ k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 };
/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */
function detectDateFormat(s: string): DatePart[] | null {
const parts: DatePart[] = [];
let hasDateToken = false;
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '"') {
let j = i + 1;
let text = "";
while (j < s.length && s[j] !== '"') {
text += s[j];
j++;
}
parts.push({ k: "lit", text });
i = j + 1;
continue;
}
if (ch === "\\") {
if (i + 1 < s.length) parts.push({ k: "lit", text: s[i + 1] });
i += 2;
continue;
}
const lower = ch.toLowerCase();
if (lower === "y" || lower === "m" || lower === "d" || lower === "a") {
let j = i;
while (j < s.length && s[j].toLowerCase() === lower) j++;
const width = j - i;
// 앞뒤가 영문 알파벳이면 딴 낱말 속에 섞인 우연한 글자(`Fixed` 의 d · `Currency` 의 y 등) —
// 자리표시로 안 봄(2026-09-28 — 그 우연 때문에 이름 형식이 날짜로 깨지던 것 고침).
const before = i > 0 ? s[i - 1] : "";
const after = j < s.length ? s[j] : "";
const boundaryOk = !/[A-Za-z]/.test(before) && !/[A-Za-z]/.test(after);
if (boundaryOk && lower === "a" && (width === 3 || width === 4)) {
parts.push({ k: "a", width });
hasDateToken = true;
} else if (
boundaryOk &&
lower !== "a" &&
(width === 1 || width === 2 || (lower === "y" && width === 4))
) {
parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 });
hasDateToken = true;
} else {
parts.push({ k: "lit", text: s.slice(i, j) });
}
i = j;
continue;
}
parts.push({ k: "lit", text: ch });
i++;
}
return hasDateToken ? parts : null;
}
function renderDatePart(part: DatePart, date: Date): string {
if (part.k === "lit") return part.text;
if (part.k === "y") {
const y = date.getUTCFullYear();
return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0");
}
if (part.k === "m") {
const m = date.getUTCMonth() + 1;
return part.width === 1 ? String(m) : String(m).padStart(2, "0");
}
if (part.k === "d") {
const d = date.getUTCDate();
return part.width === 1 ? String(d) : String(d).padStart(2, "0");
}
const wd = WEEKDAY_SHORT[date.getUTCDay()];
return part.width === 3 ? wd : `${wd}요일`;
}
function renderDateSection(parts: DatePart[], value: Frac): string {
const date = serialToDateUTC(toDouble(value));
return parts.map((p) => renderDatePart(p, date)).join("");
}
function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } {
const isNeg = cmp(value, ZERO) < 0;
const isZero = cmp(value, ZERO) === 0;
if (sections.length === 1) return { code: sections[0], forceMinus: isNeg };
if (sections.length === 2) {
return isNeg
? { code: sections[1], forceMinus: false }
: { code: sections[0], forceMinus: false };
}
if (isZero) return { code: sections[2] ?? sections[0], forceMinus: false };
if (isNeg) return { code: sections[1], forceMinus: false };
return { code: sections[0], forceMinus: false };
}
// ── 엑셀 「칸 형식 이름」(코드 대신) — `mso-number-format:Fixed` 처럼 클립보드가 이름을 넘김 ─────
// 엑셀 붙여넣기 · 일부 자리에서 형식 코드 대신 이름(Fixed·Standard·Currency·Percent·General 등)이
// 옴 — 그대로 코드로 읽으면 `y·m·d` 낱자가 날짜 자리표시로 잘못 잡혀 글자가 깨짐(2026-09-28 실측:
// `Fixed`→`Fixe11`). 우리 코드로 바꿔치기 · 모르는 이름은 일반(General)으로.
const EXCEL_NAMED_FORMATS: Record<string, string> = {
general: "General",
"general number": "General",
fixed: "0.00",
standard: "#,##0.00",
currency: "#,##0.00", // 이 문서 실측 — 통화 기호가 안 보임(사용자 확인)
percent: "0.00%",
scientific: "0.00E+00",
"short date": "yyyy-mm-dd",
"long date": "yyyy-mm-dd aaaa",
};
const BARE_WORD_RE = /^[A-Za-z][A-Za-z ]*$/;
/** 따옴표 한 겹 · 흔한 `\x` 이스케이프까지 방어적으로 풀어(대조용) 이름표와 맞대 봄 —
* 맞으면 우리 코드로 · 알파벳 낱말뿐인 모르는 이름이면 일반으로 · 아니면 원래 코드 그대로. */
function resolveExcelStyleName(code: string): string {
let key = code.trim();
if (key.length >= 2 && key.startsWith('"') && key.endsWith('"')) key = key.slice(1, -1);
key = key.replace(/\\(.)/g, "$1").trim().toLowerCase().replace(/\s+/g, " ");
const mapped = EXCEL_NAMED_FORMATS[key];
if (mapped !== undefined) return mapped;
// 알파벳 낱말뿐이면서 진짜 날짜 자리표시(`aaa` · `yyyy` 등)도 아니면 모르는 이름 — 일반으로.
if (BARE_WORD_RE.test(key) && detectDateFormat(code) === null) return "General";
return code;
}
function formatNumber(value: Frac, code: string | undefined): FormattedText {
const resolved = code === undefined ? undefined : resolveExcelStyleName(code);
if (resolved === undefined || resolved === "" || resolved.trim().toLowerCase() === "general") {
return { 글: generalFormat(value), 정렬: "right" };
}
const sections = splitSections(resolved);
const { code: chosen, forceMinus } = chooseSection(sections, value);
const { rest, color } = extractColor(chosen);
const datePlan = detectDateFormat(rest);
if (datePlan) {
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra };
}
const { toks, fillChar } = tokenize(rest);
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
if (fillChar) extra.채움글 = fillChar;
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
const sci = detectScientific(toks);
if (sci) {
const body = renderScientificSection(sci, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
const fraction = detectFraction(toks);
if (fraction) {
const body = renderFractionSection(fraction, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
// 글 자리(@)뿐인 형식을 수에 걸면 엑셀처럼 일반으로 보임(빈 칸이 되지 않게)
if (toks.some((t) => t.k === "text"))
return { 글: generalFormat(value), 정렬: "right", ...extra };
let out = "";
for (const t of toks) if (t.k === "lit") out += t.ch;
return { 글: out, 정렬: "right", ...extra };
}
const body = renderNumericSection(toks, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
function formatText(value: string, code: string | undefined): FormattedText {
if (code === undefined || code === "") return { 글: value, 정렬: "left" };
const resolved = resolveExcelStyleName(code);
const sections = splitSections(resolved);
// 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) ·
// 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로.
let textCode: string | undefined;
if (sections.length >= 4) textCode = sections[3];
else if (sections.length === 1 && sections[0].includes("@")) textCode = sections[0];
if (textCode === undefined) return { 글: value, 정렬: "left" };
const { rest, color } = extractColor(textCode);
const { toks, fillChar } = tokenize(rest);
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
if (fillChar) extra.채움글 = fillChar;
let out = "";
for (const t of toks) {
if (t.k === "text") out += value;
else if (t.k === "lit") out += t.ch;
else if (t.k === "percent") out += "%";
}
return { 글: out, 정렬: "left", ...extra };
}
/** `code` 없음 = 일반(수는 유효 11 자리 안쪽 · 칸 폭은 격자 몫) · 오류 값은 코드 글 그대로 */
export function formatValue(value: Scalar, code: string | undefined): FormattedText {
if (isErrorScalar(value)) return { 글: value.error, 정렬: "center" };
if (value === null) return { 글: "", 정렬: "right" };
if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" };
if (typeof value === "string") return formatText(value, code);
return formatNumber(value, code);
}
@@ -0,0 +1,342 @@
/* =============================================================================
* spreadsheet_parser.ts (주인 A)
* 수식 글 → 낱말 · 나무 · 나무 → 글. `eval` 금지 — 직접 짠 파서만.
* 우선순위는 엑셀과 같음: 범위 `:` > 부호 `-` `+` > `%` > `^` > `* /` > `+ -` > `&` > 비교
* (부호가 `^` 보다 먼저 — `-2^2 = 4`). 같은 단은 왼쪽부터(`^` 도 왼쪽부터 · 엑셀과 같음).
* 글 `"…"` 안 `""` = 따옴표 하나 · 시트 `'이름'!A1` · `이름!A1` · 오류 값 글자 · TRUE/FALSE.
* 참조 낱말 풀기(`parseRef` · `renderRef`)는 참조 옮김(`spreadsheet_refshift.ts`)이 같이 씀.
* ========================================================================== */
import { parseDecimal, ZERO } from "@ui/sheet/ui_template_sheet_frac";
import { colIndex, colName, quoteSheet, rowIndex } from "./spreadsheet_address";
import { getFunction } from "./spreadsheet_functions";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { ErrorCode, FormulaNode, ParseResult, RefCell, Token } from "./spreadsheet_types";
// ── 참조 낱말 ────────────────────────────────────────────────────────────────
const IDC = "[\\p{L}\\p{N}_.]";
const END = `(?!${IDC}|\\()`;
const CELL = "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)";
const RE_PREFIX = /^(?:'((?:[^']|'')+)'|([\p{L}\p{N}_.]+))!/u;
const RE_AREA = new RegExp(`^${CELL}(?::${CELL})?${END}`, "u");
const RE_COLS = new RegExp(`^(\\$?)([A-Za-z]{1,3}):(\\$?)([A-Za-z]{1,3})${END}`, "u");
const RE_ROWS = new RegExp(`^(\\$?)(\\d+):(\\$?)(\\d+)${END}`, "u");
const RE_ERROR = /^#(NULL!|DIV\/0!|VALUE!|REF!|NAME\?|NUM!|N\/A|CYCLE!)/i;
const RE_IDENT = /^[\p{L}_\\][\p{L}\p{N}_.]*/u;
/** 참조 낱말 하나 — `kind` cell = 칸 · area = `A1:B2` · cols = `A:C` · rows = `3:5`. */
export interface RefParts {
/** 적힌 시트 머리 글 그대로(`'돌-골막이'!`) · 없으면 "" */
prefix: string;
sheet: string | null;
kind: "cell" | "area" | "cols" | "rows";
a: RefCell;
b: RefCell;
}
/** 참조 글 → 조각 · 참조가 아니면 null(주소가 격자 밖이어도 null → 이름) */
export function parseRef(text: string): RefParts | null {
const pm = RE_PREFIX.exec(text);
const prefix = pm ? pm[0] : "";
const sheet = pm ? (pm[1] !== undefined ? pm[1].replace(/''/g, "'") : pm[2]) : null;
const body = text.slice(prefix.length);
const pt = (sr: string, col: number, sc: string, row: number): RefCell => ({
r: row,
c: col,
absR: sc === "$",
absC: sr === "$",
});
let m = RE_AREA.exec(body);
if (m && m[0].length === body.length) {
const a = pt(m[1], colIndex(m[2]), m[3], rowIndex(m[4]));
const b = m[5] === undefined ? { ...a } : pt(m[5], colIndex(m[6]), m[7], rowIndex(m[8]));
if (a.r < 0 || a.c < 0 || b.r < 0 || b.c < 0) return null;
return { prefix, sheet, kind: m[5] === undefined ? "cell" : "area", a, b };
}
m = RE_COLS.exec(body);
if (m && m[0].length === body.length) {
const c0 = colIndex(m[2]);
const c1 = colIndex(m[4]);
if (c0 < 0 || c1 < 0) return null;
return {
prefix,
sheet,
kind: "cols",
a: pt(m[1], c0, "", 0),
b: pt(m[3], c1, "", MAX_ROWS - 1),
};
}
m = RE_ROWS.exec(body);
if (m && m[0].length === body.length) {
const r0 = rowIndex(m[2]);
const r1 = rowIndex(m[4]);
if (r0 < 0 || r1 < 0) return null;
return {
prefix,
sheet,
kind: "rows",
a: pt("", 0, m[1], r0),
b: pt("", MAX_COLS - 1, m[3], r1),
};
}
return null;
}
const cellText = (p: RefCell): string =>
`${p.absC ? "$" : ""}${colName(p.c)}${p.absR ? "$" : ""}${p.r + 1}`;
/** 조각 → 참조 글(시트 머리는 `prefix` 그대로) */
export function renderRef(p: RefParts): string {
if (p.kind === "cols")
return `${p.prefix}${p.a.absC ? "$" : ""}${colName(p.a.c)}:${p.b.absC ? "$" : ""}${colName(p.b.c)}`;
if (p.kind === "rows")
return `${p.prefix}${p.a.absR ? "$" : ""}${p.a.r + 1}:${p.b.absR ? "$" : ""}${p.b.r + 1}`;
return p.prefix + cellText(p.a) + (p.kind === "area" ? `:${cellText(p.b)}` : "");
}
// ── 낱말 나누기 ───────────────────────────────────────────────────────────────
/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용.
* 이름(정의 안 된 글자 묶음)도 `unknown` — 파서가 이름 나무로 읽음. */
export function tokenize(formula: string): Token[] {
const out: Token[] = [];
let i = 0;
let ref: string | null;
const push = (kind: Token["kind"], len: number) => {
out.push({ kind, text: formula.slice(i, i + len), start: i, end: i + len });
i += len;
};
while (i < formula.length) {
const rest = formula.slice(i);
const ch = rest[0];
let m: RegExpExecArray | null;
if ((m = /^\s+/.exec(rest))) push("space", m[0].length);
else if (ch === '"') {
const s = /^"(?:[^"]|"")*"?/.exec(rest)!;
push("string", s[0].length);
} else if ((m = /^([\p{L}_][\p{L}\p{N}_.]*)\(/u.exec(rest)) && !/^(TRUE|FALSE)$/i.test(m[1]))
push("func", m[1].length);
else if ((ref = refAt(rest))) push(/#REF!$/i.test(ref) ? "error" : "ref", ref.length);
else if ((m = /^(TRUE|FALSE)(?![\p{L}\p{N}_.(])/iu.exec(rest))) push("bool", m[0].length);
else if ((m = RE_ERROR.exec(rest))) push("error", m[0].length);
else if ((m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest))) push("number", m[0].length);
else if ((m = RE_IDENT.exec(rest))) push("unknown", m[0].length);
else if ((m = /^(<>|<=|>=|[-+*/^&=<>%:])/.exec(rest))) push("op", m[0].length);
else if (ch === "(" || ch === ")") push("paren", 1);
else if (ch === ",") push("comma", 1);
else push("unknown", 1);
}
return out;
}
/** 머리 이 자리에서 시작하는 참조(또는 `시트!#REF!`) 글 */
function refAt(rest: string): string | null {
const head = RE_PREFIX.exec(rest)?.[0] ?? "";
const body = rest.slice(head.length);
if (head && /^#REF!/i.test(body)) return head + body.slice(0, 5);
for (const re of [RE_AREA, RE_COLS, RE_ROWS]) {
const m = re.exec(body);
if (m && parseRef(head + m[0])) return head + m[0];
}
return null;
}
// ── 나무 ────────────────────────────────────────────────────────────────────
class Fail {
constructor(
readonly message: string,
readonly at: number,
) {}
}
const COMPARE = ["=", "<>", "<", "<=", ">", ">="];
// ponytail: B 함수 표 min 이 엑셀보다 낮은 것만 덧댐 — sub5 가 표를 고치면 지움
const EXCEL_MIN: Record<string, number> = {
SUM: 1,
MIN: 1,
MAX: 1,
COUNT: 1,
COUNTA: 1,
PRODUCT: 1,
};
/** 앞 `=` 뺀 식 글 → 나무. 앞에 `=` 가 붙어 와도 됨. 함수 이름은 대문자로. */
export function parseFormula(formula: string): ParseResult {
const text = formula.startsWith("=") ? formula.slice(1) : formula;
const toks = tokenize(text).filter((t) => t.kind !== "space");
let p = 0;
const peek = (): Token | undefined => toks[p];
const isOp = (...ops: string[]) => {
const t = toks[p];
return !!t && t.kind === "op" && ops.includes(t.text);
};
const where = () => (toks[p] ? toks[p].start : text.length);
const binary = (ops: string[], next: () => FormulaNode) => (): FormulaNode => {
let left = next();
while (isOp(...ops)) {
const op = toks[p++].text as "+";
left = { type: "binary", op, left, right: next() };
}
return left;
};
const primary = (): FormulaNode => {
const t = peek();
if (!t) throw new Fail("식이 덜 끝남", text.length);
p++;
switch (t.kind) {
case "number":
return { type: "number", value: parseDecimal(t.text), text: t.text };
case "string":
if (t.text.length < 2 || !t.text.endsWith('"')) throw new Fail("따옴표가 안 닫힘", t.start);
return { type: "string", value: t.text.slice(1, -1).replace(/""/g, '"') };
case "bool":
return { type: "bool", value: t.text.toUpperCase() === "TRUE" };
case "error":
return {
type: "error",
code: t.text.slice(t.text.lastIndexOf("#")).toUpperCase() as ErrorCode,
};
case "ref": {
const r = parseRef(t.text)!;
return r.kind === "cell"
? { type: "cell", sheet: r.sheet, ref: r.a }
: { type: "range", sheet: r.sheet, from: r.a, to: r.b };
}
case "func": {
const name = t.text.toUpperCase().replace(/^_XLFN\./, "");
p++; // `(`
const args: FormulaNode[] = [];
// 인자 수는 엑셀처럼 입력 때 막음(편집기가 이 실패로 Enter 를 막음) · 모르는 함수는 풀 때 #NAME?
const done = (): FormulaNode => {
const fn = getFunction(name);
const lo = Math.max(fn?.min ?? 0, EXCEL_MIN[name] ?? 0);
if (fn && args.length < lo)
throw new Fail(`${name} 인수가 너무 적음(${lo} 개부터)`, t.start);
if (fn?.max !== undefined && args.length > fn.max)
throw new Fail(`${name} 인수가 너무 많음(${fn.max} 개까지)`, t.start);
return { type: "call", name, args };
};
if (peek()?.text === ")") {
p++;
return done();
}
for (;;) {
const k = peek();
// 빈 인자(`IF(A1,,1)`) = 0 · 글은 비움
if (k && (k.kind === "comma" || k.text === ")"))
args.push({ type: "number", value: ZERO, text: "" });
else args.push(compare());
const s = toks[p++];
if (s?.kind === "comma") continue;
if (s?.text === ")") return done();
throw new Fail("함수 괄호가 안 닫힘", s ? s.start : text.length);
}
}
case "paren": {
if (t.text !== "(") break;
const inner = compare();
if (peek()?.text !== ")") throw new Fail("괄호가 안 닫힘", where());
p++;
return inner;
}
case "unknown":
if (RE_IDENT.exec(t.text)?.[0] === t.text) return { type: "name", name: t.text };
}
throw new Fail(`읽지 못한 글: ${t.text}`, t.start);
};
const unary = (): FormulaNode => {
if (isOp("-", "+")) {
const op = toks[p++].text as "-";
return { type: "unary", op, arg: unary() };
}
return primary();
};
const percent = (): FormulaNode => {
let node = unary();
while (isOp("%")) {
p++;
node = { type: "unary", op: "%", arg: node };
}
return node;
};
const power = binary(["^"], percent);
const mul = binary(["*", "/"], power);
const add = binary(["+", "-"], mul);
const concat = binary(["&"], add);
const compare = binary(COMPARE, concat);
try {
if (!toks.length) throw new Fail("빈 식", 0);
const node = compare();
if (p < toks.length) throw new Fail(`남은 글: ${toks[p].text}`, toks[p].start);
return { ok: true, node };
} catch (e) {
if (e instanceof Fail) return { ok: false, message: e.message, at: e.at };
throw e;
}
}
// ── 나무 → 글 ─────────────────────────────────────────────────────────────────
const PREC: Record<string, number> = { "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5 };
for (const op of COMPARE) PREC[op] = 1;
function prec(node: FormulaNode): number {
if (node.type === "binary") return PREC[node.op];
if (node.type === "unary") return node.op === "%" ? 6 : 7;
return 8;
}
const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`);
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만.
* 계산 단계 보기용: `sub` 가 글을 주는 마디는 그 글(푼 값)로 갈음 · `mark` 마디는 \u0001 … \u0002 로 감쌈 */
export function formulaToText(
node: FormulaNode,
sub?: (node: FormulaNode) => string | undefined,
mark?: FormulaNode,
): string {
const own = sub?.(node);
if (own !== undefined) return own;
if (node === mark) return `\u0001${formulaToText(node, sub)}\u0002`;
const text = (child: FormulaNode) => formulaToText(child, sub, mark);
const wrap = (child: FormulaNode, need: boolean) =>
need && sub?.(child) === undefined ? `(${text(child)})` : text(child);
switch (node.type) {
case "number":
return node.text;
case "string":
return `"${node.value.replace(/"/g, '""')}"`;
case "bool":
return node.value ? "TRUE" : "FALSE";
case "error":
return node.code;
case "name":
return node.name;
case "cell":
return sheetHead(node.sheet) + cellText(node.ref);
case "range": {
const { from, to } = node;
const kind =
from.r === 0 && to.r === MAX_ROWS - 1
? "cols"
: from.c === 0 && to.c === MAX_COLS - 1
? "rows"
: "area";
return renderRef({ prefix: sheetHead(node.sheet), sheet: node.sheet, kind, a: from, b: to });
}
case "unary":
return node.op === "%"
? `${wrap(node.arg, prec(node.arg) < 6)}%`
: `${node.op}${wrap(node.arg, prec(node.arg) < 7)}`;
case "binary": {
const p = PREC[node.op];
return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`;
}
case "call":
return `${node.name}(${node.args.map(text).join(",")})`;
}
}
@@ -0,0 +1,140 @@
/* =============================================================================
* spreadsheet_paste_special.ts (2단계 · 주인 sub_laptop_1)
* 붙여넣기 골라서 — 값만 · 서식만 · 수식만(Ctrl+Shift+V). `ClipBlock`(0 계약) 을 받아
* 대상 왼위(`at`)에 맞게 명령을 만듦 — 서식은 `서식` 명령(칸마다 하나 · A 가 표 번호를 매김) ·
* 값 · 식은 `칸` 명령(대상 칸의 지금 서식 번호는 그대로 둠). 참조 옮김은 「원점」 이 있을 때만
* `$` 없는 A1 참조를 얕게 옮김(진짜 옮김은 A `spreadsheet_refshift` 몫 — 여긴 메뉴용 편의).
*
* 잇는 법(D · sub7) — 클립보드(E)가 만든 `ClipBlock` 을 들고:
* 1) 우클릭 메뉴 · Ctrl+Shift+V 로 모드 고르는 작은 팝업을 띄움(값만 · 서식만 · 수식만).
* 2) 고른 모드로 `pasteSpecial(ctx, block, ctx.selection.활성, mode, sourceOrigin?)` 호출.
* 3) 「값만」 은 원본이 아직 화면에 있을 때만 계산값을 살릴 수 있음 — `sourceOrigin` 을 줌(없으면 식 칸은 건너뜀).
* ========================================================================== */
import type {
Cell,
CellAddress,
CellRange,
CellStyle,
ClipBlock,
ClipCell,
Command,
Frac,
Scalar,
} from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import { fracToString } from "@ui/sheet/ui_template_sheet_frac";
export type PasteSpecialMode = "값" | "서식" | "수식";
const a1 = (r: number, c: number): string => {
let col = c + 1;
let letters = "";
while (col > 0) {
const rem = (col - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
col = Math.floor((col - 1) / 26);
}
return `${letters}${r + 1}`;
};
/** `$` 없는 A1 참조만 (dr, dc) 만큼 옮김 — 절대참조 · 다른 시트 이름은 손대지 않음(안전한 최소). */
export function shiftRelativeRefs(formula: string, dr: number, dc: number): string {
return formula.replace(
/(\$?)([A-Z]{1,3})(\$?)(\d+)/g,
(whole, absC, colLetters, absR, rowDigits) => {
if (absC === "$" && absR === "$") return whole;
let col = 0;
for (const ch of colLetters) col = col * 26 + (ch.charCodeAt(0) - 64);
const nextCol = absC === "$" ? col : col + dc;
const nextRow = absR === "$" ? Number(rowDigits) : Number(rowDigits) + dr;
if (nextCol < 1 || nextRow < 1) return "#REF!";
let letters = "";
let n = nextCol;
while (n > 0) {
const rem = (n - 1) % 26;
letters = String.fromCharCode(65 + rem) + letters;
n = Math.floor((n - 1) / 26);
}
return `${absC}${letters}${absR}${nextRow}`;
},
);
}
export interface PasteSpecialSource {
시트: string;
r: number;
c: number;
}
/** `block` 을 `at` 에 골라 붙임. `sourceOrigin` 은 「값만」 때 식 칸을 계산값으로 굳히는 데 씀(없으면 식 칸은 건너뜀). */
export function pasteSpecial(
ctx: SpreadsheetContext,
block: ClipBlock,
at: CellAddress,
mode: PasteSpecialMode,
sourceOrigin?: PasteSpecialSource,
): void {
const sheetId = ctx.selection.시트;
const sheet = ctx.sheet();
const dr = at.r - (block.원점?.r ?? at.r);
const dc = at.c - (block.원점?.c ?? at.c);
const commands: Command[] = [];
const 칸: Record<string, Cell | null> = {};
for (const [key, clip] of Object.entries(block.칸)) {
const [rowStr, colStr] = key.split(",");
const rr = at.r + Number(rowStr);
const cc = at.c + Number(colStr);
if (rr < 0 || cc < 0) continue;
const target = a1(rr, cc);
const existing = sheet.칸[target];
if (mode === "서식") {
if (clip.서식) commands.push(styleCommand(sheetId, rr, cc, clip.서식));
continue;
}
if (mode === "수식") {
if (clip.식 === undefined) continue;
const moved = block.원점 ? shiftRelativeRefs(clip.식, dr, dc) : clip.식;
칸[target] = { 식: moved, 서식: existing?.서식 };
continue;
}
// mode === "값"
const value = resolveClipValue(clip, sourceOrigin, Number(rowStr), Number(colStr), ctx);
if (value === undefined) continue;
칸[target] = { 값: value, 서식: existing?.서식 };
}
if (Object.keys(칸).length > 0) commands.push({ 종류: "칸", 시트: sheetId, 칸 });
if (commands.length === 0) return;
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
}
function styleCommand(sheetId: string, r: number, c: number, style: CellStyle): Command {
const 범위: CellRange = { r0: r, c0: c, r1: r, c1: c };
return { 종류: "서식", 시트: sheetId, 범위: [범위], 바꿀: { ...style } };
}
function resolveClipValue(
clip: ClipCell,
origin: PasteSpecialSource | undefined,
dr: number,
dc: number,
ctx: SpreadsheetContext,
): Cell["값"] | undefined {
if (clip.식 !== undefined) {
if (!origin) return undefined;
const scalar = ctx.engine.value(origin.시트, origin.r + dr, origin.c + dc);
return scalarFrom(scalar);
}
return clip.값;
}
function scalarFrom(value: Scalar): Cell["값"] | undefined {
if (value === null) return undefined;
if (typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "object" && "n" in value && "d" in value)
return Number(fracToString(value as Frac));
return undefined;
}
@@ -0,0 +1,12 @@
/* =============================================================================
* spreadsheet_recalc.ts (주인 A)
* 통합문서 한 벌 풀이 — 서버 Node 진입(`common_util_spreadsheet_node.ts`) · 시험이 부름.
* 화면과 같은 엔진(`createCalcEngine`)을 한 번 돌려 `snapshot()` — 계산이 두 벌이 되지 않게.
* ========================================================================== */
import { createCalcEngine } from "./spreadsheet_graph";
import type { CalcValues, Workbook } from "./spreadsheet_types";
export function recalcWorkbook(book: Workbook): CalcValues {
return createCalcEngine(book).snapshot();
}
@@ -0,0 +1,179 @@
/* =============================================================================
* spreadsheet_refshift.ts (주인 A)
* 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 · 시트 지우기 때 식 글을 고쳐 적음
* (엑셀 규칙) · 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1.
* 지운 칸 · 격자 밖으로 나간 참조는 `#REF!` · 범위는 남은 만큼 줄어듦(끝까지 지우면 `#REF!`).
* 낱말 단위로 참조만 바꿔 끼움 — 빈칸 · 괄호 · 수 글은 적힌 그대로 둠.
*
* 엑셀 규칙표:
* 넣기 — 넣는 자리 이후를 가리키면 밀림 · 범위가 넣는 자리를 품으면 늘어남(첫 행 앞 넣기는 통째 밀림)
* 지우기 — 지운 칸 하나 = `#REF!` · 범위는 남은 만큼 · 전부 지우면 `#REF!` · `$` 도 똑같이 옮김
* 옮기기 — 옮긴 덩어리 안을 가리키던 참조(범위는 통째 들 때만)는 따라감 · 덮어쓴 자리를 가리키면 `#REF!`
* 복사 — 상대만 (dr, dc) · 행 · 열 전체 범위는 그 축을 안 옮김
* ========================================================================== */
import { quoteSheet, rangeInside } from "./spreadsheet_address";
import { parseRef, renderRef, tokenize } from "./spreadsheet_parser";
import type { RefParts } from "./spreadsheet_parser";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellAddress, CellRange, Command, Workbook } from "./spreadsheet_types";
const REF_ERR = "#REF!";
/** 참조 낱말만 `fn` 으로 바꿔 끼움(null = `#REF!`) */
function mapRefs(formula: string, fn: (p: RefParts) => RefParts | string | null): string {
let out = "";
for (const t of tokenize(formula)) {
const p = t.kind === "ref" ? parseRef(t.text) : null;
if (!p) {
out += t.text;
continue;
}
const q = fn(p);
out += q === null ? REF_ERR : typeof q === "string" ? q : renderRef(q);
}
return out;
}
/** 넣기 · 지우기 한 축 — [lo, hi] → 새 [lo, hi] · 다 지워지면 null */
export function shiftSpan(
lo: number,
hi: number,
insert: boolean,
at: number,
n: number,
max: number,
): [number, number] | null {
if (insert) {
if (lo >= at) [lo, hi] = [lo + n, hi + n];
else if (hi >= at) hi += n;
if (lo >= max) return null;
return [lo, Math.min(hi, max - 1)];
}
const end = at + n - 1;
if (lo >= at && hi <= end) return null;
return [lo < at ? lo : lo > end ? lo - n : at, hi < at ? hi : hi > end ? hi - n : at - 1];
}
const sheetIdOf = (book: Workbook, name: string) =>
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null;
const box = (p: RefParts): CellRange => ({
r0: Math.min(p.a.r, p.b.r),
c0: Math.min(p.a.c, p.b.c),
r1: Math.max(p.a.r, p.b.r),
c1: Math.max(p.a.c, p.b.c),
});
/** 행열 · 옮기기 · 시트이름 · 시트지우기 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글.
* `book` 은 명령 적용 **전** 문서(시트 이름으로 id 를 찾음). `newHost` = 옮기기로 식 칸 자체가 간 시트. */
export function shiftForCommand(
formula: string,
host: string,
command: Command,
book: Workbook,
newHost: string = host,
): string {
if (command.종류 === "묶음")
return command.명령.reduce((f, c) => shiftForCommand(f, host, c, book, newHost), formula);
const target = (p: RefParts) => (p.sheet === null ? host : sheetIdOf(book, p.sheet));
const nameOf = (id: string) => book.시트.find((s) => s.id === id)?.이름 ?? "";
switch (command.종류) {
case "행넣기":
case "행지우기":
case "열넣기":
case "열지우기": {
const rows = command.종류.startsWith("행");
const insert = command.종류.endsWith("넣기");
return mapRefs(formula, (p) => {
if (target(p) !== command.시트) return p;
if (rows ? p.kind === "cols" : p.kind === "rows") return p; // 다른 축 전체 범위
const [lo, hi] = rows
? [p.a.r, p.b.r].sort((x, y) => x - y)
: [p.a.c, p.b.c].sort((x, y) => x - y);
const span = shiftSpan(lo, hi, insert, command.at, command.수, rows ? MAX_ROWS : MAX_COLS);
if (!span) return null;
const q = structuredClone(p);
// 적힌 차례(뒤집힌 범위)는 바로 세움 — 엑셀도 다시 적을 때 바로 세움
if (rows) [q.a.r, q.b.r] = span;
else [q.a.c, q.b.c] = span;
return q;
});
}
case "옮기기": {
const src = command.범위;
const dr = command.r - src.r0;
const dc = command.c - src.c0;
const dst: CellRange = { r0: command.r, c0: command.c, r1: src.r1 + dr, c1: src.c1 + dc };
return mapRefs(formula, (p) => {
const t = target(p);
if (t === null) return p;
const b = box(p);
let to = t;
const q = structuredClone(p);
if (t === command.시트 && p.kind !== "cols" && p.kind !== "rows" && rangeInside(b, src)) {
to = command.대상시트;
for (const pt of [q.a, q.b]) {
pt.r += dr;
pt.c += dc;
}
} else if (t === command.대상시트 && rangeInside(b, dst)) return null;
if (to === t && newHost === host) return q;
q.prefix = to === newHost ? "" : `${quoteSheet(nameOf(to))}!`;
return q;
});
}
case "시트이름":
return mapRefs(formula, (p) =>
p.sheet !== null && target(p) === command.시트
? { ...p, prefix: `${quoteSheet(command.이름)}!` }
: p,
);
case "시트지우기":
return mapRefs(formula, (p) => (p.sheet !== null && target(p) === command.시트 ? null : p));
default:
return formula;
}
}
/** 복사 · 채우기 — 상대 참조만 (dr, dc) 옮김 · 격자 밖은 `#REF!` */
export function moveFormula(formula: string, dr: number, dc: number): string {
return mapRefs(formula, (p) => {
const q = structuredClone(p);
for (const pt of [q.a, q.b]) {
if (!pt.absR && p.kind !== "cols") pt.r += dr;
if (!pt.absC && p.kind !== "rows") pt.c += dc;
if (pt.r < 0 || pt.r >= MAX_ROWS || pt.c < 0 || pt.c >= MAX_COLS) return null;
}
return q;
});
}
/** 구글 `data-sheets-formula`(`=2*R[0]C[-1]`) → 붙일 자리 기준 A1 식(앞 `=` 뺌) */
export function r1c1ToA1(formula: string, at: CellAddress): string {
const text = formula.startsWith("=") ? formula.slice(1) : formula;
const axis = (part: string | undefined, base: number): [number, boolean] =>
part === undefined || part === ""
? [base, false]
: part.startsWith("[")
? [base + Number(part.slice(1, -1)), false]
: [Number(part) - 1, true];
// 글 · 따옴표 시트 이름은 건너뜀 · 이름 한가운데(`ROUND`)는 안 건드림
return text.replace(
/("(?:[^"]|"")*"|'(?:[^']|'')*')|(?<![\p{L}\p{N}_.$])R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?(?![\p{L}\p{N}_.(])/gu,
(whole, quoted: string | undefined, rp: string | undefined, cp: string | undefined) => {
if (quoted) return whole;
const [r, absR] = axis(rp, at.r);
const [c, absC] = axis(cp, at.c);
if (r < 0 || r >= MAX_ROWS || c < 0 || c >= MAX_COLS) return REF_ERR;
return renderRef({
prefix: "",
sheet: null,
kind: "cell",
a: { r, c, absR, absC },
b: { r, c, absR, absC },
});
},
);
}
@@ -0,0 +1,93 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_soil_ditch",
"서식": [
{},
{ "크기": 12, "굵게": true },
{
"가로": "center",
"세로": "center",
"채움": "#F2F2F2",
"테두리": {
"위": { "선": "thin" },
"아래": { "선": "thin" },
"왼": { "선": "thin" },
"오른": { "선": "thin" }
}
},
{
"가로": "distributed",
"세로": "center",
"테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } }
},
{ "세로": "center", "테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } } },
{
"세로": "center",
"형식": "0.00",
"테두리": { "위": { "선": "hair" }, "아래": { "선": "hair" } }
},
{ "형식": "0.00" }
],
"시트": [
{
"id": "s1",
"이름": "토사개거",
"칸": {
"A1": { "값": "□수로형 토사개거 산출근거", "서식": 1 },
"A3": { "값": "공종", "서식": 2 },
"B3": { "값": "규격", "서식": 2 },
"C3": { "값": "산출 내역", "서식": 2 },
"D3": { "서식": 2 },
"E3": { "서식": 2 },
"F3": { "서식": 2 },
"G3": { "값": "단위", "서식": 2 },
"H3": { "값": "수량", "서식": 2 },
"A4": { "값": "터파기", "서식": 3 },
"B4": { "값": "토사", "서식": 4 },
"C4": { "값": "(윗폭+밑폭)÷2×깊이", "서식": 4 },
"G4": { "값": "㎥", "서식": 4 },
"H4": { "식": "ROUNDDOWN((J4+J5)/2*J6,2)", "서식": 5 },
"A5": { "값": "되메우기", "서식": 3 },
"B5": { "값": "토사", "서식": 4 },
"C5": { "값": "터파기×0.3", "서식": 4 },
"G5": { "값": "㎥", "서식": 4 },
"H5": { "식": "ROUNDDOWN(H4*0.3,2)", "서식": 5 },
"A6": { "값": "계", "서식": 3 },
"H6": { "식": "SUM(H4:H5)", "서식": 5 },
"J4": { "식": "치수!B1", "서식": 6 },
"J5": { "값": 0.3, "서식": 6 },
"J6": { "값": 0.3, "서식": 6 }
},
"병합": ["A1:H1", "C3:F3", "C4:F4", "C5:F5", "A6:G6"],
"열": {
"A": { "폭": 10 },
"C": { "폭": 6 },
"D": { "폭": 6 },
"E": { "폭": 6 },
"F": { "폭": 6 },
"J": { "숨김": true }
},
"행": { "1": { "높이": 20 } },
"틀고정": { "행": 3 },
"보기": { "눈금선": false }
},
{
"id": "s2",
"이름": "치수",
"칸": {
"A1": { "값": "윗폭(m)" },
"B1": { "값": 0.9, "서식": 6 }
}
}
],
"활성": "s1",
"계산값": {
"s1": {
"H4": { "수": "0.18" },
"H5": { "수": "0.05" },
"H6": { "수": "0.23" },
"J4": { "수": "0.9" }
}
}
}
@@ -0,0 +1,289 @@
/* =============================================================================
* spreadsheet_selection.ts (주인 D)
* 고름 모델 — 한 칸 · 범위 · 행열 전체 · 전체 · 방향키(Shift 늘림 · Ctrl 끝으로) · 병합으로 늘림 ·
* 고름 안 Enter/Tab 돌기. DOM 모름(시험이 그대로 부름). 엑셀 동작이 기준.
* ========================================================================== */
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellAddress, CellRange, Sheet } from "./spreadsheet_types";
import type { Selection } from "./spreadsheet_view_types";
export const LAST_R = MAX_ROWS - 1;
export const LAST_C = MAX_COLS - 1;
export function merges(sheet: Sheet): CellRange[] {
const out: CellRange[] = [];
for (const t of sheet.병합 ?? []) {
const m = parseRange(t);
if (m) out.push(m);
}
return out;
}
const overlaps = (a: CellRange, b: CellRange): boolean =>
a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
export const inRange = (g: CellRange, r: number, c: number): boolean =>
r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1;
/** 병합 일부를 걸친 범위 → 병합 전체를 품게 늘림(늘린 뒤 새로 걸친 것도) */
export function expand(range: CellRange, list: CellRange[]): CellRange {
const g = { ...range };
for (let grew = true; grew;) {
grew = false;
for (const m of list) {
if (!overlaps(g, m)) continue;
if (m.r0 < g.r0 || m.r1 > g.r1 || m.c0 < g.c0 || m.c1 > g.c1) {
g.r0 = Math.min(g.r0, m.r0);
g.r1 = Math.max(g.r1, m.r1);
g.c0 = Math.min(g.c0, m.c0);
g.c1 = Math.max(g.c1, m.c1);
grew = true;
}
}
}
return g;
}
/** 칸이 든 병합(없으면 칸 하나) */
export function mergeAt(sheet: Sheet, r: number, c: number): CellRange {
return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c };
}
/** 숨긴 행 · 필터로 걸러진 행 · 접힌 묶음(방향키 · Enter 가 건너뜀 — 엑셀) */
export function rowHidden(sheet: Sheet, r: number): boolean {
const row = sheet.행?.[String(r + 1)];
return !!(row?.숨김 || row?.걸러짐 || row?.묶음?.접힘);
}
export function colHidden(sheet: Sheet, c: number): boolean {
const cols = sheet.열;
if (!cols) return false;
const col = cols[colName(c)];
return !!(col?.숨김 || col?.묶음?.접힘);
}
export function hasValue(sheet: Sheet, r: number, c: number): boolean {
const cell = sheet.칸[toA1(r, c)];
return !!cell && (cell.식 !== undefined || (cell.값 !== undefined && cell.값 !== ""));
}
export function span(a: CellAddress, b: CellAddress): CellRange {
return {
r0: Math.min(a.r, b.r),
c0: Math.min(a.c, b.c),
r1: Math.max(a.r, b.r),
c1: Math.max(a.c, b.c),
};
}
/** 칸 하나 고름 — 병합 안이면 병합 전체 · 활성은 병합 왼위 */
export function selectCell(sheet: Sheet, r: number, c: number): Selection {
const m = mergeAt(sheet, r, c);
const at = { r: m.r0, c: m.c0 };
return { 시트: sheet.id, 범위: [m], 활성: at, 기준: { ...at } };
}
/** 닻(기준)에서 `to` 까지 — 활성은 닻 그대로(엑셀 Shift) */
export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): Selection {
const g = expand(span(anchor, to), merges(sheet));
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
}
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
const r0 = Math.min(a, b);
const at = { r: active ?? r0, c: firstVisible(sheet, "c") };
return {
시트: sheet.id,
범위: [expand({ r0, r1: Math.max(a, b), c0: 0, c1: LAST_C }, merges(sheet))],
활성: at,
기준: { ...at },
};
}
export function selectCols(sheet: Sheet, a: number, b: number, active?: number): Selection {
const c0 = Math.min(a, b);
const at = { r: firstVisible(sheet, "r"), c: active ?? c0 };
return {
시트: sheet.id,
범위: [expand({ r0: 0, r1: LAST_R, c0, c1: Math.max(a, b) }, merges(sheet))],
활성: at,
기준: { ...at },
};
}
export function selectAll(sheet: Sheet, active?: CellAddress): Selection {
const at = active ?? { r: firstVisible(sheet, "r"), c: firstVisible(sheet, "c") };
return {
시트: sheet.id,
범위: [{ r0: 0, c0: 0, r1: LAST_R, c1: LAST_C }],
활성: at,
기준: { ...at },
};
}
function firstVisible(sheet: Sheet, axis: "r" | "c"): number {
let i = 0;
while (i < 200 && (axis === "r" ? rowHidden(sheet, i) : colHidden(sheet, i))) i++;
return i;
}
/** 한 칸 옮김 — 숨김 건너뜀 · 끝에서 멈춤 */
function step(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
let { r, c } = at;
do {
r += dr;
c += dc;
} while (
r >= 0 &&
r <= LAST_R &&
c >= 0 &&
c <= LAST_C &&
((dr && rowHidden(sheet, r)) || (dc && colHidden(sheet, c)))
);
if (r < 0 || r > LAST_R || c < 0 || c > LAST_C) return at;
return { r, c };
}
/** 한 줄(행 또는 열)에서 값 든 자리 — 오름차순 */
function occupied(sheet: Sheet, at: CellAddress, vertical: boolean): number[] {
const out: number[] = [];
for (const key of Object.keys(sheet.칸)) {
const a = parseA1(key);
if (!a || !hasValue(sheet, a.r, a.c)) continue;
if (vertical ? a.c === at.c : a.r === at.r) out.push(vertical ? a.r : a.c);
}
return out.sort((a, b) => a - b);
}
/** Ctrl+방향 — 값 사이를 뛰어 끝으로(엑셀: 값 칸 뭉치의 끝 · 빈 칸이면 다음 값 · 없으면 시트 끝) */
function jump(sheet: Sheet, at: CellAddress, dr: number, dc: number): CellAddress {
const vertical = dr !== 0;
const d = vertical ? dr : dc;
const pos = vertical ? at.r : at.c;
const edge = d > 0 ? (vertical ? LAST_R : LAST_C) : 0;
const set = new Set(occupied(sheet, at, vertical));
const has = (i: number): boolean => set.has(i);
const put = (i: number): CellAddress => (vertical ? { r: i, c: at.c } : { r: at.r, c: i });
if (pos === edge) return at;
if (has(pos) && has(pos + d)) {
let i = pos;
while (i !== edge && has(i + d)) i += d;
return put(i);
}
const sorted = [...set].sort((a, b) => a - b);
const next = d > 0 ? sorted.find((i) => i > pos) : sorted.reverse().find((i) => i < pos);
return put(next ?? edge);
}
export interface MoveOptions {
/** Shift — 닻은 두고 움직이는 끝만 */
extend?: boolean;
/** Ctrl — 값 끝으로 */
jump?: boolean;
}
/** 방향키 · Home 류 공통 — 새 고름 */
export function move(
sheet: Sheet,
sel: Selection,
dr: number,
dc: number,
opts: MoveOptions = {},
): Selection {
const list = merges(sheet);
if (!opts.extend) {
const from = sel.활성;
const m = mergeAt(sheet, from.r, from.c);
// 병합에서 나갈 때는 병합 끝에서 한 칸
const edge = { r: dr > 0 ? m.r1 : m.r0, c: dc > 0 ? m.c1 : m.c0 };
const to = opts.jump ? jump(sheet, edge, dr, dc) : step(sheet, edge, dr, dc);
if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c);
return selectCell(sheet, to.r, to.c);
}
const g = sel.범위[sel.범위.length - 1] ?? span(sel.활성, sel.활성); // 여러 범위면 마지막(활성) 범위
const anchor = sel.기준;
// 움직이는 끝 = 닻에서 먼 쪽
const far = {
r: Math.abs(g.r1 - anchor.r) >= Math.abs(anchor.r - g.r0) ? g.r1 : g.r0,
c: Math.abs(g.c1 - anchor.c) >= Math.abs(anchor.c - g.c0) ? g.c1 : g.c0,
};
let to = opts.jump ? jump(sheet, far, dr, dc) : step(sheet, far, dr, dc);
// 줄이는 쪽이면 병합 너머까지 한 번에
for (let guard = 0; guard < 50; guard++) {
const next = expand(span(anchor, to), list);
if (!sameRange(next, g) || to === far) break;
to = step(sheet, to, dr, dc);
}
return {
시트: sheet.id,
범위: [...sel.범위.slice(0, -1), expand(span(anchor, to), list)],
활성: { ...sel.활성 },
기준: { ...anchor },
};
}
export const sameRange = (a: CellRange, b: CellRange): boolean =>
a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1;
/** 쓰인 칸 끝(Ctrl+End) */
export function usedEnd(sheet: Sheet): CellAddress {
let r = 0;
let c = 0;
for (const key of Object.keys(sheet.칸)) {
const a = parseA1(key);
if (!a) continue;
r = Math.max(r, a.r);
c = Math.max(c, a.c);
}
return { r, c };
}
/** 고름 안 Enter/Tab 돌기 — 범위가 한 칸(또는 병합 하나)이면 null(밖으로 옮김) */
export function cycle(sheet: Sheet, sel: Selection, dr: number, dc: number): Selection | null {
const g = sel.범위[0];
if (!g) return null;
const list = merges(sheet);
if (list.some((m) => sameRange(m, g)) || (g.r0 === g.r1 && g.c0 === g.c1)) return null;
// 행 전체 · 열 전체는 쓰인 칸 끝까지만 돎
const end = usedEnd(sheet);
const box = {
...g,
r1: g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1,
c1: g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1,
};
let { r, c } = sel.활성;
const cur = mergeAt(sheet, r, c);
const rows = box.r1 - box.r0 + 1;
const cols = box.c1 - box.c0 + 1;
for (let n = 0; n < rows * cols; n++) {
if (dr) {
r += dr;
if (r > box.r1) ((r = box.r0), (c = c + 1 > box.c1 ? box.c0 : c + 1));
if (r < box.r0) ((r = box.r1), (c = c - 1 < box.c0 ? box.c1 : c - 1));
} else {
c += dc;
if (c > box.c1) ((c = box.c0), (r = r + 1 > box.r1 ? box.r0 : r + 1));
if (c < box.c0) ((c = box.c1), (r = r - 1 < box.r0 ? box.r1 : r - 1));
}
if (rowHidden(sheet, r) || colHidden(sheet, c)) continue;
const m = mergeAt(sheet, r, c);
if (m.r0 !== r || m.c0 !== c || sameRange(m, cur)) continue;
return { ...sel, 범위: sel.범위.map((x) => ({ ...x })), 활성: { r, c } };
}
return null;
}
/** 고름 안 칸 전부(행열 전체는 쓰인 끝까지) — Ctrl+Enter · 지우기 대상 */
export function cellsOf(sheet: Sheet, sel: Selection): CellAddress[] {
const end = usedEnd(sheet);
const out: CellAddress[] = [];
for (const g of sel.범위) {
const r1 = g.r1 === LAST_R ? Math.max(end.r, g.r0) : g.r1;
const c1 = g.c1 === LAST_C ? Math.max(end.c, g.c0) : g.c1;
for (let r = g.r0; r <= r1; r++) for (let c = g.c0; c <= c1; c++) out.push({ r, c });
}
return out;
}
@@ -0,0 +1,113 @@
/* =============================================================================
* spreadsheet_sheet_extra.ts (3단계 · 주인 sub_laptop_4)
* 시트 숨기기 · 보이기 · 시트 탭 색. 저장 자리 `Sheet.숨김` · `Sheet.탭색`(0 계약에 방금 더함).
* 이 파일은 관리 창(전체 시트 목록 + 숨김 · 색 조작)만 그림 — 실제 탭 줄(`spreadsheet_tabs.ts`,
* E 파일)을 안 건드림.
*
* 잇는 법(E `spreadsheet_tabs.ts` · sub7 — 지금은 하나도 안 이어짐):
* 1) `mountTabs` 의 `render()` 가 `ctx.book.시트` 를 돌 때 `숨김` 인 시트는 탭을 안 그림.
* 2) 탭 엘리먼트에 `sheet.탭색` 이 있으면 밑줄 · 배경을 그 색으로(엑셀처럼).
* 3) 숨은 시트를 다시 보이려면 탭 우클릭 메뉴에 [숨긴 시트 보이기] 항목을 두고 이 파일의
* `createSheetExtraPanel(ctx)` 를 열거나, `showSheet(ctx, id)` 를 바로 부름.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { Sheet } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함.
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
}
/** 시트를 숨김 — 마지막 보이는 시트는 못 숨김(까닭 글 돌려줌 · 되면 null). 활성 시트를 숨기면
* 다른 보이는 시트로 활성을 옮김. */
export function hideSheet(ctx: SpreadsheetContext, id: string): string | null {
const others = ctx.book.시트.filter((s) => s.id !== id && !s.숨김);
if (!others.length) return "마지막 보이는 시트는 못 숨김";
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet || sheet.숨김) return null;
applySheet(ctx, id, { ...sheet, 숨김: true });
if (ctx.book.활성 === id) ctx.showSheet(others[0].id);
return null;
}
/** 숨긴 시트를 다시 보임. */
export function unhideSheet(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet || !sheet.숨김) return;
applySheet(ctx, id, { ...sheet, 숨김: false });
}
/** 탭 색 — `color` 가 null 이면 색을 지움(기본 탭 색으로). */
export function setSheetTabColor(ctx: SpreadsheetContext, id: string, color: string | null): void {
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet) return;
const next: Sheet = { ...sheet };
if (color) next.탭색 = color;
else delete next.탭색;
applySheet(ctx, id, next);
}
/** 시트 관리 창 — 전체 시트 목록 + 숨김 토글 · 탭 색 고르기. */
export function createSheetExtraPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-sheetextra" });
const list = el("ul", { className: "ss-sheetextra__list" });
let error: HTMLElement | null = null;
const showError = (msg: string | null): void => {
error?.remove();
error = null;
if (!msg) return;
error = el("p", { text: msg });
error.style.color = "var(--color-danger, red)";
root.append(error);
};
function render(): void {
list.replaceChildren(
...ctx.book.시트.map((sheet) => {
const li = el("li", { className: "ss-sheetextra__row" });
const label = el("span", { text: sheet.이름 + (sheet.숨김 ? "(숨김)" : "") });
const colorInput = el("input", {
attrs: { type: "color", value: sheet.탭색 ?? "#ffffff" },
}) as HTMLInputElement;
colorInput.addEventListener("change", () =>
setSheetTabColor(ctx, sheet.id, colorInput.value),
);
const colorClear = createButton({ label: "색 지움", variant: "ghost" });
colorClear.addEventListener("click", () => {
setSheetTabColor(ctx, sheet.id, null);
render();
});
const toggle = createButton({
label: sheet.숨김 ? "보이기" : "숨기기",
variant: "ghost",
});
toggle.addEventListener("click", () => {
if (sheet.숨김) {
unhideSheet(ctx, sheet.id);
showError(null);
} else {
showError(hideSheet(ctx, sheet.id));
}
render();
});
li.append(label, colorInput, colorClear, toggle);
return li;
}),
);
}
root.append(el("h4", { text: "시트 관리" }), list);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,359 @@
/* =============================================================================
* spreadsheet_shortcuts.ts (2단계 · 주인 sub_laptop_1)
* 엑셀 단축키 전수 표 — 데이터 파일(동작은 안 함). 키마다 뜻 · 이을 동작 이름 · 지금 상태(됨 ·
* 안됨 · 범위밖) · 자리(됨이면 어디 있는지 · 안됨이면 어디 만들지 추천)를 적어 둠.
* 2026-09-27 `spreadsheet_keys.ts`(D · sub7) 를 읽고 「됨」 을 매김 — keys.ts 가 바뀌면 이 표도 다시 맞출 것.
*
* 잇는 법(D · sub7) — `spreadsheet_keys.ts` 는 지금 각 키를 switch 문에 직접 심어 둠(표를 돌며 찾지
* 않음). 이 표는 ① 빠짐없이 다 있는지 볼 대조표 ② 「안됨」 항목을 새로 심을 때 이름표(동작 이름을
* 그대로 함수 이름으로 씀) 로 씀. 새로 심으면 이 파일의 그 항목 `상태` 를 "됨" 으로 바꾸고 `자리` 를 채움.
* 개발 중 대조가 필요하면 `findShortcut("Ctrl+B")` 로 찾아 콘솔에 남기는 정도로만 씀(런타임 배선 아님).
* ========================================================================== */
export type ShortcutState = "됨" | "안됨" | "범위밖";
export interface ShortcutEntry {
키: string;
뜻: string;
/** 이을 함수 이름(식별자) — 새로 심을 때 이 이름을 그대로 씀 */
동작: string;
상태: ShortcutState;
/** 「됨」이면 있는 자리 · 「안됨」이면 만들 자리 추천 · 「범위밖」이면 왜 안 하는지 */
자리: string;
}
export const SHORTCUTS: ShortcutEntry[] = [
// ── 이동 ──────────────────────────────────────────────────────────────
{ 키: "방향키", 뜻: "한 칸 이동", 동작: "move", 상태: "됨", 자리: "keys.ts navKey" },
{
키: "Shift+방향키",
뜻: "고름 늘리기",
동작: "extendSelection",
상태: "됨",
자리: "keys.ts navKey(move extend)",
},
{
키: "Ctrl+방향키",
뜻: "자료 끝으로 건너뜀",
동작: "jumpToEdge",
상태: "됨",
자리: "keys.ts navKey(move jump)",
},
{
키: "Ctrl+Shift+방향키",
뜻: "자료 끝까지 고름",
동작: "extendToEdge",
상태: "됨",
자리: "keys.ts navKey(move extend+jump)",
},
{ 키: "Home", 뜻: "그 행 맨 앞 칸", 동작: "moveHome", 상태: "됨", 자리: "keys.ts navKey" },
{
키: "Ctrl+Home",
뜻: "A1 로",
동작: "moveToStart",
상태: "됨",
자리: "keys.ts navKey(Ctrl+Home)",
},
{
키: "Ctrl+End",
뜻: "마지막으로 쓴 칸으로",
동작: "moveToUsedEnd",
상태: "됨",
자리: "keys.ts navKey(Ctrl+End)",
},
{
키: "PageUp/PageDown",
뜻: "화면 높이만큼 위 · 아래",
동작: "movePage",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Tab/Shift+Tab",
뜻: "확정 뒤 옆 · 앞 칸",
동작: "nextCell",
상태: "됨",
자리: "keys.ts editingKey · navKey",
},
{
키: "Enter/Shift+Enter",
뜻: "확정 뒤 아래 · 위 칸",
동작: "nextRow",
상태: "됨",
자리: "keys.ts editingKey · navKey",
},
{
키: "Ctrl+PageDown/PageUp",
뜻: "다음 · 이전 시트 탭",
동작: "nextSheet",
상태: "안됨",
자리: "spreadsheet_tabs.ts 에 만들고 keys.ts navKey 에 연결",
},
// ── 고르기 ────────────────────────────────────────────────────────────
{
키: "Ctrl+A",
뜻: "시트 전체 고름",
동작: "selectAllCells",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Ctrl+Space",
뜻: "그 열 전체 고름",
동작: "selectColumn",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Shift+Space",
뜻: "그 행 전체 고름",
동작: "selectRow",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Ctrl+클릭",
뜻: "범위를 하나 더 고름(여러 범위)",
동작: "addRangeToSelection",
상태: "안됨",
자리: "spreadsheet_selection.ts 확장 — spreadsheet_autofit.ts 머리에도 자리 적어 둠",
},
// ── 편집 ──────────────────────────────────────────────────────────────
{
키: "F2",
뜻: "칸 편집 시작 · 편집 방식 바꿈",
동작: "toggleEditMode",
상태: "됨",
자리: "keys.ts navKey/editingKey",
},
{
키: "Delete",
뜻: "내용만 지움(서식은 남김)",
동작: "clearContents",
상태: "됨",
자리: "keys.ts navKey",
},
{
키: "Backspace",
뜻: "지우고 새로 침",
동작: "clearAndType",
상태: "됨",
자리: "keys.ts navKey",
},
{ 키: "Esc", 뜻: "편집 취소", 동작: "cancelEdit", 상태: "됨", 자리: "keys.ts editingKey" },
{
키: "Alt+Enter",
뜻: "칸 안에서 줄바꿈",
동작: "insertLineBreak",
상태: "됨",
자리: "keys.ts editingKey",
},
{
키: "Ctrl+Enter",
뜻: "고른 칸 전부에 같은 값",
동작: "fillAllSelected",
상태: "됨",
자리: "keys.ts editingKey(commitAll)",
},
{
키: "Ctrl+D",
뜻: "맨 위 칸 값을 아래로 채움",
동작: "fillDown",
상태: "됨",
자리: "keys.ts navKey(fill)",
},
{
키: "Ctrl+R",
뜻: "맨 왼 칸 값을 오른쪽으로 채움",
동작: "fillRight",
상태: "됨",
자리: "keys.ts navKey(fill)",
},
{
키: "Ctrl+;",
뜻: "오늘 날짜를 침",
동작: "insertToday",
상태: "안됨",
자리: "날짜 형식이 1단계 밖이라 보류 — keys.ts navKey 에 새 case",
},
{
키: "Ctrl+Shift+;",
뜻: "지금 시각을 침",
동작: "insertNow",
상태: "안됨",
자리: "위와 같음(날짜 보류)",
},
{ 키: "Ctrl+Z", 뜻: "되돌리기", 동작: "undo", 상태: "됨", 자리: "keys.ts navKey" },
{ 키: "Ctrl+Y", 뜻: "다시", 동작: "redo", 상태: "됨", 자리: "keys.ts navKey" },
{
키: "Ctrl+-",
뜻: "칸 · 행 · 열 지우기(당겨 채움)",
동작: "deleteCells",
상태: "안됨",
자리: "대화상자(방향 고르기) 필요 — keys.ts navKey 에 새 case",
},
{
키: "Ctrl+Shift++",
뜻: "칸 · 행 · 열 넣기(밀어냄)",
동작: "insertCells",
상태: "안됨",
자리: "위와 같음(대화상자 필요)",
},
{
키: "Ctrl+9",
뜻: "고른 행 숨김",
동작: "hideRows",
상태: "안됨",
자리: "「숨김」 명령은 이미 있음 — keys.ts navKey 에 새 case만",
},
{ 키: "Ctrl+0", 뜻: "고른 열 숨김", 동작: "hideColumns", 상태: "안됨", 자리: "위와 같음" },
{ 키: "Ctrl+Shift+9", 뜻: "행 숨김 풀기", 동작: "unhideRows", 상태: "안됨", 자리: "위와 같음" },
{
키: "Ctrl+Shift+0",
뜻: "열 숨김 풀기",
동작: "unhideColumns",
상태: "안됨",
자리: "위와 같음",
},
{
키: "F4",
뜻: "식 편집 중 참조를 상대 → 절대 → 혼합으로 돌림",
동작: "cycleReferenceAbs",
상태: "안됨",
자리: "spreadsheet_editor.ts 편집 중 커서 위치 참조를 찾아 고침",
},
{
키: "Alt+=",
뜻: "위(왼) 수를 모아 SUM 자동 완성",
동작: "autoSum",
상태: "안됨",
자리: "keys.ts navKey — 위쪽 연속 수 범위를 찾아 `=SUM(...)` 를 침",
},
// ── 복사 · 붙여넣기 ──────────────────────────────────────────────────
{
키: "Ctrl+C",
뜻: "복사",
동작: "copySelection",
상태: "됨",
자리: "spreadsheet_clipboard.ts(E)",
},
{
키: "Ctrl+X",
뜻: "잘라내기",
동작: "cutSelection",
상태: "됨",
자리: "spreadsheet_clipboard.ts(E)",
},
{
키: "Ctrl+V",
뜻: "붙여넣기",
동작: "pasteSelection",
상태: "됨",
자리: "spreadsheet_clipboard.ts(E)",
},
{
키: "Ctrl+Shift+V",
뜻: "붙여넣기 골라서(값만 · 서식만 · 수식만)",
동작: "pasteSpecialDialog",
상태: "됨",
자리: "keys.ts navKey hook → spreadsheet_paste_special.ts",
},
// ── 찾기 ──────────────────────────────────────────────────────────────
{
키: "Ctrl+F",
뜻: "찾기",
동작: "openFind",
상태: "됨",
자리: "keys.ts navKey hook → spreadsheet_find_panel.ts",
},
{
키: "Ctrl+H",
뜻: "바꾸기",
동작: "openReplace",
상태: "됨",
자리: "keys.ts navKey hook → spreadsheet_find_panel.ts",
},
{ 키: "Shift+F5", 뜻: "찾기(엑셀 버릇)", 동작: "openFind", 상태: "됨", 자리: "keys.ts navKey" },
// ── 메모 ──────────────────────────────────────────────────────────────
{
키: "Shift+F2",
뜻: "메모 넣기 · 고치기",
동작: "editComment",
상태: "됨",
자리: "keys.ts navKey hook → spreadsheet_comments.ts",
},
// ── 서식 ──────────────────────────────────────────────────────────────
{
키: "Ctrl+1",
뜻: "셀 서식 대화상자",
동작: "openFormatDialog",
상태: "됨",
자리: "spreadsheet_format_dialog.ts(오늘 새로 · 2026-09-27) — keys.ts 에 hook 연결만 남음",
},
{
키: "Ctrl+B",
뜻: "굵게 토글",
동작: "toggleBold",
상태: "안됨",
자리: "keys.ts navKey 새 case — 서식 명령 { 굵게: !지금값 }",
},
{ 키: "Ctrl+I", 뜻: "기울임 토글", 동작: "toggleItalic", 상태: "안됨", 자리: "위와 같음" },
{ 키: "Ctrl+U", 뜻: "밑줄 토글", 동작: "toggleUnderline", 상태: "안됨", 자리: "위와 같음" },
{ 키: "Ctrl+5", 뜻: "취소선 토글", 동작: "toggleStrikethrough", 상태: "안됨", 자리: "위와 같음" },
{
키: "Ctrl+Shift+&",
뜻: "바깥 테두리 넣기",
동작: "addOutlineBorder",
상태: "안됨",
자리: "keys.ts navKey 새 case — 서식 명령 테두리 네 변",
},
{
키: "Ctrl+Shift+_",
뜻: "테두리 없앰",
동작: "clearBorders",
상태: "안됨",
자리: "keys.ts navKey 새 case",
},
// ── 시트 ──────────────────────────────────────────────────────────────
{
키: "Shift+F11",
뜻: "새 시트",
동작: "newSheet",
상태: "안됨",
자리: "spreadsheet_tabs.ts 버튼은 있을 수 있음 — 단축키만 keys.ts 에 더함",
},
// ── 범위 밖(Aislo 는 다른 방식이라 안 함) ────────────────────────────
{
키: "Ctrl+S/O/N",
뜻: "저장 · 열기 · 새 파일",
동작: "—",
상태: "범위밖",
자리: "Aislo 는 [저장] 버튼 · 서버 파일이라 브라우저 단축키를 흉내 안 냄",
},
{
키: "F9",
뜻: "손으로 다시 계산",
동작: "—",
상태: "범위밖",
자리: "자동 재계산이라 손으로 다시 계산할 일이 없음",
},
];
const norm = (key: string): string => key.replace(/\s+/g, "").toLowerCase();
export function findShortcut(키: string): ShortcutEntry | null {
const k = norm(키);
return SHORTCUTS.find((s) => norm(s.키) === k) ?? null;
}
export function unimplemented(): ShortcutEntry[] {
return SHORTCUTS.filter((s) => s.상태 === "안됨");
}
@@ -0,0 +1,180 @@
/* =============================================================================
* spreadsheet_sort_dialog.ts (주인 A · 화면)
* 정렬 대화 상자 · 빠른 정렬(오름 · 내림). 엔진은 `spreadsheet_sortfilter.ts`(명령 `정렬`).
* 범위 = 고른 범위 · 칸 하나만 골랐으면 이어진 값 덩어리(엑셀 「현재 영역」) · 머리 행은 짐작해 켬.
*
* 잇는 법(D · sub7):
* · 도구 모음 · 메뉴 「정렬…」 → `openSortDialog(ctx)` — 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
* · 「오름차순」 「내림차순」 단추 → `sortSelection(ctx, 내림)` — 활성 칸 열을 기준으로 바로 정렬.
* · 막히면(병합 걸침 등) 토스트로 까닭을 보임 · 되돌리기는 `ctx.dispatch` 가 쌓음.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName, rangeToA1 } from "./spreadsheet_address";
import { CommandError } from "./spreadsheet_commands";
import { currentRegion, displayText, sortCommand } from "./spreadsheet_sortfilter";
import type { SortKey, SortOptions } from "./spreadsheet_sortfilter";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_sortfilter.css";
const TEXT = {
Title: ["정렬", "Sort"],
Range: ["범위", "Range"],
Header: ["머리 행 있음", "My data has headers"],
Case: ["대소문자 구분", "Case sensitive"],
Column: ["열", "Column"],
Asc: ["오름차순", "Ascending"],
Desc: ["내림차순", "Descending"],
AddKey: ["기준 추가", "Add level"],
RemoveKey: ["빼기", "Remove"],
Cancel: ["취소", "Cancel"],
Ok: ["정렬", "Sort"],
};
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
/** 정렬 범위 — 칸 하나면 현재 영역 */
function targetRange(ctx: SpreadsheetContext): CellRange {
const g = ctx.selection.범위[0];
if (g.r0 === g.r1 && g.c0 === g.c1)
return currentRegion(ctx.book, ctx.selection.시트, g.r0, g.c0);
return g;
}
/** 머리 행 짐작 — 첫 행이 모두 글이고 둘째 행에 글 아닌 값이 있으면 머리 */
function guessHeader(ctx: SpreadsheetContext, rg: CellRange): boolean {
if (rg.r0 === rg.r1) return false;
const v = (r: number, c: number) => ctx.engine.value(ctx.selection.시트, r, c);
let allText = true;
let secondOther = false;
for (let c = rg.c0; c <= rg.c1; c++) {
const a = v(rg.r0, c);
const b = v(rg.r0 + 1, c);
if (a !== null && typeof a !== "string") allText = false;
if (b !== null && typeof b !== "string") secondOther = true;
}
return allText && secondOther;
}
function run(ctx: SpreadsheetContext, rg: CellRange, opts: SortOptions): boolean {
try {
ctx.dispatch(sortCommand(ctx.book, ctx.engine, ctx.selection.시트, rg, opts));
return true;
} catch (e) {
if (!(e instanceof CommandError)) throw e;
showToast(e.message, "error");
return false;
}
}
/** 활성 칸 열 기준 바로 정렬 */
export function sortSelection(ctx: SpreadsheetContext, desc: boolean): void {
if (ctx.readOnly) return;
const rg = targetRange(ctx);
const c = Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1);
run(ctx, rg, { 머리: guessHeader(ctx, rg), 기준: [{ 열: c, 내림: desc }] });
}
/** 정렬 대화 상자 — 닫는 함수를 돌려줌 */
export function openSortDialog(ctx: SpreadsheetContext): () => void {
const rg = targetRange(ctx);
const sheet = ctx.selection.시트;
const keys: SortKey[] = [{ 열: Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1) }];
const header = el("input", { attrs: { type: "checkbox" } });
header.checked = guessHeader(ctx, rg);
const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" });
const box = el("div", {
className: "ss-sort__box",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-sort", children: [box] });
const columnLabel = (c: number) => {
const head = header.checked ? displayText(ctx.book, ctx.engine, sheet, rg.r0, c) : "";
return head ? `${colName(c)}: ${head}` : `${t("Column")} ${colName(c)}`;
};
function drawKeys() {
list.replaceChildren(
...keys.map((k, i) => {
const col = el("select", { attrs: { "aria-label": t("Column") } });
for (let c = rg.c0; c <= rg.c1; c++)
col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } }));
col.value = String(k.열);
col.addEventListener("change", () => (k.열 = Number(col.value)));
const dir = el("select", {
children: [
el("option", { text: t("Asc"), attrs: { value: "asc" } }),
el("option", { text: t("Desc"), attrs: { value: "desc" } }),
],
});
dir.value = k.내림 ? "desc" : "asc";
dir.addEventListener("change", () => (k.내림 = dir.value === "desc"));
const remove = createButton({
label: t("RemoveKey"),
variant: "ghost",
disabled: keys.length === 1,
onClick: () => {
keys.splice(i, 1);
drawKeys();
},
});
return el("div", { className: "ss-sort__row", children: [col, dir, remove] });
}),
);
}
const close = () => {
shade.remove();
document.removeEventListener("keydown", onKey, true);
};
const ok = () => {
if (run(ctx, rg, { 머리: header.checked, 기준: keys, 대소문자: caseBox.checked })) close();
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") close();
else if (ev.key === "Enter" && !(ev.target instanceof HTMLSelectElement)) ok();
else return;
ev.preventDefault();
ev.stopPropagation();
};
header.addEventListener("change", drawKeys);
box.append(
el("div", { className: "ss-sort__title", text: t("Title") }),
el("div", { className: "ss-sort__meta", text: `${t("Range")} ${rangeToA1(rg)}` }),
el("label", { className: "ss-sort__meta", children: [header, t("Header")] }),
el("label", { className: "ss-sort__meta", children: [caseBox, t("Case")] }),
list,
el("div", {
className: "ss-sort__row",
children: [
createButton({
label: t("AddKey"),
variant: "ghost",
onClick: () => {
keys.push({ 열: rg.c0 });
drawKeys();
},
}),
],
}),
el("div", {
className: "ss-sort__foot",
children: [
createButton({ label: t("Cancel"), variant: "ghost", onClick: close }),
createButton({ label: t("Ok"), onClick: ok }),
],
}),
);
shade.addEventListener("mousedown", (ev) => {
if (ev.target === shade) close();
});
document.addEventListener("keydown", onKey, true);
drawKeys();
ctx.root.append(shade);
return close;
}
@@ -0,0 +1,134 @@
/* spreadsheet_sortfilter.css (주인 A) — 정렬 대화 상자 · 자동 필터 단추 · 값 목록 창 */
.ss-sort {
position: absolute;
inset: 0;
z-index: var(--z-dropdown);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 18%);
}
.ss-sort__box,
.ss-filter__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
background: var(--color-surface-raised);
color: var(--color-text-body);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
font-size: 13px;
}
.ss-sort__box {
min-width: 360px;
max-width: 90%;
}
.ss-sort__title {
font-weight: 600;
}
.ss-sort__meta,
.ss-sort__row,
.ss-sort__foot,
.ss-filter__foot {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.ss-sort__meta {
color: var(--color-text-secondary);
}
.ss-sort__row select {
flex: 1;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: inherit;
}
.ss-sort__row option {
background: var(--color-canvas);
color: var(--color-text-body);
}
.ss-sort__foot,
.ss-filter__foot {
justify-content: flex-end;
}
/* 필터 단추 층 — 크기 0(격자 조작을 막지 않음) · 단추만 누름 */
.ss-filter__layer {
position: absolute;
left: 0;
top: 0;
z-index: 5;
width: 0;
height: 0;
overflow: visible;
}
.ss-filter__btn {
position: absolute;
width: 16px;
height: 16px;
padding: 0;
font-size: 9px;
line-height: 14px;
text-align: center;
cursor: pointer;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-filter__btn.is-on {
color: var(--color-royal-amethyst);
border-color: var(--color-royal-amethyst);
}
.ss-filter__panel {
position: absolute;
z-index: var(--z-dropdown);
width: 220px;
}
.ss-filter__list {
display: flex;
flex-direction: column;
max-height: 220px;
overflow: auto;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
}
.ss-filter__list label {
display: flex;
gap: var(--spacing-4);
align-items: center;
white-space: nowrap;
}
.ss-filter__link {
padding: 2px var(--spacing-4);
text-align: left;
background: none;
border: none;
color: inherit;
cursor: pointer;
}
.ss-filter__link:hover {
background: var(--color-mist-violet);
}
@@ -0,0 +1,289 @@
/* =============================================================================
* spreadsheet_sortfilter.ts (주인 A)
* 정렬 · 자동 필터 엔진 — DOM 모름. 값 비교 · 보이는 글은 엔진(`CalcEngine`) · 숫자 형식(B)으로 셈.
* 명령은 결과(정렬 차례 · 걸러진 행)를 박아 둠 → 다시 하기가 같은 결과 · 적용은 `applyCommand` 가 여기로 넘김.
*
* 엑셀 규칙:
* 정렬 차례 — 오름: 수 < 글 < 참거짓(FALSE<TRUE) < 오류 · 내림은 거꾸로 · 빈 칸은 늘 맨 뒤 · 같은 값은 원래 차례.
* 글은 대소문자 가리지 않음(옵션으로 가림 = 소문자 먼저). 값은 계산값(식 칸은 푼 값).
* 정렬 옮김 — 범위 열만 행째 옮김 · 식은 복사처럼 상대 참조만 옮김(범위 밖에서 범위를 가리키던 식은 그대로) ·
* 서식 · 메모도 같이 · 행 높이는 제자리 · 걸러진 행은 제자리(보이는 행끼리만 정렬) · 병합이 걸치면 막음.
* 필터 — 첫 행 = 머리 · 값 목록 = 보이는 글(숫자 형식 적용) · 빈 칸 = "" · 조건 안 맞는 행은 `걸러짐`
* (사용자 숨김과 따로) · 행열을 넣고 지우면 필터 범위 · 조건 열도 따라감 · 끄면 걸러짐 모두 풂.
*
* 잇는 법(D · sub7 · C · sub6):
* · 정렬: 대화 상자 `openSortDialog(ctx)`(spreadsheet_sort_dialog.ts) · 도구 단추 `sortSelection(ctx, 내림)`.
* · 필터: `attachFilter(ctx)`(spreadsheet_filter.ts) 를 부품 목록에 붙이고 grid.render() 뒤 `refresh()` ·
* 도구 단추 · Ctrl+Shift+L = `toggleFilter(ctx)`.
* · 격자(C): 행 `걸러짐` 도 `숨김` 처럼 높이 0 — 머리의 숨김 표시는 사용자 `숨김` 만.
* ========================================================================== */
import { formatValue } from "./spreadsheet_numfmt";
import {
colIndex,
colName,
parseRange,
rangesOverlap,
rangeToA1,
toA1,
} from "./spreadsheet_address";
import { clone, CommandError, sheetOf } from "./spreadsheet_commands";
import type { Of } from "./spreadsheet_commands";
import { compareValues, isError, isFrac } from "./spreadsheet_eval";
import { moveFormula, shiftSpan } from "./spreadsheet_refshift";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
AutoFilter,
CalcEngine,
Cell,
CellRange,
Command,
CommandEffect,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
export interface SortKey {
/** 기준 열(0 부터 · 범위 안) */
열: number;
내림?: boolean;
}
export interface SortOptions {
/** 첫 행 = 머리(정렬 안 함) */
머리: boolean;
기준: SortKey[];
/** 글 대소문자 가림 */
대소문자?: boolean;
}
const filtered = (s: Sheet, r: number) => !!s.행?.[r + 1]?.걸러짐;
const rank = (v: Scalar) =>
isFrac(v) ? 0 : typeof v === "string" ? 1 : typeof v === "boolean" ? 2 : 3;
const blank = (v: Scalar) => v === null || v === "";
/** 엑셀 정렬 비교 — 빈 칸은 오름 · 내림 모두 맨 뒤 */
export function sortCompare(a: Scalar, b: Scalar, desc = false, caseSensitive = false): number {
if (blank(a) || blank(b)) return blank(a) === blank(b) ? 0 : blank(a) ? 1 : -1;
let d = rank(a) - rank(b);
if (d === 0 && !isError(a)) {
if (typeof a === "string" && caseSensitive) d = COLLATE_CASE.compare(a, b as string);
else if (typeof a === "string") d = COLLATE.compare(a, b as string);
else d = compareValues(a, b);
}
return desc ? -d : d;
}
const COLLATE = new Intl.Collator("ko", { sensitivity: "accent" });
const COLLATE_CASE = new Intl.Collator("ko", { sensitivity: "variant", caseFirst: "lower" });
/** 칸 하나에서 이어진 값 덩어리(엑셀 「현재 영역」) — 빈 칸만 둘러싼 테두리까지 늘림 */
export function currentRegion(book: Workbook, sheetId: string, r: number, c: number): CellRange {
const s = sheetOf(book, sheetId);
const filled = new Set<string>();
for (const [a1, cell] of Object.entries(s.칸))
if (cell.값 !== undefined || cell.식 !== undefined) filled.add(a1);
const has = (rr: number, cc: number) =>
rr >= 0 && cc >= 0 && rr < MAX_ROWS && cc < MAX_COLS && filled.has(toA1(rr, cc));
const g = { r0: r, c0: c, r1: r, c1: c };
for (let grew = true; grew;) {
grew = false;
const rowHas = (rr: number) => {
for (let cc = g.c0 - 1; cc <= g.c1 + 1; cc++) if (has(rr, cc)) return true;
return false;
};
const colHas = (cc: number) => {
for (let rr = g.r0 - 1; rr <= g.r1 + 1; rr++) if (has(rr, cc)) return true;
return false;
};
if (g.r0 > 0 && rowHas(g.r0 - 1)) (g.r0--, (grew = true));
if (g.r1 < MAX_ROWS - 1 && rowHas(g.r1 + 1)) (g.r1++, (grew = true));
if (g.c0 > 0 && colHas(g.c0 - 1)) (g.c0--, (grew = true));
if (g.c1 < MAX_COLS - 1 && colHas(g.c1 + 1)) (g.c1++, (grew = true));
}
return g;
}
function checkMerges(s: Sheet, range: CellRange) {
const hit = (s.병합 ?? []).some((m) => {
const rg = parseRange(m);
return rg && rangesOverlap(rg, range);
});
if (hit) throw new CommandError("병합한 칸이 든 범위는 정렬하지 못함 — 병합을 먼저 풂");
}
// ── 정렬 ───────────────────────────────────────────────────────────────────
/** 정렬 명령 만들기 — 값은 엔진 계산값으로 견줌 · 걸러진 행은 제자리 */
export function sortCommand(
book: Workbook,
engine: CalcEngine,
sheetId: string,
range: CellRange,
opts: SortOptions,
): Command {
const s = sheetOf(book, sheetId);
const data: CellRange = { ...range, r0: range.r0 + (opts.머리 ? 1 : 0) };
if (data.r0 > data.r1) throw new CommandError("정렬할 행이 없음");
if (!opts.기준.length) throw new CommandError("정렬 기준이 없음");
for (const k of opts.기준)
if (k.열 < range.c0 || k.열 > range.c1) throw new CommandError("정렬 기준 열이 범위 밖");
checkMerges(s, data);
const rows: number[] = [];
for (let r = data.r0; r <= data.r1; r++) if (!filtered(s, r)) rows.push(r);
const keys = new Map(rows.map((r) => [r, opts.기준.map((k) => engine.value(sheetId, r, k.열))]));
const sorted = [...rows].sort((a, b) => {
const va = keys.get(a)!;
const vb = keys.get(b)!;
for (let i = 0; i < opts.기준.length; i++) {
const d = sortCompare(va[i], vb[i], opts.기준[i].내림, opts.대소문자);
if (d) return d;
}
return a - b;
});
const order: number[] = [];
let next = 0;
for (let r = data.r0; r <= data.r1; r++) order.push(filtered(s, r) ? r : sorted[next++]);
return { 종류: "정렬", 시트: sheetId, 범위: data, 차례: order };
}
export function applySort(book: Workbook, cmd: Of<"정렬">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const { r0, r1, c0, c1 } = cmd.범위;
const n = r1 - r0 + 1;
if (
cmd.차례.length !== n ||
new Set(cmd.차례).size !== n ||
cmd.차례.some((r) => r < r0 || r > r1)
)
throw new CommandError("정렬 차례가 범위와 안 맞음");
checkMerges(s, cmd.범위);
const before = clone(s);
const take = <T>(table: Record<string, T> | undefined) => {
const out = new Map<string, T>();
for (let r = r0; r <= r1; r++)
for (let c = c0; c <= c1; c++) {
const a1 = toA1(r, c);
if (table && a1 in table) {
out.set(a1, table[a1]);
delete table[a1];
}
}
return out;
};
const cells = take<Cell>(s.칸);
const notes = take<string>(s.comments);
cmd.차례.forEach((src, i) => {
const to = r0 + i;
for (let c = c0; c <= c1; c++) {
const cell = cells.get(toA1(src, c));
if (cell) {
const moved = { ...cell };
if (moved.식 !== undefined) moved.식 = moveFormula(moved.식, to - src, 0);
s.칸[toA1(to, c)] = moved;
}
const note = notes.get(toA1(src, c));
if (note !== undefined) (s.comments ??= {})[toA1(to, c)] = note;
}
});
if (s.comments && !Object.keys(s.comments).length) delete s.comments;
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: true };
}
// ── 자동 필터 ──────────────────────────────────────────────────────────────
/** 칸의 보이는 글(숫자 형식 적용) — 빈 칸 "" */
export function displayText(
book: Workbook,
engine: CalcEngine,
sheetId: string,
r: number,
c: number,
) {
const s = sheetOf(book, sheetId);
const cell = s.칸[toA1(r, c)];
if (!cell || (cell.값 === undefined && cell.식 === undefined)) return "";
const idx = cell.서식 ?? s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? s.기본?.서식 ?? 0;
return formatValue(engine.value(sheetId, r, c), book.서식[idx]?.형식).글;
}
/** 필터 단추 목록 — 열의 서로 다른 보이는 글(정렬 차례 · 빈 칸 맨 뒤) · 켬 = 지금 보이는 값 */
export function filterItems(
book: Workbook,
engine: CalcEngine,
sheetId: string,
c: number,
): { 글: string; 켬: boolean }[] {
const s = sheetOf(book, sheetId);
const f = s.필터 && parseRange(s.필터.범위);
if (!f) return [];
const allowed = s.필터!.조건?.[colName(c)];
const seen = new Map<string, Scalar>();
for (let r = f.r0 + 1; r <= f.r1; r++) {
const text = displayText(book, engine, sheetId, r, c);
if (!seen.has(text)) seen.set(text, text === "" ? null : engine.value(sheetId, r, c));
}
return [...seen]
.sort((a, b) => sortCompare(a[1], b[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([글]) => ({ 글, 켬: !allowed || allowed.includes(글) }));
}
/** 필터 명령 — 범위 · 조건으로 걸러질 행을 셈(null = 필터 끔) */
export function filterCommand(
book: Workbook,
engine: CalcEngine,
sheetId: string,
filter: AutoFilter | null,
): Command {
if (!filter) return { 종류: "필터", 시트: sheetId, 필터: null, 걸러짐: [] };
const f = parseRange(filter.범위);
if (!f) throw new CommandError(`필터 범위 틀림: ${filter.범위}`);
const hidden: number[] = [];
const conds = Object.entries(filter.조건 ?? {}).map(
([col, list]) => [colIndex(col), new Set(list)] as const,
);
for (let r = f.r0 + 1; r <= f.r1; r++)
if (conds.some(([c, ok]) => !ok.has(displayText(book, engine, sheetId, r, c)))) hidden.push(r);
return { 종류: "필터", 시트: sheetId, 필터: filter, 걸러짐: hidden };
}
export function applyFilter(book: Workbook, cmd: Of<"필터">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const before = clone(s);
if (cmd.필터) s.필터 = clone(cmd.필터);
else delete s.필터;
const table = (s.행 ??= {});
for (const [k, info] of Object.entries(table)) {
delete info.걸러짐;
if (!Object.keys(info).length) delete table[k];
}
for (const r of cmd.걸러짐) (table[String(r + 1)] ??= {}).걸러짐 = true;
if (!Object.keys(table).length) delete s.행;
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
}
/** 행열 넣기 · 지우기 때 필터 범위 · 조건 열 따라감(머리 행 · 끝 열까지 지우면 필터 없어짐) */
export function shiftFilter(s: Sheet, rows: boolean, insert: boolean, at: number, n: number) {
const f = s.필터 && parseRange(s.필터.범위);
if (!s.필터 || !f) return;
const sp = rows
? shiftSpan(f.r0, f.r1, insert, at, n, MAX_ROWS)
: shiftSpan(f.c0, f.c1, insert, at, n, MAX_COLS);
const headGone = rows && !insert && f.r0 >= at && f.r0 < at + n;
if (!sp || headGone) {
delete s.필터;
return;
}
const next = rows ? { ...f, r0: sp[0], r1: sp[1] } : { ...f, c0: sp[0], c1: sp[1] };
s.필터.범위 = rangeToA1(next);
if (rows || !s.필터.조건) return;
const conds: Record<string, string[]> = {};
for (const [col, list] of Object.entries(s.필터.조건)) {
const c = shiftSpan(colIndex(col), colIndex(col), insert, at, n, MAX_COLS);
if (c) conds[colName(c[0])] = list;
}
s.필터.조건 = conds;
}
@@ -0,0 +1,61 @@
/* =============================================================================
* spreadsheet_spill.ts
* 긴 글 넘침(엑셀) — 칸보다 긴 글을 옆 빈 칸 위로 보임. 왼쪽 · 일반 맞춤은 오른쪽으로 · 오른쪽 맞춤은
* 왼쪽으로 · 가운데는 양쪽으로. 비지 않은 칸 · 병합 칸 · 판 끝(틀 고정 경계 · 그린 범위 밖)에서 멈춤.
* 격자(`spreadsheet_grid.ts`)가 판 하나를 그릴 때 줄마다 칸 자리(`slots`)를 모아 부름 — 계산만 여기.
* ========================================================================== */
/** 같은 판 · 같은 줄 · 병합 아닌 칸 하나(병합 칸 · 안 그린 칸은 넣지 않음 → 거기서 멈춤) */
export interface SpillSlot {
w: number;
empty: boolean;
}
/** 넘침 — 왼 · 오른쪽으로 더 쓸 폭(px) · 덮은 칸 열 범위 · 가운데 맞춤이 한쪽만 막혀 잘라 낼 폭 */
export interface Spill {
left: number;
right: number;
from: number;
to: number;
/** 가운데 맞춤 — 글 상자는 양쪽 반씩 늘리고 막힌 쪽은 이만큼 잘라 냄(글은 칸 가운데 그대로) */
clipLeft: number;
clipRight: number;
}
export type SpillAlign = "left" | "right" | "center";
/** 칸 c(폭 own)의 글 폭 need 가 넘치면 정렬 쪽 빈 칸으로 늘린 결과 · 안 넘치거나 못 늘리면 null */
export function spillOf(
slots: Map<number, SpillSlot>,
c: number,
own: number,
need: number,
align: SpillAlign,
): Spill | null {
if (need <= own) return null;
let from = c;
let to = c;
const grow = (dir: -1 | 1, want: number): number => {
let got = 0;
for (let k = c + dir; got < want; k += dir) {
const s = slots.get(k);
if (!s || !s.empty) break;
got += s.w;
if (dir < 0) from = k;
else to = k;
}
return got;
};
if (align === "center") {
const half = (need - own) / 2;
const l = Math.min(grow(-1, half), half);
const r = Math.min(grow(1, half), half);
if (!l && !r) return null;
return { left: half, right: half, from, to, clipLeft: half - l, clipRight: half - r };
}
const got = grow(align === "right" ? -1 : 1, need - own);
if (!got) return null;
return align === "right"
? { left: got, right: 0, from, to, clipLeft: 0, clipRight: 0 }
: { left: 0, right: got, from, to, clipLeft: 0, clipRight: 0 };
}
@@ -0,0 +1,115 @@
/* =============================================================================
* spreadsheet_stage3.css (주인 sub_laptop_4)
* 조건부 서식 · 데이터 유효성 · 이름 정의 · 상태 표시줄 · 시트 관리 창 — 화면 스타일 한 벌.
* 색 · 간격은 테마 토큰만.
* ========================================================================== */
.ss-condfmt,
.ss-validation,
.ss-names,
.ss-sheetextra {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
min-width: 280px;
}
.ss-condfmt__list,
.ss-validation__list,
.ss-names__list,
.ss-sheetextra__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.ss-condfmt__row,
.ss-validation__row,
.ss-names__row,
.ss-sheetextra__row {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.85rem;
}
.ss-condfmt__row > span:first-of-type,
.ss-validation__row > span:first-of-type,
.ss-names__row > span:first-of-type {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.ss-condfmt__swatch {
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid var(--color-border);
border-radius: 2px;
flex-shrink: 0;
}
.ss-condfmt__form,
.ss-validation__form,
.ss-names__form {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
align-items: flex-end;
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-8);
}
.ss-validation__dropdown {
position: absolute;
z-index: 30;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
box-shadow: var(--shadow-sm);
max-height: 160px;
overflow-y: auto;
}
.ss-validation__dropdown-item {
padding: 4px 8px;
cursor: pointer;
font-size: 0.85rem;
}
.ss-validation__dropdown-item:hover {
background: var(--color-mist-violet);
}
.ss-statusbar {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: 2px var(--spacing-8);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
font-size: 0.8rem;
color: var(--color-text-body);
}
.ss-statusbar__stats {
display: flex;
gap: var(--spacing-8);
flex: 1;
color: var(--color-text-body);
}
.ss-statusbar__zoom {
color: var(--color-text-secondary);
}
@@ -0,0 +1,132 @@
/* =============================================================================
* spreadsheet_statusbar.ts (3단계 · 주인 sub_laptop_4)
* 아래 상태 표시줄 — 고른 범위의 합계 · 평균 · 개수(COUNTA) · 숫자 개수(COUNT) · 최소 · 최대.
* 엑셀처럼 우클릭으로 보일 줄 고름(이 표시줄만의 취향 — 문서에는 안 남음 · 계약 손 안 댐).
* 확대 비율 — `ctx.grid.zoom()` 으로 처음 값 · 격자 root 의 `aislo-grid-zoom` 사건(sub6, 2026-09-27)을
* 들어 Ctrl+휠로 바뀔 때마다 다시 씀. `setZoom` 은 아직 계약에 없음(격자만 바꿈 · 상태줄은 읽기만).
* 화면 붙이기는 `spreadsheet.ts`(D)가 `createStatusBar(ctx)` 로 이미 함(root 를 격자 아래).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { isFrac, fracToDouble } from "./spreadsheet_functions_math";
import type { CellRange, Scalar } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함.
export type StatKey = "합계" | "평균" | "개수" | "숫자개수" | "최소" | "최대";
export const STAT_KEYS: StatKey[] = ["합계", "평균", "개수", "숫자개수", "최소", "최대"];
export interface SelectionStats {
합계: number | null;
평균: number | null;
개수: number;
숫자개수: number;
최소: number | null;
최대: number | null;
}
/** 고른 범위(여럿) 안 칸 값을 모아 통계를 냄 — 엔진 값 기준(식 칸도 풀린 값). */
export function computeSelectionStats(ctx: SpreadsheetContext): SelectionStats {
const sheet = ctx.selection.시트;
const nums: number[] = [];
let 개수 = 0;
const seen = new Set<number>();
const visit = (range: CellRange): void => {
for (let r = range.r0; r <= range.r1; r++) {
for (let c = range.c0; c <= range.c1; c++) {
const key = r * 1_000_000 + c;
if (seen.has(key)) continue;
seen.add(key);
const value: Scalar = ctx.engine.value(sheet, r, c);
if (value === null) continue;
개수++;
if (isFrac(value)) nums.push(fracToDouble(value));
}
}
};
for (const range of ctx.selection.범위) visit(range);
return {
합계: nums.length ? nums.reduce((a, b) => a + b, 0) : null,
평균: nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null,
개수,
숫자개수: nums.length,
최소: nums.length ? Math.min(...nums) : null,
최대: nums.length ? Math.max(...nums) : null,
};
}
function statText(key: StatKey, stats: SelectionStats): string {
const value = stats[key];
if (key === "개수" || key === "숫자개수") return `${key}: ${value}`;
return `${key}: ${value === null ? "—" : formatNumber(value)}`;
}
function formatNumber(n: number): string {
return Number.isInteger(n)
? String(n)
: n.toLocaleString(undefined, { maximumFractionDigits: 4 });
}
function zoomText(ratio: number): string {
return `확대 ${Math.round(ratio * 100)}%`;
}
/** 아래 상태 표시줄 — 우클릭 메뉴로 보일 줄 고름. */
export function createStatusBar(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-statusbar" });
const stats = el("div", { className: "ss-statusbar__stats" });
const zoom = el("span", {
className: "ss-statusbar__zoom",
text: zoomText(ctx.grid.zoom()),
});
const menu = createMapContextMenu("ss-statusbar");
root.append(stats, zoom, menu.element);
const onZoom = (ev: Event): void => {
const ratio = (ev as CustomEvent<{ zoom: number }>).detail?.zoom;
if (typeof ratio === "number") zoom.textContent = zoomText(ratio);
};
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
let visible = new Set<StatKey>(STAT_KEYS);
function render(): void {
const s = computeSelectionStats(ctx);
stats.replaceChildren(
...STAT_KEYS.filter((k) => visible.has(k)).map((k) =>
el("span", { className: "ss-statusbar__item", text: statText(k, s) }),
),
);
}
root.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
const box = root.getBoundingClientRect();
const items: MapContextMenuItem[] = STAT_KEYS.map((k) => [
`${visible.has(k) ? "✓ " : ""}${k}`,
() => {
if (visible.has(k)) visible.delete(k);
else visible.add(k);
render();
},
]);
menu.open(ev.clientX - box.left, ev.clientY - box.top, items);
});
render();
return {
root,
refresh: render,
destroy(): void {
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
menu.close();
root.remove();
},
};
}
+190
View File
@@ -0,0 +1,190 @@
/* =============================================================================
* spreadsheet_tabs.ts (주인 E)
* 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 · 숨기기 ·
* 끌어 옮기기 · 복사. 우클릭 메뉴는 `ui_template_context_menu.ts` 재사용.
* 이름 바꾸기 · 지우기 · 옮기기는 명령(`시트이름` · `시트지우기` · `시트옮기기` · `시트더하기`).
* 숨김 · 탭 색은 `spreadsheet_sheet_extra.ts`(sub4)의 `hideSheet` · `unhideSheet` 를 부름 —
* `Sheet.숨김` 인 시트는 탭을 안 그림 · 숨긴 시트 목록은 우클릭 [숨긴 시트 보이기] 하위 메뉴 ·
* `Sheet.탭색` 은 탭 밑줄 색.
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `mountTabs(ctx)` 한 번 부르고 `root` 를 탭 자리(격자 아래)에
* 붙임. `ctx.showSheet` 로 활성 시트가 바뀌면(다른 길로도) `refresh()` 를 불러 다시 그림.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { emptySheet } from "./spreadsheet_commands";
import { hideSheet, unhideSheet } from "./spreadsheet_sheet_extra";
import { st } from "./spreadsheet_text";
import type { Sheet, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_toolbar.css";
/** 없는 `s숫자` 하나 고름. */
function nextSheetId(book: Workbook): string {
const used = new Set(book.시트.map((s) => s.id));
let n = 1;
while (used.has(`s${n}`)) n++;
return `s${n}`;
}
/** 「시트2」 · 「시트2 (2)」 처럼 안 겹치는 이름. */
function nextSheetName(book: Workbook, base: string): string {
const used = new Set(book.시트.map((s) => s.이름));
if (!used.has(base)) return base;
let n = 2;
while (used.has(`${base} (${n})`)) n++;
return `${base} (${n})`;
}
export function mountTabs(ctx: SpreadsheetContext): PartHandle {
const menu = createMapContextMenu("ss");
const root = el("div", { className: "ss-tabs", children: [menu.element] });
let dragging: string | null = null;
function tabEl(id: string): HTMLElement | null {
return root.querySelector<HTMLElement>(`[data-sheet-id="${CSS.escape(id)}"]`);
}
function render(): void {
root.replaceChildren(
menu.element,
...ctx.book.시트.filter((s) => !s.숨김).map(renderTab),
addButton(),
);
}
function renderTab(sheet: Sheet): HTMLElement {
const tab = el("div", {
className: `ss-tabs__tab${sheet.id === ctx.book.활성 ? " is-active" : ""}`,
text: sheet.이름,
attrs: { "data-sheet-id": sheet.id },
});
tab.draggable = true;
if (sheet.탭색) tab.style.borderBottom = `3px solid ${sheet.탭색}`;
tab.addEventListener("click", () => ctx.showSheet(sheet.id));
tab.addEventListener("dblclick", () => beginRename(sheet));
tab.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
const box = root.getBoundingClientRect();
openMenu(sheet, ev.clientX - box.left, ev.clientY - box.top);
});
tab.addEventListener("dragstart", (ev) => {
dragging = sheet.id;
ev.dataTransfer?.setData("text/plain", sheet.id);
});
tab.addEventListener("dragover", (ev) => {
if (!dragging || dragging === sheet.id) return;
ev.preventDefault();
tab.classList.add("is-dragover");
});
tab.addEventListener("dragleave", () => tab.classList.remove("is-dragover"));
tab.addEventListener("drop", (ev) => {
ev.preventDefault();
tab.classList.remove("is-dragover");
if (!dragging || dragging === sheet.id) return;
const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id);
ctx.dispatch({ 종류: "시트옮기기", 시트: dragging, 자리 });
dragging = null;
});
return tab;
}
function beginRename(sheet: Sheet): void {
const tab = tabEl(sheet.id);
if (!tab) return;
const input = el("input", { attrs: { value: sheet.이름 } }) as HTMLInputElement;
tab.replaceChildren(input);
input.focus();
input.select();
const commit = (): void => {
const name = input.value.trim();
if (name && name !== sheet.이름)
ctx.dispatch({ 종류: "시트이름", 시트: sheet.id, 이름: name });
else render();
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") input.blur();
if (ev.key === "Escape") {
input.removeEventListener("blur", commit);
render();
}
});
input.addEventListener("blur", commit);
}
function openMenu(sheet: Sheet, x: number, y: number): void {
const items: MapContextMenuItem[] = [
[st("SheetRename"), () => beginRename(sheet)],
[
st("SheetDuplicate"),
() => {
const copy: Sheet = {
...structuredClone(sheet),
id: nextSheetId(ctx.book),
이름: nextSheetName(ctx.book, sheet.이름),
};
const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id) + 1;
ctx.dispatch({ 종류: "시트더하기", 시트: copy, 자리 });
},
],
[
st("SheetDelete"),
async () => {
if (ctx.book.시트.length <= 1) {
showToast(st("SheetDeleteLastError"), "error");
return;
}
if (await showConfirmDialog(st("SheetDeleteConfirm"))) {
ctx.dispatch({ 종류: "시트지우기", 시트: sheet.id });
}
},
],
[
st("SheetHide"),
() => {
const err = hideSheet(ctx, sheet.id);
if (err) showToast(err, "error");
},
],
];
const hidden = ctx.book.시트.filter((s) => s.숨김);
if (hidden.length) {
items.push({
label: st("SheetUnhideMenu"),
children: hidden.map((s): [string, () => void] => [s.이름, () => unhideSheet(ctx, s.id)]),
});
}
menu.open(x, y, items);
}
function addButton(): HTMLElement {
const btn = el("button", {
className: "ss-tabs__add",
text: "+",
attrs: { type: "button", title: st("SheetAdd") },
});
btn.addEventListener("click", () => {
const name = nextSheetName(ctx.book, "Sheet");
const sheet = emptySheet(nextSheetId(ctx.book), name);
ctx.dispatch({ 종류: "시트더하기", 시트: sheet, 자리: ctx.book.시트.length });
});
return btn;
}
render();
return {
root,
refresh: render,
destroy() {
menu.close();
root.remove();
},
};
}
+132
View File
@@ -0,0 +1,132 @@
/* =============================================================================
* spreadsheet_text.ts (주인 E)
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 글자 — [한국어, 영어].
* ⚠ `@ui/ui_template_locale` 를 안 씀 — `currentLanguageIndex` 하나만 필요해도 그 파일이
* 앱 전체 사전(a · b1~4 · common · m1 · m2, 압축 뒤 약 45 KB)을 통째로 끌고 와 화면 예산(28 KB)을
* 넘김(2026-09-27 브레인 지시). 자기 언어 상태를 따로 둠 — `setSpreadsheetLanguage` 로만 바꿈.
* ========================================================================== */
let currentLanguageIndex = 0;
/** 앱 전역 언어 토글과 맞추고 싶으면 D(`spreadsheet.ts`) 가 부름 — 안 부르면 한국어 기본. */
export function setSpreadsheetLanguage(index: 0 | 1): void {
currentLanguageIndex = index;
}
const TEXT = {
Undo: ["되돌리기", "Undo"],
Redo: ["다시", "Redo"],
FontName: ["글꼴", "Font"],
FontSize: ["크기", "Size"],
Bold: ["굵게", "Bold"],
Italic: ["기울임", "Italic"],
Underline: ["밑줄", "Underline"],
Strike: ["취소선", "Strikethrough"],
TextColor: ["글자색", "Text color"],
FillColor: ["채움색", "Fill color"],
ColorClear: ["지움", "Clear"],
HAlign: ["가로 정렬", "Horizontal align"],
VAlign: ["세로 정렬", "Vertical align"],
Align_general: ["일반", "General"],
Align_left: ["왼쪽", "Left"],
Align_center: ["가운데", "Center"],
Align_right: ["오른쪽", "Right"],
Align_distributed: ["균등 분할", "Distributed"],
Align_centerContinuous: ["선택 영역 가운데", "Center across"],
VAlign_top: ["위", "Top"],
VAlign_center: ["가운데", "Middle"],
VAlign_bottom: ["아래", "Bottom"],
Wrap: ["줄 바꿈", "Wrap text"],
Merge: ["병합", "Merge"],
Unmerge: ["병합 해제", "Unmerge"],
MergeLoseConfirm: [
"왼위 칸 값만 남고 나머지 칸 값은 지워짐 — 병합할까?",
"Merging keeps only the upper-left value — merge anyway?",
],
Border: ["테두리", "Border"],
Border_none: ["없음", "None"],
Border_all: ["모두", "All"],
Border_outer: ["바깥", "Outer"],
Border_top: ["위", "Top"],
Border_bottom: ["아래", "Bottom"],
Border_left: ["왼", "Left"],
Border_right: ["오른", "Right"],
BorderLine: ["선", "Line"],
BorderColor: ["색", "Color"],
NumberFormat: ["숫자 형식", "Number format"],
NumberFormat_general: ["일반", "General"],
NumberFormat_integer: ["정수 0", "Integer"],
NumberFormat_decimal2: ["소수 0.00", "Decimal 0.00"],
NumberFormat_thousands: ["천 단위 #,##0", "Thousands"],
NumberFormat_thousands2: ["천 단위 #,##0.00", "Thousands 0.00"],
NumberFormat_percent: ["백분율 0%", "Percent"],
NumberFormat_accounting: ["회계", "Accounting"],
NumberFormat_text: ["글자 @", "Text"],
NumberFormat_placeholder: ["표시 형식", "Display format"],
NumberFormat_hint: [
"이름 골라 쓰거나 엑셀 형식 코드를 바로 침",
"Pick a preset or type an Excel format code",
],
SheetAdd: ["시트 추가", "Add sheet"],
SheetRename: ["이름 바꾸기", "Rename"],
SheetDelete: ["지우기", "Delete"],
SheetDuplicate: ["복사", "Duplicate"],
SheetRenamePrompt: ["새 시트 이름", "New sheet name"],
SheetDeleteConfirm: [
"이 시트를 지울까요? 되돌릴 수 없습니다.",
"Delete this sheet? This cannot be undone.",
],
SheetDeleteLastError: ["마지막 시트는 지울 수 없음", "Cannot delete the last sheet"],
SheetHide: ["숨기기", "Hide"],
SheetUnhideMenu: ["숨긴 시트 보이기", "Show hidden sheets"],
MenuCut: ["잘라내기", "Cut"],
MenuCopy: ["복사", "Copy"],
MenuPasteHint: ["Ctrl+V 를 쓸 것", "Use Ctrl+V"],
MenuInsertRow: ["행 넣기", "Insert row"],
MenuDeleteRow: ["행 지우기", "Delete row"],
MenuInsertCol: ["열 넣기", "Insert column"],
MenuDeleteCol: ["열 지우기", "Delete column"],
MenuHide: ["숨기기", "Hide"],
MenuUnhide: ["숨김 취소", "Unhide"],
MenuClearContent: ["내용 지우기", "Clear content"],
MenuClearFormat: ["서식 지우기", "Clear format"],
MenuMerge: ["병합", "Merge"],
MenuUnmerge: ["병합 해제", "Unmerge"],
MenuCommentInsert: ["메모 삽입", "Insert comment"],
MenuCommentEdit: ["메모 편집", "Edit comment"],
MenuCommentDelete: ["메모 삭제", "Delete comment"],
ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"],
ClipboardMergeBlocked: [
"병합된 셀의 일부를 바꿀 수 없습니다",
"Can't change part of a merged cell",
],
Unit: ["단위", "Unit"],
UnitGroup_area: ["넓이·부피·길이", "Area · volume · length"],
UnitGroup_weight: ["무게·부피(액체)", "Weight · liquid volume"],
UnitGroup_count: ["개수", "Count"],
UnitGroup_rate: ["속도·각도·비율", "Speed · angle · rate"],
UnitGroup_op: ["연산·비교", "Operators"],
UnitRecent: ["최근 씀", "Recently used"],
UnitAsFormat: ["숫자 형식으로 붙이기", "Attach as number format"],
FormulaHint_label: [
"엑셀 수식까지: 엑셀에서 Ctrl+` → 복사 → 여기 Ctrl+V · 끝나면 엑셀 Ctrl+` 다시",
"Formulas too: in Excel Ctrl+` → copy → paste here (Ctrl+V) · then Ctrl+` again in Excel",
],
FormulaHint_title: ["엑셀 수식까지 옮기는 법", "How to bring formulas along"],
FormulaHint_detail: [
"엑셀 Ctrl+` = 「수식 표시」 켜기 · 칸에 계산값 대신 「=...」 글이 그대로 보임. 그 상태로 복사해 " +
"여기 붙이면 수식까지 옮겨짐(그냥 복사하면 값만 옴 · 수식 없음). 붙인 뒤 엑셀에서 Ctrl+` 다시 " +
"눌러 원래대로.",
'Excel Ctrl+` turns on "Show Formulas" — cells show "=..." text instead of the computed ' +
"value. Copy in that state and paste here to bring the formulas along (a normal copy only " +
"brings values). Press Ctrl+` again in Excel afterward to switch back.",
],
ClipboardExcelNoFormula: [
"값만 들어왔음 — 수식까지는 엑셀 Ctrl+` 뒤 복사",
"Only values came in — for formulas too, copy after Ctrl+` in Excel",
],
} as const;
export function st(key: keyof typeof TEXT): string {
return TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
}
@@ -0,0 +1,325 @@
/* =============================================================================
* spreadsheet_toolbar.css (주인 E)
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 안내 토스트 — 화면 스타일 한 벌.
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
* ========================================================================== */
.ss-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.ss-toolbar__group {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
padding-right: var(--spacing-8);
border-right: 1px solid var(--color-border);
}
.ss-toolbar__group:last-child {
border-right: 0;
}
.ss-toolbar .ui-btn {
padding: 4px 8px;
font-size: 0.82rem;
}
.ss-toolbar .ui-btn.is-on {
background: var(--color-mist-violet);
box-shadow: inset 0 0 0 1px var(--color-royal-amethyst);
}
.ss-toolbar__select {
height: 28px;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.8rem;
}
.ss-toolbar__select option {
background: var(--color-canvas);
color: var(--color-text-body);
}
.ss-toolbar__input {
height: 28px;
width: 72px;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.8rem;
}
.ss-toolbar__input--font {
width: 96px;
}
.ss-toolbar__input--size {
width: 48px;
}
.ss-toolbar__input--numfmt {
width: 140px;
}
.ss-toolbar__color {
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
}
.ss-toolbar__clear {
padding: 0 4px;
border: 0;
background: none;
color: var(--color-text-secondary);
font-size: 0.72rem;
cursor: pointer;
}
.ss-toolbar__popover {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 180px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-toolbar__popover[hidden] {
display: none;
}
.ss-toolbar__popover-row {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.ss-toolbar__popover-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-4);
}
.ss-toolbar__unit-pop {
max-height: 340px;
overflow-y: auto;
}
.ss-toolbar__unit-section + .ss-toolbar__unit-section {
margin-top: var(--spacing-8);
}
.ss-toolbar__unit-label {
margin-bottom: var(--spacing-4);
color: var(--color-text-muted);
font-size: 0.72rem;
}
.ss-toolbar__unit-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-4);
}
.ss-toolbar__unit-btn {
min-width: 0;
padding: 4px 2px;
}
.ss-toolbar__hint {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
margin-left: auto;
max-width: 320px;
}
.ss-toolbar__formula-hint {
border: 0;
padding: 0;
background: none;
color: var(--color-text-secondary);
font-size: 0.72rem;
text-align: right;
cursor: pointer;
}
.ss-toolbar__formula-hint:hover {
color: var(--color-text-body);
}
.ss-toolbar__formula-hint-detail {
color: var(--color-text-secondary);
font-size: 0.68rem;
text-align: right;
}
.ss-toolbar__formula-hint-detail[hidden] {
display: none;
}
/* ── 시트 탭 ─────────────────────────────────────────────────────────────── */
.ss-tabs {
position: relative;
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.ss-tabs__tab {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: 4px 10px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--color-paper);
color: var(--color-text-secondary);
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.ss-tabs__tab.is-active {
background: var(--color-canvas);
color: var(--color-text-body);
font-weight: 600;
}
.ss-tabs__tab.is-dragover {
box-shadow: inset 2px 0 0 var(--color-accent);
}
.ss-tabs__tab input {
width: 96px;
border: 1px solid var(--color-focus-ring);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
}
.ss-tabs__add {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-secondary);
cursor: pointer;
}
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */
.ss__context-menu {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 160px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss__context-menu[hidden] {
display: none;
}
.ss__context-menu-item {
padding: var(--spacing-8) var(--spacing-12);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-body);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.ss__context-menu-item:hover {
background: var(--color-surface-sunken, var(--color-mist-violet));
}
.ss__context-menu-item.is-danger {
color: var(--color-danger);
}
.ss__context-menu-sep {
margin: 2px 4px;
border-top: 1px solid var(--color-border);
}
/* ── 메모 편집 창(칸 우클릭 [메모 삽입 · 편집]) ──────────────────────────────── */
.ss-comment-editor {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 35%);
}
.ss-comment-editor__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 280px;
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-comment-editor__input {
min-height: 80px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
resize: vertical;
}
.ss-comment-editor__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
@@ -0,0 +1,535 @@
/* =============================================================================
* spreadsheet_toolbar.ts (주인 E)
* 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 ·
* 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식.
* 모두 `ctx.dispatch` 명령으로 — 이 파일은 문서를 직접 안 건드림.
* 활성 칸 서식을 `refresh()` 때 단추 상태로. readOnly 면 안 붙임(root null).
*
* 이음(D `spreadsheet.ts` 잇는 자리) — `mountToolbar(ctx)` 한 번 부르고 `root`(null 이면 안 붙임)를
* 도구 모음 자리에 붙임. 고름 · 문서가 바뀔 때마다(자기 dispatch 뒤 포함) `refresh()` 를 부름 —
* 스스로 구독하지 않음. `destroy()` 는 부품을 뗄 때.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { confirmMerge } from "./spreadsheet_actions";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_toolbar.css";
type Side = "위" | "아래" | "왼" | "오른";
type BorderPreset = "none" | "all" | "outer" | "top" | "bottom" | "left" | "right";
/** 앱 기본 글꼴 · 크기 — `spreadsheet_grid.ts`(C) 의 `fontCss` 가 쓰는 기본과 같은 값(거울) ·
* 칸 · 행 · 열 · 시트 기본 어디에도 없을 때 이 값을 흐리게(placeholder) 보임. */
const APP_DEFAULT_FONT = "굴림";
const APP_DEFAULT_SIZE = 10;
const FONTS = [
"굴림",
"굴림체",
"맑은 고딕",
"나눔고딕",
"돋움",
"Arial",
"Calibri",
"Times New Roman",
];
const NUMFMT_PRESETS: [label: string, code: string][] = [
[st("NumberFormat_general"), ""],
[st("NumberFormat_integer"), "0"],
[st("NumberFormat_decimal2"), "0.00"],
[st("NumberFormat_thousands"), "#,##0"],
[st("NumberFormat_thousands2"), "#,##0.00"],
[st("NumberFormat_percent"), "0%"],
[st("NumberFormat_accounting"), '_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-'],
[st("NumberFormat_text"), "@"],
];
const BORDER_LINES: BorderLine[] = [
"hair",
"thin",
"medium",
"thick",
"double",
"dotted",
"dashed",
];
type TextKey = Parameters<typeof st>[0];
const UNIT_GROUPS: [label: TextKey, items: string[]][] = [
["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞"]],
["UnitGroup_weight", ["kg", "t", "ℓ", "㎖"]],
["UnitGroup_count", ["개", "본", "식", "EA", "매"]],
["UnitGroup_rate", ["m/s", "°", "%", "‰"]],
["UnitGroup_op", ["×", "÷", "±", "≤", "≥", "Σ"]],
];
const UNIT_RECENT_MAX = 8;
const UNIT_RECENT_KEY = "aislo-ss-recent-units";
function loadRecentUnits(): string[] {
try {
const raw = localStorage.getItem(UNIT_RECENT_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
}
function pushRecentUnit(unit: string): string[] {
const list = [unit, ...loadRecentUnits().filter((u) => u !== unit)].slice(0, UNIT_RECENT_MAX);
try {
localStorage.setItem(UNIT_RECENT_KEY, JSON.stringify(list));
} catch {
/* 저장 못 해도 이번 넣기는 그대로 진행 */
}
return list;
}
/** 숫자 형식 코드 끝 따옴표 글자(전에 붙인 단위)를 떼고 새 단위를 붙임 — 없으면 "0" 에서 시작. */
function attachUnitToFormat(code: string, unit: string): string {
const base = (code || "0").replace(/"[^"]*"\s*$/, "");
return base + `"${unit}"`;
}
/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 시트 기본(`Sheet.기본.서식`) · 그다음 0. */
function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle {
const sheet = ctx.sheet();
const cell = sheet.칸[toA1(r, c)];
const idx =
cell?.서식 ??
sheet.열?.[colName(c)]?.서식 ??
sheet.행?.[String(r + 1)]?.서식 ??
sheet.기본?.서식 ??
0;
return ctx.book.서식[idx] ?? {};
}
export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
const root = el("div", { className: "ss-toolbar" });
const group = (...children: HTMLElement[]): HTMLElement =>
el("div", { className: "ss-toolbar__group", children });
const patch = (change: { [K in keyof CellStyle]?: CellStyle[K] | null }): void => {
ctx.dispatch({
종류: "서식",
시트: ctx.selection.시트,
범위: ctx.selection.범위,
바꿀: change,
});
};
// ── 되돌리기 · 다시 ────────────────────────────────────────────────────
const undoBtn = createButton({ label: "↶", variant: "ghost", onClick: () => ctx.undo() });
undoBtn.title = st("Undo");
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
redoBtn.title = st("Redo");
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
const fontInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--font",
attrs: { list: "ss-toolbar-fonts", "aria-label": st("FontName") },
}) as HTMLInputElement;
const fontList = el("datalist", {
attrs: { id: "ss-toolbar-fonts" },
children: FONTS.map((name) => el("option", { attrs: { value: name } })),
});
fontInput.append(fontList);
const commitFont = (): void => patch({ 글꼴: fontInput.value.trim() || null });
fontInput.addEventListener("change", commitFont);
const sizeInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--size",
attrs: { type: "number", min: "1", max: "409", step: "0.5", "aria-label": st("FontSize") },
}) as HTMLInputElement;
sizeInput.addEventListener("change", () => {
const n = Number(sizeInput.value);
patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? Math.min(n, 409) : null });
});
// ── 굵게 · 기울임 · 밑줄 · 취소선 ──────────────────────────────────────
const boldBtn = createButton({ label: "B", variant: "ghost" });
boldBtn.title = st("Bold");
const italicBtn = createButton({ label: "I", variant: "ghost" });
italicBtn.title = st("Italic");
const underlineBtn = createButton({ label: "U", variant: "ghost" });
underlineBtn.title = st("Underline");
const strikeBtn = createButton({ label: "S", variant: "ghost" });
strikeBtn.title = st("Strike");
const toggles: [HTMLButtonElement, keyof CellStyle][] = [
[boldBtn, "굵게"],
[italicBtn, "기울임"],
[underlineBtn, "밑줄"],
[strikeBtn, "취소선"],
];
for (const [btn, key] of toggles) {
btn.addEventListener("click", () => patch({ [key]: !btn.classList.contains("is-on") }));
}
// ── 글자색 · 채움색 ────────────────────────────────────────────────────
const textColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", "aria-label": st("TextColor"), value: "#000000" },
}) as HTMLInputElement;
textColor.addEventListener("change", () => patch({ 글자색: textColor.value }));
const textColorClear = el("button", {
className: "ss-toolbar__clear",
text: "✕",
attrs: { type: "button", title: st("ColorClear") },
});
textColorClear.addEventListener("click", () => patch({ 글자색: null }));
const fillColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", "aria-label": st("FillColor"), value: "#ffffff" },
}) as HTMLInputElement;
fillColor.addEventListener("change", () => patch({ 채움: fillColor.value }));
const fillColorClear = el("button", {
className: "ss-toolbar__clear",
text: "✕",
attrs: { type: "button", title: st("ColorClear") },
});
fillColorClear.addEventListener("click", () => patch({ 채움: null }));
// ── 가로 · 세로 정렬 · 줄 바꿈 ─────────────────────────────────────────
const HALIGN_LABEL = {
general: st("Align_general"),
left: st("Align_left"),
center: st("Align_center"),
right: st("Align_right"),
distributed: st("Align_distributed"),
centerContinuous: st("Align_centerContinuous"),
} as const;
const VALIGN_LABEL = {
top: st("VAlign_top"),
center: st("VAlign_center"),
bottom: st("VAlign_bottom"),
} as const;
const hAlignSelect = el("select", {
className: "ss-toolbar__select",
attrs: { "aria-label": st("HAlign") },
children: (Object.keys(HALIGN_LABEL) as (keyof typeof HALIGN_LABEL)[]).map((v) =>
el("option", { attrs: { value: v }, text: HALIGN_LABEL[v] }),
),
}) as HTMLSelectElement;
hAlignSelect.addEventListener("change", () =>
patch({ 가로: hAlignSelect.value as CellStyle["가로"] }),
);
const vAlignSelect = el("select", {
className: "ss-toolbar__select",
attrs: { "aria-label": st("VAlign") },
children: (Object.keys(VALIGN_LABEL) as (keyof typeof VALIGN_LABEL)[]).map((v) =>
el("option", { attrs: { value: v }, text: VALIGN_LABEL[v] }),
),
}) as HTMLSelectElement;
vAlignSelect.addEventListener("change", () =>
patch({ 세로: vAlignSelect.value as CellStyle["세로"] }),
);
const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost" });
wrapBtn.addEventListener("click", () => patch({ 줄바꿈: !wrapBtn.classList.contains("is-on") }));
// ── 병합 ───────────────────────────────────────────────────────────────
const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" });
mergeBtn.addEventListener("click", async () => {
const range = ctx.selection.범위[0];
if (!range) return;
const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
if (!merged && !(await confirmMerge(ctx, range))) return;
ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range });
});
// ── 테두리 ───────────────────────────────────────────────────────────
const borderBtn = createButton({ label: st("Border"), variant: "ghost" });
const borderPop = el("div", { className: "ss-toolbar__popover", attrs: { hidden: "true" } });
const lineSelect = el("select", {
className: "ss-toolbar__select",
children: BORDER_LINES.map((v) => el("option", { attrs: { value: v }, text: v })),
}) as HTMLSelectElement;
const lineColor = el("input", {
className: "ss-toolbar__color",
attrs: { type: "color", value: "#000000" },
}) as HTMLInputElement;
const presetsRow = el("div", { className: "ss-toolbar__popover-presets" });
const presets: [BorderPreset, string][] = [
["none", st("Border_none")],
["all", st("Border_all")],
["outer", st("Border_outer")],
["top", st("Border_top")],
["bottom", st("Border_bottom")],
["left", st("Border_left")],
["right", st("Border_right")],
];
for (const [preset, label] of presets) {
const b = createButton({
label,
variant: "ghost",
onClick: () => {
applyBorderPreset(preset);
borderPop.hidden = true;
},
});
presetsRow.append(b);
}
borderPop.append(
el("div", {
className: "ss-toolbar__popover-row",
children: [el("span", { text: st("BorderLine") }), lineSelect],
}),
el("div", {
className: "ss-toolbar__popover-row",
children: [el("span", { text: st("BorderColor") }), lineColor],
}),
presetsRow,
);
borderBtn.addEventListener("click", (ev) => {
ev.stopPropagation();
borderPop.hidden = !borderPop.hidden;
});
borderPop.addEventListener("click", (ev) => ev.stopPropagation());
const closeBorderPop = (): void => {
borderPop.hidden = true;
};
document.addEventListener("click", closeBorderPop);
function cellsFor(
range: CellRange,
edge: BorderPreset,
): { r: number; c: number; sides: Side[] }[] {
const { r0, c0, r1, c1 } = range;
const out: { r: number; c: number; sides: Side[] }[] = [];
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const sides: Side[] = [];
if (edge === "all") sides.push("위", "아래", "왼", "오른");
else {
if ((edge === "outer" || edge === "top") && r === r0) sides.push("위");
if ((edge === "outer" || edge === "bottom") && r === r1) sides.push("아래");
if ((edge === "outer" || edge === "left") && c === c0) sides.push("왼");
if ((edge === "outer" || edge === "right") && c === c1) sides.push("오른");
}
if (sides.length) out.push({ r, c, sides });
}
}
return out;
}
function applyBorderPreset(preset: BorderPreset): void {
const range = ctx.selection.범위[0];
if (!range) return;
if (preset === "none") {
ctx.dispatch({
종류: "서식",
시트: ctx.selection.시트,
범위: ctx.selection.범위,
바꿀: { 테두리: null },
});
return;
}
const style: BorderSide = { 선: lineSelect.value as BorderLine, 색: lineColor.value };
const cmds: Command[] = cellsFor(range, preset).map(({ r, c, sides }) => {
const cur = { ...(styleAt(ctx, r, c).테두리 ?? {}) };
for (const s of sides) cur[s] = style;
return {
종류: "서식",
시트: ctx.selection.시트,
범위: [{ r0: r, c0: c, r1: r, c1: c }],
바꿀: { 테두리: cur },
};
});
if (cmds.length === 1) ctx.dispatch(cmds[0]);
else if (cmds.length > 1) ctx.dispatch({ 종류: "묶음", 명령: cmds });
}
// ── 숫자 형식 ─────────────────────────────────────────────────────────
const numfmtInput = el("input", {
className: "ss-toolbar__input ss-toolbar__input--numfmt",
attrs: {
list: "ss-toolbar-numfmt",
"aria-label": st("NumberFormat"),
title: st("NumberFormat_hint"),
placeholder: st("NumberFormat_placeholder"),
},
}) as HTMLInputElement;
const numfmtList = el("datalist", {
attrs: { id: "ss-toolbar-numfmt" },
children: NUMFMT_PRESETS.map(([label, code]) =>
el("option", {
attrs: { value: code },
text: `${label} (${code || st("NumberFormat_general")})`,
}),
),
});
numfmtInput.append(numfmtList);
numfmtInput.addEventListener("change", () => patch({ 형식: numfmtInput.value || null }));
// ── 단위 기호 ─────────────────────────────────────────────────────────
const unitBtn = createButton({ label: `${st("Unit")} ▾`, variant: "ghost" });
const unitPop = el("div", {
className: "ss-toolbar__popover ss-toolbar__unit-pop",
attrs: { hidden: "true" },
});
const unitAsFormat = el("input", { attrs: { type: "checkbox", id: "ss-unit-as-format" } });
const unitAsFormatRow = el("label", {
className: "ss-toolbar__popover-row",
attrs: { for: "ss-unit-as-format" },
children: [unitAsFormat, el("span", { text: st("UnitAsFormat") })],
});
const recentGrid = el("div", { className: "ss-toolbar__unit-grid" });
const recentSection = el("div", {
className: "ss-toolbar__unit-section",
attrs: { hidden: "true" },
children: [
el("div", { className: "ss-toolbar__unit-label", text: st("UnitRecent") }),
recentGrid,
],
});
function insertUnit(unit: string): void {
if (ctx.editor.editing()) {
const active = document.activeElement;
if (active instanceof HTMLTextAreaElement && ctx.root.contains(active)) {
const s = active.selectionStart ?? active.value.length;
const e = active.selectionEnd ?? active.value.length;
active.value = active.value.slice(0, s) + unit + active.value.slice(e);
active.setSelectionRange(s + unit.length, s + unit.length);
active.dispatchEvent(new Event("input", { bubbles: true }));
}
} else if (unitAsFormat.checked) {
const { r, c } = ctx.selection.활성;
patch({ 형식: attachUnitToFormat(styleAt(ctx, r, c).형식 ?? "", unit) });
} else {
const { r, c } = ctx.selection.활성;
const sheet = ctx.sheet();
const key = toA1(r, c);
const text = cellToText(sheet.칸[key]) + unit;
ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: { [key]: textToCell(text, sheet.칸[key]) } });
}
renderRecent(pushRecentUnit(unit));
unitPop.hidden = true;
}
function renderRecent(list: string[]): void {
recentGrid.textContent = "";
recentSection.hidden = list.length === 0;
for (const u of list) {
const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) });
b.className += " ss-toolbar__unit-btn";
recentGrid.append(b);
}
}
renderRecent(loadRecentUnits());
unitPop.append(unitAsFormatRow, recentSection);
for (const [labelKey, items] of UNIT_GROUPS) {
const grid = el("div", { className: "ss-toolbar__unit-grid" });
for (const u of items) {
const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) });
b.className += " ss-toolbar__unit-btn";
grid.append(b);
}
unitPop.append(
el("div", {
className: "ss-toolbar__unit-section",
children: [el("div", { className: "ss-toolbar__unit-label", text: st(labelKey) }), grid],
}),
);
}
unitBtn.addEventListener("click", (ev) => {
ev.stopPropagation();
unitPop.hidden = !unitPop.hidden;
});
unitPop.addEventListener("click", (ev) => ev.stopPropagation());
const closeUnitPop = (): void => {
unitPop.hidden = true;
};
document.addEventListener("click", closeUnitPop);
// ── 엑셀 수식 복사 안내(오른쪽 위 흐린 글씨 · 누르면 두세 줄 설명) ─────────
const formulaHintBtn = el("button", {
className: "ss-toolbar__formula-hint",
attrs: { type: "button", title: st("FormulaHint_title") },
text: st("FormulaHint_label"),
});
const formulaHintDetail = el("div", {
className: "ss-toolbar__formula-hint-detail",
attrs: { hidden: "true" },
text: st("FormulaHint_detail"),
});
formulaHintBtn.addEventListener("click", (ev) => {
ev.stopPropagation();
formulaHintDetail.hidden = !formulaHintDetail.hidden;
});
const formulaHint = el("div", {
className: "ss-toolbar__hint",
children: [formulaHintBtn, formulaHintDetail],
});
root.append(
group(undoBtn, redoBtn),
group(fontInput, sizeInput),
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
group(textColor, textColorClear, fillColor, fillColorClear),
group(hAlignSelect, vAlignSelect, wrapBtn),
group(mergeBtn),
group(borderBtn, borderPop),
group(numfmtInput),
group(unitBtn, unitPop),
formulaHint,
);
function refresh(): void {
undoBtn.disabled = !ctx.history.canUndo();
redoBtn.disabled = !ctx.history.canRedo();
const { r, c } = ctx.selection.활성;
const style = styleAt(ctx, r, c);
fontInput.value = style.글꼴 ?? "";
fontInput.placeholder = style.글꼴 ? "" : (ctx.book.기본?.글꼴 ?? APP_DEFAULT_FONT);
sizeInput.value = style.크기 != null ? String(style.크기) : "";
sizeInput.placeholder =
style.크기 != null ? "" : String(ctx.book.기본?.크기 ?? APP_DEFAULT_SIZE);
boldBtn.classList.toggle("is-on", !!style.굵게);
italicBtn.classList.toggle("is-on", !!style.기울임);
underlineBtn.classList.toggle("is-on", !!style.밑줄);
strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general";
vAlignSelect.value = style.세로 ?? "bottom";
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0];
mergeBtn.textContent = "";
mergeBtn.append(
el("span", {
className: "ui-btn__label",
text:
range && (ctx.sheet().병합 ?? []).includes(rangeToA1(range))
? st("Unmerge")
: st("Merge"),
}),
);
}
refresh();
return {
root,
refresh,
destroy() {
document.removeEventListener("click", closeBorderPop);
document.removeEventListener("click", closeUnitPop);
root.remove();
},
};
}
+285
View File
@@ -0,0 +1,285 @@
/* =============================================================================
* spreadsheet_trace.ts (주인 A)
* 선행 · 종속 칸 추적 화살표(엑셀 [수식 분석]) — 엔진 의존 그래프(`engine.precedents`)로 한 단계씩 넓힘.
* 엑셀 규칙: 누를 때마다 한 단계 더(이미 편 칸 너머만) · 범위는 파란 상자 + 화살 · 오류 칸에서 나온 화살은 빨강 ·
* 다른 시트 칸은 점선 화살 + 시트 표 아이콘 · 문서가 바뀌면 화살을 모두 지움.
*
* 잇는 법(D · sub7):
* · 부품 붙이기: `const trace = attachTrace(ctx)` — SVG 겹판을 격자 overlay(editorSlot 층)에 얹음.
* grid.render() 뒤마다 `trace.refresh()` · 스크롤은 스스로 따라감 · 시트를 바꾸면 그 시트 화살만 그림.
* · 도구 모음 [수식 분석]: 「선행 참조 추적」 → `trace.precedents()` · 「종속 참조 추적」 → `trace.dependents()` ·
* 「화살표 지우기」 → `trace.clear()`(종류만 지우려면 `clear("선행")`). 더 펼 칸이 없으면 false(토스트 몫).
* ========================================================================== */
import { cellId, inRange, parseA1 } from "./spreadsheet_address";
import { isError } from "./spreadsheet_eval";
import type { CalcEngine, CellRange, SheetCellAddress, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
export type TraceKind = "선행" | "종속";
/** 화살 하나 — 늘 가리켜진 쪽(from) → 식 칸(to) */
export interface TraceArrow {
종류: TraceKind;
from: { 시트: string; 범위: CellRange };
to: SheetCellAddress;
/** 시작 칸 · 범위에 오류 값이 있음 → 빨강 */
오류: boolean;
}
export interface TraceState {
arrows: TraceArrow[];
/** 이미 편 칸(`종류:시트!id`) */
opened: Set<string>;
}
const key = (kind: TraceKind, sheet: string, r: number, c: number) =>
`${kind}:${sheet}!${cellId(r, c)}`;
const one = (r: number, c: number): CellRange => ({ r0: r, c0: c, r1: r, c1: c });
/** 시트의 식 칸 목록(범위 안만) */
function formulaCells(book: Workbook, sheet: string, rg?: CellRange): SheetCellAddress[] {
const s = book.시트.find((x) => x.id === sheet);
const out: SheetCellAddress[] = [];
for (const [a1, cell] of Object.entries(s?.칸 ?? {})) {
const at = cell.식 !== undefined ? parseA1(a1) : null;
if (at && (!rg || inRange(rg, at.r, at.c))) out.push({ 시트: sheet, ...at });
}
return out;
}
function hasError(book: Workbook, engine: CalcEngine, sheet: string, rg: CellRange): boolean {
if (rg.r0 === rg.r1 && rg.c0 === rg.c1) return isError(engine.value(sheet, rg.r0, rg.c0));
const s = book.시트.find((x) => x.id === sheet);
for (const a1 of Object.keys(s?.칸 ?? {})) {
const at = parseA1(a1);
if (at && inRange(rg, at.r, at.c) && isError(engine.value(sheet, at.r, at.c))) return true;
}
return false;
}
/** 칸을 가리키는 식 칸들(모든 시트) */
export function dependentsOf(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): SheetCellAddress[] {
const out: SheetCellAddress[] = [];
for (const s of book.시트)
for (const f of formulaCells(book, s.id))
if (
engine.precedents(f.시트, f.r, f.c).some((d) => d.시트 === sheet && inRange(d.범위, r, c))
)
out.push(f);
return out;
}
/** 한 단계 넓힘 — 처음이면 그 칸 · 아니면 그 칸에서 이어진 화살 끝 중 아직 안 편 칸. 더한 화살이 있으면 true */
export function traceStep(
book: Workbook,
engine: CalcEngine,
state: TraceState,
kind: TraceKind,
at: SheetCellAddress,
): boolean {
// 이어진 칸을 따라가며 안 편 칸(앞날개)을 모음
const frontier: SheetCellAddress[] = [];
const seen = new Set<string>();
const walk = [at];
while (walk.length) {
const p = walk.pop()!;
const k = key(kind, p.시트, p.r, p.c);
if (seen.has(k)) continue;
seen.add(k);
if (!state.opened.has(k)) {
frontier.push(p);
continue;
}
for (const a of state.arrows) {
if (a.종류 !== kind) continue;
if (kind === "선행" && a.to.시트 === p.시트 && a.to.r === p.r && a.to.c === p.c)
walk.push(...formulaCells(book, a.from.시트, a.from.범위));
if (kind === "종속" && a.from.시트 === p.시트 && inRange(a.from.범위, p.r, p.c))
walk.push(a.to);
}
}
let added = false;
for (const p of frontier) {
state.opened.add(key(kind, p.시트, p.r, p.c));
const links =
kind === "선행"
? engine.precedents(p.시트, p.r, p.c).map((d) => ({ from: d, to: p }))
: dependentsOf(book, engine, p.시트, p.r, p.c).map((to) => ({
from: { 시트: p.시트, 범위: one(p.r, p.c) },
to,
}));
for (const { from, to } of links) {
state.arrows.push({
종류: kind,
from,
to,
오류: hasError(book, engine, from.시트, from.범위),
});
added = true;
}
}
return added;
}
// ── 화면 겹판 ────────────────────────────────────────────────────────────────
const SVG = "http://www.w3.org/2000/svg";
const svg = <K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
) => {
const n = document.createElementNS(SVG, tag);
for (const [a, v] of Object.entries(attrs)) n.setAttribute(a, String(v));
return n;
};
export interface TraceHandle extends PartHandle {
/** 활성 칸 선행 추적 한 단계 — 더한 화살이 없으면 false */
precedents(): boolean;
dependents(): boolean;
clear(kind?: TraceKind): void;
/** 지금 화살(시험 · 상태 표시용) */
arrows(): readonly TraceArrow[];
}
export function attachTrace(ctx: SpreadsheetContext): TraceHandle {
const layer = svg("svg", { class: "ss-trace" });
const defs = svg("defs", {});
for (const kind of ["plain", "error", "sheet"]) {
const m = svg("marker", {
id: `ss-trace-head-${kind}`,
viewBox: "0 0 10 10",
refX: 9,
refY: 5,
markerWidth: 7,
markerHeight: 7,
orient: "auto-start-reverse",
});
m.append(svg("path", { d: "M0,0 L10,5 L0,10 z", class: `ss-trace__head is-${kind}` }));
defs.append(m);
}
let state: TraceState = { arrows: [], opened: new Set() };
let book = ctx.book;
/** 칸 상자 → 겹판 자리(filter 와 같은 맞춤) */
function boxIn(r: number, c: number) {
const { layer: host, box } = ctx.grid.editorSlot(r, c);
const a = host.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) };
}
function draw(): void {
if (ctx.book !== book) {
state = { arrows: [], opened: new Set() };
book = ctx.book;
}
const { layer: host } = ctx.grid.editorSlot(0, 0);
if (layer.parentElement !== host) host.append(layer);
const here = ctx.sheet().id;
const parts: SVGElement[] = [defs];
const boxes = new Set<string>();
for (const a of state.arrows) {
const fromHere = a.from.시트 === here;
const toHere = a.to.시트 === here;
if (!fromHere && !toHere) continue;
const tone = a.오류 ? "error" : fromHere && toHere ? "plain" : "sheet";
const t = toHere ? boxIn(a.to.r, a.to.c) : null;
const rg = a.from.범위;
const f = fromHere ? boxIn(rg.r0, rg.c0) : null;
const end = t
? { x: t.x + t.w / 2, y: t.y + t.h / 2 }
: { x: f!.x + f!.w + 60, y: f!.y - 24 };
const start = f
? { x: f.x + Math.min(f.w, 24) / 2, y: f.y + f.h / 2 }
: { x: t!.x - 60, y: t!.y - 24 };
if (
f &&
(rg.r0 !== rg.r1 || rg.c0 !== rg.c1) &&
!boxes.has(`${rg.r0},${rg.c0},${rg.r1},${rg.c1}`)
) {
boxes.add(`${rg.r0},${rg.c0},${rg.r1},${rg.c1}`);
const b = boxIn(rg.r1, rg.c1);
parts.push(
svg("rect", {
class: `ss-trace__box is-${a.오류 ? "error" : "plain"}`,
x: f.x + 1,
y: f.y + 1,
width: Math.max(0, b.x + b.w - f.x - 2),
height: Math.max(0, b.y + b.h - f.y - 2),
}),
);
}
if (f)
parts.push(
svg("circle", { class: `ss-trace__dot is-${tone}`, cx: start.x, cy: start.y, r: 3 }),
);
else
parts.push(
svg("rect", {
class: "ss-trace__sheet",
x: start.x - 9,
y: start.y - 7,
width: 18,
height: 14,
}),
);
parts.push(
svg("line", {
class: `ss-trace__line is-${tone}`,
x1: start.x,
y1: start.y,
x2: end.x,
y2: end.y,
"marker-end": `url(#ss-trace-head-${tone})`,
"data-from": `${a.from.시트}!${rg.r0},${rg.c0}`,
"data-to": `${a.to.시트}!${a.to.r},${a.to.c}`,
}),
);
if (!t)
parts.push(
svg("rect", { class: "ss-trace__sheet", x: end.x, y: end.y - 7, width: 18, height: 14 }),
);
}
layer.replaceChildren(...parts);
}
const step = (kind: TraceKind) => {
if (ctx.book !== book) draw();
const { r, c } = ctx.selection.활성;
const added = traceStep(ctx.book, ctx.engine, state, kind, { 시트: ctx.sheet().id, r, c });
draw();
return added;
};
const onScroll = () => draw();
ctx.grid.root.addEventListener("scroll", onScroll, true);
draw();
return {
root: layer as unknown as HTMLElement,
refresh: draw,
precedents: () => step("선행"),
dependents: () => step("종속"),
clear(kind) {
if (!kind) state = { arrows: [], opened: new Set() };
else {
state.arrows = state.arrows.filter((a) => a.종류 !== kind);
for (const k of [...state.opened]) if (k.startsWith(`${kind}:`)) state.opened.delete(k);
}
draw();
},
arrows: () => state.arrows,
destroy() {
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}
+556
View File
@@ -0,0 +1,556 @@
/* ====================================================================== * spreadsheet_types.ts
* 산출근거 스프레드시트 계약 — 저장 문서 · 칸 · 서식 · 수식 나무 · 값 · 명령 · 계산 엔진 모양.
* 근거 `tmp/M02_분석/9_엑셀검토.md` · PLAN 11-8. 이 파일 · `spreadsheet_view_types.ts` · 견본 JSON 은
* 0 계약 주인만 고침 — 다른 창은 브레인에게 고침을 청함. 타입만 둠(서버 Node · 시험이 그대로 씀).
*
* ── 규칙 ────────────────────────────────────────────────────────────────────
* · 외부 라이브러리 0 · 오픈소스 코드 안 베낌(Formula.js · ssf 는 시험 대조 답으로만).
* · 가져다 쓰는 것 = `@ui/ui_template_elements`(el · createButton · 이미 앱 번들) ·
* `@ui/sheet/ui_template_sheet_frac`(BigInt 분수 · 집계표와 끝수 한 벌) 둘뿐.
* · M02 만 `import()` 로 늦게 불러옴 — 다른 페이지 · `A00_Common/main.ts` 가 정적 import 금지.
* · 엔진 파일(A · B)은 DOM 을 모름 — 화면 즉시 풀이와 [저장] 때 서버 Node 풀이가 같은 TS(CLAUDE.md 5장 ②).
* · 보이는 칸만 그림 · 파일마다 700 줄 안쪽(예산 넘기 전에 쪼갬) · 화면 글자는 그 파일 안에 둠.
* · xlsx 가져오기 · 내보내기 · 인쇄 · PDF 는 만들지 않음(단추도 없음). xlsx 는 시험(F)만 읽음.
*
* ── 크기 예산 (vite build · 압축(gzip) 뒤 · M02 면적산출 · 재료산출을 열 때만 받는 조각) ──
* 엔진 A+B 48 KB · 화면 C+D+E 72 KB · css 8 KB · 합 128 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험).
*
* ── 파일 · 주인 · 예산 줄 수 ─────────────────────────────────────────────────
* 0 spreadsheet_types.ts 이 계약(타입) ~450
* 0 spreadsheet_view_types.ts 화면 부품 사이 계약(DOM 타입) ~150
* 0 spreadsheet_sample.json 저장 견본 = `basis/<열 id>.json` 한 벌
* A spreadsheet_address.ts A1 ↔ 행열 · 범위 · 열 글자 · 시트 이름 따옴표 ~150
* A spreadsheet_parser.ts 낱말 나누기 · 수식 나무 · 나무 → 글 ~500
* A spreadsheet_eval.ts 나무 풀기 · 오류 전파 · 형 바꾸기 ~350
* A spreadsheet_graph.ts 계산 엔진 — 의존 그래프 · 딸린 칸만 · 순환 ~350
* A spreadsheet_refshift.ts 넣기 · 지우기 · 옮기기 · 복사 때 참조 옮김 ~300
* A spreadsheet_commands.ts 명령 적용 → 되돌림 짝 · 빈 통합문서 ~550
* A spreadsheet_history.ts 되돌리기 · 다시 ~100
* A spreadsheet_commands_sheet.ts 시트 명령(더하기 · 지우기 · 이름 · 옮기기 · 통째) ~100
* A spreadsheet_sortfilter.ts 정렬 · 자동 필터 엔진(차례 · 걸러짐 셈 · 적용) ~300
* A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250
* A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300
* A spreadsheet_sortfilter.css ~150
* A spreadsheet_trace.ts 선행 · 종속 추적 화살표(한 단계씩 · SVG 겹판) ~270
* A spreadsheet_eval_steps.ts 수식 계산 단계 보기 창 ~230
* A spreadsheet_errcheck.ts 오류 검사 까닭 · ⚠ 단추 · 풍선(세모는 C 가 그림) ~200
* A spreadsheet_analysis.css 위 셋 화면 스타일 ~200
* A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80
* B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120
* B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400
* B spreadsheet_functions_misc.ts 논리 · 글 · 찾기 · 세기 ~500
* B spreadsheet_numfmt.ts 숫자 형식 코드 → 보이는 글 ~400
* B spreadsheet_fill.ts 채우기 핸들 연속값 ~200
* C spreadsheet_grid.ts 격자 — 보이는 칸만 · 틀고정 4 판 · 병합 · 고름 그림 ~650
* C spreadsheet_grid_cells.ts 판 하나 칸 그리기 · 값 글 · #### · 넘침 붙이기 ~320
* C spreadsheet_grid_heads.ts 행열 머리 칸 · 숨김 표시 · 묶음 윤곽 단추 ~200
* C spreadsheet_spill.ts 긴 글 넘침 계산(옆 빈 칸) ~60
* C spreadsheet_headers.ts 열 글자 · 행 번호 · 폭 · 높이 끌기 · 숨김 표시 ~350
* C spreadsheet_cellstyle.ts 서식 → CSS(hair · 균등 분할 · 겹친 테두리) ~250
* C spreadsheet_grid.css ~200
* D spreadsheet.ts 진입 createSpreadsheet · 부품 잇기 · 문맥 ~350
* D spreadsheet_selection.ts 고름 모델 · Shift/Ctrl 방향 · 병합으로 늘림 ~300
* D spreadsheet_editor.ts 칸 편집기 · 수식 입력줄 · 주소 상자 · 한글 조합 · 참조 가리키기 ~600
* D spreadsheet_keys.ts 엑셀 단축키 지도 ~300
* D spreadsheet_mouse.ts 끌기 고르기 · 채우기 끌기 · 두 번 누르기 ~300
* D spreadsheet.css ~150
* D spreadsheet_links.ts 아래 알약 막대(합계 줄 아래) — 입력값 · 자재를 칸에 연결(늦게 불러옴) ~350
* E spreadsheet_toolbar.ts 서식 도구 모음 ~550
* E spreadsheet_tabs.ts 시트 탭 ~300
* E spreadsheet_menu.ts 우클릭 메뉴 ~300
* E spreadsheet_clipboard.ts 복사 · 잘라내기 · 붙여넣기(TSV · HTML) ~550
* E spreadsheet_toolbar.css ~150
* 3 spreadsheet_condfmt.ts 조건부 서식 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~260
* 3 spreadsheet_validation.ts 데이터 유효성 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~230
* 3 spreadsheet_names.ts 이름 정의 · 이름 관리 창(주인 sub_laptop_4 · 안 이어짐) ~220
* 3 spreadsheet_statusbar.ts 아래 상태 표시줄 — 합계 · 평균 · 개수 등(주인 sub_laptop_4 · 안 이어짐) ~150
* 3 spreadsheet_autosum.ts 자동 합계(Alt+= · Σ, 주인 sub_laptop_4 · 안 이어짐) ~90
* 3 spreadsheet_sheet_extra.ts 시트 숨김 · 보이기 · 탭 색(주인 sub_laptop_4 · 계약 더함 · 안 이어짐) ~150
* 3 spreadsheet_stage3.css 위 여섯 화면 스타일 한 벌 ~140
* F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40
* F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60
* F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 ·
* M02_MasterTemplete_Structure.ts 끼우기 · resources/tester/test_spreadsheet_*.py(실무 2,699 칸 대조 · 크기)
*
* ── 좌표 ────────────────────────────────────────────────────────────────────
* 안쪽은 0 부터(r = 행 · c = 열) · 저장 · 식 글은 엑셀 A1(1 부터). 행 최대 1,048,576 · 열 최대 16,384(XFD).
* ========================================================================== */
import type { Frac } from "@ui/sheet/ui_template_sheet_frac";
export type { Frac };
// ═════════════════════════════════════════════════════════════════════════════
// 1. 저장 문서 — 구조물집계표 열마다 한 파일 `resources/master_template/basis/<열 id>.json`
// (층 규칙은 M02 와 같음 · 프로젝트 `templates/basis/` · `_initial/` 사본). 견본 = spreadsheet_sample.json.
// 키는 한글 · 열거 값(정렬 · 선 · 오류)은 엑셀 이름 그대로.
// ═════════════════════════════════════════════════════════════════════════════
export const MAX_ROWS = 1_048_576;
export const MAX_COLS = 16_384;
/** 칸에 넣은 값 — 수는 JSON 수(엑셀처럼 유효 15 자리) · 글 · 참거짓. 식 칸에는 없음. */
export type CellInput = number | string | boolean;
export interface Cell {
/** 입력 값 — `식` 이 있으면 없음. `'` 로 시작해 친 글은 `'` 를 떼고 글로 둠. */
값?: CellInput;
/** A1 식 — 앞 `=` 뺌(`ROUNDDOWN((G31+J31/2)*P31,2)`). 행열을 넣고 지우면 글을 고쳐 적음. */
식?: string;
/** `서식` 표 번호 — 없으면 행 · 열 서식 · 시트 `기본.서식` · 그다음 0(기본). */
서식?: number;
}
export type HAlign = "general" | "left" | "center" | "right" | "distributed" | "centerContinuous";
export type VAlign = "top" | "center" | "bottom";
export type BorderLine = "hair" | "thin" | "medium" | "thick" | "double" | "dotted" | "dashed";
export interface BorderSide {
선: BorderLine;
/** `#RRGGBB` · 없으면 검정. */
색?: string;
}
/** 칸 서식 한 벌 — 없는 칸은 기본값. 같은 서식은 표에 한 번만(칸은 번호). */
export interface CellStyle {
글꼴?: string;
/** pt */
크기?: number;
굵게?: boolean;
기울임?: boolean;
밑줄?: boolean;
취소선?: boolean;
/** `#RRGGBB` */
글자색?: string;
/** `#RRGGBB` — 없으면 채움 없음. */
채움?: string;
가로?: HAlign;
세로?: VAlign;
줄바꿈?: boolean;
테두리?: { 위?: BorderSide; 아래?: BorderSide; 왼?: BorderSide; 오른?: BorderSide };
/** 엑셀 숫자 형식 코드 그대로(`0.00` · `#,##0` · `_-* #,##0.00_-;…` · `@`) · 없으면 일반. */
형식?: string;
}
/** 묶음(윤곽) — 층 1 부터(중첩 단계) · 접힘 이면 격자가 숨김처럼 그림(숨김 · 걸러짐과는 화면 표시가 다름 — 윤곽 +/- 단추). */
export interface Outline {
층: number;
접힘?: boolean;
}
export interface ColumnInfo {
/** 엑셀 글자 폭 단위(기본 글꼴 숫자 폭 기준) — 화면이 px 로 바꿈. */
폭?: number;
숨김?: boolean;
서식?: number;
묶음?: Outline;
}
export interface RowInfo {
/** pt */
높이?: number;
숨김?: boolean;
서식?: number;
/** 자동 필터가 숨긴 행 — 사용자 `숨김` 과 따로(필터 풀면 보임) · 격자는 숨김처럼 그림. */
걸러짐?: boolean;
묶음?: Outline;
}
/** 자동 필터 — `범위` 첫 행 = 머리(단추) · `조건` = 열 글자 → 보일 값(보이는 글 · 빈 칸은 ""). 조건 없는 열은 다 보임. */
export interface AutoFilter {
범위: string;
조건?: Record<string, string[]>;
}
export interface Sheet {
/** 안 바뀌는 id(`s1` …) — 이름을 바꿔도 그대로. 식 글은 이름으로 가리킴. */
id: string;
이름: string;
/** A1 열쇠 · 빈 칸은 안 적음. */
칸: Record<string, Cell>;
/** `C3:AD3` — 겹치지 않음 · 왼위 칸만 값 · 서식은 칸마다(테두리 때문). */
병합?: string[];
/** 열 글자 열쇠(`A` · `AB`). */
열?: Record<string, ColumnInfo>;
/** 행 번호 열쇠(1 부터 · `"31"`). */
행?: Record<string, RowInfo>;
/** 위에서 고정할 행 수 · 왼쪽에서 고정할 열 수. */
틀고정?: { 행?: number; 열?: number };
보기?: { 눈금선?: boolean };
/** 시트 통째(모두 고르기) — 서식 번호 · 열 폭(글자 단위) · 행 높이(pt). 칸 · 행 · 열 값이 없을 때 씀 ·
* 없으면 통합문서 `기본`. */
기본?: { 서식?: number; 열폭?: number; 행높이?: number };
/** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */
comments?: Record<string, string>;
/** 자동 필터(시트에 하나) */
필터?: AutoFilter;
/** 조건부 서식 — 배열 앞쪽이 우선순위 위(`spreadsheet_condfmt.ts`). */
조건부서식?: CondFmtRule[];
/** 데이터 유효성(`spreadsheet_validation.ts`). */
유효성?: DataValidationRule[];
/** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는
* A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */
이름정의?: NamedRange[];
/** 시트 숨김(`spreadsheet_sheet_extra.ts`) — 탭은 `spreadsheet_tabs.ts`(E)가 이 값으로 걸러야 함(안 이어짐). */
숨김?: boolean;
/** 시트 탭 색 `#RRGGBB`(`spreadsheet_sheet_extra.ts`) — 탭 칠은 E 몫(안 이어짐). */
탭색?: string;
/** 알약 연결(`spreadsheet_links.ts` · sub7 계약 더함) — 아래 알약(입력값 · 자재)이 가리키는 이 시트 칸.
* 행열 넣기 · 지우기에 따라감(지운 칸이면 빠짐) · 바꿈은 `시트통째` 로(되돌리기 짝). */
연결?: CellLink[];
}
/** 자재DB 한 줄 — 재료산출 알약. */
export interface LinkMaterial {
코드: string;
이름: string;
규격: string;
단위: string;
}
/** 알약 ↔ 칸 한 줄 — `키` = 입력값 키 또는 자재 코드 · 키 하나에 칸 하나 · 칸 하나에 키 하나. */
export interface CellLink {
키: string;
이름?: string;
단위?: string;
/** A1 */
칸: string;
자재?: LinkMaterial;
}
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
export interface WorkbookDefaults {
글꼴?: string;
크기?: number;
열폭?: number;
행높이?: number;
}
export interface Workbook {
종류: "통합문서";
판: 1;
/** 구조물집계표 열 id(파일 이름과 같음). */
열: string;
기본?: WorkbookDefaults;
/** 서식 표 — [0] = 기본(빈 `{}`). */
서식: CellStyle[];
시트: Sheet[];
/** 열 때 보일 시트 id. */
활성?: string;
/** 서버만 씀 — [저장] 때 Node 풀이 결과(시트 id → A1 → 값). 브라우저 값은 안 적음 · 화면은 안 읽음. */
계산값?: CalcValues;
}
// ═════════════════════════════════════════════════════════════════════════════
// 2. 주소
// ═════════════════════════════════════════════════════════════════════════════
export interface CellAddress {
r: number;
c: number;
}
/** 닫힌 범위 — r0 ≤ r1 · c0 ≤ c1. 행 전체 = c0 0 · c1 MAX_COLS-1(열 전체도 같은 꼴). */
export interface CellRange {
r0: number;
c0: number;
r1: number;
c1: number;
}
/** 시트까지 붙은 칸 — 엔진 · 명령이 씀. */
export interface SheetCellAddress extends CellAddress {
시트: string;
}
// ═════════════════════════════════════════════════════════════════════════════
// 3. 수식 — 낱말 · 나무 (A 가 만들고 B · D · E 가 읽음)
// ═════════════════════════════════════════════════════════════════════════════
export type ErrorCode =
"#NULL!" | "#DIV/0!" | "#VALUE!" | "#REF!" | "#NAME?" | "#NUM!" | "#N/A" | "#CYCLE!"; // 순환 참조 — 엑셀엔 없음 · 칸에 보임(9_엑셀검토 3-3)
export type TokenKind =
| "number"
| "string"
| "bool"
| "error"
| "ref" // 칸 · 범위 · 행 · 열 참조(시트 붙은 것 포함) — 편집기가 색 테두리로 씀
| "func" // 함수 이름(뒤 `(` 앞까지)
| "op"
| "paren"
| "comma"
| "space"
| "unknown";
/** 낱말 — `start` · `end` 는 식 글(앞 `=` 뺀) 안 글자 자리. 편집기 참조 가리키기 · 색칠용. */
export interface Token {
kind: TokenKind;
text: string;
start: number;
end: number;
}
/** 나무 안 칸 참조 — 시트 없으면 식이 든 시트. `$` 는 abs. */
export interface RefCell {
r: number;
c: number;
absR: boolean;
absC: boolean;
}
export type FormulaNode =
| { type: "number"; value: Frac; text: string }
| { type: "string"; value: string }
| { type: "bool"; value: boolean }
| { type: "error"; code: ErrorCode }
/** 칸 하나 */
| { type: "cell"; sheet: string | null; ref: RefCell }
/** 범위(`A1:B5` · `A:A` · `3:3`) — 행 · 열 전체는 끝을 MAX 로. 한쪽이라도 `#REF!` 면 error 나무 */
| { type: "range"; sheet: string | null; from: RefCell; to: RefCell }
/** 글자 그대로 남은 이름(정의 없음) → 풀면 `#NAME?` */
| { type: "name"; name: string }
| { type: "unary"; op: "-" | "+" | "%"; arg: FormulaNode }
| {
type: "binary";
op: "+" | "-" | "*" | "/" | "^" | "&" | "=" | "<>" | "<" | "<=" | ">" | ">=";
left: FormulaNode;
right: FormulaNode;
}
| { type: "call"; name: string; args: FormulaNode[] };
/** 파싱 실패 — 칸에는 식 글을 그대로 두고 값은 `#NAME?`(엑셀은 입력을 막지만 우리는 까닭과 함께 받음). */
export interface ParseFailure {
ok: false;
message: string;
/** 식 글 안 자리 */
at: number;
}
export type ParseResult = { ok: true; node: FormulaNode } | ParseFailure;
// ═════════════════════════════════════════════════════════════════════════════
// 4. 값 — 풀이 중(분수) · 저장(십진 글)
// ═════════════════════════════════════════════════════════════════════════════
export interface ErrorValue {
error: ErrorCode;
/** 풍선 까닭(한글) */
why?: string;
}
/** 칸 하나 값 — 수는 BigInt 분수(무리수 함수만 double → 유효 15 자리 십진 → 분수).
* null = 빈 칸 — 수에서 0 · 글에서 "" · 범위 함수는 건너뜀(엑셀 규칙). */
export type Scalar = Frac | string | boolean | ErrorValue | null;
/** 범위 값 — 함수가 칸마다 읽음(빈 칸 null). */
export interface RangeValue {
kind: "range";
rows: number;
cols: number;
at(r: number, c: number): Scalar;
}
export type EvalResult = Scalar | RangeValue;
/** 저장 · 서버 결과 한 칸 — 수는 십진 글(끝수 뒤 · 30 자리까지). */
export type CalcValue =
{ 수: string } | { 글: string } | { 참: boolean } | { 오류: ErrorCode; 까닭?: string };
/** 시트 id → A1 → 값 (식 칸만). */
export type CalcValues = Record<string, Record<string, CalcValue>>;
// ═════════════════════════════════════════════════════════════════════════════
// 5. 풀이 문맥 · 함수 (A 의 eval ↔ B 의 함수 표)
// ═════════════════════════════════════════════════════════════════════════════
export interface EvalContext {
/** 식이 든 칸 */
sheet: string;
r: number;
c: number;
/** 칸 값 — 식 칸이면 엔진이 먼저 풀어 줌 · 순환이면 `#CYCLE!` */
cell(sheet: string, r: number, c: number): Scalar;
range(sheet: string, range: CellRange): RangeValue;
/** 시트 이름 → id (없으면 null → `#REF!`) */
sheetId(name: string): string | null;
}
/** 인자는 늦게 풂(IF · IFERROR · AND 가 고른 것만 풀게). 범위 인자는 RangeValue 로 옴. */
export type LazyArg = () => EvalResult;
export interface SheetFunction {
/** 인자 수 — 최대 없으면 끝없음 */
min: number;
max?: number;
call(args: LazyArg[], ctx: EvalContext): EvalResult;
}
// ═════════════════════════════════════════════════════════════════════════════
// 6. 명령 — 문서를 바꾸는 길은 이것뿐(되돌리기 · 서버 재계산 · 캐시가 한 길로 감)
// ═════════════════════════════════════════════════════════════════════════════
export type Command =
/** 칸 여럿 한 번에 — null = 칸 지움(값 · 식 · 서식 모두). 붙여넣기 · 채우기 · 지우기도 이것. */
| { 종류: "칸"; 시트: string; 칸: Record<string, Cell | null> }
/** 내용만 지움(Delete) — 서식은 남김 */
| { 종류: "내용지움"; 시트: string; 범위: CellRange[] }
/** 서식 바꿈 — 칸마다 지금 서식에 `바꿀` 을 덮어 새 서식 번호(표에 없으면 더함). null 항목 = 그 속성 지움 */
| {
종류: "서식";
시트: string;
범위: CellRange[];
바꿀: { [K in keyof CellStyle]?: CellStyle[K] | null };
}
| { 종류: "서식지움"; 시트: string; 범위: CellRange[] }
/** 행 넣기 · 지우기 — 참조 옮김(지운 칸 참조는 #REF!) · 병합 늘고 줆 */
| { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기"; 시트: string; at: number; 수: number }
/** 잘라 붙이기 — 옮긴 칸을 가리키던 식도 따라감 */
| { 종류: "옮기기"; 시트: string; 범위: CellRange; 대상시트: string; r: number; c: number }
| { 종류: "병합"; 시트: string; 범위: CellRange }
| { 종류: "병합풀기"; 시트: string; 범위: CellRange }
/** 폭(글자 단위) · 높이(pt) — null = 기본으로 */
| { 종류: "열폭"; 시트: string; 열: number[]; 폭: number | null }
| { 종류: "행높이"; 시트: string; 행: number[]; 높이: number | null }
| { 종류: "숨김"; 시트: string; 축: "행" | "열"; 번호: number[]; 숨김: boolean }
| { 종류: "틀고정"; 시트: string; 행: number; 열: number }
| { 종류: "눈금선"; 시트: string; 보임: boolean }
| { 종류: "시트더하기"; 시트: Sheet; 자리: number }
| { 종류: "시트지우기"; 시트: string }
/** 이름 바꾸면 다른 시트 식 글의 이름도 바꿈 */
| { 종류: "시트이름"; 시트: string; 이름: string }
| { 종류: "시트옮기기"; 시트: string; 자리: number }
/** 되돌림 짝 전용 — 시트 통째 바꿔 넣기(행열 지우기 되돌림 등) */
| { 종류: "시트통째"; 시트: Sheet; 서식?: CellStyle[] }
/** 정렬 — `차례[i]` = 범위 r0+i 자리에 올 원래 행(범위 열만 옮김 · 식은 복사처럼 상대 참조만 옮김).
* 차례는 `sortCommand`(값 비교는 엔진)가 만듦 — 다시 하기가 같은 결과가 되게 명령에 박음. */
| { 종류: "정렬"; 시트: string; 범위: CellRange; 차례: number[] }
/** 자동 필터 켜기 · 조건 바꾸기 · 끄기(null) — `걸러짐` = 숨길 행(`filterCommand` 가 값으로 셈) */
| { 종류: "필터"; 시트: string; 필터: AutoFilter | null; 걸러짐: number[] }
/** 행 · 열 묶기(윤곽) · 접기 · 펴기 · 풀기(`묶음` null) — 격자 윤곽 +/- 단추가 씀 */
| { 종류: "묶음설정"; 시트: string; 축: "행" | "열"; 번호: number[]; 묶음: Outline | null }
/** 여러 명령 = 되돌리기 한 번 */
| { 종류: "묶음"; 명령: Command[] };
/** 명령을 적용한 뒤 계산 엔진에 알릴 것 — 칸만 바뀌었으면 칸 목록 · 모양이 바뀌었으면 rebuild. */
export interface CommandEffect {
/** 되돌림 짝 */
undo: Command;
/** 값 · 식이 바뀐 칸 — 딸린 칸만 다시 풂 */
cells: SheetCellAddress[];
/** 행열 · 옮기기 · 시트 명령 — 엔진을 통째로 다시 */
rebuild: boolean;
}
// ═════════════════════════════════════════════════════════════════════════════
// 7. 계산 엔진 — 통합문서를 참조로 쥠(명령이 문서를 바꾸면 알려 줌)
// ═════════════════════════════════════════════════════════════════════════════
export interface CalcEngine {
/** 바뀐 칸 → 딸린 칸만 다시 풂 · 다시 푼 칸(바뀐 칸 포함) 목록 */
update(changed: SheetCellAddress[]): SheetCellAddress[];
/** 문서를 통째로 다시 읽고 전부 풂(행열 · 시트 명령 · setDoc 뒤) */
rebuild(book: Workbook): void;
/** 칸 값 — 식 칸이면 푼 값 · 아니면 입력 값(수는 분수) */
value(sheet: string, r: number, c: number): Scalar;
/** 식 칸이 가리키는 칸 · 범위(편집기 색 테두리 · 오류 추적) */
precedents(sheet: string, r: number, c: number): { 시트: string; 범위: CellRange }[];
/** 식 칸 전부 → 저장 모양(서버 `계산값` · 시험 대조) */
snapshot(): CalcValues;
}
// ═════════════════════════════════════════════════════════════════════════════
// 8. 되돌리기 · 채우기 · 숫자 형식 · 클립보드 (A · B · E 공개 모양)
// ═════════════════════════════════════════════════════════════════════════════
export interface History {
/** 적용이 끝난 명령과 그 되돌림 짝을 쌓음 · 다시 스택은 비움 */
push(done: Command, undo: Command): void;
undo(): Command | null;
redo(): Command | null;
canUndo(): boolean;
canRedo(): boolean;
clear(): void;
}
export type FillDirection = "down" | "up" | "right" | "left";
/** 숫자 형식 결과 — `색` 은 `[Red]` 같은 형식 색 · `채움글` 은 `*x` 반복 글(격자가 폭만큼 늘림). */
export interface FormattedText {
글: string;
색?: string;
채움글?: string;
/** 형식이 정한 정렬(일반 정렬일 때 수 = 오른쪽 · 글 = 왼쪽) */
정렬: "left" | "right" | "center";
}
/** 붙여넣기로 읽은 칸 뭉치 — 왼위 기준 상대 자리. */
export interface ClipCell {
값?: CellInput;
/** 앞 `=` 뺀 A1 식 — `원점` 기준으로 적힌 글 */
식?: string;
서식?: CellStyle;
}
/** 클립보드 형 — 브라우저가 읽는 것은 `text/plain`(TSV) · `text/html` 둘.
* 우리 → 밖: 둘 다 씀(보이는 값 + 인라인 서식 표) · 우리 → 우리: HTML 속 data 속성에 뭉치 JSON.
* 밖 → 우리 수식: ① 구글 `data-sheets-formula`(R1C1 상대) ② 엑셀 「수식 표시」(Ctrl+`) 로 복사한 글 칸이
* `=` 뒤에 글이 있고 식으로 읽히면 식(「=」 한 글자 칸은 글) — 실제 되는지는 E 가 이 PC 엑셀로 시험. */
export interface ClipBlock {
rows: number;
cols: number;
/** `r,c` 열쇠(0 부터 · 뭉치 왼위 기준) */
칸: Record<string, ClipCell>;
/** 뭉치 안 병합(0 부터) */
병합: CellRange[];
/** 식 글이 적힌 기준 왼위 — 붙일 때 (붙일 자리 − 원점) 만큼 상대 참조를 옮김.
* null = 옮기지 않음(엑셀 수식 표시 글 — 적힌 주소 그대로 · 구글 R1C1 은 붙일 자리로 풀어 null). */
원점: CellAddress | null;
출처: "aislo" | "google" | "excel" | "text";
}
// ═════════════════════════════════════════════════════════════════════════════
// 9. 3단계 — 조건부 서식 · 데이터 유효성 · 이름 정의 (Sheet 확장 · sub_laptop_4 계약 더함 · 안 이어짐)
// ═════════════════════════════════════════════════════════════════════════════
export type CondFmtCompareOp =
"보다큼" | "보다작음" | "크거나같음" | "작거나같음" | "같음" | "다름";
export type CondFmtCondition =
| { 종류: "비교"; 연산: CondFmtCompareOp; 값: number }
| { 종류: "사이"; 아래: number; 위: number }
| { 종류: "글포함"; 글: string }
/** 임의 수식 글 — A 의 파서 · eval 이 있어야 풀림(`spreadsheet_condfmt.ts` 머리 「잇는 법」). */
| { 종류: "수식"; 식: string };
/** 조건부 서식이 먹이는 스타일 — `서식` 표 번호를 안 씀(규칙마다 직접 가짐). */
export interface CondFmtStyle {
채움?: string;
글자색?: string;
굵게?: boolean;
}
export interface CondFmtRule {
id: string;
범위: CellRange[];
조건: CondFmtCondition;
스타일: CondFmtStyle;
}
export type DataValidationKind =
{ 종류: "목록"; 값들: string[] } | { 종류: "수범위"; 아래?: number; 위?: number };
export interface DataValidationRule {
id: string;
범위: CellRange[];
규칙: DataValidationKind;
/** 어긴 값을 넣으려 하면 막을지 · 경고만 하고 넣을지(둘 다 D 편집기 잇기 몫 · 지금 안 이어짐). */
어기면: "막음" | "경고";
메시지?: string;
}
/** 이름 정의 — 이 이름을 정의한 시트 안 범위만 가리킴(다른 시트를 가리키는 이름은 3단계 밖). */
export interface NamedRange {
이름: string;
범위: CellRange;
}
@@ -0,0 +1,219 @@
/* =============================================================================
* spreadsheet_validation.ts (3단계 · 주인 sub_laptop_4)
* 데이터 유효성 — 목록(드롭다운) · 수 범위. 어기면 막거나(칸에 못 들어감) 경고만(그래도 들어감).
* 저장 자리 `Sheet.유효성`(0 계약에 방금 더함). 규칙은 범위 하나에 하나만 먹임(엑셀과 같이 — 겹치면
* 배열 앞쪽이 이김).
*
* 잇는 법(D · sub7 — 지금은 하나도 안 이어짐):
* 1) 편집기(`spreadsheet_editor.ts`)가 칸 편집을 커밋하기 전에 `checkValue(rule, candidate)` 를
* 불러 `어기면 === "막음"` 이면 `dispatch` 를 안 부르고(입력 취소) 메시지를 토스트로 · `"경고"` 면
* 토스트만 띄우고 그대로 진행.
* 2) 칸 편집이 시작될 때(`editor.begin`) `findValidation(sheet, r, c)` 가 「목록」 규칙이면
* `createValidationDropdown(ctx, r, c, rule)` 을 칸 옆에 띄우고 고르면 그 값을 칸에 넣음.
* 3) 행 · 열 넣기 · 지우기 뒤 `remapValidationRanges` 를 그 시트 `유효성` 에 적용(조건부 서식과 같은
* 자리 · A 몫 · 지금 안 이어짐).
* 4) 도구 모음 · 메뉴에 [데이터 유효성] 항목을 두고 `createValidationPanel(ctx)` 를 띄움.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { inRange, rangeToA1 } from "./spreadsheet_address";
import type {
CellInput,
CellRange,
DataValidationKind,
DataValidationRule,
Sheet,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 아직 안 이어짐(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 한 줄 더함.
export type { DataValidationKind, DataValidationRule };
/** 칸이 든 범위를 덮는 첫 규칙(배열 앞쪽 우선) — 없으면 undefined. */
export function findValidation(sheet: Sheet, r: number, c: number): DataValidationRule | undefined {
return (sheet.유효성 ?? []).find((rule) => rule.범위.some((range) => inRange(range, r, c)));
}
export interface ValidationCheck {
ok: boolean;
어기면?: "막음" | "경고";
메시지: string;
}
function defaultMessage(kind: DataValidationKind): string {
if (kind.종류 === "목록") return `목록 안 값만 됨(${kind.값들.join(", ")})`;
const 아래 = kind.아래 ?? -Infinity;
const 위 = kind.위 ?? Infinity;
return `${아래 === -Infinity ? "" : 아래}${위 === Infinity ? " 이상" : ` ~ ${위}`} 수만 됨`;
}
function matchesKind(kind: DataValidationKind, candidate: CellInput): boolean {
if (kind.종류 === "목록")
return typeof candidate !== "boolean" && kind.값들.includes(String(candidate));
if (typeof candidate !== "number") return false;
if (kind.아래 !== undefined && candidate < kind.아래) return false;
if (kind.위 !== undefined && candidate > kind.위) return false;
return true;
}
/** 후보 값이 규칙을 지키는지 — 어기면 `어기면`(막음 · 경고)과 안내 글을 돌려줌. */
export function checkValue(rule: DataValidationRule, candidate: CellInput): ValidationCheck {
if (matchesKind(rule.규칙, candidate)) return { ok: true, 메시지: "" };
return { ok: false, 어기면: rule.어기면, 메시지: rule.메시지 || defaultMessage(rule.규칙) };
}
/** 행 · 열 넣기 · 지우기 뒤 범위 옮김 — `shift` 가 null 이면 그 범위는 버림(규칙이 범위를 다 잃으면 규칙째 버림). */
export function remapValidationRanges(
rules: DataValidationRule[],
shift: (range: CellRange) => CellRange | null,
): DataValidationRule[] {
const out: DataValidationRule[] = [];
for (const rule of rules) {
const moved = rule.범위.map(shift).filter((r): r is CellRange => r !== null);
if (moved.length) out.push({ ...rule, 범위: moved });
}
return out;
}
function applySheet(ctx: SpreadsheetContext, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
let seq = 0;
const newId = (): string => `dv${Date.now().toString(36)}${seq++}`;
export function addValidationRule(
ctx: SpreadsheetContext,
rule: Omit<DataValidationRule, "id">,
): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 유효성: [...(sheet.유효성 ?? []), { ...rule, id: newId() }] });
}
export function removeValidationRule(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.sheet();
applySheet(ctx, { ...sheet, 유효성: (sheet.유효성 ?? []).filter((r) => r.id !== id) });
}
/** 목록 규칙일 때 칸 옆에 띄울 작은 드롭다운(고르면 `onPick` 이 불림 — 실제 `dispatch` 는 부른 쪽 몫). */
export function createValidationDropdown(
ctx: SpreadsheetContext,
r: number,
c: number,
rule: DataValidationRule,
onPick: (value: string) => void,
): HTMLElement | null {
if (rule.규칙.종류 !== "목록") return null;
const box = ctx.grid.cellBox(r, c);
const dropdown = el("div", { className: "ss-validation__dropdown" });
dropdown.style.left = `${box.x}px`;
dropdown.style.top = `${box.y + box.h}px`;
dropdown.style.minWidth = `${box.w}px`;
dropdown.replaceChildren(
...rule.규칙.값들.map((value) => {
const item = el("div", { className: "ss-validation__dropdown-item", text: value });
item.addEventListener("click", () => onPick(value));
return item;
}),
);
return dropdown;
}
const kindText = (k: DataValidationKind): string =>
k.종류 === "목록" ? `목록: ${k.값들.join(", ")}` : `수범위: ${k.아래 ?? "−∞"} ~ ${k.위 ?? "+∞"}`;
/** 유효성 관리 창 — 지금 시트 규칙 목록 + 고른 범위에 규칙 더하기. */
export function createValidationPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-validation" });
const list = el("ul", { className: "ss-validation__list" });
const kindSelect = createSelectField({
label: "규칙",
options: [
{ value: "목록", text: "목록(드롭다운)" },
{ value: "수범위", text: "수 범위" },
],
});
const listValues = createInputField({ label: "목록 값(쉼표로 나눔)" });
const min = createInputField({ label: "아래(수범위)" });
const max = createInputField({ label: "위(수범위)" });
const onBreak = createSelectField({
label: "어기면",
options: [
{ value: "막음", text: "막음" },
{ value: "경고", text: "경고" },
],
});
const message = createInputField({ label: "안내 글(생략 가능)" });
const addBtn = createButton({ label: "규칙 추가" });
addBtn.addEventListener("click", () => {
const 범위 = ctx.selection.범위;
if (!범위.length) return;
const 규칙 = build규칙();
if (!규칙) return;
addValidationRule(ctx, {
범위: [...범위],
규칙,
어기면: onBreak.select.value as "막음" | "경고",
메시지: message.input.value || undefined,
});
render();
});
function build규칙(): DataValidationKind | null {
if (kindSelect.select.value === "목록") {
const 값들 = listValues.input.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return 값들.length ? { 종류: "목록", 값들 } : null;
}
const 아래 = min.input.value ? Number(min.input.value) : undefined;
const 위 = max.input.value ? Number(max.input.value) : undefined;
return 아래 === undefined && 위 === undefined ? null : { 종류: "수범위", 아래, 위 };
}
function render(): void {
const rules = ctx.sheet().유효성 ?? [];
list.replaceChildren(
...rules.map((rule) => {
const li = el("li", { className: "ss-validation__row" });
const label = el("span", {
text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`,
});
const del = createButton({ label: "지움", variant: "danger" });
del.addEventListener("click", () => {
removeValidationRule(ctx, rule.id);
render();
});
li.append(label, del);
return li;
}),
);
}
const form = el("div", {
className: "ss-validation__form",
children: [
kindSelect.root,
listValues.root,
min.root,
max.root,
onBreak.root,
message.root,
addBtn,
],
});
root.append(el("h4", { text: "데이터 유효성 규칙" }), list, form);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
@@ -0,0 +1,206 @@
/* =============================================================================
* spreadsheet_view_menu.ts (2단계 · 주인 sub_laptop_1)
* 이동 대화상자(F5 · 주소 · 정의된 이름) · 틀 고정 메뉴(첫 행 · 첫 열 · 현재 칸 기준 · 풀기).
*
* 정의된 이름 목록은 `Sheet.이름정의`(3단계 · sub_laptop_4 계약 · 안 이어짐) 가 있으면
* 그대로 보여 줌 — 없으면 빈 목록(주소 입력만 됨). 이름으로 다른 시트를 못 가리키는
* 지금 계약대로 활성 시트 안에서만 찾음.
*
* 잇는 법(D · sub7) — F5 → `openGoToDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함.
* 틀 고정 단추(E 도구 모음)는 눌렀을 때 `openFreezePanesMenu(ctx, button)` 를 부르면 그
* 단추 아래 작은 메뉴가 뜸.
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { dialogKeys } from "./spreadsheet_actions";
import { parseRange, rangeToA1 } from "./spreadsheet_address";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
goToTitle: "이동",
addressLabel: "참조",
namedRanges: "정의된 이름",
ok: "확인",
cancel: "취소",
notFound: "주소를 찾을 수 없음",
freezeFirstRow: "첫 행 고정",
freezeFirstColumn: "첫 열 고정",
freezeAtActive: "틀 고정(현재 칸 기준)",
unfreeze: "틀 고정 풀기",
};
// ═════════════════════════════════════════════════════════════════════════════
// 이동(F5)
// ═════════════════════════════════════════════════════════════════════════════
/** 범위(또는 칸) 글을 읽어 그 범위로 고름을 옮기고 보이게 스크롤함. */
export function goTo(ctx: SpreadsheetContext, target: CellRange): void {
const top = { r: target.r0, c: target.c0 };
ctx.select({ 시트: ctx.selection.시트, 범위: [target], 활성: top, 기준: top }, true);
}
export function openGoToDialog(ctx: SpreadsheetContext): void {
const overlay = el("div", { className: "ui-confirm" });
const panel = el("div", { className: "ui-confirm__panel" });
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
const errorMsg = el("p", { text: "" });
errorMsg.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;";
const addressField = createInputField({
label: TEXT.addressLabel,
value: rangeToA1(
ctx.selection.범위[0] ?? {
r0: ctx.selection.활성.r,
c0: ctx.selection.활성.c,
r1: ctx.selection.활성.r,
c1: ctx.selection.활성.c,
},
),
});
const goToAddress = (): void => {
const target = parseRange(addressField.input.value);
if (!target) {
errorMsg.textContent = TEXT.notFound;
return;
}
goTo(ctx, target);
close();
};
addressField.input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") goToAddress();
});
const names = ctx.sheet().이름정의 ?? [];
const namesList = el("div", {
children: names.map((n) =>
createButton({
label: `${n.이름} (${rangeToA1(n.범위)})`,
variant: "ghost",
onClick: () => {
goTo(ctx, n.범위);
close();
},
}),
),
});
namesList.style.cssText =
"display:flex;flex-direction:column;gap:2px;max-height:160px;overflow:auto;";
panel.append(
el("h3", { text: TEXT.goToTitle }),
addressField.root,
errorMsg,
...(names.length > 0 ? [el("p", { text: TEXT.namedRanges }), namesList] : []),
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({ label: TEXT.ok, variant: "filled", onClick: goToAddress }),
],
}),
);
overlay.append(panel);
document.body.append(overlay);
dialogKeys(overlay, close);
addressField.input.focus();
addressField.input.select();
}
// ═════════════════════════════════════════════════════════════════════════════
// 틀 고정
// ═════════════════════════════════════════════════════════════════════════════
export function freezeFirstRow(ctx: SpreadsheetContext): void {
ctx.dispatch({
종류: "틀고정",
시트: ctx.selection.시트,
행: 1,
열: ctx.sheet().틀고정?.열 ?? 0,
});
}
export function freezeFirstColumn(ctx: SpreadsheetContext): void {
ctx.dispatch({
종류: "틀고정",
시트: ctx.selection.시트,
행: ctx.sheet().틀고정?.행 ?? 0,
열: 1,
});
}
/** 활성 칸의 왼위 자리를 기준으로 그 위 · 왼쪽을 고정(활성 칸이 A1 이면 사실상 풀기와 같음). */
export function freezeAtActiveCell(ctx: SpreadsheetContext): void {
const at = ctx.selection.활성;
// 고정 판이 격자를 거의 다 덮으면 나머지가 안 움직임(시트 머리부터 고정) — 화면 안쪽 칸만
const box = ctx.grid.cellBox(at.r, at.c);
const g = ctx.grid.root;
if (box.y > g.clientHeight * 0.8 || box.x > g.clientWidth * 0.8) {
showToast("고정할 칸이 너무 아래 · 오른쪽 — 화면 안쪽 칸에서 고정할 것", "warning");
return;
}
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: at.r, 열: at.c });
}
export function unfreezePanes(ctx: SpreadsheetContext): void {
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: 0, 열: 0 });
}
/** 도구 모음 단추 아래(또는 화면 가운데, `anchor` 없으면) 뜨는 작은 메뉴 — 누르면 바로 적용하고 닫힘. */
export function openFreezePanesMenu(ctx: SpreadsheetContext, anchor?: HTMLElement): void {
const menu = el("div", { className: "ui-confirm__panel" });
menu.style.cssText =
"position:fixed;display:flex;flex-direction:column;gap:2px;min-width:180px;z-index:1000;padding:6px;";
const items: [string, () => void][] = [
[TEXT.freezeFirstRow, () => freezeFirstRow(ctx)],
[TEXT.freezeFirstColumn, () => freezeFirstColumn(ctx)],
[TEXT.freezeAtActive, () => freezeAtActiveCell(ctx)],
[TEXT.unfreeze, () => unfreezePanes(ctx)],
];
const close = (): void => {
menu.remove();
document.removeEventListener("mousedown", onOutside, true);
document.removeEventListener("keydown", onEscape);
};
const onOutside = (ev: MouseEvent): void => {
if (!menu.contains(ev.target as Node)) close();
};
const onEscape = (ev: KeyboardEvent): void => {
if (ev.key === "Escape") close();
};
menu.append(
...items.map(([label, action]) =>
createButton({
label,
variant: "ghost",
onClick: () => {
action();
close();
},
}),
),
);
document.body.append(menu);
if (anchor) {
const box = anchor.getBoundingClientRect();
menu.style.top = `${box.bottom + 4}px`;
menu.style.left = `${box.left}px`;
} else {
menu.style.top = "40%";
menu.style.left = "50%";
menu.style.transform = "translate(-50%, -50%)";
}
setTimeout(() => {
document.addEventListener("mousedown", onOutside, true);
document.addEventListener("keydown", onEscape);
}, 0);
}
@@ -0,0 +1,119 @@
/* =============================================================================
* spreadsheet_view_types.ts
* 화면 부품(C 격자 · D 입력 · E 도구) 사이 계약 — 문맥 하나를 D 가 만들어 C · E 에 넘김.
* 0 계약 주인만 고침. 파일 목록 · 주인 · 규칙은 `spreadsheet_types.ts` 머리.
*
* 흐름: 사용자 조작 → (D · E) `ctx.dispatch(명령)` → A 적용 · 되돌림 쌓기 · 엔진 update/rebuild →
* C `grid.invalidate(칸)`(보이는 칸만 다시) → `onChange(문서)`. 서버 왕복 없음 · 자동저장 없음.
* ========================================================================== */
import type {
CalcEngine,
CellAddress,
CellRange,
Command,
History,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
/** 고름 — 범위 여럿(Ctrl+누르기) · 활성 칸은 편집 · 수식 입력줄이 보이는 칸 · 기준은 Shift 늘림의 닻. */
export interface Selection {
시트: string;
범위: CellRange[];
활성: CellAddress;
기준: CellAddress;
}
/** 격자 안 한 점이 가리키는 것 — 머리 끝(`colEdge` · `rowEdge`)은 폭 · 높이 끌기 자리. */
export interface HitResult {
kind: "cell" | "colHead" | "rowHead" | "corner" | "colEdge" | "rowEdge" | "fillHandle";
r: number;
c: number;
}
/** 칸 자리 — 격자 내용 좌표(px · 스크롤 전) · 병합이면 병합 전체. */
export interface CellBox {
x: number;
y: number;
w: number;
h: number;
}
/** 편집 중 식 참조 색 테두리(D 가 채우고 C 가 그림). */
export interface RefHighlight {
시트: string;
범위: CellRange;
/** 0 부터 차례 — 색은 C 가 차례로 고름 */
색번호: number;
}
/** C 격자 — `createGrid(ctx)` 가 돌려줌. */
export interface GridHandle {
/** 격자 뿌리(스크롤 판 · 틀고정 판 포함) — D 가 창 안에 붙임 */
root: HTMLElement;
/** 보이는 칸 통째로 다시(시트 바꿈 · 행열 · 폭 · 틀고정 뒤) */
render(): void;
/** 값 · 서식이 바뀐 칸만 다시(보이는 것만) */
invalidate(cells: SheetCellAddress[]): void;
/** 고름 · 참조 색 테두리 · 채우기 손잡이 다시 그림 */
renderSelection(): void;
setRefHighlights(list: RefHighlight[]): void;
cellBox(r: number, c: number): CellBox;
/** 화면 좌표 → 가리킨 것(격자 밖 null) */
hitTest(clientX: number, clientY: number): HitResult | null;
/** 칸이 보이게 스크롤(틀고정 판이면 그대로) */
reveal(r: number, c: number): void;
/** 지금 보이는 행열(틀고정 판 뺀 본문 판) */
visibleRange(): CellRange;
/** 편집기를 얹을 층과 칸 자리(화면 좌표 · 병합이면 전체) — D 편집기가 씀 */
editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox };
/** 지금 확대(1 = 100%) — 바뀌면 `root` 에서 CustomEvent("aislo-grid-zoom", { detail: { zoom } }) 를 쏨(E 상태줄이 들음) */
zoom(): number;
destroy(): void;
}
/** D 편집기 — E 도구 모음 · C 가 부름. */
export interface EditorHandle {
/** 편집 중인가 — 방향키 · 붙여넣기 뜻이 달라짐 */
editing(): boolean;
/** 활성 칸 편집 시작(`initial` = 친 글자로 시작) */
begin(initial?: string): void;
/** 확정 · 취소 */
commit(): void;
cancel(): void;
/** 수식 입력줄 · 주소 상자를 활성 칸으로 다시 */
sync(): void;
}
/** D 가 만들어 C · E 에 넘기는 문맥 — 문서는 이것 하나를 여럿이 봄. */
export interface SpreadsheetContext {
/** 부품 뿌리 */
root: HTMLElement;
/** 지금 문서 — 명령만이 바꿈(직접 고치지 말 것) */
book: Workbook;
readOnly: boolean;
engine: CalcEngine;
history: History;
selection: Selection;
/** 활성 시트 */
sheet(): Sheet;
/** 명령 하나 적용 → 되돌림 쌓기 → 엔진 → 다시 그림 → onChange. readOnly 면 안 함 */
dispatch(command: Command): void;
undo(): void;
redo(): void;
/** 고름 바꿈 → 다시 그림 · 수식 입력줄 · onSelect */
select(selection: Selection, reveal?: boolean): void;
/** 활성 시트 바꿈(탭) */
showSheet(sheetId: string): void;
grid: GridHandle;
editor: EditorHandle;
}
/** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */
export interface PartHandle {
root: HTMLElement | null;
refresh(): void;
destroy(): void;
}
+24 -10
View File
@@ -360,16 +360,23 @@ export async function searchMemberCandidates(query: string): Promise<Member[]> {
}
/** 이미 가입한 사람을 회사에 붙인다 — 계정을 대신 만들지 않는다. */
export function addCompanyMember(userId: number): Promise<{ member: Member }> {
return request("/dashboard/admin/members", {
export function addCompanyMember(
userId: number,
companyId?: number | null,
): Promise<{ member: Member }> {
return request(`/dashboard/admin/members${companyQuery(companyId)}`, {
method: "POST",
body: body({ user_id: userId }),
});
}
/** 아직 가입하지 않은 사람에게 가입 안내 메일만 보낸다. */
export function inviteMember(email: string, name?: string | null): Promise<unknown> {
return request("/dashboard/admin/members/invite", {
export function inviteMember(
email: string,
name?: string | null,
companyId?: number | null,
): Promise<unknown> {
return request(`/dashboard/admin/members/invite${companyQuery(companyId)}`, {
method: "POST",
body: body({ email, name: name || null }),
});
@@ -395,8 +402,11 @@ export function removeCompanyMember(userId: number): Promise<unknown> {
return request(`/dashboard/admin/members/${userId}`, { method: "DELETE" });
}
export async function fetchCompanyJoinRequests(): Promise<JoinRequest[]> {
const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests");
/** companyId 는 시스템관리자가 대시보드에서 고른 회사(29-1) — 없으면 자기 회사. */
export async function fetchCompanyJoinRequests(companyId?: number | null): Promise<JoinRequest[]> {
const data = await request<{ requests: JoinRequest[] }>(
`/dashboard/admin/join-requests${companyQuery(companyId)}`,
);
return data.requests;
}
@@ -410,8 +420,10 @@ export function processJoinRequest(
});
}
export async function fetchCompanyProjects(): Promise<ProjectItem[]> {
const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects");
export async function fetchCompanyProjects(companyId?: number | null): Promise<ProjectItem[]> {
const data = await request<{ projects: ProjectItem[] }>(
`/dashboard/admin/projects${companyQuery(companyId)}`,
);
return data.projects;
}
@@ -425,8 +437,10 @@ export async function fetchAllCompanies(): Promise<CompanyInfo[]> {
return data.companies;
}
export async function fetchAllUsers(): Promise<DashboardUser[]> {
const data = await request<{ users: DashboardUser[] }>("/dashboard/admin/users");
export async function fetchAllUsers(companyId?: number | null): Promise<DashboardUser[]> {
const data = await request<{ users: DashboardUser[] }>(
`/dashboard/admin/users${companyQuery(companyId)}`,
);
return data.users;
}
+5 -3
View File
@@ -338,15 +338,17 @@ async def soft_delete_project(project_id: str, actor_id: int, request: Any | Non
return changed
async def list_all_users() -> list[dict[str, Any]]:
async def list_all_users(company_id: int | None = None) -> list[dict[str, Any]]:
where = " AND u.company_id = %s" if company_id else ""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone,
f"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone,
u.role, u.status, u.company_id,
c.name AS company_name, u.last_login
FROM users u LEFT JOIN companies c ON c.id = u.company_id
WHERE u.deleted_at IS NULL ORDER BY u.created_at DESC LIMIT 200"""
WHERE u.deleted_at IS NULL{where} ORDER BY u.created_at DESC LIMIT 200""",
(company_id,) if company_id else (),
)
rows = list(await cursor.fetchall())
for row in rows:
+30 -166
View File
@@ -1,28 +1,14 @@
"""B01_Dashboard 역할별 대시보드 API."""
import html
import mimetypes
import os
from typing import Any
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Path,
Query,
Request,
Response,
UploadFile,
)
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response
from pymysql.err import IntegrityError
from common_util.common_util_auth import require_company, require_system_admin, verify_session
from common_util.common_util_email import send_email
from common_util.common_util_project_delete import hard_delete_project
from common_util.common_util_storage import read_stored_asset
from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
from .B01_Dashboard_Map import fetch_base_tile, geocode_address, search_address
@@ -47,17 +33,6 @@ from .B01_Dashboard_Repository import (
update_project,
update_user_profile,
)
from .B01_Dashboard_Repository_Assets import (
ASSET_EXTENSIONS,
ASSET_KINDS,
ASSET_MAX_BYTES,
create_company_asset,
delete_company_asset,
get_company_asset,
list_company_assets,
update_company_asset,
write_company_asset_file,
)
from .B01_Dashboard_Repository_Company import (
create_company,
get_user_company,
@@ -75,9 +50,9 @@ from .B01_Dashboard_Repository_Members import (
list_company_members,
list_unassigned_users,
remove_company_member,
set_company_logo,
set_project_members,
)
from .B01_Dashboard_Router_Assets import router as assets_router
from .B01_Dashboard_Schema import (
AddMemberRequest,
AdminUpdateUserRequest,
@@ -88,52 +63,19 @@ from .B01_Dashboard_Schema import (
JoinCompanyRequest,
ProcessJoinRequest,
UiPrefsRequest,
UpdateCompanyAssetRequest,
UpdateCompanyLogoRequest,
UpdateProjectRequest,
UpdateUserRequest,
)
from .B01_Dashboard_Scope import (
_require_company_id,
_same_company,
_scope_company,
require_company_admin,
)
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
async def require_company_admin(
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]:
raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.")
return session
def _require_company_id(session: dict[str, Any]) -> int:
company_id = session.get("company_id")
if company_id is None:
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
return int(company_id)
def _same_company(session: dict[str, Any], company_id: int | None) -> bool:
return company_id is not None and int(session.get("company_id") or 0) == int(company_id)
def _scope_company(session: dict[str, Any], company_id: int | None) -> int:
"""회사 범위 자원(구성원·로고·서명)의 회사. 시스템관리자만 남의 회사를 지정할 수 있다."""
if session["role"] == "SYSTEM_ADMIN" and company_id is not None:
return int(company_id)
own = _require_company_id(session)
if company_id is not None and int(company_id) != own:
raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.")
return own
async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]:
asset = await get_company_asset(asset_id)
if not asset:
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.")
_scope_company(session, int(asset["company_id"]))
return asset
async def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
if session["role"] == "SYSTEM_ADMIN":
return True
@@ -326,9 +268,10 @@ async def admin_member_candidates(
@router.post("/admin/members")
async def admin_add_member(
payload: AddMemberRequest,
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
member = await attach_company_member(_require_company_id(session), payload.user_id)
member = await attach_company_member(_scope_company(session, company_id), payload.user_id)
if not member:
raise HTTPException(
status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다."
@@ -339,10 +282,11 @@ async def admin_add_member(
@router.post("/admin/members/invite")
async def admin_invite_member(
payload: InviteMemberRequest,
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
"""아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다."""
company = await get_user_company(session.get("company_id"))
company = await get_user_company(_scope_company(session, company_id))
company_name = (company or {}).get("name") or "회사"
# 메일 본문에 사람이 넣은 글자가 그대로 들어가면 남의 편지함에 임의 HTML 을 보낼 수 있다.
safe_company = html.escape(company_name)
@@ -369,8 +313,12 @@ async def admin_remove_member(
@router.get("/admin/join-requests")
async def admin_join_requests(session: dict[str, Any] = Depends(require_company_admin)):
return {"status": "success", "requests": await list_join_requests(_require_company_id(session))}
async def admin_join_requests(
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
scoped = _scope_company(session, company_id)
return {"status": "success", "requests": await list_join_requests(scoped)}
@router.patch("/admin/join-requests/{request_id}")
@@ -392,10 +340,13 @@ async def admin_process_join_request(
@router.get("/admin/projects")
async def admin_projects(session: dict[str, Any] = Depends(require_company_admin)):
async def admin_projects(
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
return {
"status": "success",
"projects": await list_company_projects(_require_company_id(session)),
"projects": await list_company_projects(_scope_company(session, company_id)),
}
@@ -470,9 +421,13 @@ async def system_create_company(
@router.get("/admin/users")
async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
async def system_users(
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_system_admin),
):
"""company_id = 대시보드에서 고른 회사 사람만(29-1) · 없으면 전체."""
_ = session
return {"status": "success", "users": await list_all_users()}
return {"status": "success", "users": await list_all_users(company_id)}
@router.patch("/admin/users/{user_id}/role")
@@ -607,95 +562,4 @@ async def system_projects(session: dict[str, Any] = Depends(require_system_admin
return {"status": "success", "projects": await list_all_projects()}
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
@router.get("/company/assets")
async def company_assets(
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(verify_session),
):
return {
"status": "success",
"assets": await list_company_assets(_scope_company(session, company_id)),
}
@router.post("/company/assets")
async def company_add_asset(
kind: str = Form(...),
label: str = Form(...),
user_id: int | None = Form(None, gt=0),
company_id: int | None = Form(None, gt=0),
file: UploadFile = File(...),
session: dict[str, Any] = Depends(verify_session),
):
kind = kind.upper()
if kind not in ASSET_KINDS:
raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.")
label = label.strip()[:100]
if not label:
raise HTTPException(status_code=400, detail="이름을 넣어 주세요.")
suffix = os.path.splitext(file.filename or "")[1].lower()
if suffix not in ASSET_EXTENSIONS:
raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.")
blob = await file.read()
if not blob or len(blob) > ASSET_MAX_BYTES:
raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.")
scoped = _scope_company(session, company_id)
if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}:
raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.")
path = write_company_asset_file(scoped, kind, suffix, blob)
asset_id = await create_company_asset(
scoped, kind, label, path, user_id, int(session["user_id"])
)
return {"status": "success", "asset_id": asset_id}
@router.put("/company/assets/{asset_id}")
async def company_update_asset(
asset_id: int,
payload: UpdateCompanyAssetRequest,
session: dict[str, Any] = Depends(verify_session),
):
await _company_asset(session, asset_id)
await update_company_asset(asset_id, payload.label.strip(), payload.user_id)
return {"status": "success"}
@router.delete("/company/assets/{asset_id}")
async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
await _company_asset(session, asset_id)
await delete_company_asset(asset_id)
return {"status": "success"}
@router.put("/company/logo")
async def company_set_logo(
payload: UpdateCompanyLogoRequest,
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
"""회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정).
프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다.
"""
scoped = _scope_company(session, company_id)
if payload.logo_asset_id is not None:
asset = await get_company_asset(payload.logo_asset_id)
if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO":
raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.")
if not await set_company_logo(scoped, payload.logo_asset_id):
raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/company/assets/{asset_id}/file")
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
asset = await _company_asset(session, asset_id)
blob = read_stored_asset(asset["file_path"])
if blob is None:
raise HTTPException(status_code=404, detail="그림 파일이 없습니다.")
media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream"
return Response(content=blob, media_type=media_type)
router.include_router(assets_router)
@@ -0,0 +1,129 @@
"""B01_Dashboard 회사 공유 도면 자산(로고·서명) API — 본체 라우터가 include 함."""
import mimetypes
import os
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import read_stored_asset
from .B01_Dashboard_Repository_Assets import (
ASSET_EXTENSIONS,
ASSET_KINDS,
ASSET_MAX_BYTES,
create_company_asset,
delete_company_asset,
get_company_asset,
list_company_assets,
update_company_asset,
write_company_asset_file,
)
from .B01_Dashboard_Repository_Members import list_company_members, set_company_logo
from .B01_Dashboard_Schema import UpdateCompanyAssetRequest, UpdateCompanyLogoRequest
from .B01_Dashboard_Scope import _scope_company, require_company_admin
router = APIRouter()
async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]:
asset = await get_company_asset(asset_id)
if not asset:
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.")
_scope_company(session, int(asset["company_id"]))
return asset
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
@router.get("/company/assets")
async def company_assets(
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(verify_session),
):
return {
"status": "success",
"assets": await list_company_assets(_scope_company(session, company_id)),
}
@router.post("/company/assets")
async def company_add_asset(
kind: str = Form(...),
label: str = Form(...),
user_id: int | None = Form(None, gt=0),
company_id: int | None = Form(None, gt=0),
file: UploadFile = File(...),
session: dict[str, Any] = Depends(verify_session),
):
kind = kind.upper()
if kind not in ASSET_KINDS:
raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.")
label = label.strip()[:100]
if not label:
raise HTTPException(status_code=400, detail="이름을 넣어 주세요.")
suffix = os.path.splitext(file.filename or "")[1].lower()
if suffix not in ASSET_EXTENSIONS:
raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.")
blob = await file.read()
if not blob or len(blob) > ASSET_MAX_BYTES:
raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.")
scoped = _scope_company(session, company_id)
if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}:
raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.")
path = write_company_asset_file(scoped, kind, suffix, blob)
asset_id = await create_company_asset(
scoped, kind, label, path, user_id, int(session["user_id"])
)
return {"status": "success", "asset_id": asset_id}
@router.put("/company/assets/{asset_id}")
async def company_update_asset(
asset_id: int,
payload: UpdateCompanyAssetRequest,
session: dict[str, Any] = Depends(verify_session),
):
await _company_asset(session, asset_id)
await update_company_asset(asset_id, payload.label.strip(), payload.user_id)
return {"status": "success"}
@router.delete("/company/assets/{asset_id}")
async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
await _company_asset(session, asset_id)
await delete_company_asset(asset_id)
return {"status": "success"}
@router.put("/company/logo")
async def company_set_logo(
payload: UpdateCompanyLogoRequest,
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
"""회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정).
프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다.
"""
scoped = _scope_company(session, company_id)
if payload.logo_asset_id is not None:
asset = await get_company_asset(payload.logo_asset_id)
if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO":
raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.")
if not await set_company_logo(scoped, payload.logo_asset_id):
raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/company/assets/{asset_id}/file")
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
asset = await _company_asset(session, asset_id)
blob = read_stored_asset(asset["file_path"])
if blob is None:
raise HTTPException(status_code=404, detail="그림 파일이 없습니다.")
media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream"
return Response(content=blob, media_type=media_type)
+36
View File
@@ -0,0 +1,36 @@
"""B01_Dashboard 권한 · 회사 범위 도우미 — 라우터 둘(본체 · 자산)이 함께 씀."""
from typing import Any
from fastapi import Depends, HTTPException
from common_util.common_util_auth import verify_session
async def require_company_admin(
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]:
raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.")
return session
def _require_company_id(session: dict[str, Any]) -> int:
company_id = session.get("company_id")
if company_id is None:
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
return int(company_id)
def _same_company(session: dict[str, Any], company_id: int | None) -> bool:
return company_id is not None and int(session.get("company_id") or 0) == int(company_id)
def _scope_company(session: dict[str, Any], company_id: int | None) -> int:
"""회사 범위 자원의 회사 — 시스템관리자면 인자 우선(대시보드 회사 고르기) · 남은 세션 회사."""
if session["role"] == "SYSTEM_ADMIN" and company_id is not None:
return int(company_id)
own = _require_company_id(session)
if company_id is not None and int(company_id) != own:
raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.")
return own
@@ -0,0 +1,100 @@
/* =============================================================================
* 회사 조회 후 고르기(시스템 관리자 회사 알약 · 29-5)
* 찾기 칸(이름 · 사업자번호 · 띄어쓰기 단어 모두 맞음) → 거른 목록(처음엔 최근 등록 순) →
* 줄 누르면 고름. 목록은 LIMIT 줄까지만 그림 — 회사가 많아도 찾기로 좁힘.
* ========================================================================== */
import { createButton, createInputField } from "@ui/ui_template_elements";
import type { CompanyInfo } from "./B01_Dashboard_Api_Fetch";
import { L } from "./B01_Dashboard_UI_Common";
const LIMIT = 50;
/** 이름 · 사업자번호(줄표 빼고도)에 모든 단어가 들어간 회사 — 순서는 받은 그대로 */
export function filterCompanies(companies: CompanyInfo[], text: string): CompanyInfo[] {
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
return companies.filter((c) => {
const no = c.business_registration_number ?? "";
const hay = `${c.name} ${no} ${no.replace(/-/g, "")}`.toLowerCase();
return words.every((w) => hay.includes(w));
});
}
export function openCompanyPicker(
companies: CompanyInfo[],
currentId: number | null,
onPick: (company: CompanyInfo) => void,
): void {
const modal = document.createElement("div");
modal.className = "b01-dashboard__modal";
const panel = document.createElement("div");
panel.className = "b01-dashboard__modal-panel";
const heading = document.createElement("h3");
heading.className = "b01-dashboard__modal-title";
heading.textContent = L("B01_Dashboard_PickCompany");
const query = createInputField({
label: L("B01_Dashboard_Field_Search"),
placeholder: L("B01_Dashboard_PickCompanyHint"),
});
const count = document.createElement("p");
count.className = "b01-dashboard__company-count";
const list = document.createElement("div");
list.className = "b01-dashboard__company-list";
const close = (): void => {
modal.remove();
document.removeEventListener("keydown", onKey, true);
};
const pick = (company: CompanyInfo): void => {
close();
onPick(company);
};
let shown: CompanyInfo[] = [];
const render = (): void => {
const found = filterCompanies(companies, query.input.value);
shown = found.slice(0, LIMIT);
count.textContent =
found.length > LIMIT
? L("B01_Dashboard_PickCompanyMore")
.replace("{count}", String(found.length))
.replace("{limit}", String(LIMIT))
: L("B01_Dashboard_PickCompanyCount").replace("{count}", String(found.length));
list.replaceChildren(
...shown.map((company) => {
const row = document.createElement("button");
row.type = "button";
row.className = "b01-dashboard__company-row";
row.classList.toggle("is-current", company.id === currentId);
const name = document.createElement("span");
name.textContent = company.name;
const no = document.createElement("small");
no.textContent = company.business_registration_number ?? "";
row.append(name, no);
row.addEventListener("click", () => pick(company));
return row;
}),
);
};
// Enter = 첫 줄 고름 · Esc = 닫기
const onKey = (event: KeyboardEvent): void => {
if (event.key === "Escape") close();
else if (event.key === "Enter" && event.target === query.input && shown[0]) pick(shown[0]);
else return;
event.preventDefault();
event.stopPropagation();
};
query.input.addEventListener("input", render);
document.addEventListener("keydown", onKey, true);
modal.addEventListener("click", (event) => {
if (event.target === modal) close();
});
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
actions.append(createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: close }));
panel.append(heading, query.root, count, list, actions);
modal.append(panel);
document.body.append(modal);
render();
query.input.focus();
}

Some files were not shown because too many files have changed in this diff Show More