From 78ffc8743c872fb212c6a12213149c89df3105b3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:11:07 +0900 Subject: [PATCH 01/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2098?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 2a930d328..41f5f6749 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -121,3 +121,4 @@ 95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문) 96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선 97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선 +98. 왜 프로젝트의 B06의 좌측패널의 구조물 리스트에서 노면공의 구조물 범위에 들어온 측점들중 다른 구조물의 리스트를 선택했음에도 노면공의 횡단면도 전체가 선택되는거야. 일단 조사만 진행해줘. 코드 구현은 나중에 할래 From 350853997defa4a2a1c087fbca54935603890616 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:14:58 +0900 Subject: [PATCH 02/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2099?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 41f5f6749..30b7153d9 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -122,3 +122,4 @@ 96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선 97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선 98. 왜 프로젝트의 B06의 좌측패널의 구조물 리스트에서 노면공의 구조물 범위에 들어온 측점들중 다른 구조물의 리스트를 선택했음에도 노면공의 횡단면도 전체가 선택되는거야. 일단 조사만 진행해줘. 코드 구현은 나중에 할래 +99. 테이블 양식에서 설계내역서의 레벨 기능이 제대로 구현안되어 있음. 외부에서 어떻게 해야하는지 확인해봐. (조사) From a23ccc2b04f6c1e5fb8fc8b128f0ea9d24a5060f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:25:34 +0900 Subject: [PATCH 03/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20100?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 30b7153d9..effcbdbae 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -123,3 +123,4 @@ 97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선 98. 왜 프로젝트의 B06의 좌측패널의 구조물 리스트에서 노면공의 구조물 범위에 들어온 측점들중 다른 구조물의 리스트를 선택했음에도 노면공의 횡단면도 전체가 선택되는거야. 일단 조사만 진행해줘. 코드 구현은 나중에 할래 99. 테이블 양식에서 설계내역서의 레벨 기능이 제대로 구현안되어 있음. 외부에서 어떻게 해야하는지 확인해봐. (조사) +100. (99번 보충) 테이블에서 제대로 안된다는 건 결국 들여쓰기에따라 레벨이 지정이 되고 폰트들도 자동으로 변경이 되어야하는데 안되고 있잖아. 그냥 들여쓰기만 하는거 아니야? From ef59f498965c1e655e57ea4c613fd70c5dd46bee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:29:58 +0900 Subject: [PATCH 04/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=A1=B0=EC=82=AC?= =?UTF-8?q?=20AE(=ED=91=9C=20=EB=A0=88=EB=B2=A8=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=82=AC=EB=A1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- .../raw/요구_1010_2/조사_AE_표레벨외부사례.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AE_표레벨외부사례.md diff --git a/docs/raw/요구_1010_2/조사_AE_표레벨외부사례.md b/docs/raw/요구_1010_2/조사_AE_표레벨외부사례.md new file mode 100644 index 000000000..67747815f --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AE_표레벨외부사례.md @@ -0,0 +1,109 @@ +# 조사 AE — 표 레벨 기능 외부 사례 (요구 99 · 조사만) + +대상 = B09 설계내역서 레벨(182 · 184 · 200-2 · 201 · 204-1) + 공용 표 `ui_template_data_table.ts` level 옵션. 코드 · 자료 고침 없음. + +## 1. 한눈 결론 + +- 지금 레벨은 「들여쓰기 + 굵게」 보기와 「한 줄씩 옮기는 손질」뿐. 외부 표가 모두 가진 **접기 · 펴기(▸ ▾)와 레벨 단추(1 · 2 · 3 · 전체)가 없음** — 사용자가 「제대로 안 됨」이라 느낄 가장 큰 까닭. +- 키가 일반 표와 거꾸로 — 우리 ↑↓ = 줄을 옮김(내역이 바뀜) · ←→ = 레벨. 외부(엑셀 · MS Project · 웹 표 · 접근성 기준)는 ↑↓ = 고른 줄만 옮김 · ←→ = 접기 · 펴기 · 레벨은 Alt+Shift+←→ 나 Tab. 게다가 **되돌리기(Ctrl+Z)가 없음**. +- 번호는 1 · 2단 묶음만 이름 앞에 붙음 — 실무 엑셀은 「공종」 칸을 따로 두고 Ⅰ · 1 · 4.1 · Ⅱ 로 적음. 경쟁사 STmate 는 번호 꼴까지 고르게 함. +- 한 줄만 고를 수 있어 여러 줄 한꺼번에 들여쓰기 · 옮기기 · 지우기 · 복사 · 붙여넣기 불가. 끌기는 표 가장자리에서 저절로 내려가지 않음. +- 넣는 길 — 새 라이브러리 없이 공용 표에 「접기」 옵션 하나(줄 차례 + 레벨만으로 숨김 셈) → 설계내역서 · 총괄 · 원가계산서 · 목록표가 같이 얻음. 키 체계 · 번호 칸은 사용자 결정 거리. + +## 2. 지금 구현 · 빈틈 + +지금 생김새: + +| 부분 | 지금 | 자리 | +|---|---|---| +| 저장 꼴 | 평면 배열 + parent — `user.bill` v2 `{v:2, rows:[{key · parent · kind group · 고친 칸}], gone}` · 배열 차례 = 형제 차례 | `B09_Estimation_UI_Tabs_Bill_Edit.ts` 머리 | +| 셈 | `Calc_Bill` 짝(.py ↔ .ts)이 나무를 다시 세움 → 부모 먼저 차례 · `level` 1~5 · `kind` title / group / item · 자식 생긴 항목 = 저절로 묶음 · 묶음 금액 = 자손 항목 합(묶음 머리 줄에) · 깊이 5 | `B09_Estimation_Calc_Bill.ts` tree() · computeBill() | +| 번호 | 직접비 1 · 2단 묶음만 `1` · `1.1` — 이름 글 앞에 붙임(고칠 때 떼어 냄) · 관급 · 3단 이하 없음 | 같은 파일 끝 · `…_UI_Tabs_Bill.ts` | +| 그리기 | 공용 표 `level {key, of}` → 명칭 칸 왼쪽 맞춤 + 레벨마다 1em 들여쓰기 · 묶음 · 제목 굵게 · 손 줄 왼쪽 띠 · 줄 1,000개용 보이는 구간만 그림 | `ui_template_data_table.ts` · `.css` 177~189 | +| 손질 | 우클릭(위 · 아래 · 하위 줄 추가 · 새 공종 · 위로 · 아래로 · 들여 · 내어쓰기 · 묶음 ↔ 항목 · 자동값으로 · 지우기) · 도구 단추 넷 · 끌기(위아래 = 자리 · 좌우 = 레벨 · 자손 함께 · 빨간 점선 = 못 놓음) | `…_Bill_Edit.ts` · `…_Bill_Level.ts` · `…_Bill_Drag.ts` | +| 키 | 고른 줄 + 글 칸 편집 아님 → Tab / Shift+Tab · ← → = 레벨 · ↑ ↓ · Alt+↑↓ = 옮김 · Esc = 편집 끝 / 고름 풂 | `…_Bill_Level.ts` keyAct() | +| 고름 | 한 줄(줄 키 하나) · 표시 = 호버와 같은 색 + 밑줄(`is-focus`) | 같은 파일 · `.css` 149 | + +빈틈: + +| # | 빈틈 | 지금 | 사용자가 겪는 것 | 외부 기준 | +|---|---|---|---|---| +| 1 | 접기 · 펴기 없음 | 늘 전부 펼침 | 줄이 많으면 공종 뼈대가 한눈에 안 보임 | 엑셀 ± · MS Project · AG Grid · Tabulator · STmate 레벨 거름 | +| 2 | 레벨 단추 없음 | — | 「1 · 2단만 보기」(실무 내역서총괄표 꼴) 불가 | 엑셀 1 2 3 · STmate 「= 이상 ~ ====== 이상」 | +| 3 | 계층 표시 약함 | 1em 들여쓰기 + 굵게 · ▸▾ 없음 · 트리 선 없음 · 글 크기 차 지움(200-2) | 3 · 4 · 5단 구분이 흐림 · 자식 있는 줄인지 모름 | Tabulator 가지 선 · AG Grid 쉐브론 · Handsontable 머리 들여쓰기 | +| 4 | 키 충돌 | ↑↓ = 줄 옮김 · ←→ = 레벨 · Tab 가로챔 | 표를 키로 훑다 내역 차례가 바뀜 · Tab 으로 표 밖에 못 나감 | ↑↓ = 고름 · ←→ = 접기/펴기 · Alt+Shift+←→ = 레벨 | +| 5 | 되돌리기 없음 | 키 한 번 = 바로 바뀜 | 잘못 누르면 손으로 되돌려야 함 | 모든 도구 Ctrl+Z | +| 6 | 한 줄만 고름 | 고름 = 줄 키 하나 | 10줄을 한 단 내리려면 10번 | MS Project · 구글 시트 = 여러 줄 고르고 Alt+Shift+→ | +| 7 | 복사 · 붙여넣기 없음 | — | 비슷한 묶음을 또 만들 때 줄마다 새로 | 엑셀 · Handsontable 줄 복사 | +| 8 | 번호 약함 | 1 · 2단만 · 이름 글에 붙음 · 관급 없음 | 실무 엑셀 꼴(공종 칸 Ⅰ · 1 · 4.1 · Ⅱ)과 다름 | MS Project 개요 번호 자동 · WBS 꼴 · STmate 번호 자동입력 | +| 9 | 끌기 다듬기 | 가장자리 자동 스크롤 없음 · 잡을 손잡이 없음(글 칸은 못 잡음) · 그린 구간 밖 못 놓음 | 긴 내역에서 먼 자리로 못 끎 · 잡을 곳을 모름 | AG Grid 끌기 = 자동 스크롤 · 줄 위 0.5초 머물면 자식 | +| 10 | 고른 줄 · 함께 갈 줄 표시 | 호버 색과 같음 · 자손 표시 없음 | 무엇을 골랐는지 · 옮기면 몇 줄이 가는지 흐림 | AG Grid · MS Project 고른 줄 진한 색 | +| 11 | 접근성 | role · aria 없음 | 화면 읽기 프로그램이 계층을 모름 | ARIA treegrid `aria-level` · `aria-expanded` | +| 12 | 보기 전용 표도 같음 | 총괄 · 원가계산서 · 목록표 · 노임 · 산근 · B08 = 들여쓰기만 | 큰 목록표에서도 접을 수 없음 | 공용 옵션 하나면 같이 해결 | + +## 3. 외부 공통 꼴 + +| 기능 | 엑셀(그룹 · 윤곽) | MS Project | AG Grid · TanStack · Tabulator | 견적 프로그램(STmate · 실무 엑셀) | ARIA treegrid | +|---|---|---|---|---|---| +| 저장 꼴 | 시트 차례 + 행마다 윤곽 수준(최대 8) | 작업 차례 + 개요 수준 | AG = 평면 + `parentId`(`treeDataParentIdField`) · TanStack = `getSubRows` → 펼친 줄만 평면 + `row.depth` · Tabulator = `_children` | STmate `BOQ11` 한 표에 말단 + 제목(T1~T5) + 계(E1~E5) + 제외(C) · `SORTCODE` / `ENDCODE` 로 짝 | 줄마다 `aria-level`(1부터) | +| 접기 · 펴기 | 묶음마다 ± | 요약 작업 ▸ · Alt+Shift+- / = | 쉐브론 ▸▾ · 처음 펼칠 레벨 지정(`groupDefaultExpanded` · `dataTreeStartExpanded` 레벨별) | STmate = 레벨 거름 · 실무 = 내역서총괄표가 2단까지 접은 같은 나무 | `aria-expanded` · → 펴기 · ← 접기 | +| 레벨 한꺼번에 | 1 2 3 단추(그 레벨까지 펼침) | 개요 레벨 1~9 보기 | 레벨별 펼침 배열 | STmate 「= 이상 ~ ====== 이상」 · 「1레벨(소계) 이상 ~ 6레벨(총대계) 이상」 | — | +| 계층 표시 | 여백의 윤곽 막대 | 들여쓰기 + 요약 굵게 | 들여쓰기 px + 가지 선 + 아이콘 | 실무 = 공종 번호 칸 + 묶음 뒤 빈 줄(들여쓰기 없음) | 레벨 값 | +| 요약 줄 합 | SUBTOTAL · 요약 줄 아래(기본) / 위 선택 | 요약 작업 = 하위 합 자동 | `aggFunc` 로 부모 줄 합 · 접혀도 합은 보임 | 실무 = 묶음 머리 줄에 합 · STmate = 제목 줄 + 계 줄 둘 다 | — | +| 들여 · 내어쓰기 | 그룹 / 그룹 해제 | Alt+Shift+→ / ← · 여러 줄 함께 · 위 작업이 요약이 됨 | Handsontable 메뉴 「자식 줄 넣기 · 부모에서 떼기」 | STmate 「계표시(레벨) 1단계 상향 / 하향」 | 편집 규칙 없음(앱 몫) | +| 번호 | 없음(손 · 수식) | 개요 번호 1 · 1.1 자동(손으로 못 고침) · WBS 꼴 = 레벨마다 숫자 · 대문자 · 소문자 · 구분자 | 없음 | STmate 「공종번호 자동입력」 = 가..하 · a..z · 0 · 00 · 000 · 앞뒤 글자 · 실무 Ⅰ / 1 / 4.1 / Ⅱ | `aria-posinset` · `aria-setsize` | +| 끌어 놓기 | 행 잘라 붙이기 | 끌기 | AG = 줄 위 0.5초 머물면 그 줄 자식 · 자기 아래 금지 · Tabulator = 나무에선 끌기 막음 | — | — | +| 키 이동 | ↑↓ 칸 이동 | ↑↓ 고름 | ↑↓ 고름 | — | ↑↓ 줄 · Home / End · Tab = 다음 단추 · 표 밖 | +| 여러 줄 | 범위 고름 | 범위 고른 뒤 들여쓰기 | 여러 줄 고름 | STmate 「선택된 항목만」 일괄 보정 | — | + +공통 꼴 요약 — 줄은 평면 배열(차례 + 깊이 또는 부모 키) · 화면엔 펼친 줄만 · 자식 있는 줄에 ▸▾ · 접혀도 묶음 합은 보임 · 레벨 단추로 한 번에 접음 · 번호는 차례로 자동. + +## 4. 우리 표에 넣을 것 (제안 · 새 의존성 없음) + +| 차례 | 무엇 | 크기 | 꼴 | +|---|---|---|---| +| ① | 공용 표 접기 · 펴기 | 작음~중간 · `ui_template_data_table.ts` +60줄 안팎(428 → 500 안) · CSS +15 · 새 시험 1 | `level: { key, of, fold?: { id: (i) => string, open?: number } }` — 자식 = 바로 아래 줄 레벨이 더 큼(차례 + 레벨만, parent 필요 없음) · 접힌 줄 아래 더 깊은 줄 숨김 · ▸▾ 단추 = 레벨 칸 글 앞 · `data-i` = 원래 줄 번호 그대로(메뉴 · 끌기 · 고침 그대로) · 보이는 구간 셈은 펼친 줄 목록 위에서 · 접힘 = 줄 키 모음(표 안에 두고 setRows 뒤에도 지킴) · `t.foldTo(n)` | +| ② | 레벨 단추 [1] [2] [3] [전체] | 작음 · `…_Bill_Level.ts` +20 | 설계내역서 도구 줄 · 보기 전용 표는 줄 많은 표(목록표 · 집계표)만 | +| ③ | 되돌리기 Ctrl+Z · 다시 Ctrl+Y | 작음 · +30 | `user.bill` 이전 판을 메모리에 쌓음(탭 안 · 저장 안 함) · 캐시 · [저장] 흐름 그대로 | +| ④ | 키 정리 | 작음 · keyAct +10 · 시험 200_2 · 204_1 기대값 | 안 = ↑↓ 고름 옮김 · ←→ 접기 / 펴기(ARIA) · Alt+Shift+←→ 와 Tab / Shift+Tab = 레벨 · Alt+↑↓ = 줄 옮김 — 204 「방향키 = 레벨」 뒤집음 | +| ⑤ | 계층 표시 | 작음 · CSS +15 | ▸▾(①) + 레벨 칸 세로 안내선(배경 그라데이션 · 1em 간격 · 요소 안 늘림) + 고른 줄 진한 색 + 함께 갈 자손 옅은 띠 | +| ⑥ | 여러 줄 고르기 | 중간 · +100 · 시험 | Shift+클릭 · Shift+↑↓ = 이어진 줄 · 같은 부모 형제끼리만 한꺼번에 들여 · 내어 · 옮김 · 지움(MS Project 꼴) | +| ⑦ | 끌기 다듬기 | 작음 · `…_Bill_Drag.ts` +20 | 표 위아래 끝 근처 자동 스크롤 · 레벨 칸 왼쪽 손잡이 ⋮⋮ · 접힌 묶음은 자손째(지금 규칙 그대로) | +| ⑧ | 번호 칸 | 중간 · 셈 짝 .py ↔ .ts + 거울 시험 | 실무 꼴 「공종」 칸 따로(Ⅰ · 1 · 4.1 · Ⅱ) · 3단 이하 번호 여부 · 번호 꼴(STmate · WBS 꼴) | +| ⑨ | 줄 복사 · 붙여넣기 · 접근성 | 중간 / 작음 | 묶음째 복사(새 hand: 키) · `role="treegrid"` · 줄 `aria-level` · `aria-expanded`(① 안에서 몇 줄) | + +- 설계내역서 = ①~⑨ · 보기 전용 표(총괄 · 원가계산서 · 목록표 · 노임 · 산근 · B08 집계) = ① · ② · ⑤ · ⑨(접근성)만 — 옵션을 켠 표만 바뀜. +- 접힘 · 펼침 = 보기 상태 — 작업본 · 초기값에 넣지 않음. +- 묶음 합은 이미 머리 줄에 있어 접어도 금액이 보임(외부 꼴과 같음). + +사용자 결정 거리: + +- ⬜ AE-a 키 체계 — 추천 표준(④) / 지금(방향키 = 레벨) 그대로. +- ⬜ AE-b 처음 펼침 — 추천 전부 펼침 + 레벨 단추 / 2단까지만. +- ⬜ AE-c 번호 — 추천 실무 꼴 「공종」 칸 따로 · 1 · 2단 + 관급 Ⅱ / 지금대로 이름 앞. +- ⬜ AE-d 접힘 기억 — 추천 탭 안에서만(새로 열면 펼침) / 브라우저에 기억. + +## 5. 출처 + +웹: + +- 엑셀 윤곽 · 그룹 — https://support.microsoft.com/en-us/office/outline-group-data-in-a-worksheet-08ce98c4-0063-4d42-8ac7-8278c49e9aff +- 엑셀 단축키(Ctrl+8 윤곽 기호) — https://support.microsoft.com/en-us/office/keyboard-shortcuts-in-excel-1798d9d5-842a-42b8-9c99-9b7213f0040f +- 구글 시트 행 그룹(Alt+Shift+→ / ←) — https://www.howtogeek.com/755108/how-to-group-and-ungroup-rows-and-columns-in-google-sheets/ +- MS Project 단축키(들여 · 내어쓰기 · 하위 숨김) — https://support.microsoft.com/en-us/office/keyboard-shortcuts-for-project-7a4dc22b-ceb8-4a97-ac6f-6f9eaf36414a +- MS Project WBS · 개요 번호 — https://support.microsoft.com/en-us/project/create-wbs-codes-in-project-desktop · https://support.microsoft.com/en-us/project/wbs-code-definition-dialog-box +- AG Grid 평면 + parentId — https://www.ag-grid.com/vue-data-grid/tree-data-self-referential/ +- AG Grid 나무 끌기 — https://www.ag-grid.com/vue-data-grid/tree-data-row-dragging/ +- TanStack Table 펼치기 — https://tanstack.com/table/v8/docs/guide/expanding · https://tanstack.com/table/v8/docs/api/features/expanding +- Handsontable 부모 · 자식 줄 — https://handsontable.com/docs/row-parent-child +- Tabulator 나무 — https://tabulator.info/docs/6.3/tree +- ARIA treegrid — https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/ · https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/examples/treegrid-1/ +- 견적 도구 접기(접어도 합 보임) — https://help.constructiononline.com/en/expand/collapse-estimate-category-1 + +지식DB · 실무: + +- STmate 내역 편집 「계표시(레벨) 1단계 상향/하향」 · `+=` ~ `+=====` 레벨 — `resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/26_편집기와_사이클식.md` §5 +- STmate 레벨 거름 · 공종번호 자동입력 `wITNum` — 같은 폴더 `34_화면구조_정리.md` §2.1 · `22_UI_폼_분석.md` §3.5 +- STmate `BOQ11` T · E · C 줄 · `SORTCODE` / `ENDCODE` — 같은 폴더 `16_골든셋_회귀_결과.md` §4.1 · `02_데이터셋과_파일형식.md` +- 다산 ESTX `Level` 칸(-3 ~ 3 · 뜻 확인 불가) — `resources/knowledge/original/원가계산/다산소프트/ESTX_값사전_견적내역.md` +- 실무 내역서 꼴(공종 칸 Ⅰ · 1 · 4.1 · Ⅱ · 묶음 머리 줄 합 · 내역서총괄표 = 2단까지) — `resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(…)내역+수량.xlsx` 「내역서」 · 「내역서총괄표」 시트 From ac8dc77c00718cc5c8db4cdf2ae3bafd55b3758f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:32:39 +0900 Subject: [PATCH 05/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=A1=B0=EC=82=AC?= =?UTF-8?q?=20AD(=EB=85=B8=EB=A9=B4=EA=B3=B5=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- .../raw/요구_1010_2/조사_AD_노면공범위고름.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AD_노면공범위고름.md diff --git a/docs/raw/요구_1010_2/조사_AD_노면공범위고름.md b/docs/raw/요구_1010_2/조사_AD_노면공범위고름.md new file mode 100644 index 000000000..8a816448d --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AD_노면공범위고름.md @@ -0,0 +1,73 @@ +# 조사 AD — B06 목록에서 다른 구조물을 골랐는데 노면공 범위 전체가 켜짐 (요구 98) + +## 1. 한눈 결론 + +- 목록을 누르는 길 자체는 정상 — 다른 구조물을 고르면 노면공 강조가 꺼짐(스크래치 재현 12 경우 · 무작위 3000 차례에서 샌 경우 0). +- 노면공이 켜지는 자리 = B06 이 폼을 다시 세우는 `load()`(보기 오감 · 새로고침 · 재진입). 측점만 남은 고름(구조물 id 없음)을 「그 측점을 덮는 첫 구조물」로 여는데, 노면공을 빼는 거름(152-5)을 안 넘겨 목록 맨 앞의 노면공이 열림. +- 목록과 잇는 고리 = 이미 고른 카드에 있는 구조물을 목록에서 고르면 고름 한 칸(세션)이 안 바뀜 → 옛 「측점만」 고름이 남아 있다가 위 `load()` 에서 노면공으로 바뀜. + +## 2. 클릭 길 표 + +| 단계 | 어디 | 하는 일 | +| --- | --- | --- | +| 목록 줄 누름 | `B05_Profile_UI_Structures_List.ts:225`(구조물) · `:235`(관) | 패널 콜백 부름 | +| 폼 올림 | `B05_Profile_UI_Structures_Panel.ts:438-455` → `_Commit.ts:94-158`(loadForm → onSelect) · `:163-205`(loadPipeForm) | 폼 = 고른 것 · 알림 | +| B06 받음 | `B06_Section_UI_Page_Structures_Panel.ts:212-230` onSelect · `:314-324` onPipeSelect | preferredId = 고른 id · 카드 찾기 · 알약 · **세션은 노면공일 때만 직접 적음(:229)** · 관은 늘 적음 | +| 카드 찾기 | `design_view_cross.ts:118-126` → `B06_Section_UI_Section_View.ts:587-597` focusStation · `:598-606` revealStation | 다른 카드면 카드 리스너 돎 · **같은 카드면 스크롤만(리스너 안 돎)** · 노면공 = 카드 고르지 않고 스크롤만 | +| 카드 리스너 | `design_view_cross.ts:384-411` | showAtChainage(`B06 panel :556-572` · 노면공 뺀 거름) · 세션 = 목록이 고른 것만 id(:397) · 알약 맞춤 selectMark(…, false)(:403) | +| 범위 강조 | `B06_Section_UI_Section_View.ts:607-612` selectMark → `:246-270` applySpan | 테두리 = 고른 구간형 · 노면공이면 span 끔이어도 늘 켬 + 포장층 `is-active` | +| 다시 그릴 때 | `design_view_cross.ts:464-495` restoreStructurePick | 세션 값이 바뀐 때만 다시 얹음(prefer → 카드 → selectStructure) | +| 보기 다시 보임 · 진입 | `design_view_cross.ts:564-572`(onShow) · `:619`(첫 마운트) → `B06 panel load() :406-414` | 세션으로 폼 다시 세움 — **`section.selectAt` 에 거름 없음** | + +## 3. 까닭(근거) + +① 본 원인 — `load()` 가 노면공을 덮어 엶 +- `B06_Section_UI_Page_Structures_Panel.ts:410` — `section.selectAt({ chainageM, structureId })` 를 거름(coverable) 없이 부름. +- 공용 `selectAt`(`B05_Profile_UI_Structures_Panel.ts:620-623`) → `resolveSelection` 기본 거름 = 전부(`design_selection.ts:86`) → 「덮는 첫 구조물」(`:96`) = 노면공(목록 맨 앞 · 시점 30). +- 이어지는 일: selectById(노면공) → onSelect(노면공) → 177-4 길(범위 첫 카드로 스크롤 · 알약 · 세션 = {첫 카드, 노면공}) → 범위 카드 전부 테두리 + 포장층 → `.then(restoreStructurePick)` 이 노면공을 한 번 더 얹음. +- `load()` 는 폼에 이미 고른 것이 있어도(`hasSelection`) 보지 않고 덮음 — 목록으로 고른 구조물이 노면공으로 바뀜. +- 같은 파일 `:561-563` 주석이 「공용 selectAt 은 노면공을 덮어 엶」이라 적고 showAtChainage 는 피했으나 `load()` 한 곳이 빠짐(216-1 은 B05 측점선만 고침). + +② 목록 고름이 세션에 안 적히는 경우 +- onSelect 는 노면공일 때만 selectDesign(`:229`) · 그 밖은 카드 리스너(`design_view_cross.ts:397-398`)에 맡김. +- 그 구조물이 가는 카드가 이미 고른 카드면 focusStation 이 스크롤만(`Section_View.ts:590-596`) → 리스너 안 돎 → 세션 = 옛 값 그대로. + +③ 「측점만」 세션이 흔한 까닭 +- 189-1 카드 하나 고름 = id 없음(`design_view_cross.ts:397`) — 카드가 옹벽 폼을 열어도 세션은 측점만. +- 216-1 B05 종단 측점선 = `remember(측점, null)`(`design_view_profile.ts:244`). +- 위 ② 의 같은 카드 목록 고름. + +## 4. 재현 조건 + +스크래치(시스템 임시 폴더 · 저장소 밖) — `helper_165_pavement_flow.cjs` 를 옮겨 진짜 B06 패널 · 세션 · 넘김 모듈에 `load()` 끝 · `restoreStructurePick` 을 덧붙임. 자료 = 226fc805 꼴(노면공 30~405 맨 앞 · 소단 35~45 · 표지 127.86 · 관 95.38 · 세월교 195.3 · 카드 20 m + 35 · 45 · 관 측점). + +| 차례 | 결과 | +| --- | --- | +| 카드 120 누름(관 없는 범위 안) → B05↔B06 오감 | 폼 = 노면공 · 범위 카드 23 · 포장층 켬 · 세션 id = 노면공 | +| 카드 120 → 목록 표지(같은 카드 120 · 폼 표지 · 세션 {120, 없음} 그대로) → 오감 | 노면공 · 23 | +| B05 측점선 120 → B06 | 노면공 · 23 | +| 대조 — 세션이 관 자리(95.38) · 목록 표지(카드 바뀜 · 세션 id 표지) → 오감 | 관 · 표지 그대로(안 샘) | +| 대조 — 노면공 고름 뒤 목록 관 · 소단 · 표지 · 세월교(오감 없음) | 모두 노면공 강조 꺼짐 | + +- 조건 셋: 그 측점이 노면공 범위 안 · 0.51 m 안에 관 없음 · 세션에 구조물 id 없음 + B06 폼 다시 세움(B05↔B06 오감 · F5 · 다른 페이지 갔다 옴). +- 관 자리 측점은 관이 먼저라 안 생김 · 옹벽 등을 다른 카드로 고르면 세션에 id 가 적혀 안 생김. +- 사용자 화면 차례(오감 · 새로고침 여부)는 미확인 — 오감 없이 목록 누름 즉시 켜졌다면 다른 길 · 화면 재현 필요. + +## 5. 고칠 방향 제안(고치지 않음) + +1안(본 원인 · 한 곳 · 1줄) — `B06_Section_UI_Page_Structures_Panel.ts:410` 의 `section.selectAt(…)` 에 `coverable` 을 둘째 인자로 넘김. +- 효과: 측점만 남은 세션은 노면공을 덮어 열지 않음(152-5 · 189-1 ③ 과 같은 규칙) · id 있는 세션(노면공 고름 포함)과 관은 그대로. +- 위험 낮음 — 바뀌는 것은 「id 없음 + 관 없음 + 노면공만 덮음」 한 갈래. 시험: test_189_1 · 188_1 · 165 · 216_1 + 새 시험(오감 뒤 노면공 안 엶). + +2안(세션 맞춤 · 1~2줄) — 같은 파일 `:229` 의 `if (pave)` 를 떼어 구조물 고름은 늘 selectDesign({기준 카드, id}). +- 효과: 같은 카드 목록 고름도 고름 한 칸이 그 구조물로(188-1 「마지막 고름 하나」) → `load()` 가 id 로 엶. +- 위험 낮음 — 리스너가 같은 값을 또 적어도 selectDesign 의 같은 값 거름으로 알림 없음 · 부재키는 다른 대상이면 버리는 규칙 그대로. + +덧 — `load()` 에 `if (!section.hasSelection())` 가드(B05 `restorePick` `design_view_profile.ts:250` 꼴)도 가능하나 1안과 겹침 · 선택. +권 — 1안 + 2안(파일 하나 · 2~3줄). + +같은 구멍(별건 · 사용자 판단): B05 3D 고름 `B05_Profile_UI_Structure_Pick_Session.ts:64-67` 도 기본 거름이라 노면공 id 를 세션에 적음 → B06 에서 노면공. + +곁가지(스크래치에서 봄): +- 옹벽 범위 안 카드 120 누름 → 옹벽 폼이 열리며 카드가 옹벽 기준 카드 100 으로 옮겨 감(showAtChainage → onSelect → focusChainage). +- 넘김 되얹기의 카드 찾기가 「0.51 m 안 첫 카드」(`B06_Section_UI_Page_Structure_Pick.ts:60`) — 관 80.17 세션이 카드 80 으로 감. From 833e4146a92c446999ebfc09a051647e9e58e21e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:36:07 +0900 Subject: [PATCH 06/92] =?UTF-8?q?feat(table):=20=ED=91=9C=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20=EA=B8=80=EA=BC=B4=20=EC=9E=90=EB=8F=99=20=E2=80=94?= =?UTF-8?q?=20=EB=A0=88=EB=B2=A8=20=ED=81=AC=EA=B8=B0=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EA=B5=B5=EA=B2=8C=20=C2=B7=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=EB=82=B4=EC=97=AD=EC=84=9C=20=C2=B7=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=ED=91=9C=20=EA=B0=99=EC=9D=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99(228-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 표 level 옵션: 레벨 칸 class is-lv1~5 · 묶음 줄 is-node(group 지정 · 안 주면 바로 아래 줄이 더 깊은 줄) - 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(토큰) · 굵게 = 묶음 · 항목 줄은 14 이하 - 들여쓰기 한 단 = 표 기본 1em 토큰(레벨 글자 크기와 무관 · 끌기 한 단과 같음) - 설계내역서 group = 항목 아닌 줄(빈 공종도 묶음) · 들여 · 내어쓰기는 셈 다시 → 레벨 · 글꼴 바로 - 새 시험 test_228_1 · test_200_2 · 201_1 · 185_1 기대값 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 7 +- .../tester/test_185_1_b09_total_level.py | 2 +- .../tester/test_200_2_b09_bill_level_bar.py | 12 +- .../tester/test_201_1_table_level_merge.py | 10 +- .../tester/test_228_1_table_level_font.py | 229 ++++++++++++++++++ ui_template/ui_template_data_table.css | 34 ++- ui_template/ui_template_data_table.ts | 29 ++- 7 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 resources/tester/test_228_1_table_level_font.py diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 314939f03..8363c7930 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -96,7 +96,12 @@ export const buildBill: TabBuild = (ctx): BillTable => { const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : ""; return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null; }, - level: { key: "name", of: (i) => rows[i]?.level as number | undefined }, + // 228-1 레벨 = 셈 나무 깊이(들여 · 내어쓰기 · 끌기 = 다시 셈 · 다시 그림) · 글꼴 = 레벨 · 묶음(빈 공종도) + level: { + key: "name", + of: (i) => rows[i]?.level as number | undefined, + group: (i) => rows[i]?.kind !== "item", + }, menu: (i) => billMenu(ctx, raw, i), drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), onCell: (i, key) => { diff --git a/resources/tester/test_185_1_b09_total_level.py b/resources/tester/test_185_1_b09_total_level.py index 6608e4ae1..896400301 100644 --- a/resources/tester/test_185_1_b09_total_level.py +++ b/resources/tester/test_185_1_b09_total_level.py @@ -71,6 +71,6 @@ def test_CSS_옛_레벨_규칙_없음_공용_표로(): assert "is-indent" not in css and "is-flat" not in css and "is-lv" not in css common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text(encoding="utf-8") assert re.search( - r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);", + r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* var\(--dt-indent\)\);", common, ) diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 3a544d33c..f9d610b6c 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -1,5 +1,6 @@ """200-2 B09 설계내역서 레벨 글 · 레벨 도구 줄 — 글 칸 왼쪽 맞춤 · 레벨 = 공종 · 명칭 칸 padding 들여쓰기 · -글자 크기 한 벌 · 단추 넷(고른 줄 · 못 하면 disabled) · 키 셋(Tab · Shift+Tab · Alt+↑↓) · 편집 중엔 키 안 먹음. +글꼴 = 공용 표 레벨 글꼴(228-1 · 레벨 · 묶음 따라) · 단추 넷(고른 줄 · 못 하면 disabled) · +키 셋(Tab · Shift+Tab · Alt+↑↓) · 편집 중엔 키 안 먹음. - 단추 · 키 = 우클릭 메뉴(billMenu) 같은 항목을 부름 — 줄 꼴 · 저장 꼴은 184-3 시험 그대로 - 표 · 문서 = 가짜 DOM(줄 tr · 글 칸 input) · 다시 그림 = buildBill + levelBar 다시 @@ -173,13 +174,16 @@ def test_키_셋_편집_중엔_안_먹음(): assert out["keyAct"] == [IN, OUT, UP, DOWN, None, UP, None] -def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌(): +def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표(): css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") bill = '.b09-estimation [data-b09-table="bill"]' - # 201-1 레벨 들여쓰기 = 공용 표 level 옵션(test_201_1) · 레벨별 글자 크기 규칙 없음(한 벌) + # 레벨 들여쓰기 · 글꼴 = 공용 표 level 옵션(test_201_1 · test_228_1) · 페이지 CSS 에 따로 두지 않음 assert "is-lv" not in css and "font-size: 13px" not in css + common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + assert "td.ui-data-table__lv.is-lv1.is-node" in common src = (B09 / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8") - assert 'level: { key: "name", of: (i) => rows[i]?.level' in src + assert "of: (i) => rows[i]?.level as number | undefined," in src + assert 'group: (i) => rows[i]?.kind !== "item",' in src # 글 칸 왼쪽 맞춤(수량만 오른쪽) assert re.search( re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;", diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py index dbcf7093d..de42f1eea 100644 --- a/resources/tester/test_201_1_table_level_merge.py +++ b/resources/tester/test_201_1_table_level_merge.py @@ -1,6 +1,7 @@ """201-1 공용 표 level(보기 전용) · mergeSame(같은 값 세로 병합) 옵션 + B08 · B09 적용 표. - level { key, of } = 그 열 칸 class ui-data-table__lv · 줄 style --dt-lv(레벨 − 1) · 글 상자 칸도 · 다루기 단추 없음 + · 레벨 글꼴 class is-lv1~5 · 묶음 is-node(228-1 · test_228_1) - mergeSame [열] = 위아래 같은 글이 이어지면 rowspan · 앞 병합 열이 갈리면 갈림 · 빈 값 · 빈칸 글 · 고칠 칸 안 합침 · 합계 줄 안 합침 · 그린 구간(win) 안에서만 · 병합 표 칸 = data-c(열 번호) - 옵션 없으면 무변(rowspan · data-c · --dt-lv 없음) @@ -93,12 +94,13 @@ def test_mergeSame_같은_값만_rowspan(): def test_level_보기_전용_들여쓰기(): rows = _run()["level"] assert [re.search(r'style="--dt-lv:(\d)"', r).group(1) for r in rows] == ["0", "1", "0"] - assert 'a' in rows[0] + # 아래 줄이 더 깊음 = 묶음 + assert 'a' in rows[0] assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만 - assert '' in rows[1] # 글 상자 칸도 + assert '' in rows[1] # 글 상자 칸도 assert "button" not in "".join(rows) # 다루기 단추 없음(설계내역서만) css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") - pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);" + pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* var\(--dt-indent\)\);" assert re.search(r"td\.ui-data-table__lv \{\s*" + pad + r"\s*text-align: left;", css) assert re.search(r"td\.ui-data-table__lv \.ui-data-table__input \{\s*" + pad, css) assert re.search(r"td\.is-merged \{\s*vertical-align: top;", css) @@ -117,7 +119,7 @@ APPLIED = [ ("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'mergeSame: ["kind"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'), - ("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'level: { key: "name"'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'group: (i) => rows[i]?.kind !== "item"'), ("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"), ] diff --git a/resources/tester/test_228_1_table_level_font.py b/resources/tester/test_228_1_table_level_font.py new file mode 100644 index 000000000..cd230f001 --- /dev/null +++ b/resources/tester/test_228_1_table_level_font.py @@ -0,0 +1,229 @@ +"""228-1 표 레벨 = 들여쓰기에 따라 레벨 · 글꼴(크기 · 굵기) 자동 — 공용 표 level 옵션 · 설계내역서 · 보기 전용 표. + +- 공용 표(`ui_template_data_table.ts`) level 칸 class = is-lv1~5(6 이상 = 5) · 묶음 줄 = is-node + (group 이 주면 그 값 · 안 주면 바로 아래 줄 레벨이 더 큰 줄 · 그린 구간 밖 줄도 봄) +- CSS = 묶음 굵게 · 크기 Lv1 16 · Lv2 15 · Lv3 14(기본) · Lv4 13 · Lv5 12 · 항목 줄은 14 보다 크지 않게 · + 들여쓰기 한 단 = 표 기본 1em(레벨 글자 크기와 무관) +- 설계내역서 = 들여 · 내어쓰기(메뉴 · 단추 · 키 · 끌기 같은 함수) → 셈 다시 → 그 줄과 자손 레벨 · 글꼴 바로 바뀜 +- 보기 전용 2단 표(blockLevel) · 총괄 · 원가계산서 = 같은 규칙(머리 줄 16 굵게 · 내용 14 보통) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_182_b09_bill_level import BOOKS, INPUTS, USER # noqa: E402 +from test_184_3_b09_bill_menu import _node, _ts # noqa: E402 +from test_185_1_b09_total_level import SUMS # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) +TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")) +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", + "UI_Tabs_Bill_Level"] # fmt: skip +IN, OUT = "들여쓰기 →", "내어쓰기 ←" + +#: 줄마다 [레벨 칸 class(레벨 · 묶음만), --dt-lv] — 레벨 열 칸만 +CLS = """ +const cls = (html) => html.split("")[1].split("")[0].split("").filter(Boolean) + .map((r) => [(r.match(/class="([^"]*ui-data-table__lv[^"]*)"/) || [])[1]?.split(" ") + .filter((c) => /^(ui-data-table__lv|is-lv\\d|is-node)$/.test(c)).join(" ") ?? null, + Number((r.match(/--dt-lv:(\\d+)/) || [])[1])]); +""" + + +def _cls(body: str, names=SRC) -> dict: + return json.loads(_node(_ts(names) + TABLE + CLS + body)) + + +# --- 공용 표 ------------------------------------------------------------------------------ + +TABLE_JS = """ +const columns = [{ key: "n", label: "명칭" }, { key: "v", label: "값", kind: "number" }]; +const lv = [1, 2, 3, 4, 5, 6, 2, null]; +const rows = lv.map((_, i) => ({ n: `r${i}`, v: i })); +const of = (i) => lv[i]; +console.log(JSON.stringify({ + auto: cls(renderDataTableHtml({ columns, rows, level: { key: "n", of } })), + given: cls(renderDataTableHtml({ columns, rows, level: { key: "n", of, group: (i) => i === 7 } })), + win: cls(renderDataTableHtml({ columns, rows, level: { key: "n", of } }, { from: 2, to: 4, rowH: 28 })), + edit: cls(renderDataTableHtml({ columns, rows, level: { key: "n", of }, editable: (i, k) => k === "n" })), +})); +""" + + +def test_공용_표_레벨_글꼴_class(): + out = _cls(TABLE_JS, []) + assert out["auto"] == [ + ["ui-data-table__lv is-lv1 is-node", 0], + ["ui-data-table__lv is-lv2 is-node", 1], + ["ui-data-table__lv is-lv3 is-node", 2], + ["ui-data-table__lv is-lv4 is-node", 3], + ["ui-data-table__lv is-lv5 is-node", 4], + ["ui-data-table__lv is-lv5", 5], # 6 이상 = 5 글꼴 · 들여쓰기는 그대로 + ["ui-data-table__lv is-lv2", 1], # 아래 줄이 얕음 = 항목 + ["ui-data-table__lv is-lv1", 0], # 끝 줄 · 빈 레벨 = 1 + ] + assert [c for c, _ in out["given"]][6:] == [ + "ui-data-table__lv is-lv2", + "ui-data-table__lv is-lv1 is-node", + ] + assert "is-node" not in "".join(c for c, _ in out["given"][:6]) + # 그린 구간(2~3줄)의 끝 줄도 구간 밖 아래 줄로 묶음을 셈 + assert [c for c, _ in out["win"] if c] == [ + "ui-data-table__lv is-lv3 is-node", + "ui-data-table__lv is-lv4 is-node", + ] + assert out["edit"] == out["auto"] # 글 상자 칸도(글 상자 = font: inherit) + + +def test_CSS_레벨_글꼴_규칙(): + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + tokens = dict(re.findall(r"(--dt-(?:lv\d-size|indent)): ([^;]+);", css)) + assert tokens == { + "--dt-indent": "var(--text-body-sm)", + "--dt-lv1-size": "var(--text-body)", + "--dt-lv2-size": "15px", + "--dt-lv4-size": "13px", + "--dt-lv5-size": "var(--text-caption)", + } + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") + assert "--text-body: 16px;" in theme and "--text-body-sm: 14px;" in theme + assert "--text-caption: 12px;" in theme + + def rule(sel: str) -> str: + m = re.search( + re.escape(f".ui-data-table__table td.ui-data-table__lv{sel} {{") + r"([^}]*)\}", css + ) + return m.group(1).strip() if m else "" + + assert rule(".is-node") == "font-weight: var(--font-weight-bold);" + assert rule(".is-lv1.is-node") == "font-size: var(--dt-lv1-size);" + assert rule(".is-lv2.is-node") == "font-size: var(--dt-lv2-size);" + assert rule(".is-lv4") == "font-size: var(--dt-lv4-size);" + assert rule(".is-lv5") == "font-size: var(--dt-lv5-size);" + # 항목 Lv1 · 2 · Lv3 = 표 기본 14px(크기 규칙 없음) · 굵게 = 묶음만 + assert not rule(".is-lv1") and not rule(".is-lv2") and not rule(".is-lv3") + assert css.count("var(--dt-lv, 0) * var(--dt-indent)") == 2 and "* 1em" not in css + # 줄 높이 28px 안 — 가장 큰 글자(16px × 줄 간격 1.3) + assert "--dt-row-h: 28px;" in css and 16 * 1.3 < 28 + + +# --- 설계내역서: 들여 · 내어쓰기 → 레벨 · 글꼴 바로 ------------------------------------------ + +BILL_JS = """ +let user = __USER__; +const sheets = { bill: { rows: __ROWS__, totals: null } }; +const ctx = { sheets, payload: {}, get user() { return user; }, setUser: (u) => (user = u) }; +const spec = buildBill(ctx); +const html = cls(renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: spec.level, + editable: spec.editable })); +const out = { view: Object.fromEntries(spec.rows.map((r, i) => [r.key, html[i]])) }; +const runs = __RUNS__; +if (runs.length) { + const [key, label] = runs[0]; + const item = billMenu(ctx, spec.rows, spec.rows.findIndex((r) => r.key === key)) + .find((x) => Array.isArray(x) && x[0] === label); + item[1](); + out.bill = user.bill; +} +console.log(JSON.stringify(out)); +""" + + +def _bill(user, run=None): + rows = compute_estimation(INPUTS, user, None, BOOKS)["bill"]["rows"] + body = ( + BILL_JS.replace("__USER__", json.dumps(user, ensure_ascii=False)) + .replace("__ROWS__", json.dumps(rows, ensure_ascii=False)) + .replace("__RUNS__", json.dumps([run] if run else [], ensure_ascii=False)) + ) + return _cls(body) + + +def _after(run): + """메뉴 한 번(들여 · 내어쓰기) → 저장 꼴 → 셈 다시(화면 setUser 와 같은 길) → 다시 그림.""" + bill = _bill(USER, run)["bill"] + return _bill({**USER, "bill": bill})["view"] + + +def test_내어쓰기_그_줄과_자손_레벨_글꼴_바로(): + before = _bill(USER)["view"] + # 손 묶음 g2(Lv4 · 자식 b · c Lv5) + assert before["hand:g2"] == ["ui-data-table__lv is-lv4 is-node", 3] + assert before["hand:b"] == ["ui-data-table__lv is-lv5", 4] + assert before["hand:c"] == ["ui-data-table__lv is-lv5", 4] + assert before["hand:g1"] == ["ui-data-table__lv is-lv3 is-node", 2] + after = _after(["hand:g2", OUT]) + assert after["hand:g2"] == ["ui-data-table__lv is-lv3 is-node", 2] # 14 굵게 + assert after["hand:b"] == ["ui-data-table__lv is-lv4", 3] # 자손도 한 단 위 · 13 + assert after["hand:c"] == ["ui-data-table__lv is-lv4", 3] + assert after["hand:g1"] == before["hand:g1"] # 손 묶음 = 자식 없어도 묶음(group) + + +def test_들여쓰기_항목이_묶음이_되면_굵게(): + before = _bill(USER)["view"] + assert before["earth:5"] == ["ui-data-table__lv is-lv3", 2] + assert before["hand:x"] == ["ui-data-table__lv is-lv3", 2] + after = _after(["earth:5", IN]) # 위 형제 hand:x 아래로 + assert after["earth:5"] == ["ui-data-table__lv is-lv4", 3] + assert after["hand:x"] == ["ui-data-table__lv is-lv3 is-node", 2] # 자식 생긴 항목 = 묶음 굵게 + # 제목 줄 = 묶음 · Lv1 16 · Lv2 15 + assert before["sec:1"] == ["ui-data-table__lv is-lv1 is-node", 0] + assert before["sec:1.2"] == ["ui-data-table__lv is-lv2 is-node", 1] + + +# --- 보기 전용 표: 같은 규칙 -------------------------------------------------------------- + +BLOCK_JS = """ +const columns = [{ key: "name", label: "명칭" }]; +const rows = ["머리", "줄1", "줄2", "합계", "머리2", "줄3"].map((name) => ({ name })); +const top = (i) => [0, 3, 4].includes(i); +console.log(JSON.stringify(cls(renderDataTableHtml({ columns, rows, level: blockLevel("name", top) })))); +""" + +TOTAL_JS = f""" +const user = {{ estimate: {{}} }}; +const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, + setUser: () => {{}} }}; +const view = (t) => {{ + const html = cls(renderDataTableHtml({{ columns: t.columns, rows: t.rows, level: t.level }})); + return t.rows.map((r, i) => [r.key, r.level, html[i][0]]); +}}; +console.log(JSON.stringify({{ total: view(buildTotal(ctx)), cost: view(buildCost(ctx)) }})); +""" + + +def test_보기_전용_2단_표_머리_16_굵게_내용_14_보통(): + out = _cls(BLOCK_JS) + assert [c for c, _ in out] == [ + "ui-data-table__lv is-lv1 is-node", + "ui-data-table__lv is-lv2", + "ui-data-table__lv is-lv2", + "ui-data-table__lv is-lv1", # 합계(아래 줄 = 다음 머리) = 14 · 굵게는 줄 class is-title + "ui-data-table__lv is-lv1 is-node", + "ui-data-table__lv is-lv2", + ] + + +def test_총괄_원가계산서_같은_규칙(): + out = _cls(TOTAL_JS, ["Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total"]) + for tab in ("total", "cost"): + rows = out[tab] + for i, (key, lv, c) in enumerate(rows): + node = i + 1 < len(rows) and (rows[i + 1][1] or 1) > (lv or 1) + assert c == f"ui-data-table__lv is-lv{min(5, lv or 1)}" + ( + " is-node" if node else "" + ), (tab, key) + cost = {k: c for k, _, c in out["cost"]} + assert ( + "is-lv3" in cost["direct_material"] and "is-node" not in cost["direct_material"] + ) # 세목 14 보통 + assert cost["material"].endswith("is-lv2 is-node") # 비목 소계(아래 세목) 15 굵게 diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 787d9e90f..76f9682eb 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -10,6 +10,13 @@ --dt-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface)); --dt-line: var(--color-border); --dt-edge: color-mix(in srgb, var(--color-text-secondary) 45%, var(--color-border)); + /* 들여쓰기 한 단 = 표 기본 글자 1em(레벨 글자 크기와 무관 · 끌기 한 단과 같음) */ + --dt-indent: var(--text-body-sm); + /* 레벨 글자 크기(PLAN 228-1) — 줄 높이 28px 안 · Lv3 = 표 기본(14px) */ + --dt-lv1-size: var(--text-body); + --dt-lv2-size: 15px; + --dt-lv4-size: 13px; + --dt-lv5-size: var(--text-caption); width: fit-content; /* 표가 좁으면 틀도 표만큼 */ max-width: 100%; max-height: calc(100vh - 260px); @@ -173,9 +180,9 @@ outline-offset: -2px; } -/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em 들여쓰기(글 상자도) */ +/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em(표 기본) 들여쓰기(글 상자도) */ .ui-data-table__table td.ui-data-table__lv { - padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em); + padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * var(--dt-indent)); text-align: left; } @@ -184,10 +191,31 @@ } .ui-data-table__table td.ui-data-table__lv .ui-data-table__input { - padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em); + padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * var(--dt-indent)); text-align: left; } +/* 레벨 글꼴(PLAN 228-1) — 묶음 줄(is-node) 굵게 · 크기는 레벨 · 항목 줄은 14px 보다 크지 않게(Lv1 · 2 항목 = 기본) */ +.ui-data-table__table td.ui-data-table__lv.is-node { + font-weight: var(--font-weight-bold); +} + +.ui-data-table__table td.ui-data-table__lv.is-lv1.is-node { + font-size: var(--dt-lv1-size); +} + +.ui-data-table__table td.ui-data-table__lv.is-lv2.is-node { + font-size: var(--dt-lv2-size); +} + +.ui-data-table__table td.ui-data-table__lv.is-lv4 { + font-size: var(--dt-lv4-size); +} + +.ui-data-table__table td.ui-data-table__lv.is-lv5 { + font-size: var(--dt-lv5-size); +} + /* 같은 값 세로 병합(PLAN 201-1 · mergeSame) — 한 칸 글은 위에 */ .ui-data-table__table td.is-merged { vertical-align: top; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 7f0b93d82..02c865ad1 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -12,6 +12,8 @@ * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) * 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도) + * 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게 + * 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(px) · 항목 줄은 14 보다 크지 않게 * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ @@ -68,8 +70,13 @@ export interface DataTableOptions { cellClass?: (index: number, key: string) => string | null | undefined; /** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */ fillViewport?: boolean; - /** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) */ - level?: { key: string; of: (index: number) => number | null | undefined }; + /** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) · + * group = 묶음 줄인지(안 주면 바로 아래 줄 레벨이 더 큰 줄) — 레벨 · 묶음 따라 글꼴(228-1) */ + level?: { + key: string; + of: (index: number) => number | null | undefined; + group?: (index: number) => boolean; + }; /** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */ mergeSame?: string[]; } @@ -160,7 +167,10 @@ function rowHtml( const key = cols[j].key; const span = spans?.[j] ?? 1; if (!span) continue; // 위 칸에 합쳐짐 - const lvc = lv?.key === key ? " ui-data-table__lv" : ""; + const lvc = + lv?.key === key + ? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}` + : ""; const c = spans ? ` data-c="${j}"` : ""; if (edit !== null && editable?.(edit, key)) { const v = r[key]; @@ -199,9 +209,9 @@ function rowHtml( : `${tr}`; } -/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ +/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ interface RowMore { - lv?: { key: string; n: number }; + lv?: { key: string; n: number; node: boolean }; spans?: number[]; } @@ -276,6 +286,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null; for (let i = from; i < to; i++) { const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0; + const node = o.level ? isNode(o.level, i, n) : false; body += rowHtml( cols, fmts, @@ -290,7 +301,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin o.rowClass?.(i), o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, { - ...(o.level ? { lv: { key: o.level.key, n: lvN } } : {}), + ...(o.level ? { lv: { key: o.level.key, n: lvN, node } } : {}), ...(spans ? { spans: spans[i - from] } : {}), }, ); @@ -304,6 +315,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin ); } +/** 묶음 줄(228-1) — group 이 주면 그 값 · 안 주면 바로 아래 줄 레벨이 더 큼 */ +function isNode(lv: NonNullable, i: number, n: number): boolean { + if (lv.group) return lv.group(i); + return i + 1 < n && (lv.of(i + 1) ?? 1) > (lv.of(i) ?? 1); +} + /** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */ const OVER = 40; From 1357c859c8cb01175fb5eef0f0b38381d68f7154 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:37:30 +0900 Subject: [PATCH 07/92] =?UTF-8?q?chore(plan):=2010-10=20228=EC=9E=A5(?= =?UTF-8?q?=ED=91=9C=20=EB=A0=88=EB=B2=A8=20=EA=B8=80=EA=BC=B4)=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-10_plan_221-227.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/raw/plans/2026-10-10_plan_221-227.md b/docs/raw/plans/2026-10-10_plan_221-227.md index 0dc8f340a..27829b4a7 100644 --- a/docs/raw/plans/2026-10-10_plan_221-227.md +++ b/docs/raw/plans/2026-10-10_plan_221-227.md @@ -42,3 +42,8 @@ - 검증완료 — test_m02_card_shell · test_modal_top_and_add_word 14 passed · test_200_2 4 passed · typecheck 통과 · 코드 글만 고침(Bill_Level 안내 「더하기」→「추가」) - [x] 225-2 (sub1) test_79 관 보호공 기본 자리 d(유입) 0.6 → 0.4 로 바뀜 — 211(유입 흙막이 관 + 0.7)의 당연한 결과인지 · 흠인지 판정 · 결과면 기대값 · 흠이면 고침 — 시험 통과 - 검증완료 — 판정 = 당연한 결과(흠 아님): 벽 바닥은 관 invert 상한(노견 − 관경 − 토피 0.5)에 고정 · 벽이 0.2 높아져 윗면 노견 − 0.5 → − 0.3 · d = 올림(1.2 × (1.5 − 1.2) · 0.1) = 0.4 · 225-1 관 길이와 무관 · 기대값 d 0.4 · 윗면 노견 − 0.3 으로 고침(시험 글에 까닭) · test_79 · 211_1 · 225_1 27 passed · typecheck 0 + +## 228. 표 레벨 = 들여쓰기에 따라 레벨 · 글꼴(크기 · 굵기) 자동 (sub3) +정함(브레인) = 사용자 99 · 100번 · 200-2 에서 글자 크기를 한 벌로 맞춘 것은 브레인 판정 잘못(23번 원문 「들여쓰기와 폰트 · 사이즈로 구분」) → 되살림 · 공용 표 level 옵션(201)에서 레벨마다 글꼴 자동: Lv1 16px 굵게 · Lv2 15px 굵게 · Lv3 14px 보통 · Lv4 13px 보통 · Lv5 12px 보통 + 들여쓰기 1em/레벨(줄 높이 28px 안) · 설계내역서는 들여쓰기 · 내어쓰기 · 끌기 · 방향키로 부모가 바뀌면 그 자리에서 레벨 · 글꼴이 바로 바뀜(다시 그림 확인) · 보기 전용 표(총괄 · 원가계산서 · 일위대가표 등)도 같은 글꼴 규칙 · 조사 AE(외부 사례) 결과로 덧붙일 것(접기/펴기 등)은 따로 +- [x] 228-1 레벨 글꼴 자동 · 들여쓰기 = 레벨 바로 반영 — test_200_2 · 201_1 · 204_1 · 182 고침 · 새 시험 + - 검증완료 — test_228_1 새 6 + 200_2 · 201_1 · 204_1 · 182 · 184_3 · 185_1 = 37 통과 · typecheck 0 · 굵게 = 묶음(브레인 답 A) · 내어쓰기 g2 Lv4→3 · 자손 Lv5→4 · 들여쓰기로 자식 생긴 항목 = 굵게 · 커밋 833e4146a From dc027cd73833565fbcbe31dcb57b70fac92b7a07 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:37:55 +0900 Subject: [PATCH 08/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20101~103?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index effcbdbae..2b3cd6935 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -124,3 +124,6 @@ 98. 왜 프로젝트의 B06의 좌측패널의 구조물 리스트에서 노면공의 구조물 범위에 들어온 측점들중 다른 구조물의 리스트를 선택했음에도 노면공의 횡단면도 전체가 선택되는거야. 일단 조사만 진행해줘. 코드 구현은 나중에 할래 99. 테이블 양식에서 설계내역서의 레벨 기능이 제대로 구현안되어 있음. 외부에서 어떻게 해야하는지 확인해봐. (조사) 100. (99번 보충) 테이블에서 제대로 안된다는 건 결국 들여쓰기에따라 레벨이 지정이 되고 폰트들도 자동으로 변경이 되어야하는데 안되고 있잖아. 그냥 들여쓰기만 하는거 아니야? +101. B09의 설계내역서 탭은 지금 하위탭이 없음. 여기에 보조탭 공간을 활용하여 행의 들여쓰기, 내어쓰기, 위, 아래 버튼들을 넣어줘. +102. B05에서 종단 새창으로 띄우고 브라우져 최대화한경우 마우스 스크롤로 종단을 움직이는 기능이 작동이 안됨. 최대화를 안하면 작동됨. +103. 노면공 범위 관련은 1번 방향이 더 좋을것같아. 대신 하이라이트는 여러군데 연동되어 있어 좀더 폭넓게 조사해보고 작동해줘. From 315accf9ca6aefe38ed494d686f3031d611a9b61 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:42:26 +0900 Subject: [PATCH 09/92] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EB=A0=88=EB=B2=A8=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=20=ED=83=AD=20=EC=A4=84=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=C2=B7=20=ED=91=9C=20=EC=9C=84=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=20=EC=A4=84=20=EB=B9=84=EC=9B=80(229-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B09_Estimation/B09_Estimation_UI_Page.css | 12 --- B09_Estimation/B09_Estimation_UI_Shell.ts | 5 +- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 32 ++++--- .../tester/test_200_2_b09_bill_level_bar.py | 10 +- .../tester/test_202_1_table_hint_bottom.py | 4 +- resources/tester/test_204_1_b09_bill_drag.py | 2 +- .../test_229_1_b09_level_buttons_subbar.py | 94 +++++++++++++++++++ 7 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 resources/tester/test_229_1_b09_level_buttons_subbar.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 9e41af3dc..86cf8fc3c 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -81,18 +81,6 @@ text-align: left; } -/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */ -.b09-estimation__level { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.b09-estimation__level .b09-estimation__hint { - margin: 0; -} - /* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ .b09-estimation .ui-data-table__table tbody tr.is-pick td { --pick-t: inset 0 0 0 0 transparent; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index c3c67a6c6..bd47dacfb 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -260,11 +260,12 @@ export function renderB09Estimation(root: HTMLElement): void { }), ); } - // 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 · 202-1 안내 줄 = 표 아래 + // 200-2 설계내역서 = 고른 줄 강조 · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 const bill = (spec as BillTable).menu ? (spec as BillTable) : null; const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined); const level = bill ? levelBar(table.root, bill) : null; - panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : [])); + if (level) bars.setTools(level.tools); + panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : [])); if (focus !== undefined) { const index = rowIndex(spec.rows, focus); if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 2d0b40f52..3ff2cab15 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -1,7 +1,9 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Bill_Level.ts - * B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄. + * B09 설계내역서 레벨 단추(PLAN 200-2 · 229-1) — 보조 탭 줄 오른쪽 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · + * 키 · 안내 한 줄(표 아래). * + * 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) · 안내 글 = 표 아래(202-1) * 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음 * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) @@ -38,7 +40,7 @@ let picked: string | null = null; /** 지금 그린 도구 줄 — 키가 부름 */ let live: { root: HTMLElement; - bar: HTMLElement; + tools: HTMLElement[]; run: (label: string) => void; sync: () => void; } | null = null; @@ -78,7 +80,13 @@ function listenKeys(): void { document.addEventListener("keydown", (event) => { if (!live?.root.isConnected || picked === null) return; const at = document.activeElement; - if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return; + if ( + at && + at !== document.body && + !live.root.contains(at) && + !live.tools.includes(at as HTMLElement) + ) + return; // Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂 if (event.key === "Escape") { if (!editing(at)) { @@ -95,8 +103,11 @@ function listenKeys(): void { }); } -/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */ -export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { +/** 레벨 단추 넷(보조 탭 줄로) · 안내 글(표 아래로) — 표 root 에 줄 누름(고름)을 붙임 */ +export function levelBar( + root: HTMLElement, + table: BillTable, +): { tools: HTMLElement[]; guide: HTMLElement } { const index = (): number => picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked); const run = (label: string): void => menuActs(table, index()).get(label)?.(); @@ -110,13 +121,10 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { button.dataset.b09Level = label; return button; }); - const guide = document.createElement("span"); - guide.className = "b09-estimation__hint"; + const guide = document.createElement("p"); + guide.className = "ui-general-block__empty"; guide.textContent = GUIDE; attachHoverInfo(guide, GUIDE_MORE); - const bar = document.createElement("div"); - bar.className = "b09-estimation__level"; - bar.append(...buttons, guide); const sync = (): void => { const acts = menuActs(table, index()); ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label))); @@ -132,7 +140,7 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { }); attachDrag(root, table, (key) => (picked = key)); sync(); - live = { root, bar, run, sync }; + live = { root, tools: buttons, run, sync }; listenKeys(); - return bar; + return { tools: buttons, guide }; } diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index f9d610b6c..c37ad1136 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -68,7 +68,7 @@ const draw = () => { const at = (key) => table.rows.findIndex((r) => r.key === key); const tr = (key) => root.kids[at(key)]; const pick = (key) => root.fire("click", { target: tr(key).kids[0] }); -const buttons = () => bar.kids.filter((k) => k.tagName === "BUTTON"); +const buttons = () => bar.tools; const off = () => buttons().map((b) => b.disabled); const order = () => (user.bill?.rows ?? []).map((e) => [e.key, e.parent ?? null]); const key = (k, more = {}) => { @@ -79,7 +79,7 @@ const key = (k, more = {}) => { const out = {}; draw(); out.labels = buttons().map((b) => [b.label, b.dataset.b09Level, b.variant]); -out.guide = bar.kids.at(-1).textContent; +out.guide = bar.guide.textContent; out.hover = hovers.length; out.none = off(); pick("earth:4"); @@ -189,12 +189,14 @@ def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표(): re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;", css, ) - assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css) + assert ".b09-estimation__level" not in css # 229-1 표 위 도구 줄 없앰 def test_셸_배선(): src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src - assert "...(level ? [level] : []), table.root" in src + assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 + assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 + assert "b09-estimation__level" not in lvl diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index a4db1829a..35bacaafc 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -105,9 +105,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): def test_잇기_쉘_안내_줄_아래_도구_비움(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []))" + "panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []))" in shell - ) # 200-2 레벨 줄 · 203-1 연결 점검 탭 없앰과 합침 + ) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 안내 줄 = 맨 아래 assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) assert "setTools: bars.setTools" in shell diff --git a/resources/tester/test_204_1_b09_bill_drag.py b/resources/tester/test_204_1_b09_bill_drag.py index 20367728b..185ffb4cf 100644 --- a/resources/tester/test_204_1_b09_bill_drag.py +++ b/resources/tester/test_204_1_b09_bill_drag.py @@ -57,7 +57,7 @@ out.altLeft = press("earth:4", "ArrowLeft", { altKey: true }); user = {}; draw(); pick("earth:5"); doc.activeElement = tr("earth:5").kids[0].kids[0]; out.editing = [key("ArrowRight"), key("ArrowUp"), order()]; -out.guide = bar.kids.at(-1).textContent; +out.guide = bar.guide.textContent; console.log(JSON.stringify(out)); """ diff --git a/resources/tester/test_229_1_b09_level_buttons_subbar.py b/resources/tester/test_229_1_b09_level_buttons_subbar.py new file mode 100644 index 000000000..2d40334c7 --- /dev/null +++ b/resources/tester/test_229_1_b09_level_buttons_subbar.py @@ -0,0 +1,94 @@ +"""229-1 B09 설계내역서 레벨 단추 = 보조 탭 줄 오른쪽 — 단추 넷 · 다른 탭엔 없음 · 표 위 도구 줄 없음. + +- 단추 = 보조 탭 줄 도구 자리(`setTools`) · 안내 글 = 표 아래 · 못 하는 동작 = 흐림(200-2 그대로) +- 쉘 배선 = 소스 글 대조(test_200_2 · test_202_1 과 같은 꼴) +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +B09 = "B09_Estimation/B09_Estimation_UI_" +FILES = [TABS, f"{B09}TabBar.ts", f"{B09}Tabs_Bill_Level.ts"] +# 가짜 DOM 에 줄 찾기(closest · querySelector(All))만 더함 · 끌기 = 빈 함수 +PRELUDE = r""" +document.addEventListener = () => {}; +const attachDrag = () => {}; +E.prototype.closest = function () { for (let e = this; e; e = e.parent) if (e.tagName === "TR") return e; return null; }; +E.prototype.querySelectorAll = function () { return this.all("TR").filter((t) => t.cls.has("is-focus")); }; +E.prototype.querySelector = function (s) { + const i = s.match(/data-i="(-?\d+)"/)?.[1]; + return this.children.find((c) => c.dataset.i === i) ?? null; +}; +""" + + +def test_설계내역서_보조_줄에_단추_넷_다른_탭엔_없음(): + out = run( + FILES, + """ +const bars = createTabBars(() => {}); +const items = ["들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"].map((t) => [t, () => {}]); +const table = { rows: [{ key: "r1" }], menu: (i) => (i === 0 ? items.slice(1, 3) : []) }; +const root = document.createElement("div"); +const tr = document.createElement("tr"); +tr.dataset.i = "0"; +root.append(tr); +const toolsOf = () => bars.sub.children[bars.sub.children.length - 1]; +const snap = (lv) => ({ + labels: toolsOf().children.map((b) => b.textContent), + levels: toolsOf().children.map((b) => b.dataset.b09Level ?? null), + guideIn: lv ? lv.guide.textContent.length > 0 : null, +}); +const show = (tab) => { + bars.draw(tab); + bars.setTools([]); + if (tab !== "bill") return null; + const lv = levelBar(root, table); + bars.setTools(lv.tools); + return lv; +}; +const bill = show("bill"); +const a = snap(bill); +const disabledBefore = bill.tools.map((b) => b.disabled); +tr.click(); +const disabledAfter = bill.tools.map((b) => b.disabled); +show("dg"); +const b = snap(null); +show("bill"); +const c = snap(null); +console.log(JSON.stringify({ + a, disabledBefore, disabledAfter, b: b.labels, c: c.labels, + guideTag: bill.guide.tagName, guideCls: bill.guide.className, subBlank: bars.sub.classList.contains("b09-estimation__tabs--blank"), +})); +""", + PRELUDE, + ) + names = ["→ 들여쓰기", "← 내어쓰기", "↑ 위로", "↓ 아래로"] + assert out["a"]["labels"] == names # 설계내역서 = 단추 넷(보조 줄 오른쪽 도구 자리) + assert out["a"]["levels"] == ["들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"] + assert out["a"]["guideIn"] is True # 안내 글 = 단추 줄이 아니라 따로(표 아래로) + assert out["guideTag"] == "P" and "ui-general-block__empty" in out["guideCls"] + assert out["subBlank"] # 하위 탭 없는 묶음 = 빈 보조 줄(숨은 단추 + 도구) + assert out["disabledBefore"] == [True] * 4 # 고른 줄 없음 = 흐림 + assert out["disabledAfter"] == [True, False, False, True] # 줄 누름 = 메뉴 항목대로 + assert out["b"] == [] # 다른 탭(일위대가표) = 단추 없음 + assert out["c"] == names # 설계내역서로 돌아오면 다시 있음 + + +def test_표_위_도구_줄_없음_쉘_배선(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (level) bars.setTools(level.tools);" in shell + assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in shell + assert "...(level ? [level] : [])" not in shell # 표 위 자리 없음 + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + lvl = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") + assert "b09-estimation__level" not in css and "b09-estimation__level" not in lvl + # 키 · 단추 = 우클릭 메뉴 같은 항목 · 키 안 바뀜(Tab · Shift+Tab · Alt+↑↓ · 방향키) + assert re.search(r"ACTS\[e\.shiftKey \? 1 : 0\]\[1\]", lvl) From dd483a8fbde2ff5826ddcb45e68b1ff9239cd9a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:42:52 +0900 Subject: [PATCH 10/92] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=83=88=20=EC=B0=BD=20=EC=B5=9C=EB=8C=80=ED=99=94=20=EB=95=8C?= =?UTF-8?q?=20=ED=9C=A0=20=EC=9D=B4=EB=8F=99=20=E2=80=94=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EB=9F=AC=EA=B0=80=20=EB=86=93=EC=9D=B8=20?= =?UTF-8?q?=EC=B0=BD=EC=9D=98=20=ED=99=94=EB=A9=B4=20=ED=8B=80=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=EA=B3=84=20=EC=82=AC=EC=9A=A9(230-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 창 requestAnimationFrame 은 새 창 최대화로 메인 창이 가려지면 멈춤 · 휠은 막히고 이동 0. 휠 이동 · 끌기 뒤 클릭 삼킴 · 글자 선택 풀기 · 스크롤 뒤 다시 그리기 시계를 ownerDocument.defaultView 로. 새 시험 test_230_1_popout_wheel(가려진 메인 창 · 새 창 보통 · 최대화 · 원래 창). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_profile_viewport.ts | 7 +- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 20 ++- .../tester/helper_230_1_popout_wheel.cjs | 158 ++++++++++++++++++ resources/tester/test_230_1_popout_wheel.py | 62 +++++++ 4 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 resources/tester/helper_230_1_popout_wheel.cjs create mode 100644 resources/tester/test_230_1_popout_wheel.py diff --git a/A00_Common/design/design_profile_viewport.ts b/A00_Common/design/design_profile_viewport.ts index ea193f514..7a9b7bb3a 100644 --- a/A00_Common/design/design_profile_viewport.ts +++ b/A00_Common/design/design_profile_viewport.ts @@ -29,15 +29,18 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void { /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ let settledScrollLeft = 0; let scrollSettleTimer = 0; + /** 시계를 건 창 — 새 창으로 빼면 그 창 시계를 쓴다(가려진 메인 창 시계는 늦게 돎 · 230-1). */ + let timerView: Window = window; // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. body.addEventListener("scroll", () => { if (deps.editing()) return; const fitted = deps.fitElevation(); - window.clearTimeout(scrollSettleTimer); + timerView.clearTimeout(scrollSettleTimer); if (!needsFullRedraw(fitted)) return; - scrollSettleTimer = window.setTimeout(() => { + timerView = body.ownerDocument.defaultView ?? window; + scrollSettleTimer = timerView.setTimeout(() => { if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; deps.draw(); diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index d174fc835..1f6aa5ed4 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -37,16 +37,25 @@ const BASE_FRAME_MS = 1000 / 60; * 쓰면 한 번에 목표까지 튄다. */ const MAX_FRAME_MS = 64; +/** 스크롤러가 지금 놓인 창 — 종단을 새 창으로 빼면 그 창이다(230-1). + * 메인 창 전역 `requestAnimationFrame` 을 쓰면, 새 창을 최대화해 메인 창이 가려질 때 + * 브라우저가 메인 창 프레임을 멈춰 휠 이동이 한 걸음도 안 나갔다. */ +function viewOf(scroller: HTMLElement): Window { + return scroller.ownerDocument.defaultView ?? window; +} + export function attachWheelHorizontalScroll(scroller: HTMLElement): void { let target = -1; /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ let applied = -1; let frame = 0; + /** `frame` 을 받아 둔 창 — 걷을 때도 같은 창에서 걷는다. */ + let frameView: Window = window; /** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */ let lastFrameAt = 0; function stop(): void { - if (frame) cancelAnimationFrame(frame); + if (frame) frameView.cancelAnimationFrame(frame); frame = 0; target = -1; applied = -1; @@ -72,7 +81,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS); scroller.scrollLeft = scroller.scrollLeft + rest * ratio; applied = scroller.scrollLeft; - frame = requestAnimationFrame(step); + frame = frameView.requestAnimationFrame(step); } scroller.addEventListener( @@ -89,7 +98,8 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { target = next; if (!frame) { applied = scroller.scrollLeft; - frame = requestAnimationFrame(step); + frameView = viewOf(scroller); + frame = frameView.requestAnimationFrame(step); } event.preventDefault(); }, @@ -128,7 +138,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { }; scroller.addEventListener("click", swallow, { capture: true, once: true }); // 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다. - requestAnimationFrame(() => + viewOf(scroller).requestAnimationFrame(() => scroller.removeEventListener("click", swallow, { capture: true }), ); } @@ -164,7 +174,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { // 끄는 동안 글자가 파랗게 잡히지 않게 한다. 포인터 잡기를 미룬 뒤로 브라우저가 // 기본 글자 선택을 시작했다(2026-09-06 사용자 지적). 이미 잡힌 것도 푼다. scroller.style.userSelect = "none"; - window.getSelection()?.removeAllRanges(); + viewOf(scroller).getSelection()?.removeAllRanges(); } scroller.scrollLeft = dragLeft - dx; scroller.scrollTop = dragTop - dy; diff --git a/resources/tester/helper_230_1_popout_wheel.cjs b/resources/tester/helper_230_1_popout_wheel.cjs new file mode 100644 index 000000000..57b2cb4fe --- /dev/null +++ b/resources/tester/helper_230_1_popout_wheel.cjs @@ -0,0 +1,158 @@ +/* 종단 새 창 + 최대화 휠(계획서 230-1) 단위검증 헬퍼 — 휠 이동(`B05_Profile_UI_Profile_Wheel.ts`) · + * 스크롤 뒤 다시 그리기 시계(`design_profile_viewport.ts`)를 그 자리에서 트랜스파일해 돌리고 JSON 으로 냄. + * 메인 창은 「가려짐」 — 화면 틀 · 시계가 돌지 않음(새 창 최대화가 메인 창을 덮은 꼴). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +function load(file, mods = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (p) => mods[p] ?? require(p)); + return box.exports; +} + +// ---- 가짜 창 — frozen 이면 화면 틀 · 시계가 안 돎 ---- +function fakeWindow(name, frozen) { + const win = { name, frozen, now: 0, timers: [], seq: 0, frames: 0 }; + win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); + win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.requestAnimationFrame = (fn) => win.setTimeout(() => (win.frames++, fn(win.now)), 16); + win.cancelAnimationFrame = win.clearTimeout; + win.getSelection = () => ({ removeAllRanges: () => (win.cleared = true) }); + win.tick = (ms) => { + if (win.frozen) return; + const end = win.now + ms; + for (;;) { + const next = win.timers.filter((t) => t.at <= end).sort((a, b) => a.at - b.at)[0]; + if (!next) break; + win.now = next.at; + win.clearTimeout(next.id); + next.fn(); + } + win.now = end; + }; + return win; +} + +function fakeScroller(view, { width, scrollWidth }) { + const calls = {}; + let left = 0; + const el = { + ownerDocument: { defaultView: view }, + clientWidth: width, + clientHeight: 400, + scrollWidth, + scrollTop: 0, + style: {}, + addEventListener: (type, fn) => (calls[type] ??= []).push(fn), + removeEventListener: () => {}, + hasPointerCapture: () => false, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + fire: (type, ev = {}) => { + const e = { prevented: false, preventDefault: () => (e.prevented = true), ...ev }; + (calls[type] ?? []).forEach((fn) => fn(e)); + return e; + }, + }; + Object.defineProperty(el, "scrollLeft", { + get: () => left, + set: (v) => (left = Math.min(Math.max(0, v), el.scrollWidth - el.clientWidth)), + }); + return el; +} + +const main = fakeWindow("main", true); +globalThis.window = main; +globalThis.requestAnimationFrame = main.requestAnimationFrame; // 옛 코드가 쓰던 전역 = 메인 창 +globalThis.cancelAnimationFrame = main.cancelAnimationFrame; +const roCallbacks = []; +globalThis.ResizeObserver = class { + constructor(fn) { + roCallbacks.push(fn); + } + observe() {} + disconnect() {} +}; + +const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts")); +const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), { + "../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod, + "@util/common_util_chart_ywindow": { needsFullRedraw: () => true }, +}); + +function wheelDown(scroller, view, notches) { + const prevented = []; + for (let i = 0; i < notches; i++) + prevented.push(scroller.fire("wheel", { deltaY: 120 }).prevented); + view.tick(1000); + return { left: Math.round(scroller.scrollLeft), prevented }; +} + +const out = {}; + +// ① 새 창 보통 크기(1400) — 메인 창은 가려짐 +const popup = fakeWindow("popup", false); +const s1 = fakeScroller(popup, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s1); +out.popupNormal = { + ...wheelDown(s1, popup, 3), + popupFrames: popup.frames, + mainTimers: main.timers.length, +}; + +// ② 새 창 최대화(1400 → 1920) 뒤 휠 — 크기 바뀜 듣개가 돈 뒤에도 이동 +const popup2 = fakeWindow("popup2", false); +const s2 = fakeScroller(popup2, { width: 1400, scrollWidth: 6000 }); +let draws = 0; +let lightSyncs = 0; +const last = { width: 1400, height: 400 }; +const unbind = viewport.bindProfileViewport({ + body: s2, + editing: () => false, + fitElevation: () => ({}), + draw: () => draws++, + lastSize: () => last, + scheduleLightSync: () => lightSyncs++, + scheduleDraw: () => {}, +}); +s2.clientWidth = 1920; +s2.clientHeight = 1000; +roCallbacks.at(-1)(); +const before = main.timers.length; +const moved = wheelDown(s2, popup2, 2); +s2.fire("scroll"); +popup2.tick(200); +out.popupMax = { + ...moved, + limit: s2.scrollWidth - s2.clientWidth, + lightSyncs, + draws, + mainTimersAdded: main.timers.length - before, +}; +unbind(); + +// ③ 끝까지 굴림 — 한도에서 멈추고 더는 막지 않음(기본 스크롤로 넘김) +const s3 = fakeScroller(popup, { width: 1920, scrollWidth: 2200 }); +wheelMod.attachWheelHorizontalScroll(s3); +const atEnd = wheelDown(s3, popup, 5); +out.popupEnd = { ...atEnd, after: s3.fire("wheel", { deltaY: 120 }).prevented }; + +// ④ 원래 창(가려지지 않음) — 자기 창 화면 틀로 그대로 이동 +main.frozen = false; +const s4 = fakeScroller(main, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s4); +out.mainWindow = wheelDown(s4, main, 3); + +// ⑤ 끌기 — 글자 선택은 스크롤러가 놓인 창에서 풂 +const s5 = fakeScroller(popup, { width: 1400, scrollWidth: 6000 }); +wheelMod.attachWheelHorizontalScroll(s5); +s5.fire("pointerdown", { button: 0, pointerId: 1, clientX: 500, clientY: 100, target: null }); +s5.fire("pointermove", { pointerId: 1, clientX: 400, clientY: 100 }); +out.drag = { left: s5.scrollLeft, popupCleared: !!popup.cleared, mainCleared: !!main.cleared }; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_230_1_popout_wheel.py b/resources/tester/test_230_1_popout_wheel.py new file mode 100644 index 000000000..26e4ef6e5 --- /dev/null +++ b/resources/tester/test_230_1_popout_wheel.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""종단 새 창 + 최대화 휠(계획서 230-1) 회귀검증. + +까닭 — 휠 이동 · 스크롤 뒤 다시 그리기가 메인 창 전역 화면 틀 · 시계를 썼음. 새 창을 최대화하면 +메인 창이 가려져 브라우저가 그 틀을 멈춤 → 휠은 막히고(preventDefault) 이동은 0. +고침 — 스크롤러가 놓인 창(`ownerDocument.defaultView`)의 틀 · 시계를 씀. + +`helper_230_1_popout_wheel.cjs` 가 메인 창을 「가려짐」(틀 · 시계 멈춤)으로 두고 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_230_1_popout_wheel.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_새_창_보통_휠_이동_메인_틀_안_씀(r): + p = r["popupNormal"] + assert p["left"] == 360 # 세 칸 × 120 + assert p["prevented"] == [True, True, True] + assert p["popupFrames"] > 0 and p["mainTimers"] == 0 + + +def test_새_창_최대화_뒤_휠_이동(r): + p = r["popupMax"] + assert p["limit"] == 4080 # 6000 − 1920 — 최대화해도 스크롤할 거리 남음 + assert p["lightSyncs"] == 1 # 크기 바뀜 듣개가 돎 + assert p["left"] == 240 # 두 칸 × 120 + assert p["draws"] == 1 # 스크롤 뒤 다시 그리기도 새 창 시계로 + assert p["mainTimersAdded"] == 0 + + +def test_끝에서_멈추고_기본_스크롤로_넘김(r): + p = r["popupEnd"] + assert p["left"] == 280 # 한도 2200 − 1920 + assert p["prevented"][-1] is False and p["after"] is False + + +def test_원래_창_휠_그대로(r): + assert r["mainWindow"] == {"left": 360, "prevented": [True, True, True]} + + +def test_끌기_글자_선택은_새_창에서_풂(r): + d = r["drag"] + assert d["left"] == 100 + assert d["popupCleared"] is True and d["mainCleared"] is False From 057cbaa5b5c613f80b41999f41bf6efbe7743886 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:48:39 +0900 Subject: [PATCH 11/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20104?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 2b3cd6935..8f9f9c70b 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -127,3 +127,4 @@ 101. B09의 설계내역서 탭은 지금 하위탭이 없음. 여기에 보조탭 공간을 활용하여 행의 들여쓰기, 내어쓰기, 위, 아래 버튼들을 넣어줘. 102. B05에서 종단 새창으로 띄우고 브라우져 최대화한경우 마우스 스크롤로 종단을 움직이는 기능이 작동이 안됨. 최대화를 안하면 작동됨. 103. 노면공 범위 관련은 1번 방향이 더 좋을것같아. 대신 하이라이트는 여러군데 연동되어 있어 좀더 폭넓게 조사해보고 작동해줘. +104. 3D의 측점의 노선을 선택할때 하이라이트되는데 이게 바로 꺼지는 상황이야. 일단 하이라이트가 계속 문제되는데 전체적인 조사가 필요해. 일단 조사부터 해줘. (조사) From 42673505e7b2781fc54e84b27d3418db6a69b694 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:49:12 +0900 Subject: [PATCH 12/92] =?UTF-8?q?fix(B06):=20=EC=B8=A1=EC=A0=90=EB=A7=8C?= =?UTF-8?q?=20=EB=82=A8=EC=9D=80=20=EA=B3=A0=EB=A6=84=EC=9D=98=20=EB=85=B8?= =?UTF-8?q?=EB=A9=B4=EA=B3=B5=20=EB=B2=94=EC=9C=84=20=EA=B0=95=EC=A1=B0=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C=20=C2=B7=20B05=203D=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B5=AC=EB=A9=8D=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B2=A8=20=EA=B0=90=20=C2=B7=20=EA=B4=80=20=EB=84=98=EA=B9=80?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C(231-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B06 폼 다시 세움(load) 의 selectAt 에 노면공 거름(coverable) · 측점 고름이 연 폼은 카드 그대로 · 범위 테두리 없음 - B05 3D 부재 고름 resolveAt · selectAt 에 같은 거름 — 노면공 id 를 고름 한 칸에 안 적음 - 넘김 되얹기 카드 = 찾는 거리 안 가장 가까운 카드(관 80.17 이 카드 80 으로 안 감) - 새 시험 test_231_1 · test_106 배선 문자열 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_view_cross.ts | 2 +- A00_Common/design/design_view_profile.ts | 9 +- .../B05_Profile_UI_Structure_Pick_Session.ts | 7 +- .../B05_Profile_UI_Structures_Panel.ts | 3 +- .../B05_Profile_UI_Structures_Panel_Types.ts | 7 +- .../B06_Section_UI_Page_Structure_Pick.ts | 7 +- .../B06_Section_UI_Page_Structures_Panel.ts | 32 +- resources/tester/helper_231_1_pave_reload.cjs | 385 ++++++++++++++++++ .../tester/test_106_span_highlight_wording.py | 5 +- resources/tester/test_231_1_pave_reload.py | 115 ++++++ 10 files changed, 552 insertions(+), 20 deletions(-) create mode 100644 resources/tester/helper_231_1_pave_reload.cjs create mode 100644 resources/tester/test_231_1_pave_reload.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 21bb3380c..985c16f40 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -191,7 +191,7 @@ export async function mountCrossView( if (!isWallSelecting()) focusStationAt(chainageM, revealOnly); }, releaseCard: () => releaseCross(), - markStructure: (structureId) => sectionView.selectMark(structureId), + markStructure: (structureId, span) => sectionView.selectMark(structureId, span), applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), }); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 9a1fe7824..013ccdf01 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -356,7 +356,14 @@ export async function mountProfileView( shared, ); - wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe); + // 3D 부재 고름도 측점 고름과 같은 거름 — 노면공 id 를 고름 한 칸에 적지 않음(231-1) + wireStructurePick( + viewer.structurePick, + activeProjectId, + panel.structures, + selectStationOfPipe, + (entry) => coversAtStation(bridge.typeGroup(entry.type_id)), + ); /** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의 * 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */ diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index 585175a53..84ce34729 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -17,6 +17,7 @@ import { } from "../A00_Common/design/design_selection"; import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick"; import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; +import type { StructureInstance } from "./B05_Profile_Api_Structures"; /** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍에 구조물 id 가 붙는다. */ export interface StructurePickHandoff { @@ -53,6 +54,8 @@ export function wireStructurePick( structures: StructuresSection, /** 기존 선택 동기화(`selectStationOfPipe`) — 누가거리 하나로 전 화면을 맞춘다. */ selectStation: (chainageM: number | null) => void, + /** 측점이 덮어 열어도 되는 구조물 — 노면공은 뺌(측점선 · 카드 고름과 같은 거름 · 231-1). */ + coverable?: (entry: StructureInstance) => boolean, ): void { controls.onPick = (pick: StructurePick | null): void => { if (!pick) { @@ -61,10 +64,10 @@ export function wireStructurePick( return; } const selection: DesignSelection = { chainageM: pick.chainageM, structureId: null }; - const { structureId } = structures.resolveAt(selection); + const { structureId } = structures.resolveAt(selection, coverable); selectDesign(projectId, { ...selection, structureId }, { partKey: pick.key, from: "b05" }); selectStation(pick.chainageM); - if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId }); + if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId }, coverable); }; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 2b7e97e5e..526d27113 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -616,7 +616,8 @@ export function createStructuresSection( if (!pipe) return; loadPipeForm(pipe); }, - resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures), + resolveAt: (selection, coverable) => + resolveSelection(selection, pipeFacilities, structures, coverable), selectAt(selection, coverable) { const hit = resolveSelection(selection, pipeFacilities, structures, coverable); if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 589623338..676d199a2 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -171,8 +171,11 @@ export interface StructuresSection { hasSelection: () => boolean; /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ selectById: (structureId: string | null) => void; - /** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */ - resolveAt: (selection: DesignSelection) => ResolvedSelection; + /** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). `coverable` = selectAt 과 같음. */ + resolveAt: ( + selection: DesignSelection, + coverable?: (entry: StructureInstance) => boolean, + ) => ResolvedSelection; /** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다. * 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. `coverable` = 덮어 열어도 되는 구조물(측점 고름 · 216-1). */ selectAt: ( diff --git a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts index 4ec8b2bb4..28a495bf7 100644 --- a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts +++ b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts @@ -19,7 +19,7 @@ import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import type { StructurePickHandoff } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { coversChainage, - isNear, + nearestWithin, type SelectableStructure, } from "../A00_Common/design/design_selection"; @@ -57,7 +57,8 @@ export function applyStructurePick( const covering = structures.find((entry) => coversChainage(entry, handoff.at)); const target = (byId && cardOfStructure(sections, byId, handoff.at)) || - sections.find((section) => isNear(section.chainage_m, handoff.at)) || + // 찾는 거리 안 가장 가까운 카드 — 첫 카드면 관 80.17 이 이웃 측점 80 카드로 감(231-1) + nearestWithin(sections, handoff.at, (section) => section.chainage_m) || (covering && cardOfStructure(sections, covering, handoff.at)); if (!target) return false; // 조정창은 앞칸(측 이름)만 본다 — 뒤칸은 3D 강조를 부재 하나로 좁히는 몫이다. @@ -83,7 +84,7 @@ export function applyStructurePick( type Sections = SectionDetailResponse["cross_sections"]; /** 구조물이 자리한 카드 — 구간형이면 그 안의 첫 카드, 점형이면 가장 가까운 카드. 옹벽 · 기슭막이는 - * 횡단 제원이라 시작 측점 카드가 주인이다(관은 `isNear` 로 카드를 바로 찾는다). */ + * 횡단 제원이라 시작 측점 카드가 주인이다(관은 `nearestWithin` 으로 카드를 바로 찾는다). */ function cardOfStructure( sections: Sections, structure: SelectableStructure, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index e3c76208c..6fdb971ce 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -89,8 +89,8 @@ export interface B06StructuresPanelDeps { focusChainage: (chainageM: number, revealOnly?: boolean) => void; /** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */ releaseCard?: () => void; - /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */ - markStructure?: (structureId: string | null) => void; + /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */ + markStructure?: (structureId: string | null, span?: boolean) => void; /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — @@ -167,6 +167,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group); /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ let preferredId: string | null = null; + /** 측점 고름(카드 하나 · 측점만 남은 고름 다시 세움)이 폼을 여는 중 — 폼 알림이 카드를 옮기지 않게(231-1). */ + let openingAtStation = false; /** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */ const focusOf = (structure: StructureInstance): number => { const anchor = structureAnchorM(structure); @@ -220,10 +222,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const pave = !coverable(structure); const id = structure.structure_id ?? null; preferredId = id; - deps.focusChainage(focusOf(structure), pave); + // 측점 고름(카드 하나 · 다시 세움)이 연 폼 = 누른 카드 그대로 — 구조물 기준 카드로 옮겨 가지 않음(189-1 · 231-1) + if (!openingAtStation) deps.focusChainage(focusOf(structure), pave); preferredId = null; // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) - deps.markStructure?.(id); + deps.markStructure?.(id, !openingAtStation); // 측점 고름이 연 것 = 카드 하나 · 범위 테두리 없음(231-1) // 카드 고름 리스너를 안 거치니 세션 고름은 여기서(B05 로 가도 그 노면공) const at = { chainageM: focusOf(structure), structureId: id }; if (pave) selectDesign(deps.projectId, at, { from: "b06" }); @@ -406,11 +409,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const pick = readStructurePick(deps.projectId); const picked = currentChainageM ?? pick?.at ?? null; // 관이면 그 관, 옹벽 · 기슭막이 같은 구조물이면 저장된 구조물 id 로 폼을 세운다. + // 측점만 남은 고름은 노면공을 덮어 열지 않음 — 카드 고름과 같은 거름(152-5 · 231-1) if (picked !== null) { - section.selectAt({ - chainageM: picked, - structureId: currentChainageM === null ? (pick?.structureId ?? null) : null, - }); + const structureId = currentChainageM === null ? (pick?.structureId ?? null) : null; + openingAtStation = structureId === null; + try { + section.selectAt({ chainageM: picked, structureId }, coverable); + } finally { + openingAtStation = false; + } } } catch (error) { showToast( @@ -567,7 +574,14 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.selectPipeByChainage(hit.pipeChainageM); // 알약은 폼 뒤 — 폼이 연 구조물을 닫으며 알약을 끄므로(165-2 · 측점선 없는 물넘이포장도 53-2) deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); - } else section.selectById(hit.structureId); + } else { + openingAtStation = preferredId === null; + try { + section.selectById(hit.structureId); + } finally { + openingAtStation = false; + } + } return { chainageM, structureId: hit.structureId }; }, }; diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs new file mode 100644 index 000000000..3319d3183 --- /dev/null +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -0,0 +1,385 @@ +/* 231-1 노면공 범위 고름 — 「측점만 남은 고름」이 B05↔B06 오감 · 다시 세움에서 노면공 범위를 켜지 않는지. + * 진짜 `createB06StructuresPanel` · `applyStructurePick` · `wireStructurePick` · `readStructurePick` 를 Node 로 돌리고 + * 폼 · 카드 · 알약 흉내는 helper_165 와 같은 꼴(공용 폼 `selectAt` = B05 패널 본문 그대로). 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +global.requestAnimationFrame = (fn) => fn(); + +const store = new Map(); +const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const pickSession = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"), + { "../A00_Common/design/design_selection": selection }, +); +const structurePick = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structure_Pick.ts"), + { "../A00_Common/design/design_selection": selection }, +); + +const PID = "p231"; +const TYPES = [ + { type_id: "pavement_concrete", group: "G" }, + { type_id: "retaining_wall", group: "C" }, + { type_id: "hazard_sign", group: "E" }, + { type_id: "pipe", group: "A", managed_by: "pipe_points" }, +]; +const groupOf = (typeId) => TYPES.find((type) => type.type_id === typeId)?.group; +const atStation = (entry) => selection.coversAtStation(groupOf(entry.type_id)); +const PAVE = { + structure_id: "pave", + type_id: "pavement_concrete", + placement: "interval", + chainage_m: 30, + start_m: 30, + end_m: 250, +}; +const WALL = { + structure_id: "wall", + type_id: "retaining_wall", + placement: "interval", + chainage_m: 100, + start_m: 100, + end_m: 160, +}; +const SIGN = { structure_id: "sign", type_id: "hazard_sign", chainage_m: 127.86 }; +const PIPES = [{ chainage_m: 80.17, facility: "pipe", source: "spacing", options: {} }]; +const CARDS = [...Array.from({ length: 16 }, (_, i) => i * 20), 80.17] + .sort((a, b) => a - b) + .map((at) => ({ station_id: `s${at}`, chainage_m: at })); +const cardAt = (at) => CARDS.find((card) => Math.abs(card.chainage_m - at) < 0.01); + +/* ── 공용 폼 흉내(B05 패널 loadForm · loadPipeForm · resetForm · selectAt 본문) ── */ +let binding = null; +let structures = []; +const form = { id: null, pipe: null }; +const loadForm = (target) => { + form.id = target?.structure_id ?? null; + form.pipe = null; + binding.onSelect(target); +}; +const loadPipeForm = (chainageM) => { + const hadStructure = form.id !== null; + form.id = null; + form.pipe = chainageM; + if (hadStructure) binding.onSelect(null); +}; +const resetForm = () => { + const hadPipe = form.pipe !== null; + form.pipe = null; + loadForm(null); + if (hadPipe) binding.onPipeSelect(null); +}; +const node = { addEventListener() {}, querySelector: () => ({ append() {} }) }; +const section = { + root: node, + listRoot: node, + facility: { root: node, setRevetSideLabels() {}, recount() {} }, + setTypes() {}, + setStructures(next) { + structures = next; + }, + setPipeFacilities() {}, + hasSelection: () => form.id !== null || form.pipe !== null, + selectById(id) { + if (id === null) { + if (form.id !== null || form.pipe !== null) resetForm(); + return; + } + loadForm(structures.find((entry) => entry.structure_id === id) ?? null); + }, + selectPipeByChainage(chainageM) { + if (chainageM === null) { + if (form.pipe !== null) resetForm(); + return; + } + loadPipeForm(chainageM); + }, + resolveAt: (sel, coverable) => selection.resolveSelection(sel, PIPES, structures, coverable), + selectAt(sel, coverable) { + const hit = selection.resolveSelection(sel, PIPES, structures, coverable); + if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM); + else this.selectById(hit.structureId); + return hit; + }, +}; + +const current = { structures: [] }; +const panelModule = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"), + { + "../A00_Common/design/design_shared": { + bindDesignView: (_shared, _view, parts) => { + binding = parts.structures; + }, + sharedStructures: () => section, + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructureTypes: async () => TYPES, + fetchStructures: async () => ({ structures: [] }), + readPendingStructures: () => current.structures, + pipesToStructureMarks: (pipes) => + pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + })), + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + writePendingStructures() {}, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { + readPendingPipes: () => null, + writePendingPipes() {}, + }, + "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge": { mergeFacilityOptions: (x) => x }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }), + }, + "../common_util/common_util_summary_item": { + linkFields: () => ({}), + readGroupItems: (x) => x ?? {}, + readSummaryItem: (x) => x ?? null, + }, + "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": pickSession, + "../A00_Common/design/design_selection": selection, + "@ui/ui_template_elements": { showToast() {} }, + "./B06_Section_UI_Adjust_Dock": { + adjustDockRoot: () => ({}), + refreshAdjustSlots() {}, + resetAdjustDock() {}, + setAdjustSideSlots() {}, + }, + "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + }, +); + +/* ── 종단 알약 · 횡단 카드 흉내(helper_165 와 같음) ── */ +let marks = []; +const profile = { + markedId: null, + pill: null, + selectMark(id) { + this.markedId = id; + this.pill = id; + }, + showCard(card) { + if (!card) return; + const pipe = PIPES.find((entry) => Math.abs(entry.chainage_m - card.chainage_m) < 0.51); + this.pill = pipe ? `pipe-${pipe.chainage_m.toFixed(2)}` : null; + const marked = marks.find((entry) => entry.structure_id === this.markedId); + if (!pipe && marked && selection.coversChainage(marked, card.chainage_m)) + this.pill = this.markedId; + }, +}; +const isPavement = (id) => { + const mark = marks.find((entry) => entry.structure_id === id); + return !!mark && groupOf(mark.type_id) === "G"; +}; +const view = { + selected: null, + spanMarkId: null, + listener: null, + selectStation(id) { + const previous = this.selected; + this.selected = this.selected === id ? null : id; + profile.showCard(CARDS.find((card) => card.station_id === this.selected) ?? null); + if (this.selected !== previous) this.listener(this.selected); + }, + revealStation() { + this.selected = null; + profile.showCard(null); + }, + focusStation(id) { + if (id === null) this.selected = null; + else if (this.selected !== id) this.selectStation(id); + else profile.showCard(CARDS.find((card) => card.station_id === id)); + }, + selectMark(id, span = true) { + this.spanMarkId = span || isPavement(id) ? id : null; + profile.selectMark(id); + }, +}; +const focusStationAt = (at, revealOnly = false) => { + const nearest = CARDS.reduce((best, card) => + Math.abs(card.chainage_m - at) < Math.abs(best.chainage_m - at) ? card : best, + ); + if (revealOnly) view.revealStation(nearest.station_id); + else view.focusStation(nearest.station_id); +}; + +let panel = null; +/** design_view_cross.ts 카드 고름 리스너 본문(벽 누름 없음). */ +view.listener = (stationId) => { + const target = stationId ? CARDS.find((card) => card.station_id === stationId) : null; + if (!target) { + panel.showAtChainage(null); + selection.selectDesign(PID, selection.NO_SELECTION, { from: "b06" }); + return; + } + const preferred = panel.preferred() !== null; + const opened = panel.showAtChainage(target.chainage_m); + const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null }; + selection.selectDesign(PID, picked, { from: "b06" }); + const pipeAt = panel.currentChainage(); + const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); + view.selectMark(mark, false); +}; + +async function build(list) { + current.structures = list; + store.clear(); + form.id = null; + form.pipe = null; + view.selected = null; + view.spanMarkId = null; + profile.markedId = null; + profile.pill = null; + panel = panelModule.createB06StructuresPanel({ + projectId: PID, + shared: { view: () => "cross" }, + detail: () => ({ cross_sections: CARDS }), + stationInterval: () => 20, + focusChainage: (at, revealOnly) => focusStationAt(at, revealOnly), + markStructure: (id, span) => view.selectMark(id, span), + onMarks: (next) => { + marks = next; + }, + }); + await panel.load(); +} + +/** design_view_cross.ts `restoreStructurePick` 의 focusStation 차례 그대로. */ +function restore() { + const handoff = pickSession.readStructurePick(PID); + structurePick.applyStructurePick( + handoff, + { cross_sections: CARDS }, + { + focusStation: (stationId) => { + panel.prefer(handoff?.structureId ?? null); + view.focusStation(stationId); + panel.prefer(null); + const sid = handoff?.structureId; + if (sid && structures.some((entry) => entry.structure_id === sid)) + panel.selectStructure(sid); + }, + refreshCard() {}, + revetSelect() {}, + fordSelect() {}, + boxSelect() {}, + }, + structures, + ); +} + +/** B05 로 갔다 B06 으로 돌아옴 — B05 `restorePick`(폼 비었을 때만 · id 길) → B06 onShow(load → 되얹기). */ +async function roundTrip() { + const pick = pickSession.readStructurePick(PID); + if (!section.hasSelection() && pick?.structureId) + section.selectAt({ chainageM: pick.at, structureId: pick.structureId }); + await panel.load(); + restore(); +} + +function snap() { + const mark = marks.find((entry) => entry.structure_id === view.spanMarkId); + const pave = mark && isPavement(mark.structure_id); + const card = CARDS.find((entry) => entry.station_id === view.selected); + return { + form: form.id ?? (form.pipe === null ? null : `pipe-${form.pipe.toFixed(2)}`), + paveLayer: pave ? mark.structure_id : null, + span: view.spanMarkId, + card: card ? card.chainage_m : null, + sid: selection.readSelection(PID)?.structureId ?? null, + }; +} + +const cardPick = (at) => view.selectStation(cardAt(at).station_id); +const listPick = (id) => loadForm(structures.find((entry) => entry.structure_id === id)); + +/** B05 3D 부재 고름 — 진짜 `wireStructurePick` 에 B05 와 같은 거름(design_view_profile). */ +function pick3d(chainageM) { + const controls = {}; + pickSession.wireStructurePick(controls, PID, section, () => undefined, atStation); + controls.onPick({ chainageM, kind: "revet", key: "own" }); +} + +(async () => { + const out = {}; + // A. 노면공 범위 안 빈 카드 → 오감 + await build([PAVE, SIGN]); + cardPick(120); + out.cardBefore = snap(); + await roundTrip(); + out.cardAfter = snap(); + await roundTrip(); + out.cardAfter2 = snap(); + + // B. 같은 카드에서 목록 표지 → 오감(세션 = 측점만 그대로) + await build([PAVE, SIGN]); + cardPick(120); + listPick("sign"); + out.signPick = snap(); + await roundTrip(); + out.signAfter = snap(); + + // C. 목록 노면공 → 오감 = 그대로 범위 강조(id 길) + await build([PAVE, SIGN]); + listPick("pave"); + out.paveBefore = snap(); + await roundTrip(); + out.paveAfter = snap(); + + // D. 옹벽 범위 안 카드 = 그 카드 그대로(기준 카드 100 으로 안 옮김) · 오감 뒤도 + await build([PAVE, WALL]); + cardPick(120); + out.wallCard = snap(); + await roundTrip(); + out.wallAfter = snap(); + listPick("wall"); // 목록 고름은 기준 카드로 감(그대로) + out.wallList = snap(); + + // E. 관 80.17 넘김(측점만) = 카드 80.17(이웃 80 아님) + await build([PAVE]); + selection.selectDesign(PID, { chainageM: 80.17, structureId: null }, { from: "b05" }); + restore(); + out.pipeHandoff = snap(); + + // F. B05 3D 부재 고름 — 노면공 범위 안이라도 노면공 id 를 적지 않음 + await build([PAVE, WALL]); + pick3d(120); + out.pick3dWall = { sid: snap().sid, form: snap().form }; + await build([PAVE]); + pick3d(200); + out.pick3dBare = { sid: snap().sid, form: snap().form }; + await roundTrip(); + out.pick3dAfter = snap(); + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py index e0b5f9c97..c3275bb7a 100644 --- a/resources/tester/test_106_span_highlight_wording.py +++ b/resources/tester/test_106_span_highlight_wording.py @@ -40,7 +40,10 @@ def test_측점_고름은_카드_하나_목록_고름은_여럿(): """137-4 — 측점 고름 리스너는 span 끔 · 목록(노면공) 길 · 알약은 기본(여럿) 그대로.""" cross = _read("A00_Common", "design", "design_view_cross.ts") assert "sectionView.selectMark(mark, false);" in cross # 152-5 덮는 것 없으면 null = 풂 - assert "markStructure: (structureId) => sectionView.selectMark(structureId)," in cross + # 목록 길 = 기본(여럿) · 측점 고름이 연 폼만 span 끔(231-1) + assert ( + "markStructure: (structureId, span) => sectionView.selectMark(structureId, span)," in cross + ) types = _read("B06_Section", "B06_Section_UI_Section_View_Types.ts") assert "selectMark: (structureId: string | null, span?: boolean) => void;" in types diff --git a/resources/tester/test_231_1_pave_reload.py b/resources/tester/test_231_1_pave_reload.py new file mode 100644 index 000000000..7363f9b3e --- /dev/null +++ b/resources/tester/test_231_1_pave_reload.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""231-1 노면공 범위 고름 — 측점만 남은 고름은 노면공 범위 강조를 켜지 않음(조사 AD 1안 + 같은 구멍). + +까닭: B06 폼 다시 세움(`load()` — B05↔B06 오감 · 새로 고침)이 측점만 남은 고름을 거름 없이 `selectAt` 으로 +열어 「그 측점을 덮는 첫 구조물」= 노면공이 열리고 범위 강조 · 고름 기록까지 노면공으로 바뀜. +B05 3D 부재 고름(`wireStructurePick`)도 거름 없이 노면공 id 를 고름 한 칸에 적었음. +고침: 측점 고름이 여는 모든 길에 `coversAtStation`(노면공 뺌 · 152-5) 한 벌 · 노면공은 목록 · 알약 · 형상 누름(id)만. +곁: 측점 고름이 연 폼은 카드를 구조물 기준 카드로 옮기지 않음 · 넘김 카드 = 찾는 거리 안 가장 가까운 카드. +진짜 TS 를 `helper_231_1_pave_reload.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_231_1_pave_reload.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +CARD_120 = {"form": None, "paveLayer": None, "span": None, "card": 120, "sid": None} +PAVE = {"form": "pave", "paveLayer": "pave", "span": "pave", "card": None, "sid": "pave"} + + +def test_범위_안_카드_고름_오감해도_노면공_안_켬_폼_그대로(out) -> None: + assert out["cardBefore"] == CARD_120 + assert out["cardAfter"] == CARD_120 + assert out["cardAfter2"] == CARD_120 # 두 번 오가도 + + +def test_같은_카드_목록_고름_뒤_오감도_노면공_안_켬(out) -> None: + assert out["signPick"]["form"] == "sign" + after = out["signAfter"] + assert after["form"] != "pave" + assert after["paveLayer"] is None + assert after["span"] is None + assert after["card"] == 120 + + +def test_목록_노면공은_오감해도_범위_강조_그대로(out) -> None: + assert out["paveBefore"] == PAVE + assert out["paveAfter"] == PAVE + + +def test_옹벽_범위_안_카드는_그_카드_그대로(out) -> None: + wall = {"form": "wall", "paveLayer": None, "span": None, "card": 120, "sid": None} + assert out["wallCard"] == wall # 기준 카드 100 으로 안 옮김 + assert out["wallAfter"] == wall # 오감 뒤도 · 범위 테두리 없음(137-4) + # 목록 고름은 그대로 기준 카드 · 범위 테두리 · id 기록 + assert out["wallList"] == { + "form": "wall", + "paveLayer": None, + "span": "wall", + "card": 100, + "sid": "wall", + } + + +def test_관_넘김은_가장_가까운_카드(out) -> None: + assert out["pipeHandoff"]["card"] == 80.17 # 이웃 측점 80 아님 + assert out["pipeHandoff"]["form"] == "pipe-80.17" + + +def test_B05_3D_고름은_노면공_id_를_안_적음(out) -> None: + assert out["pick3dWall"] == {"sid": "wall", "form": "wall"} # 범위 안 옹벽 = 옹벽 + assert out["pick3dBare"] == {"sid": None, "form": None} # 노면공만 덮음 = 안 엶 + after = out["pick3dAfter"] + assert after["paveLayer"] is None + assert after["sid"] is None + assert after["form"] is None + + +def test_배선() -> None: + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "section.selectAt({ chainageM: picked, structureId }, coverable);" in panel + assert "if (!openingAtStation) deps.focusChainage(focusOf(structure), pave);" in panel + assert "deps.markStructure?.(id, !openingAtStation);" in panel + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert ( + "markStructure: (structureId, span) => sectionView.selectMark(structureId, span)," in cross + ) + pick = _read("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts") + assert "structures.resolveAt(selection, coverable)" in pick + assert "structures.selectAt({ ...selection, structureId }, coverable)" in pick + profile = _read("A00_Common", "design", "design_view_profile.ts") + at = profile.index("wireStructurePick(") + assert "coversAtStation(bridge.typeGroup(entry.type_id))" in profile[at : at + 300] + handoff = _read("B06_Section", "B06_Section_UI_Page_Structure_Pick.ts") + assert "nearestWithin(sections, handoff.at, (section) => section.chainage_m)" in handoff From 3ec57791691c80f93d646d873b317d2cd746c008 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:52:01 +0900 Subject: [PATCH 13/92] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=83=88=20=EC=B0=BD=20=EC=B5=9C=EB=8C=80=ED=99=94=20=EB=95=8C?= =?UTF-8?q?=20=ED=8E=B8=EC=A7=91=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20=E2=80=94=20=EB=86=93=EC=9D=B8=20=EC=B0=BD?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=ED=8B=80=20=C2=B7=20=EC=8B=9C=EA=B3=84?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=20=ED=95=A8=EC=88=98=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC(230-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공용 common_util_view_window(viewOf · frameOf) 신설. design_profile rebuild · 접기 · render, Heights 높이 맞춤, 표 · 유토곡선 크기 끌기, 유토곡선 세로창 미끄러짐, 끌기 뒤 그리기, ▲▼ · 방향키 길게 누르기, 배수유역도, 구조물 말풍선 — 메인 창 전역 틀 · 시계 대신 요소가 놓인 창 것. 새 시험 test_230_2_popout_frames(메인 창 틀 멈춤 흉내 · 새 창 패널 코드 훑기). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_profile.ts | 7 +- A00_Common/design/design_profile_viewport.ts | 3 +- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 8 +- B05_Profile/B05_Profile_UI_Profile_Drag.ts | 12 +- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 19 +- B05_Profile/B05_Profile_UI_Profile_Heights.ts | 3 +- .../B05_Profile_UI_Profile_MassHaul.ts | 13 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 18 +- .../B05_Profile_UI_Profile_TableOverlay.ts | 3 +- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 9 +- .../B05_Profile_UI_Structures_Marks.ts | 4 +- common_util/common_util_mass_haul_view.ts | 15 +- common_util/common_util_view_window.ts | 18 ++ .../tester/helper_230_1_popout_wheel.cjs | 6 +- .../tester/helper_230_2_popout_frames.cjs | 227 ++++++++++++++++++ resources/tester/test_230_2_popout_frames.py | 105 ++++++++ 16 files changed, 429 insertions(+), 41 deletions(-) create mode 100644 common_util/common_util_view_window.ts create mode 100644 resources/tester/helper_230_2_popout_frames.cjs create mode 100644 resources/tester/test_230_2_popout_frames.py diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 8145a6ba3..7dda48d16 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -77,6 +77,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; +import { frameOf } from "@util/common_util_view_window"; import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; import { noticeDroppedEdits } from "./design_save"; import { createProfileOverlays } from "./design_profile_overlays"; @@ -341,7 +342,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { scheduleCrossPreview(); if (redrawPending) return; redrawPending = true; - requestAnimationFrame(() => { + frameOf(root, () => { redrawPending = false; draw(); }); @@ -443,7 +444,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { }); // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). - bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw)); + bindProfileCollapse(root, panelHandle, () => frameOf(root, draw)); /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ function syncDrainageSpans(): void { @@ -482,7 +483,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { nextDetail.balloon_offsets ?? undefined, ); draw(); - requestAnimationFrame(draw); + frameOf(root, draw); // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). diff --git a/A00_Common/design/design_profile_viewport.ts b/A00_Common/design/design_profile_viewport.ts index 7a9b7bb3a..3be92e541 100644 --- a/A00_Common/design/design_profile_viewport.ts +++ b/A00_Common/design/design_profile_viewport.ts @@ -6,6 +6,7 @@ import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; +import { viewOf } from "@util/common_util_view_window"; /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ const SCROLL_SETTLE_MS = 80; @@ -39,7 +40,7 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void { const fitted = deps.fitElevation(); timerView.clearTimeout(scrollSettleTimer); if (!needsFullRedraw(fitted)) return; - timerView = body.ownerDocument.defaultView ?? window; + timerView = viewOf(body); scrollSettleTimer = timerView.setTimeout(() => { if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 28f7f2d5f..1deeea3c5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import { viewOf } from "../common_util/common_util_view_window"; import { computeDetailBasins, fetchDetailPipePoints, @@ -172,6 +173,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let offsetX = 0; let offsetY = 0; let frameHandle = 0; + /** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */ + let frameView: Window = window; let loadSequence = 0; let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택 /** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */ @@ -301,7 +304,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra function scheduleDraw(): void { if (frameHandle) return; - frameHandle = window.requestAnimationFrame(() => { + frameView = viewOf(root); + frameHandle = frameView.requestAnimationFrame(() => { frameHandle = 0; draw(); }); @@ -667,7 +671,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra dispose() { loadSequence += 1; if (frameHandle) { - window.cancelAnimationFrame(frameHandle); + frameView.cancelAnimationFrame(frameHandle); frameHandle = 0; } resizeObserver.disconnect(); diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index e3cbe853c..b5c22166a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -12,6 +12,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights"; import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; +import { viewOf } from "../common_util/common_util_view_window"; const MIN_CHART_HEIGHT = 100; /** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. @@ -123,9 +124,12 @@ export interface ProfileDrag { export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { const { root, body, massHaul, tableOverlay } = ctx; let resizeTimer = 0; + /** 시계는 종단이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; const scheduleDraw = (): void => { - window.clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(ctx.draw, 120); + timerView.clearTimeout(resizeTimer); + timerView = viewOf(root); + resizeTimer = timerView.setTimeout(ctx.draw, 120); }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. @@ -211,9 +215,9 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { mainDragCooldown = false; }, scheduleDraw, - cancelDraw: () => window.clearTimeout(resizeTimer), + cancelDraw: () => timerView.clearTimeout(resizeTimer), dispose() { - window.clearTimeout(resizeTimer); + timerView.clearTimeout(resizeTimer); heightResizer.dispose(); window.removeEventListener("pointerup", clearDragFlags); window.removeEventListener("pointercancel", clearDragFlags); diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index babaca90c..2c653d574 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -23,6 +23,7 @@ import { useDesignStore, } from "../A00_Common/design/design_store"; import { clearState } from "../A00_Common/b_page_state"; +import { viewOf } from "../common_util/common_util_view_window"; /** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ const BUTTON_CLEARANCE_PX = 23; @@ -38,13 +39,18 @@ export const HOLD_INTERVAL_MS = 100; * 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나 * 반대로 멈추지 않는 상황이 생기지 않는다. */ -function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } { +function createHoldRepeater(node: Node): { + start: (action: () => void) => void; + stop: () => void; +} { let delayTimer = 0; let repeatTimer = 0; + /** 시계는 버튼 층이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; function stop(): void { - window.clearTimeout(delayTimer); - window.clearInterval(repeatTimer); + timerView.clearTimeout(delayTimer); + timerView.clearInterval(repeatTimer); delayTimer = 0; repeatTimer = 0; window.removeEventListener("pointerup", stop); @@ -54,8 +60,9 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () = function start(action: () => void): void { stop(); - delayTimer = window.setTimeout(() => { - repeatTimer = window.setInterval(action, HOLD_INTERVAL_MS); + timerView = viewOf(node); + delayTimer = timerView.setTimeout(() => { + repeatTimer = timerView.setInterval(action, HOLD_INTERVAL_MS); }, HOLD_DELAY_MS); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop); @@ -329,7 +336,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { layer.className = "b05-profile-edit"; layer.style.width = `${width}px`; - const repeater = createHoldRepeater(); + const repeater = createHoldRepeater(layer); const edited = new Set(Object.keys(alignment.edits.station_offsets)); /** diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 24561487d..3771fdf1e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -11,6 +11,7 @@ import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; +import { frameOf } from "../common_util/common_util_view_window"; /** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */ const MIN_CHART_HEIGHT = 100; @@ -182,7 +183,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { function scheduleLightSync(): void { if (lightSyncPending) return; lightSyncPending = true; - requestAnimationFrame(() => { + frameOf(deps.body, () => { lightSyncPending = false; syncHeightsLight(); }); diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index a85a79bf7..2ffa158bc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -56,6 +56,7 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { frameOf } from "../common_util/common_util_view_window"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ /* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ @@ -235,7 +236,7 @@ export function createRouteMassHaulPanel( syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; - requestAnimationFrame(() => { + frameOf(overlay, () => { resizeRedrawPending = false; onChanged(); }); @@ -488,9 +489,13 @@ export function createRouteMassHaulPanel( ); drawnOnce = true; // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. - scheduleMassHaulSettle(windowState, () => { - if (lastParams) draw(lastParams); - }); + scheduleMassHaulSettle( + windowState, + () => { + if (lastParams) draw(lastParams); + }, + overlay, + ); } return { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 61b8c5add..64127e8f2 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -10,6 +10,7 @@ * ========================================================================== */ import { isCtrlKey } from "../A00_Common/key_shortcut"; +import { viewOf } from "../common_util/common_util_view_window"; import type { AlignmentBase, AlignmentEdits, @@ -254,11 +255,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { * 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */ const HOLD_SETTLE_MS = 700; let settleTimer = 0; + /** 시계는 패널이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; /** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */ function stopHold(): void { - window.clearTimeout(holdDelayTimer); - window.clearInterval(holdRepeatTimer); + timerView.clearTimeout(holdDelayTimer); + timerView.clearInterval(holdRepeatTimer); holdDelayTimer = 0; holdRepeatTimer = 0; if (heldKey === null) return; @@ -267,8 +270,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { holdStation = null; window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", stopHold); - window.clearTimeout(settleTimer); - settleTimer = window.setTimeout(() => { + timerView.clearTimeout(settleTimer); + settleTimer = timerView.setTimeout(() => { settleTimer = 0; history.resume(); }, HOLD_SETTLE_MS); @@ -278,7 +281,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { function flushHold(): void { stopHold(); if (!settleTimer) return; - window.clearTimeout(settleTimer); + timerView.clearTimeout(settleTimer); settleTimer = 0; history.resume(); } @@ -292,8 +295,9 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { heldKey = key; window.addEventListener("keyup", onKeyUp); window.addEventListener("blur", stopHold); - holdDelayTimer = window.setTimeout(() => { - holdRepeatTimer = window.setInterval(() => { + timerView = viewOf(ctx.root); // 앞선 덩어리는 flushHold 가 이미 닫음 + holdDelayTimer = timerView.setTimeout(() => { + holdRepeatTimer = timerView.setInterval(() => { if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다. }, HOLD_INTERVAL_MS); }, HOLD_DELAY_MS); diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index ea11fbcec..e206a08f0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -15,6 +15,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { frameOf } from "../common_util/common_util_view_window"; /* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ const HEIGHT_KEY = stateKey("table-height") ?? ""; @@ -87,7 +88,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; - requestAnimationFrame(() => { + frameOf(overlay, () => { resizeRedrawPending = false; onChanged(); }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index 1f6aa5ed4..f1298db83 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -18,6 +18,8 @@ * 유토곡선·측점 테이블)이 함께 얻는다. * ========================================================================== */ +import { viewOf } from "../common_util/common_util_view_window"; + /** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — * 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */ const EASE = 0.35; @@ -37,13 +39,6 @@ const BASE_FRAME_MS = 1000 / 60; * 쓰면 한 번에 목표까지 튄다. */ const MAX_FRAME_MS = 64; -/** 스크롤러가 지금 놓인 창 — 종단을 새 창으로 빼면 그 창이다(230-1). - * 메인 창 전역 `requestAnimationFrame` 을 쓰면, 새 창을 최대화해 메인 창이 가려질 때 - * 브라우저가 메인 창 프레임을 멈춰 휠 이동이 한 걸음도 안 나갔다. */ -function viewOf(scroller: HTMLElement): Window { - return scroller.ownerDocument.defaultView ?? window; -} - export function attachWheelHorizontalScroll(scroller: HTMLElement): void { let target = -1; /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index a2a03a295..9f883cc85 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -22,6 +22,7 @@ import { import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon"; import { stationLabel as formatStation } from "../common_util/common_util_station"; +import { frameOf } from "../common_util/common_util_view_window"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 * 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여 @@ -268,7 +269,8 @@ function buildBalloon( return span; }), ); - requestAnimationFrame(() => flipIfClipped(balloon)); + // 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2). + queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon))); return balloon; } diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 06149fb23..95d618d03 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -23,6 +23,7 @@ import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul"; import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view"; import { segmentOf } from "./common_util_mass_haul_curve"; import { L, stationLabel, svgElement, svgText } from "./common_util_svg"; +import { viewOf } from "./common_util_view_window"; /** * 유토곡선 X축을 **호출한 쪽 종단 그래프와 맞추는** 값. 좌우 여백과 누가거리 최댓값이 @@ -74,6 +75,8 @@ export interface MassHaulWindowState { settled: boolean; /** 예약해 둔 다음 프레임(중복 예약 방지). */ frame: number; + /** `frame` 을 받아 둔 창 — 걷을 때도 같은 창에서(230-2). */ + view?: Window; } export function createMassHaulWindowState(): MassHaulWindowState { @@ -81,11 +84,17 @@ export function createMassHaulWindowState(): MassHaulWindowState { } /** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */ -export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void { - if (state.frame) cancelAnimationFrame(state.frame); +/** `node` = 곡선이 놓인 요소 — 그 창의 틀에서 잰다(새 창 최대화 때 메인 창 틀은 멈춤 · 230-2). */ +export function scheduleMassHaulSettle( + state: MassHaulWindowState, + redraw: () => void, + node?: Node, +): void { + if (state.frame) (state.view ?? window).cancelAnimationFrame(state.frame); state.frame = 0; if (state.settled) return; - state.frame = requestAnimationFrame(() => { + state.view = viewOf(node); + state.frame = state.view.requestAnimationFrame(() => { state.frame = 0; redraw(); }); diff --git a/common_util/common_util_view_window.ts b/common_util/common_util_view_window.ts new file mode 100644 index 000000000..88070a390 --- /dev/null +++ b/common_util/common_util_view_window.ts @@ -0,0 +1,18 @@ +/* ============================================================================= + * common_util_view_window.ts + * 요소가 지금 놓인 창 — 종단 패널을 새 창으로 빼면 그 창이다(230-1 · 230-2). + * + * 메인 창 전역 `requestAnimationFrame` · 시계를 쓰면, 새 창을 최대화해 메인 창이 가려질 때 + * 브라우저가 메인 창 화면 틀을 멈추고 시계를 늦춰 휠 · 편집 · 다시 그리기가 멈췄다. + * 화면 틀 · 시계는 이 함수로 그 요소의 창에서 받는다. + * ========================================================================== */ + +/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */ +export function viewOf(node: Node | null | undefined): Window { + return node?.ownerDocument?.defaultView ?? window; +} + +/** 요소가 놓인 창의 다음 화면 틀에 부른다. */ +export function frameOf(node: Node | null | undefined, callback: FrameRequestCallback): number { + return viewOf(node).requestAnimationFrame(callback); +} diff --git a/resources/tester/helper_230_1_popout_wheel.cjs b/resources/tester/helper_230_1_popout_wheel.cjs index 57b2cb4fe..b05bb981b 100644 --- a/resources/tester/helper_230_1_popout_wheel.cjs +++ b/resources/tester/helper_230_1_popout_wheel.cjs @@ -79,9 +79,13 @@ globalThis.ResizeObserver = class { disconnect() {} }; -const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts")); +const viewWindow = load(path.join(ROOT, "common_util", "common_util_view_window.ts")); +const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), { + "../common_util/common_util_view_window": viewWindow, +}); const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), { "../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod, + "@util/common_util_view_window": viewWindow, "@util/common_util_chart_ywindow": { needsFullRedraw: () => true }, }); diff --git a/resources/tester/helper_230_2_popout_frames.cjs b/resources/tester/helper_230_2_popout_frames.cjs new file mode 100644 index 000000000..00fbd81f7 --- /dev/null +++ b/resources/tester/helper_230_2_popout_frames.cjs @@ -0,0 +1,227 @@ +/* 종단 새 창 최대화 — 같은 꼴 나머지(계획서 230-2) 단위검증 헬퍼. + * 메인 창은 「가려짐」(화면 틀 · 시계 멈춤) · 종단 요소는 새 창 소속. 실제 TS 의 해당 함수 본문을 + * 잘라 그 자리에서 트랜스파일해 돌리고 JSON 으로 냄 — 다시 그리기 · 높이 맞춤 · 표 · 유토곡선 · + * 끌기 뒤 그리기 · 길게 누르기 · 배수유역도가 새 창 틀 · 시계로 도는지. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); +const run = (code, scope) => { + const js = ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const names = Object.keys(scope); + return new Function(...names, js)(...names.map((n) => scope[n])); +}; +/** `start` 부터 짝 맞는 닫는 중괄호까지 잘라 냄 — 본문 여는 괄호가 반환 타입 뒤면 `open` 으로 짚음. */ +function block(src, start, open = "{") { + const at = src.indexOf(start); + if (at < 0) throw new Error(`본문 없음: ${start}`); + let depth = 0; + for (let i = src.indexOf(open, at) + open.length - 1; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) return src.slice(at, i + 1); + } + throw new Error(`닫힘 없음: ${start}`); +} + +// ---- 가짜 창 — frozen 이면 화면 틀 · 시계가 안 돎 ---- +function fakeWindow(name, frozen) { + const win = { name, frozen, now: 0, timers: [], seq: 0, frames: 0, listeners: [] }; + win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); + win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.setInterval = (fn, ms) => { + const id = ++win.seq; + const again = (at) => win.timers.push({ id, at, fn: () => (fn(), again(win.now + ms)) }); + again(win.now + ms); + return id; + }; + win.clearInterval = win.clearTimeout; + win.requestAnimationFrame = (fn) => win.setTimeout(() => (win.frames++, fn(win.now)), 16); + win.cancelAnimationFrame = win.clearTimeout; + win.addEventListener = (type, fn) => win.listeners.push([type, fn]); + win.removeEventListener = (type, fn) => + (win.listeners = win.listeners.filter(([t, f]) => t !== type || f !== fn)); + win.tick = (ms) => { + if (win.frozen) return; + const end = win.now + ms; + for (;;) { + const next = win.timers.filter((t) => t.at <= end).sort((a, b) => a.at - b.at)[0]; + if (!next) break; + win.now = next.at; + win.clearTimeout(next.id); + next.fn(); + } + win.now = end; + }; + return win; +} + +const main = fakeWindow("main", true); +const popup = fakeWindow("popup", false); +globalThis.window = main; +globalThis.requestAnimationFrame = main.requestAnimationFrame; // 옛 코드가 쓰던 전역 = 메인 창 +globalThis.cancelAnimationFrame = main.cancelAnimationFrame; +const inPopup = () => ({ ownerDocument: { defaultView: popup }, style: { setProperty() {} } }); + +const vw = {}; +run( + read("common_util", "common_util_view_window.ts").replace(/export /g, "") + + "\nObject.assign(vw, { viewOf, frameOf });", + { vw }, +); +const { viewOf, frameOf } = vw; +const out = {}; +const mainBusy = () => main.timers.length; + +// ① 공용 함수 — 새 창 요소는 새 창 · 안 붙은 요소는 메인 창 +out.viewOf = { + popup: viewOf(inPopup()).name, + detached: viewOf({ ownerDocument: { defaultView: null } }).name, + none: viewOf(null).name, +}; + +// ② 계획고 편집 뒤 다시 그리기(design_profile rebuild) — 두 번 불러도 한 번만 그림 +{ + const s = { draws: 0, previews: 0 }; + const src = block( + read("A00_Common", "design", "design_profile.ts"), + "function rebuild(): void {", + ); + run(`let redrawPending = false; ${src}\nrebuild(); rebuild(); s.rebuild = true;`, { + s, + base: null, + alignment: null, + buildAlignment: () => null, + store: { edits: () => ({}) }, + scheduleCrossPreview: () => s.previews++, + draw: () => s.draws++, + root: inPopup(), + frameOf, + }); + popup.tick(50); + out.rebuild = { draws: s.draws, previews: s.previews, mainTimers: mainBusy() }; +} + +// ③ 높이 맞춤(Heights scheduleLightSync) +{ + const s = { syncs: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Heights.ts"), + "function scheduleLightSync(): void {", + ); + run(`let lightSyncPending = false; ${src}\nscheduleLightSync(); scheduleLightSync();`, { + deps: { body: inPopup() }, + syncHeightsLight: () => s.syncs++, + frameOf, + }); + popup.tick(50); + out.heights = { syncs: s.syncs, mainTimers: mainBusy() }; +} + +// ④ 표 · 유토곡선 크기 끌기(TableOverlay · MassHaul onResize) +for (const [key, file] of [ + ["table", "B05_Profile_UI_Profile_TableOverlay.ts"], + ["massHaul", "B05_Profile_UI_Profile_MassHaul.ts"], +]) { + const s = { changed: 0, handles: 0 }; + const src = block(read("B05_Profile", file), "onResize: () => {").replace( + /^onResize: /, + "const onResize = ", + ); + run(`let resizeRedrawPending = false; ${src}\nonResize(); onResize();`, { + syncHandlePosition: () => s.handles++, + onChanged: () => s.changed++, + overlay: inPopup(), + frameOf, + }); + popup.tick(50); + out[key] = { changed: s.changed, handles: s.handles, mainTimers: mainBusy() }; +} + +// ⑤ 유토곡선 세로창 미끄러짐(scheduleMassHaulSettle) +{ + const s = { redraws: 0 }; + const src = block( + read("common_util", "common_util_mass_haul_view.ts"), + "export function scheduleMassHaulSettle(", + ).replace(/^export /, ""); + const state = { held: null, settled: false, frame: 0 }; + run( + `${src}\nscheduleMassHaulSettle(state, () => s.redraws++, node); scheduleMassHaulSettle(state, () => s.redraws++, node);`, + { + s, + state, + node: inPopup(), + viewOf, + }, + ); + popup.tick(50); + out.settle = { + redraws: s.redraws, + frame: state.frame, + view: state.view?.name, + mainTimers: mainBusy(), + }; +} + +// ⑥ 끌기 뒤 다시 그리기(Profile_Drag scheduleDraw · 120ms 모음) +{ + const s = { draws: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Drag.ts"), + "const scheduleDraw = (): void => {", + ); + run(`let resizeTimer = 0; let timerView = window; ${src}\nscheduleDraw(); scheduleDraw();`, { + window: main, + root: inPopup(), + ctx: { draw: () => s.draws++ }, + viewOf, + }); + popup.tick(200); + out.drag = { draws: s.draws, mainTimers: mainBusy() }; +} + +// ⑦ ▲▼ 길게 누르기(Profile_Edit createHoldRepeater) — 0.5초 뒤 초당 10회 +{ + const s = { acts: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"), + "function createHoldRepeater(", + "} {", + ); + run(`${src}\nconst r = createHoldRepeater(node); r.start(() => s.acts++); s.r = r;`, { + s, + window: main, + node: inPopup(), + viewOf, + HOLD_DELAY_MS: 500, + HOLD_INTERVAL_MS: 100, + }); + popup.tick(1000); + const acts = s.acts; + s.r.stop(); + popup.tick(1000); + out.hold = { acts, afterStop: s.acts, mainTimers: mainBusy(), popupTimers: popup.timers.length }; +} + +// ⑧ 배수유역도(Drainage_Panel scheduleDraw) +{ + const s = { draws: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"), + "function scheduleDraw(): void {", + ); + run(`let frameHandle = 0; let frameView = window; ${src}\nscheduleDraw(); scheduleDraw();`, { + window: main, + root: inPopup(), + draw: () => s.draws++, + viewOf, + }); + popup.tick(50); + out.drainage = { draws: s.draws, mainTimers: mainBusy() }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_230_2_popout_frames.py b/resources/tester/test_230_2_popout_frames.py new file mode 100644 index 000000000..a0f51bd03 --- /dev/null +++ b/resources/tester/test_230_2_popout_frames.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""종단 새 창 최대화 — 같은 꼴 나머지(계획서 230-2) 회귀검증. + +230-1 과 같은 까닭 — 메인 창 전역 화면 틀 · 시계는 새 창 최대화로 메인 창이 가려지면 멈추거나 +늦게 돎. 새 창으로 가는 종단 패널(종단도 · 표 · 유토곡선 · 배수유역도)의 틀 · 시계는 공용 +`viewOf` · `frameOf`(common_util_view_window) 로 그 요소가 놓인 창에서 받음. + +`helper_230_2_popout_frames.cjs` 가 메인 창을 「가려짐」(틀 · 시계 멈춤)으로 두고 실제 함수 본문을 돌림. +옛 코드로는 아래 다시 그리기가 모두 0 번. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + +# 새 창으로 가는 종단 패널의 화면 코드 +POPOUT_FILES = [ + "A00_Common/design/design_profile.ts", + "A00_Common/design/design_profile_viewport.ts", + "B05_Profile/B05_Profile_UI_Profile_Wheel.ts", + "B05_Profile/B05_Profile_UI_Profile_Heights.ts", + "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts", + "B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts", + "B05_Profile/B05_Profile_UI_Profile_Drag.ts", + "B05_Profile/B05_Profile_UI_Profile_Edit.ts", + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts", + "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", + "B05_Profile/B05_Profile_UI_Structures_Marks.ts", + "common_util/common_util_mass_haul_view.ts", +] +# 메인 창 전역 틀 · 시계 — `x.requestAnimationFrame` 처럼 창을 짚은 것은 제외 +MAIN_WINDOW_CLOCK = re.compile( + r"(? Date: Sat, 10 Oct 2026 17:52:53 +0900 Subject: [PATCH 14/92] =?UTF-8?q?fix(B06):=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=EB=8F=84=20=EB=8A=98=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=E2=80=94=20=EA=B0=99=EC=9D=80=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=95=88=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EB=8F=84=20?= =?UTF-8?q?=EC=98=A4=EA=B0=90=20=C2=B7=20=EC=83=88=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EB=92=A4=20=EA=B7=B8=20=ED=8F=BC(231-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록 · 알약 · 형상 고름은 노면공이 아니어도 그 구조물 id 를 고름 한 칸에 적음 · 측점 고름이 연 것은 측점만(189-1) - 새 시험 test_231_2 · test_231_1 · test_177_4 기대값 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B06_Section_UI_Page_Structures_Panel.ts | 5 +- resources/tester/helper_231_1_pave_reload.cjs | 48 +++++++++- .../tester/test_177_4_pavement_span_box.py | 3 +- resources/tester/test_231_1_pave_reload.py | 4 +- .../tester/test_231_2_list_pick_record.py | 88 +++++++++++++++++++ 5 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 resources/tester/test_231_2_list_pick_record.py diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 6fdb971ce..fc0d5a3ae 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -227,9 +227,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc preferredId = null; // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) deps.markStructure?.(id, !openingAtStation); // 측점 고름이 연 것 = 카드 하나 · 범위 테두리 없음(231-1) - // 카드 고름 리스너를 안 거치니 세션 고름은 여기서(B05 로 가도 그 노면공) + // 목록 · 알약 · 형상 고름은 늘 고름 한 칸에 그 구조물 — 같은 카드라 카드 리스너가 안 돌아도 + // 오감 · 새로 고침 뒤 그 폼(188-1 · 231-2) · 측점 고름이 연 것은 측점만(카드 리스너 몫 · 189-1) const at = { chainageM: focusOf(structure), structureId: id }; - if (pave) selectDesign(deps.projectId, at, { from: "b06" }); + if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" }); }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 3319d3183..24d17d3e8 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -249,9 +249,10 @@ view.listener = (stationId) => { view.selectMark(mark, false); }; -async function build(list) { +/** 새로 세움 — keep = 새로 고침(F5 · 세션은 남고 화면 · 폼은 처음부터). */ +async function build(list, keep = false) { current.structures = list; - store.clear(); + if (!keep) store.clear(); form.id = null; form.pipe = null; view.selected = null; @@ -305,6 +306,12 @@ async function roundTrip() { restore(); } +/** 새로 고침(F5) — 첫 마운트 `load().then(restoreStructurePick)` 차례(231-2). */ +async function refresh() { + await build(current.structures, true); + restore(); +} + function snap() { const mark = marks.find((entry) => entry.structure_id === view.spanMarkId); const pave = mark && isPavement(mark.structure_id); @@ -320,6 +327,11 @@ function snap() { const cardPick = (at) => view.selectStation(cardAt(at).station_id); const listPick = (id) => loadForm(structures.find((entry) => entry.structure_id === id)); +/** 좌측 목록 관 줄 — B05 패널 `onSelectPipe`(loadPipeForm → onPipeSelect). */ +const listPipe = (at) => { + loadPipeForm(at); + binding.onPipeSelect(at); +}; /** B05 3D 부재 고름 — 진짜 `wireStructurePick` 에 B05 와 같은 거름(design_view_profile). */ function pick3d(chainageM) { @@ -378,6 +390,38 @@ function pick3d(chainageM) { out.pick3dBare = { sid: snap().sid, form: snap().form }; await roundTrip(); out.pick3dAfter = snap(); + + // 231-2 목록 고름도 늘 고름 기록 — 카드 120 → 목록 관 · 표지 · 옹벽 → 오감 · 새로 고침 + await build([PAVE, WALL, SIGN]); + cardPick(120); + listPipe(80.17); + out.listPipe = snap(); + await roundTrip(); + out.listPipeTrip = snap(); + await refresh(); + out.listPipeF5 = snap(); + + await build([PAVE, SIGN]); + cardPick(120); + listPick("sign"); // 같은 카드 120 — 카드 리스너 안 돎 + out.listSign = snap(); + await roundTrip(); + out.listSignTrip = snap(); + await refresh(); + out.listSignF5 = snap(); + + await build([PAVE, WALL]); + cardPick(120); + listPick("wall"); + out.listWall = snap(); + await roundTrip(); + out.listWallTrip = snap(); + + await build([PAVE, SIGN]); + cardPick(120); + listPick("pave"); + await refresh(); + out.listPaveF5 = snap(); process.stdout.write(JSON.stringify(out)); })().catch((error) => { process.stderr.write(String(error?.stack ?? error)); diff --git a/resources/tester/test_177_4_pavement_span_box.py b/resources/tester/test_177_4_pavement_span_box.py index b604678ee..324224a05 100644 --- a/resources/tester/test_177_4_pavement_span_box.py +++ b/resources/tester/test_177_4_pavement_span_box.py @@ -71,4 +71,5 @@ def test_스크롤만_길은_리스너를_안_거침(): assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") assert "const pave = !coverable(structure);" in panel - assert 'if (pave) selectDesign(deps.projectId, at, { from: "b06" });' in panel + # 목록 고름은 노면공뿐 아니라 늘 고름 기록(231-2) + assert 'if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" });' in panel diff --git a/resources/tester/test_231_1_pave_reload.py b/resources/tester/test_231_1_pave_reload.py index 7363f9b3e..898c92503 100644 --- a/resources/tester/test_231_1_pave_reload.py +++ b/resources/tester/test_231_1_pave_reload.py @@ -57,9 +57,9 @@ def test_범위_안_카드_고름_오감해도_노면공_안_켬_폼_그대로(o def test_같은_카드_목록_고름_뒤_오감도_노면공_안_켬(out) -> None: assert out["signPick"]["form"] == "sign" after = out["signAfter"] - assert after["form"] != "pave" + assert after["form"] == "sign" # 목록 고름도 기록 — 오감 뒤 그 폼(231-2) assert after["paveLayer"] is None - assert after["span"] is None + assert after["span"] != "pave" assert after["card"] == 120 diff --git a/resources/tester/test_231_2_list_pick_record.py b/resources/tester/test_231_2_list_pick_record.py new file mode 100644 index 000000000..a1a41b153 --- /dev/null +++ b/resources/tester/test_231_2_list_pick_record.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""231-2 목록 고름도 늘 고름 기록 — 조사 AD 2안. + +까닭: B06 목록 고름은 노면공일 때만 고름 한 칸에 적고 그 밖은 카드 리스너에 맡겼음 → 같은 카드 안 구조물을 +목록으로 고르면 리스너가 안 돌아 기록이 「측점만」 그대로 → 오감 · 새로 고침 뒤 그 폼이 사라짐. +고침: 목록 · 알약 · 형상 고름은 늘 그 구조물 id 를 적음 · 측점 고름(카드 · 다시 세움)이 연 것은 측점만(189-1). +B05 목록은 원래 늘 적음(`rememberPick(…, structure_id)`). 헬퍼 = `helper_231_1_pave_reload.cjs`. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_231_1_pave_reload.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_카드_120_뒤_목록_관_오감_새로고침_관_폼(out) -> None: + for key in ("listPipe", "listPipeTrip", "listPipeF5"): + got = out[key] + assert got["form"] == "pipe-80.17", key + assert got["card"] == 80.17, key + assert got["paveLayer"] is None, key + + +def test_같은_카드_목록_표지는_기록되고_오감_새로고침_뒤_그대로(out) -> None: + sign = {"form": "sign", "paveLayer": None, "span": "sign", "card": 120, "sid": "sign"} + assert out["listSign"] == sign + assert out["listSignTrip"] == sign + assert out["listSignF5"] == sign + + +def test_목록_옹벽은_기준_카드_오감_뒤_그대로(out) -> None: + wall = {"form": "wall", "paveLayer": None, "span": "wall", "card": 100, "sid": "wall"} + assert out["listWall"] == wall + assert out["listWallTrip"] == wall + + +def test_목록_노면공은_새로고침_뒤도_범위_그대로(out) -> None: + assert out["listPaveF5"] == { + "form": "pave", + "paveLayer": "pave", + "span": "pave", + "card": None, + "sid": "pave", + } + + +def test_측점_고름이_연_것은_측점만(out) -> None: + assert out["wallCard"]["sid"] is None # 카드 하나(189-1) + assert out["wallAfter"]["sid"] is None # 다시 세움도 + assert out["cardAfter"]["sid"] is None + + +def test_배선() -> None: + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert 'if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" });' in panel + assert "if (pave) selectDesign(" not in panel + profile = _read("A00_Common", "design", "design_view_profile.ts") + # B05 목록은 원래 늘 그 구조물 id 를 적음 + assert "rememberPick(structureAnchorM(structure), structure.structure_id ?? null)" in profile From a8dc1bd7e148dab02351ee3fc7032849c8c53483 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:57:16 +0900 Subject: [PATCH 15/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20105?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 8f9f9c70b..c3d325c81 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -128,3 +128,4 @@ 102. B05에서 종단 새창으로 띄우고 브라우져 최대화한경우 마우스 스크롤로 종단을 움직이는 기능이 작동이 안됨. 최대화를 안하면 작동됨. 103. 노면공 범위 관련은 1번 방향이 더 좋을것같아. 대신 하이라이트는 여러군데 연동되어 있어 좀더 폭넓게 조사해보고 작동해줘. 104. 3D의 측점의 노선을 선택할때 하이라이트되는데 이게 바로 꺼지는 상황이야. 일단 하이라이트가 계속 문제되는데 전체적인 조사가 필요해. 일단 조사부터 해줘. (조사) +105. 추가로 하이라이트에서 소수점 단위 구조물 측점들의 경우 1단위로 사사오입하여 측점을 지정하게 반영했던걸로 기억하는데 언제 했고 어떤식으로 했는지 알아보고 어떻게 개선할지 조사만 해봐. (조사) From 49f9dc73f85b1e378de9e4d2dd170f685cc0ec58 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:00:22 +0900 Subject: [PATCH 16/92] =?UTF-8?q?test(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=ED=8F=BC=20=EC=9A=94=EC=95=BD=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=20=E2=80=94=20@util=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=C2=B7=20=EA=B0=80=EC=A7=9C=20=EC=9A=94=EC=86=8C=20=EB=B3=B4?= =?UTF-8?q?=ED=83=AC=20=C2=B7=20=EC=88=A8=EA=B8=B4=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=80=EA=B0=92=2054-26=20=EB=A7=9E=EC=B6=A4(230-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 헬퍼가 @util/common_util_cross_structure_areas 를 못 찾던 것 — common_util 폴더로 별칭. 가짜 요소에 style.setProperty · select.options · Option 보탬. 숨긴 길이는 칸 없이 값만 쥠(54-26) — 기대값 맞추고 그린 칸 확인 추가 · 코드 안 고침. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../helper_b05_structures_form_summary.cjs | 24 +++++++++++++++++-- .../test_b05_structures_form_summary.py | 10 +++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 09c3e4f7c..1d65a9cc3 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -16,7 +16,11 @@ class FakeNode { this.tagName = tag; this.children = []; this.dataset = {}; - this.style = {}; + this.style = { + setProperty(name, value) { + this[name] = value; + }, + }; this.listeners = {}; this._text = ""; this._classes = new Set(); @@ -66,6 +70,10 @@ class FakeNode { (this.listeners.click || []).forEach((h) => h({ target: this })); } scrollIntoView() {} + // 고르기 칸 보기(syncChoices) — 자식 중 option 만 + get options() { + return this.children.filter((c) => c.tagName === "option"); + } // 시험에 필요한 두 가지 선택자만 — `[data-option-keys]` · `.클래스` querySelectorAll(selector) { const match = selector.startsWith("[") @@ -82,6 +90,9 @@ class FakeNode { } } globalThis.document = { createElement: (tag) => new FakeNode(tag) }; +globalThis.Option = function Option(text, value) { + return Object.assign(new FakeNode("option"), { _text: text, value }); +}; globalThis.Option = function Option(text, value) { const node = new FakeNode("option"); node._text = String(text); @@ -115,6 +126,10 @@ function loadTs(file) { if (STUBS[request]) return STUBS[request]; // 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + // 공용 계산(@util/…) — tsconfig 별칭대로 common_util 폴더에서 읽음 + if (request.startsWith("@util/")) { + return loadTs(path.join(ROOT, "common_util", `${request.slice("@util/".length)}.ts`)); + } if (request.startsWith(".") && request.endsWith(".json")) { return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8")); } @@ -297,7 +312,12 @@ const draw = (includeDetail) => { }, { height_m: 2 }, ); - return { keys: optionInputs.map((e) => e.key), hidden: optionRow.hidden }; + return { + keys: optionInputs.map((e) => e.key), + // 그린 칸 — 숨긴 길이(54-26)는 값만 쥐고 칸은 안 그림 + drawn: optionRow.querySelectorAll("[data-option-keys]").map((e) => e.dataset.optionKeys), + hidden: optionRow.hidden, + }; }; out.drawB05 = draw(false); // B05 — 상세 칸 없음 + visible:false 숨김 out.drawB06 = draw(true); // B06 — 상세 칸까지, 단 visible:false 는 여기서도 숨김 diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 22ad774a1..3fadfc00f 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -36,11 +36,15 @@ def test_visible_false_만_숨김(out): def test_visible_false_옵션은_폼에_안_그림_정의는_그대로(out): + # 숨긴 길이는 칸을 안 그리고 값만 쥠(54-26 · 범위 계산 · 저장이 옛 값을 이어 씀) · 상세는 B05 에 없음 assert out["drawB05"] == { - "keys": ["height_m"], + "keys": ["height_m", "length_m"], + "drawn": ["height_m"], "hidden": False, - } # 길이 숨김 · 상세는 B05 에 없음 - assert out["drawB06"]["keys"] == ["height_m", "stone_kind"] # B06 은 상세까지 · 숨김은 여기도 + } + # B06 은 상세까지 · 숨김은 여기도 칸 없음 + assert out["drawB06"]["drawn"] == ["height_m", "stone_kind"] + assert out["drawB06"]["keys"] == ["height_m", "stone_kind", "length_m"] assert out["definitionKept"] == ["height_m", "length_m", "stone_kind", "back_len_cm"] From 4f1803b733de17bd505c9e4ec4ab9f5ac4afd5c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:28:28 +0900 Subject: [PATCH 17/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20106~107=20=C2=B7=20=EC=A1=B0=EC=82=AC=20AF(=EA=B0=95?= =?UTF-8?q?=EC=A1=B0=20=EC=B2=B4=EA=B3=84=20=C2=B7=20=EC=86=8C=EC=88=98=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 + docs/raw/요구_1010_2/조사_AF_강조체계.md | 166 ++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AF_강조체계.md diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index c3d325c81..5e00edcfa 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -129,3 +129,5 @@ 103. 노면공 범위 관련은 1번 방향이 더 좋을것같아. 대신 하이라이트는 여러군데 연동되어 있어 좀더 폭넓게 조사해보고 작동해줘. 104. 3D의 측점의 노선을 선택할때 하이라이트되는데 이게 바로 꺼지는 상황이야. 일단 하이라이트가 계속 문제되는데 전체적인 조사가 필요해. 일단 조사부터 해줘. (조사) 105. 추가로 하이라이트에서 소수점 단위 구조물 측점들의 경우 1단위로 사사오입하여 측점을 지정하게 반영했던걸로 기억하는데 언제 했고 어떤식으로 했는지 알아보고 어떻게 개선할지 조사만 해봐. (조사) +106. (105번 보충) 일반측점이 있고 소수점 단위로 근처의 측점이 있다면 입력되는 기준의 측점을 값을 정수단위로 맞춰야하는게 아니냐는 거야. 선택이 안되는게 문제가 아니라 사용자가 소수점을 못넣게 하고 넣더라도 정수로 변경하여 맞출수 있는 기능이 필요하지 않을까해서 조사를 시킨거야. 조사만 해. +107. (질문) 노면공이 아닌데 범위값을 지정하는 구조물이 뭐가 있는거야? — 답: B 측구 · 산마루측구 · 소단측구 · 도수로·산비탈수로 · 절토사면 배수로 · 맹암거 / C 옹벽 · 돌쌓기(찰 · 메) · 흙막이 · 큰돌쌓기 · 소단 / E 대피소 · 정차·작업장 · 가드레일 / F 비탈면 녹화 diff --git a/docs/raw/요구_1010_2/조사_AF_강조체계.md b/docs/raw/요구_1010_2/조사_AF_강조체계.md new file mode 100644 index 000000000..f4b602a42 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AF_강조체계.md @@ -0,0 +1,166 @@ +# 조사 AF — 강조(하이라이트) 체계 전수 · 3D 측점 띠 바로 꺼짐 (요구 104 · 105) + +기준 = HEAD `a22deacf6`(231-1 · 231-2 · 230-2 합친 뒤) · 코드 · 자료 · git 안 바꿈 · 재현은 시스템 임시 폴더 스크래치. +약칭 — DSel `A00_Common/design/design_selection.ts` · DVP `…/design_view_profile.ts` · DVC `…/design_view_cross.ts` · DP `…/design_profile.ts` · MK3 `B05_Profile/B05_Profile_UI_Markers.ts` · MI `…_Viewer_Marker_Input.ts` · SPK `…_Viewer_Structure_Pick.ts` · PKS `…_Structure_Pick_Session.ts` · SY `…_Selection.ts` · R `…_Profile_Render.ts` · SP5 `…_Structures_Panel.ts` · CM `…_Structures_Panel_Commit.ts` · LI `…_Structures_List.ts` · DrP `…_Drainage_Panel.ts` · DrPi `…_Drainage_Pipes.ts` · SV `B06_Section/B06_Section_UI_Section_View.ts` · SVP `…_Section_View_Profile.ts` · CV `…_Cross_View.ts` · SP6 `…_Page_Structures_Panel.ts` · DET `…_Cross_Detail.ts` · DTS `…_Cross_Detail_Select.ts` · NC `…_Cross_Notice_Chips.ts` + +## 1. 한눈 결론 + +- 104 까닭 확정 — 3D 띠를 누르면 **누를 때 켜고(마커 듣개) 뗄 때 끔(3D 부재 고름 듣개)**. 부재 고름은 띠를 못 보고 「빈 곳 누름」으로 읽어 고름 전체를 지움(세션 · 띠 · 핀 · 종단 선). 09-04(2878ab961)부터 있던 구멍 · 오늘 띠 누름이 폼 · 세션까지 움직이게 되어(188-1 · 216-1) 눈에 띔. +- 스크래치 재현 — 진짜 코드로 누름 뒤 빨강 · 핀 · 세션 {at:120} → 뗌 뒤 노랑 · 핀 끔 · 세션 비움(코리도 있든 없든 같음). 「누름이 띠에 맞으면 부재 고름 건너뜀」 흉내로 뗌 뒤에도 유지. +- 강조 자리 = 화면 7곳 · 25개 남짓 · 상태 변수 15개 넘게 · 공유는 세션 한 칸(`structure-pick`)뿐. 알림 장치(`subscribeSelection`)는 있으나 **듣는 곳 0** — 화면끼리 손으로 서로 부르는 사슬. +- 되풀이 까닭 — 원천 여럿 · 같은 누름을 듣개 둘이 받음 · 재진입 가드가 뒤 고름을 버림 · 「해제」 뜻이 화면마다 다름 · 다시 계산이 강조를 버림 · 찾는 거리 제각각. +- 추천 — (가) 104 구멍 두 줄 먼저(작음) → (나) 「세션 한 칸 = 유일 원천 · 화면은 구독해 파생」으로 단계별 묶음(해제 규칙은 사용자 협의 뒤). + +## 2. 강조 지도 + +| 자리 | 켬 | 끔 · 리셋 | 상태 | 듣는 것 · 다시 그림 때 | +| --- | --- | --- | --- | --- | +| 3D 측점 띠(빨강) + 핀 | MK3:493 `selectStation` ← MI:124 누름 · DVP:143 · :149 · :229 · :332 | 같은 함수 null ← PKS:63 → SY:112 · DVP:332 · :568 | MK3:121 `selectedStationId` | 다시 그림(MK3:403)은 유지(:437) · 숨은 핀은 마지막 자리에 남음(:487-491) | +| 3D 구조물 부재(자체발광) | SPK:134 누름 · :153 `selectAtChainage` · :160 `select` | SPK:134 빈 곳 = null · `selectAtChainage(null)` | SPK:53 `selected` | 코리도 새로 만듦 → reapply(`B05_Profile_UI_Viewer.ts:166`) 유지 | +| 종단 측점 세로선 · 표 값 칸 · 유토곡선 선 | DP:243 `selectStation`(토글 · 바깥 알림 :248 는 이것만) · :519 `setSelectedStation` · :569 `setSelectedStructure` | R:223 빈 곳 · R:457 · DP:610 · `setSelectedStructure(null)`(keepStation 거짓이면 끔 · B06 만 참 SVP:268 · 177-3) | DP:211 | 매 그림 다시 칠함(R:130 · :267) | +| 종단 구조물 알약 · 구간 띠 | R:326-332 누름 · DP:246 · :522 · :570 짝 | 다시 누름 · R:226 · DP:562-564 목록에 없으면 조용히 버림 | DP:218 | 관 알약 id = `pipe-`+toFixed(2)(`B05_Profile_UI_Page_Structures.ts:207`) → 관이 움직이면 끊김 | +| 배수유역도 유역 채움 · 유역 목록 줄 | DrP:323 `selectBasin` | **DrP:364 재계산마다 끔** | DrP:128 | 지도 그림 rAF(DrP:305) | +| 배수유역도 관 마커 | DrPi:178 ← DrP:642 | **DrPi:172 `setPipes` 재계산마다 null** | DrPi:70 | 폼 · 목록 · 종단은 관을 계속 고름 → 지도만 꺼짐 | +| 배수유역도 측점 마름모 | DrP:598 `markStation`(SY:73 만) | 같은 함수 null | DrP:130 | 지도 · 유역에서 고를 땐 안 옮김(SY:98-125) | +| 좌측 목록 줄 | LI:223(구조물) · LI:231-233(관 · **폭 0.05 m**) | `renderList` 마다 다시 셈 | SP5:177 · :179 · :183 | 관 0.51 m 안 옮김에 `selectedPipeChainage` 안 고침(SP5:590-600) → 줄 강조 빠짐 | +| 좌측 폼 | CM:94 `loadForm`(늘 onSelect) · CM:163 `loadPipeForm` | SP5:374 `resetForm` · EV `startNew`(+세션 비움) | 위와 같음 | `setStructures`(SP5:556 · 저장 · 새로 읽기 뒤)가 관 폼 닫고 onSelect(null) → 종단 선 · 알약 끔(DVP:349) | +| B06 카드 고름 테두리 | SV:293 `selectStation` · SV:587 `focusStation` | SV:295 다시 누름 · :322 `clearSelection` · :598 `revealStation`(알림 없이) | SV:102 | 다시 그림이 다시 얹음(SV:350) | +| B06 `--span` 범위 테두리 · 포장층 | SV:607 `selectMark` → :256 `applySpan` | `selectMark(null)` · span 거짓(SV:609) | SV:245 `spanMarkId` | 다시 얹음(SV:374) · **카드 해제 DVC:388 · `releaseCross` DVC:498 이 `selectMark(null)` 안 부름 → 남을 수 있음** | +| B06 면적 띠 | CV:286 · SV:309 | SV:298 · CV:221 | SV:104 · CV:192 | 고른 카드에서만 다시 얹음 | +| B06 벽 · 집수정 · 관 · 도수로 형상 · 세월교 · BOX 몸체 | CV:232 · `…_View_Box.ts:52` | 같은 토글 · CV:262 · DVC:498 | `…_Page_Station_Controls.ts:272` · `…_Ford_Controls.ts:227` · :428 | 다시 얹음 | +| B06 계획선 조각(보라) | CV:308 → `…_Design_Pick.ts:523` | CV:205 | `…_Design_Pick.ts:278` | 고른 카드 · 다른 고름 없을 때만 다시 얹음(CV:325) | +| B06 좌측 조정 묶음 | `…_Adjust_Dock.ts:162` | :114 | DOM | 카드 다시 그릴 때마다 다시 붙고 좌측을 스크롤(:184) | +| 횡단 → 종단 선 · 알약 | SVP:270 `showCard` · SVP:266 | `showCard(null)` | DP 상태 공유 | — | +| [상세] 모달 칸 · 그림 | DET:304 | 다시 누름 · 키 없어짐 DET:297 | DET:293 | paint 마다 다시 얹음(DET:337) | +| [상세] 모달 선 조각 | DTS:193 | **DTS:212 다시 칠할 때마다 지움**(창 크기 · 카드 다시 세움 DET:316 · :412) | DTS:167 | 「바로 꺼짐」 또 한 자리 | +| 알림 칩 측점 단추(207) | NC:65(SV 카드 고름 읽음) | 고르면 목록 닫음 NC:88 | NC:47 | 구독 없음 → 열린 동안 고름이 바뀌면 낡음 | + +## 3. 고름 원천 · 차례 + +원천 여덟 무리(공유는 ①뿐): +① 세션 한 칸 `structure-pick` {at · sid · key} — DSel:150 `selectDesign`. 쓰는 곳 B05: DVP:118 `rememberPick`(알약 :182 · 측점 :244 · 관 목록 :322 · 구조물 목록 :345) · PKS:62 · :68(3D) · EV `startNew` / B06: DVC:372(부재) · :390(카드 해제) · :398(카드) · :515(`releaseCross`) · SP6:207 · :233(231-2 목록 고름 늘) · :319(관). 읽는 곳: DVP:135 · PKS:34(되살림 DVP:252 · :560) · DVC:466 · :569 · SP6:197 · :409. +② 3D 띠 MK3:121 ③ 3D 부재 SPK:53 ④ 종단 DP:211 · :218 ⑤ 좌측 폼 SP5:177-183 ⑥ 배수유역도 DrP:128 · :130 · DrPi:70 ⑦ B06 SV:102 · :104 · :245 + 형상 · 계획선 · `preferredId` · `openingAtStation`(SP6) ⑧ 모달 · 칩. + +누가 이기나: +- B05 안 = 손 사슬 + 재진입 가드 `selectionSyncing`(DVP:216). 가드가 켜진 동안 들어온 고름은 **통째로 버려짐**(SY:79 · :99 · DVP:323 · :346) → 188-1 이 끝에 `remember`(SY:96)로 덮어 「마지막 하나」를 맞춤. +- 세션 = 한 누름 안에서도 여러 번 적힘 — 측점선 누름 → 폼 콜백이 구조물 id 적음 → 끝에 측점으로 덮음. 마지막 `selectDesign` 이 이김. +- B05 ↔ B06 = 숨은 보기는 세션만 남기고 보일 때 다시 풂(DVP:558 `syncPick` · DVC:564 onShow → SP6 `load()` → `restoreStructurePick`). 다시 풀 때 거름(노면공 `coverable`)이 길마다 따로 넘겨져 한 곳 빠지면 다른 것이 열림(조사 AD · 231). +- 덮어쓰는 길 — (a) 3D 빈 곳 누름 PKS:62 세션 비움 = 104 (b) 새로 놓기 (c) 카드 해제 · `releaseCross` (d) `setStructures` 가 관 폼 닫음 (e) 배수 재계산이 지도 강조 끔. + +## 4. 104번 까닭 · 재현 + +차례(3D 띠 하나 누름): +1. 누름 — MI:105 `handlePointerDown` → :124 `markers.selectObject(hit)` → MK3:523 → :493 띠 빨강 · 핀 · :502 알림 → DVP:380 → 종단 선 + SY:78 → 폼 열기(`openAtStation`) · 세션 {at}. MI:125 는 `stopPropagation` — 같은 캔버스의 다른 듣개는 그대로 돎. +2. 같은 누름을 SPK:121 `handleDown` 도 받아 적음 — `blocked()`(MI:184)는 「점 끌기 · 이동 대기」만이라 띠 누름은 안 막힘. +3. 뗌 — SPK:128 `handleUp` → :98 `pickAt` 은 **코리도 무리만** 봄(띠는 마커 무리) → 띠 뒤 리본 · 지형이거나 코리도가 없으면 null → :136 `onPick(null)` → PKS:61-64 세션 비움 + `selectStationOfPipe(null)` → SY:112 띠 노랑 · 핀 끔 → DVP:381 종단 선 끔 · 좌측 `selectPipeByChainage(null)`. +4. 결과 — 누름~뗌 사이(0.1초 안팎)만 빨강. 1에서 연 구조물 폼은 남을 수 있음(관 폼만 닫음 SP5:603-609) · 세션이 비어 B06 로 가면 카드 없음 · `--span` 은 남을 수 있음(2장). + +갈래: +- 관 측점 띠 = 뗌이 띠 뒤 관 부재를 집으면 관 측점으로 다시 켜짐(SY:109) · 리본에 맞으면 꺼짐. +- 벽 등 구조물 위 일반 측점 = 부재 강조만 남고 띠는 꺼짐(`selectStationOfPipe` 는 관 측점만 찾음 SY:103-108). +- 언제부터 — 2878ab961(09-04 3D 부재 고름)의 「null → `selectStation(null)`」. 08-05 규칙 「3D 빈 곳 누름은 측점 고름을 안 풂」(MK3:526-529)과 정면으로 어긋남. + +스크래치 재현(진짜 MK3 · MI · SPK · SY · PKS · DSel + three 0.185 · 가짜 캔버스 · 저장소 밖): + +| 경우 | 누름 뒤 | 뗌 뒤 | +| --- | --- | --- | +| 코리도 있음(리본) | 띠 #ef4444 · 핀 보임 · 세션 {at:120} | 띠 #facc15 · 핀 끔 · 세션 null | +| 코리도 없음 | 같음 | 같음 | +| 흉내 고침(누름이 마커 · 띠에 맞으면 부재 고름 건너뜀) | 같음 | 빨강 · 핀 · 세션 그대로 | + +시험 빈칸 — MI · SPK 를 한 누름으로 함께 도는 시험 없음(test_231_1 은 `onPick` 을 바로 부름) → 104 를 못 잡음. 위 스크래치 꼴이 고칠 때 시험 바탕. +곁 구멍 — three 레이캐스트는 `visible` 을 안 봄(three.cjs `intersect`) → 숨은 핀(마지막 고른 자리에 남음) · 꺼 둔 측점선 · 라벨이 누름을 가로챔(MI:89 첫 맞음). 띠 가운데를 누르면 숨은 핀에 먼저 맞아 띠가 안 켜짐(스크래치에서 실제 겪음). + +## 5. 되풀이 구조 원인 + +1. 원천이 여럿 — 공유 세션 하나 + 화면마다 자기 변수. 세션은 「넘김용」이고 화면 안 강조는 자기 변수가 진짜라 둘이 어긋남(104 = 변수 켬 → 세션 지움 → 변수 끔). +2. 알림 장치 놀고 있음 — `subscribeSelection`(DSel:135) 듣는 곳 0 · DVP 한 파일에 고름 길 일곱(측점선 :148 · 지도 :176-177 · 알약 :180 · 관 목록 :321 · 구조물 목록 :342 · 3D 띠 :380 · 3D 부재 PKS:60)이 저마다 다른 화면을 다른 차례로 부름 → 길마다 빠지는 화면이 다름(지도 마름모 · 관 → 구조물 때 지도 강조 남음). +3. 재진입 가드가 「막기」 — 순서 의존 · 뒤 고름 버림 → 덮어쓰기 덧댐(188-1). +4. 같은 누름을 듣개 둘이 받음 — 3D(마커 · 부재) · 종단(측점선 · 도구 R:207-221) · B06(카드 · 형상 SV:426 캡처). 서로 다른 결론을 차례로 적음. +5. 「해제」 뜻이 제각각 — 3D 빈 곳(08-05 안 풂 ↔ 09-04 풂) · 종단 빈 곳 풂 · 카드 다시 누름 풂 · 보기 오감 `releaseCross` 풂. 어디까지 푸는지(세션 · 폼 · span)도 다름. +6. 다시 계산이 상태를 버림 — 배수 재계산(DrP:364 · DrPi:172) · [상세] 조각(DTS:212) · `setStructures`. 반대로 B06 카드는 다시 그림이 다시 얹음 → 화면마다 규칙이 다름. +7. 찾는 거리 · 키 제각각 — 0.51(DSel) · 0.05(LI:232) · 0.01(SPK:75) · 0.006(213) · 관 알약 id toFixed(2) → 관이 조금만 움직여도 강조가 안 이어짐(7장과 같은 뿌리). +8. 거름(노면공)을 부르는 쪽마다 넘겨야 함 → 한 곳 빠지면 샘(AD · 231-1 이 다섯 곳에 덧댐). + +## 6. 고칠 방향 비교 · 추천 + +(가) 구멍만 막기 — 각 1~5줄 · 위험 낮음 · 고친 곳마다 시험 하나 +- a1(104) SPK 가 마커 · 띠에 맞은 누름을 건너뜀(`B05_Profile_UI_Viewer.ts:261` `blocked` 에 「누름이 마커에 맞음」 · MI 에 값 하나) + `onPick(null)` 은 3D 부재 강조만 풀고 세션 · 측점 고름은 안 지움(PKS:61-64 · 08-05 규칙). +- a2 숨은 핀 · 꺼진 측점선을 레이캐스트에서 뺌(MI:89 · `visible` 거름). +- a3 배수 재계산 뒤 고른 관 · 유역 다시 얹음(DrP:364). +- a4 카드 해제 · `releaseCross` 에 `selectMark(null)`(DVC:388 · :498). +- a5 목록 관 줄 폭 0.05 → `PIPE_MATCH_M` · 옮김 때 고른 자리 갱신(LI:232 · SP5:590-600). +- a6 지도 · 유역 고름 때도 마름모 옮김(SY:98-125 에 `markStation`). +- 흠 — 오늘만 177-3 · 177-4 · 188 · 189 · 216 · 231 · 104 일곱 번째 덧댐 · 다음 길이 또 샘. + +(나) 원천 하나 + 강조 파생 +- 원천 = DSel 세션 한 칸(이미 있음) + 고름 갈래 칸 하나(측점 · 구조물 · 부재 · 관 — 189 세 갈래를 값으로). +- 쓰기 = 모든 누름은 `selectDesign` 만 · 화면끼리 직접 부르기와 `selectionSyncing` 지움. +- 읽기 = 화면마다 `subscribeSelection` 듣개 하나 → 「값 → 그 화면 강조」 함수 하나(3D 띠 = 측점 id · 종단 = `graphTargetAt` · 폼 = `resolveSelection` + `coversAtStation` · 카드 · span = `applyStructurePick`) · 다시 그림 · 다시 계산 뒤에도 같은 함수로 다시 얹음. +- 해제 규칙 표 하나 · 찾는 거리 한 값(7장). +- 크기 큼(DVP · SY · PKS · DVC · SP6 · SV · DP · CM · DrP 등 10파일 안팎 · 300~500줄) · 위험 중~높음 — 지금 규칙을 박은 시험(165 · 177_3 · 177_4 · 188_1 · 189_1 · 216_1 · 231_1 · selection_single)이 안전망. +- 차례 — ① 값 모양 · 해제 규칙 사용자 협의 ② B05 3D · 종단을 구독으로(104 길 포함) ③ 좌측 폼 ④ B06 카드 · span ⑤ 지도 · 모달 · 칩 ⑥ 직접 부르기 · 가드 지움. 단계마다 위 시험 통과. + +231 겹침 — 231-1(64c9470f9) · 231-2(4db793117) 모두 메인에 합쳐짐. a1 은 231 이 만진 PKS `onPick` 안 null 갈래만 → 충돌 없음 · test_231_1 함께 돌림. 231 의 `coverable` 은 (나) ③ 폼 파생 함수 한 곳으로 들어감. + +추천 — a1 + a2 먼저(104 바로 풂) → (나)는 협의 뒤 단계별 · a3~a6 은 (나) ②~⑤에 흡수(급하면 따로). +⬜ 사용자 협의 — 「3D 빈 곳 누름 = 고름 해제」 여부(08-05 안 풂 ↔ 09-04 풂) · 해제가 어디까지(세션 · 폼 · span)인지. + +## 7. 소수 측점 1단위 반올림 (요구 105) + +언제 · 어떻게 +| 날짜 · 커밋 | 장 | 내용 | +| --- | --- | --- | +| 07-30 12f166650 · 08-02 32fd199db | — | 「같은 자리」 폭 0.51 m — ±0.5 라 사실상 1 m 사사오입 맞춤(고름 · 유역↔관 짝) | +| **08-28 0979927df** | — | **기억하신 그것** — 서버가 관 · 구조물 측점을 `int(round())` 로 같은 정수 m 격자 측점에 붙임(관 80.17 = 카드 80 에 이름표만 · 까닭 = 횡단 파일 이름이 정수 m) | +| 09-29 867c4650c · 08e493f13 | 20장(사용자 확정 9-29) | 붙임 풂 — 카드 80 과 80.17 따로 · 파일 이름 소수 2자리 · 0.005 m 안만 합침(`B05_Profile_Engine_Sections_Core.py:349-353`) · **화면 표기만 정수**(119.8 → 5+20) | +| 09-29 8a9456579 | 24-2d | 0.51 을 `PIPE_MATCH_M`(DSel:17) 한 값으로 — 사본 · 손으로 적은 0.51 은 TS 21곳 남음 | +| 10-10 002165f04 · ca3786115 | 213-1 · 219-1 | 같은 측점 폭 0.006 m(서버 · TS 짝 `B05_Profile_UI_Profile_Alignment.ts:202`) | +| 10-10 42673505e | 231-1 | 넘김 카드 한 곳만 「찾는 거리 안 가장 가까운 카드」(관 80.17 → 카드 80 막음) | + +지금 동작(관 80.17 · 카드 80) +- 고름 풀기 = 0.51 안 관 먼저(DSel:94 · SY:131-142 · SVP:43 · :276-279) → 카드 80 을 눌러도 관 80.17 폼 · 종단 관 선. 「관 먼저」는 9-29 전 「한 카드」 전제. +- 0.51 안 **첫** 항목 — DVC:128-131 `ownerAt` · `B06_Section_UI_Page_Structures_Spec.ts:146-148` · `…_Pipe_Options.ts:68-69` · `B05_Profile_UI_Profile_Structures.ts:121` · `:181` · `B05_Profile_UI_IrregularStations.ts:483-543` → 1 m 안 소수 측점 둘이면 엉뚱한 것을 고름 · 옮김 · 지움(앞 셋은 코드로 읽은 추정). +- 표기 `common_util/common_util_station.ts:24` 반올림 → 80 과 80.17 둘 다 「4+0」 · 119.8 = 「5+20」. +- 바른 본보기 — `B06_Section_UI_Page_Structure_Pick.ts:61`(231-1) · `design_structure_pipe_calc.ts:63-68`. + +흠 — ① 9-29 뒤 0.17 m 떨어진 두 카드를 폭 0.51 이 한 자리로 봄 ② 관 측점 ↔ 일반 측점 혼동 ③ 폭 일곱 갈래(0.005 · 0.006 · 0.01 · 0.02 · 0.05 · 0.1 · 0.51) ④ 다른 두 카드가 같은 글자로 보임. + +지금 자료(검증 프로젝트 13개 · 읽기만) +- 관 136/136 개 모두 소수(자동 배치 3자리 484.213 · 고친 것 2자리) · 20 m 일반 측점 1 m 안 관 = 프로젝트당 0~4개(대부분 11 중 4) · 엄상준_검증 80.17(초기 80.374 · 카드 80 옆) · 263.31(초기 263.206 · 213 · 219 방향키 흠 자취). +- 구조물(structures.json 있는 4개) 1/4 소수(표지 127.86). + +근거(지식DB) +- 별표2(`resources/knowledge/original/법률/산림자원의 조성 및 관리에 관한 법률 시행규칙/별표/별표2_…md` L25) — 20 m 중심말뚝 · 구조물 지점 보조말뚝 · 정밀도 규정 없음. +- 교재 `산림과임업기술(임도)/2. 임도/5. 설계.md` L157 — 「No.11+13」 정수 예 · 소수는 곡선 BC · EC(L184-189)뿐. +- 실무 자료 — 오솔길 PIN 보조측점 449개 · 구조물BOM 42개 · 울진 · 영덕 수량산출 = 구조물 · 횡단 측점 전부 정수 m · 소수는 곡선점 · 운반거리 셈표뿐. +- 품셈 1-2-2(`원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md` L53-60) — 연장 · 관 길이 소수 2자리(측점 규정 아님). +- 관이 꼭 소수 자리여야 할 까닭 — 못 찾음. 계곡 관도 0.5 m 안 옮김은 실무 꼴과 같음. 곡선점은 소수가 맞으나 구조물 기준 측점 아님. + +정수 맞춤 — 넣을 자리(지금은 어디서도 정수로 안 맞춤 · 0.01 이거나 날값) +| 길 | 자리 | 지금 | 맞춤 | +| --- | --- | --- | --- | +| 좌측 폼 시작 · 기준 · 종료 측점 | `B05_Profile_UI_Structures_Fields.ts:142` · `:144`(잔여 칸 step 0.1 · 막지 않음) · `:156-171` 정규화 · `:182-199` 읽기(날값) | 날값 | step 1 · 확정 때 반올림 | +| 구간형 시작 · 끝 | `B05_Profile_UI_Structures_Panel_Commit.ts:307-308`(기준 − 앞 길이 · + 길이 0.1 m) | 소수 | 기준만 맞추면 시작 · 끝 소수 남음 · 측점은 세 곳에 심김(`B05_Profile_Engine_Sections.py:285-311`) | +| 종단 알약 · 측점선 끌기 · 우클릭 추가 | `B05_Profile_UI_Structures_Marks.ts:339` · `B06_Section_UI_Longitudinal.ts:186` · `B05_Profile_UI_Profile_Structures.ts:81` · `B05_Profile_UI_Viewer_Menu.ts:79` | 0.01 | 1 m | +| 방향키 | `B05_Profile_UI_Profile_Panel_Tools.ts:36` · `:237` | 0.1 m | 1 m(219 소속 규칙 그대로) | +| 배수유역도 관 놓기 · 끌기 | `B05_Profile_UI_Drainage_Pipes.ts:236` · `:264-294` | 날값 | 1 m | +| B06 관 옮김 · 폼 | `B06_Section_UI_Page_Structures_Panel.ts:483` · `:265-270`(**0.51 m 넘을 때만 옮김 → 80.17→80 고침은 무시됨**) | 0.01 | 1 m · 폭 거름 손봄 | +| 서버 자동 배치 · 재계산 · 노선 바뀜 재투영 | `common_util/common_util_drainage_detail.py:437-457`(1 m 걸음이나 시작이 소수) · `:476-490`(관 재계산 한 곳 `round(,2)`) · `common_util/common_util_drainage_pipes.py:207-230`(좌표 정본 → 다시 소수) | 소수 | 재계산 한 곳 · 재투영 뒤 반올림 | +| 기존 자료 | pipe_points.json · structures.json | 소수 | [정수 맞춤] 단추 | + +- 받는 곳에서 한 번 — 화면은 `moveById`(SP5:670) · 폼 commit · 관 목록 문(DrPi `moveTo` · `addAtChainage` · `addAt` · 끌기) · B06 `movePipeTo` · `KEY_STEP_M`, 서버는 관 재계산 한 곳 + 재투영 뒤. 서버에서만 자르면 시설 옵션 이어받기 폭 0.05(`common_util_drainage_pipes.py:371`)를 넘어 관 종류 · 옵션이 기본값으로 돌아감 → 화면 입력에서 맞춤이 먼저. +- 다시 쓸 함수 — `design_profile_free.ts:79` `snapStation`(215-1 79 m → 80 m) · `B05_Profile_UI_Profile_Guard.ts:79` `snapToMasterStations` · 서버 격자 합침 `B05_Profile_Engine_Sections_Core.py:347-354`. + +영향 — 관 길이 · 형상은 가까운 측점에 붙어 그대로(`B06_Section_Engine_Culvert.py:639-666`) · 정수 관이 일반 측점 자리면 서버가 이미 한 카드로 합침(Sections_Core:349-353 · 08-28 꼴) · 정수 파일 이름은 옛 규칙과 같아짐(`B05_Profile_Engine_Sections.py:130-134`) · 225 관보호공 0.1 m 이동은 횡단 안 자리라 무관. +기존 자료 정리 함정 — 한꺼번에 옮기면 213 편집 키 「사라진 것 하나 · 새것 하나」 규칙(`B05_Profile_UI_Profile_Alignment.ts:189-199`)이 안 맞아 옛 계획고 편집이 [저장] 때 버려짐 · B08 · M02 `pipe@측점` 키는 [저장]을 거쳐야 이름이 따라감(`M02_Item_Snapshot.py:227`) · 80.17 자리 횡단 설계 · B07 도면 id 는 고아 · 관 id `pipe-`+toFixed(2) 세션 고름 한 번 풀림 → 단추는 하나씩 옮기는 길(213 따라감) + [저장] 거침. +깨질 시험 — test_b05_station_roundtrip(119.8 · 120 따로) · test_b06_patch_station · test_b05_b06_save_roundtrip · test_b05_extra_stations_supplier · test_213_1(263.206). + +방안 +- A 정수 강제 — 입력 · 끌기 · 방향키 · 자동 배치 모두 1 m. 실무 표기와 같음 · 9-29 확정을 뒤집음. 단 81 처럼 격자 옆 1 m 카드는 남음. +- B 일반 측점 붙기(스냅) — 일반 측점 ±폭 안이면 그 측점으로(snapStation 재사용) · 밖이면 정수 m. 「격자 옆 소수 측점 없애기」에 가장 바로. +- C [정수 맞춤] 단추 — 기존 소수 관 · 구조물을 하나씩 옮겨 편집 키가 따라가게 · [저장]으로 마침. +- 공통 — 고름 · 강조는 측점 id(카드 `station_id` · 관 id)로 정확히 잇기 · 같은 측점 폭 상수 하나 · 반올림은 표시에만. + +추천 — A + B + C(입력 1 m 단계 · 일반 측점 붙기 · 기존 자료 단추) · 고름 폭은 한 값으로. 정수 · 붙기면 관 80 = 카드 80 한 자리라 흠 ①②④가 함께 사라짐. 크기 중(화면 받는 곳 6~7곳 각 1~3줄 + 서버 두 곳 + 단추) · 위험 중(213 편집 키 · B08 키 · 깨질 시험 다섯). +⬜ 사용자 확정 — 9-29 「저장값 소수 · 표기만 정수」를 「저장값도 정수 m」로 바꿀지 · 붙기 폭(예 1 m) · 구간형 시작 · 끝도 정수인지. From 2a272e40c480b066e446301a6995928ee9d6495f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:42:19 +0900 Subject: [PATCH 18/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 5e00edcfa..26ce29d52 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -131,3 +131,4 @@ 105. 추가로 하이라이트에서 소수점 단위 구조물 측점들의 경우 1단위로 사사오입하여 측점을 지정하게 반영했던걸로 기억하는데 언제 했고 어떤식으로 했는지 알아보고 어떻게 개선할지 조사만 해봐. (조사) 106. (105번 보충) 일반측점이 있고 소수점 단위로 근처의 측점이 있다면 입력되는 기준의 측점을 값을 정수단위로 맞춰야하는게 아니냐는 거야. 선택이 안되는게 문제가 아니라 사용자가 소수점을 못넣게 하고 넣더라도 정수로 변경하여 맞출수 있는 기능이 필요하지 않을까해서 조사를 시킨거야. 조사만 해. 107. (질문) 노면공이 아닌데 범위값을 지정하는 구조물이 뭐가 있는거야? — 답: B 측구 · 산마루측구 · 소단측구 · 도수로·산비탈수로 · 절토사면 배수로 · 맹암거 / C 옹벽 · 돌쌓기(찰 · 메) · 흙막이 · 큰돌쌓기 · 소단 / E 대피소 · 정차·작업장 · 가드레일 / F 비탈면 녹화 +108. 셋다 너의 제안대로 해주고(104 구멍 둘 먼저 · 고름 한 칸 원천으로 단계별 묶음 · 구조물 기준 측점 정수 맞춤) 노면공은 아닌데 범위를 갖는 구조물은 예전의 자료인것같은데 지금 구현하는 구성을 다시 확인해봐. From 2b9e2476a91ac15cfd0d190f86bf1a36ed10117b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:44:26 +0900 Subject: [PATCH 19/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20109~110?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 26ce29d52..d2179d721 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -132,3 +132,5 @@ 106. (105번 보충) 일반측점이 있고 소수점 단위로 근처의 측점이 있다면 입력되는 기준의 측점을 값을 정수단위로 맞춰야하는게 아니냐는 거야. 선택이 안되는게 문제가 아니라 사용자가 소수점을 못넣게 하고 넣더라도 정수로 변경하여 맞출수 있는 기능이 필요하지 않을까해서 조사를 시킨거야. 조사만 해. 107. (질문) 노면공이 아닌데 범위값을 지정하는 구조물이 뭐가 있는거야? — 답: B 측구 · 산마루측구 · 소단측구 · 도수로·산비탈수로 · 절토사면 배수로 · 맹암거 / C 옹벽 · 돌쌓기(찰 · 메) · 흙막이 · 큰돌쌓기 · 소단 / E 대피소 · 정차·작업장 · 가드레일 / F 비탈면 녹화 108. 셋다 너의 제안대로 해주고(104 구멍 둘 먼저 · 고름 한 칸 원천으로 단계별 묶음 · 구조물 기준 측점 정수 맞춤) 노면공은 아닌데 범위를 갖는 구조물은 예전의 자료인것같은데 지금 구현하는 구성을 다시 확인해봐. +109. 추가로 소단에 대한 측점 표기가 3D에서 측점 표현이 안되는것같은데 현재 구조물 리스트에서 내용과 현재 구조물군과 종류의 내용 중 누락된게 뭐가 있는지 조사해봐. (조사) +110. (질문) 지금 조사를 하고 있는 너의 별도 에이전트가 있어? From b521328bf992a9ae80dda22fee25b07e44b52d52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:48:57 +0900 Subject: [PATCH 20/92] =?UTF-8?q?fix(b05):=203D=20=EB=9D=A0=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=EB=92=A4=20=EA=B0=95=EC=A1=B0=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=20=C2=B7=20=EC=88=A8=EC=9D=80=20=ED=95=80=20=C2=B7=20=EA=BA=BC?= =?UTF-8?q?=20=EB=91=94=20=EC=B8=A1=EC=A0=90=EC=84=A0=20=EB=88=84=EB=A6=84?= =?UTF-8?q?=20=EC=95=88=20=EA=B0=80=EB=A1=9C=EC=B1=94=20(232-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마커 · 띠에 맞은 누름은 3D 부재 고름이 빈 곳 누름으로 읽지 않음(pressedMarker) - 3D 빈 곳 누름 = 고름 안 풂 · 같은 띠 · 같은 부재 다시 누름 = 풂 - 핀 · 라벨 = 표시만(레이캐스트 뺌) · 숨은 측점선은 누름 대상에서 뺌 - 3D 누름 길 통째 시험(three + 가짜 캔버스) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B05_Profile/B05_Profile_UI_Markers.ts | 8 +- .../B05_Profile_UI_Structure_Pick_Session.ts | 1 + B05_Profile/B05_Profile_UI_Viewer.ts | 1 + .../B05_Profile_UI_Viewer_Marker_Input.ts | 14 +- .../B05_Profile_UI_Viewer_Structure_Pick.ts | 19 +- resources/tester/helper_232_1_3d_press.cjs | 213 ++++++++++++++++++ resources/tester/test_232_1_3d_press.py | 91 ++++++++ 7 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 resources/tester/helper_232_1_3d_press.cjs create mode 100644 resources/tester/test_232_1_3d_press.py diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index f6f4e1918..e0709d413 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -397,6 +397,8 @@ export function createRouteMarkers( sprite.position.copy(position); sprite.position.y += 3.4; sprite.renderOrder = 10; + // 라벨은 읽기용 — 띠 가운데 위에 떠 누름을 가로채지 않음(232-1) + sprite.raycast = () => undefined; return sprite; } @@ -477,6 +479,8 @@ export function createRouteMarkers( head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다. head.position.y = PIN_HEIGHT + 1.7; group.add(beam, head); + // 핀은 표시만 — 누름을 가로채지 않음(띠 가운데 누름이 핀에 먼저 맞았음 · 232-1) + beam.raycast = head.raycast = () => undefined; group.renderOrder = 9; group.visible = false; interactionGroup.add(group); @@ -520,7 +524,9 @@ export function createRouteMarkers( deleteSelected, selectObject(object: THREE.Object3D | undefined) { if (typeof object?.userData.stationId === "string") { - selectStation(object.userData.stationId); + // 같은 띠 다시 누름 = 풂(232-1) + const id = object.userData.stationId as string; + selectStation(id === selectedStationId ? null : id); selectionListener?.(null); return; } diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index 84ce34729..631e51bff 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -58,6 +58,7 @@ export function wireStructurePick( coverable?: (entry: StructureInstance) => boolean, ): void { controls.onPick = (pick: StructurePick | null): void => { + // null = 같은 부재 다시 누름(풂) — 빈 곳 누름은 여기까지 안 옴(232-1) if (!pick) { selectDesign(projectId, NO_SELECTION, { from: "b05" }); selectStation(null); diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index fde2c5705..bd13562a0 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -259,6 +259,7 @@ export function createRouteViewer(): RouteViewer { camera: cameraRig.camera, group: () => corridor.group(), blocked: () => markerInput.blocked(), + pressedMarker: () => markerInput.pressedMarker(), }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index afeb0ab9b..7ab032e7e 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -24,6 +24,8 @@ export interface MarkerPointerControls { beginMoveSelected: () => void; /** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */ blocked: () => boolean; + /** 마지막 누름이 마커 · 측점 띠에 맞았나 — 3D 구조물 고름이 그 누름을 「빈 곳」으로 읽지 않게(232-1). */ + pressedMarker: () => boolean; /** 등록한 캔버스 이벤트를 모두 떼어낸다. */ dispose: () => void; } @@ -43,6 +45,7 @@ export function bindMarkerPointerControls(options: { let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; let draggingMarker = false; let lastDragPoint: { x: number; y: number; z: number } | null = null; + let downOnMarker = false; /** 화면 좌표 → 정규화 장치 좌표(-1~1). */ function pointerOf(clientX: number, clientY: number): THREE.Vector2 { @@ -86,7 +89,14 @@ export function bindMarkerPointerControls(options: { function markerHit(event: PointerEvent): THREE.Object3D | undefined { const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera()); - return raycaster.intersectObject(markers.group, true)[0]?.object; + // 숨은 것(꺼 둔 측점선 · 라벨)은 누름을 가로채지 않음 — three 레이캐스트는 visible 을 안 봄(232-1) + return raycaster.intersectObject(markers.group, true).find((entry) => shown(entry.object)) + ?.object; + } + + function shown(object: THREE.Object3D | null): boolean { + for (let node = object; node; node = node.parent) if (!node.visible) return false; + return true; } function finishMarkerInteraction(selectCandidate: boolean): void { @@ -105,6 +115,7 @@ export function bindMarkerPointerControls(options: { function handlePointerDown(event: PointerEvent): void { if (event.button !== 0) return; const hit = markerHit(event); + downOnMarker = !!hit; const pointId = markers.pointIdForObject(hit); if (pointId) { dragCandidate = { @@ -184,6 +195,7 @@ export function bindMarkerPointerControls(options: { movingSelected = true; }, blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + pressedMarker: () => downOnMarker, dispose: () => { canvas.removeEventListener("dragover", handleDragOver); canvas.removeEventListener("drop", handleDrop); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index 1fab0d9d3..b48cd5216 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -20,7 +20,7 @@ export interface StructurePick { } export interface StructurePickControls { - /** 3D에서 골랐을 때 알린다. null = 빈 곳(지형·리본) 클릭으로 해제. */ + /** 3D에서 골랐을 때 알린다. null = 같은 부재 다시 누름으로 해제(빈 곳 누름은 안 알림 · 232-1). */ onPick?: (pick: StructurePick | null) => void; /** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */ selectAtChainage: (chainageM: number | null) => void; @@ -47,8 +47,10 @@ export function bindStructurePick(options: { group: () => THREE.Object3D | null; /** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */ blocked: () => boolean; + /** 이 누름이 마커 · 측점 띠에 맞았나 — 참이면 그 누름은 마커 몫(빈 곳 누름 아님 · 232-1). */ + pressedMarker?: () => boolean; }): StructurePickControls { - const { canvas, camera, group, blocked } = options; + const { canvas, camera, group, blocked, pressedMarker } = options; let selected: StructurePick | null = null; let down: { x: number; y: number; pointerId: number } | null = null; @@ -94,7 +96,7 @@ export function bindStructurePick(options: { }); } - /** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=해제)이다. */ + /** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=빈 곳)이다. */ function pickAt(clientX: number, clientY: number): StructurePick | null { const root = group(); if (!root || !root.visible) return null; @@ -120,7 +122,7 @@ export function bindStructurePick(options: { function handleDown(event: PointerEvent): void { down = - event.button === 0 && !blockedBeforeDown && !blocked() + event.button === 0 && !blockedBeforeDown && !blocked() && !pressedMarker?.() ? { x: event.clientX, y: event.clientY, pointerId: event.pointerId } : null; } @@ -131,7 +133,14 @@ export function bindStructurePick(options: { if (!start || event.pointerId !== start.pointerId) return; if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return; if (blocked()) return; - selected = pickAt(event.clientX, event.clientY); + const pick = pickAt(event.clientX, event.clientY); + // 빈 곳(지형 · 리본) 누름은 고름을 안 풂(08-05 사용자 결정 · 232-1) — 같은 부재 다시 누름만 풂 + if (!pick) return; + const same = + selected !== null && + Math.abs(selected.chainageM - pick.chainageM) < 0.01 && + selected.key === pick.key; + selected = same ? null : pick; applyHighlight(); controls.onPick?.(selected); } diff --git a/resources/tester/helper_232_1_3d_press.cjs b/resources/tester/helper_232_1_3d_press.cjs new file mode 100644 index 000000000..26ebb5991 --- /dev/null +++ b/resources/tester/helper_232_1_3d_press.cjs @@ -0,0 +1,213 @@ +/* 232-1 3D 누름 길 통째 — 진짜 마커(MK3) · 마커 입력(MI) · 3D 부재 고름(SPK) · 선택 동기화(SY) · + * 넘김(PKS) · 고름 한 칸(DSel) 을 three + 가짜 캔버스로 한 누름(창 캡처 → 캔버스 듣개 차례)씩 돌림. + * 배선은 `B05_Profile_UI_Viewer.ts` · `design_view_profile.ts` 와 같은 꼴. 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const NM = path.join(ROOT, "config", "node_modules"); +const ts = require(path.join(NM, "typescript")); +const THREE = require(path.join(NM, "three", "build", "three.cjs")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (request === "three") return THREE; + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* ── 창 · 캔버스 흉내(듣개 차례 = 창 캡처 → 캔버스 등록 차례) ── */ +const windowListeners = []; +globalThis.window = { + addEventListener: (type, fn) => windowListeners.push({ type, fn }), + removeEventListener: (type, fn) => { + const i = windowListeners.findIndex((entry) => entry.type === type && entry.fn === fn); + if (i >= 0) windowListeners.splice(i, 1); + }, +}; +globalThis.document = { createElement: () => ({ getContext: () => null, width: 0, height: 0 }) }; +const canvasListeners = []; +const canvas = { + addEventListener: (type, fn) => canvasListeners.push({ type, fn }), + removeEventListener() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 400 }), + setPointerCapture() {}, + hasPointerCapture: () => false, + releasePointerCapture() {}, +}; +function fire(type, clientX, clientY) { + const event = { + type, + button: 0, + pointerId: 1, + clientX, + clientY, + preventDefault() {}, + stopPropagation() {}, + }; + for (const entry of windowListeners.filter((e) => e.type === type)) entry.fn(event); + for (const entry of canvasListeners.filter((e) => e.type === type)) entry.fn(event); +} + +/* ── 진짜 모듈 ── */ +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { + "@util/common_util_svg": { + inferStationInterval: () => 20, + stationLabel: (chainage) => `0+${chainage}`, + }, +}); +const MI = loadTs("B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts", { + "./B05_Profile_UI_Markers": MK3, +}); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", + }, +}); +const PKS = loadTs("B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts", { + "../A00_Common/design/design_selection": DSel, +}); + +/* ── 장면: 지형 범위 · 위에서 내려다보는 직교 카메라 ── */ +const PID = "p232"; +const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] }; +const scene = new THREE.Scene(); +const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 500); +camera.position.set(0, 200, 0); +camera.up.set(0, 0, -1); +camera.lookAt(0, 0, 0); +camera.updateProjectionMatrix(); +camera.updateMatrixWorld(true); +const clientOf = (x, y, z = 5) => { + const p = MK3.modelToScene({ x, y, z }, bounds).project(camera); + return [((p.x + 1) / 2) * 400, ((1 - p.y) / 2) * 400]; +}; + +const markers = MK3.createRouteMarkers(scene, () => bounds); +const STATIONS = [100, 120].map((at) => ({ + station_id: `s${at}`, + chainage_m: at, + center_x: at, + center_y: 100, + center_z: 5, + frame: { left_xy: [0, 1] }, + kind: "regular", +})); +markers.renderStationLines(STATIONS, 3); + +// 코리도 — 리본(신원표 없음 · 「빈 곳」) + 부재 하나(측점 160 · inlet) +const corridor = new THREE.Group(); +const ribbon = new THREE.Mesh( + new THREE.BoxGeometry(400, 0.1, 400), + new THREE.MeshLambertMaterial(), +); +ribbon.position.y = -0.5; +const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +member.position.copy(MK3.modelToScene({ x: 160, y: 160, z: 6 }, bounds)); +member.userData = { chainageM: 160, kind: "pipe", key: "inlet" }; +corridor.add(ribbon, member); +scene.add(corridor); +scene.updateMatrixWorld(true); + +const markerInput = MI.bindMarkerPointerControls({ + canvas, + camera: () => camera, + controls: { enabled: true }, + markers, + getTerrain: () => null, + getBounds: () => bounds, +}); +const structurePick = SPK.bindStructurePick({ + canvas, + camera: () => camera, + group: () => corridor, + blocked: () => markerInput.blocked(), + pressedMarker: () => markerInput.pressedMarker(), +}); + +/* ── 종단 보기 배선 흉내(design_view_profile) ── */ +let graph = null; +let form = null; +let syncing = false; +const sync = SY.createSelectionSync({ + stations: () => [], + isSyncing: () => syncing, + setSyncing: (v) => (syncing = v), + selectMarker: (id) => markers.selectStation(id), + selectGraph: (id) => (graph = id), + selectSidebar: (at) => (form = at), + openAtStation: (at) => (form = at), + selectBasin() {}, + markStation() {}, + selectStructure3D: (at) => structurePick.selectAtChainage(at), + regularChainage: (id) => STATIONS.find((s) => s.station_id === id)?.chainage_m, + remember: (at) => + DSel.selectDesign(PID, at === null ? DSel.NO_SELECTION : { chainageM: at, structureId: null }), +}); +markers.onStationSelectionChange((id) => { + graph = id; + sync.syncIrregularSelection(id); +}); +PKS.wireStructurePick( + structurePick, + PID, + { + resolveAt: () => ({ structureId: null }), + hasSelection: () => form !== null, + selectAt: (sel) => (form = sel.chainageM), + }, + sync.selectStationOfPipe, +); + +/* ── 읽기 ── */ +const bar = (id) => markers.group.children[1].children.find((o) => o.userData.stationId === id); +const pin = () => markers.group.children.find((g) => g.renderOrder === 9); +const state = () => ({ + s100: "#" + bar("s100").material.color.getHexString(), + s120: "#" + bar("s120").material.color.getHexString(), + pin: pin().visible, + session: DSel.readSelection(PID), + graph, + member: member.material.emissive.getHex() !== 0, +}); +const press = (xy) => { + fire("pointerdown", ...xy); + const down = state(); + fire("pointerup", ...xy); + return { down, up: state() }; +}; + +const out = {}; +const edge120 = clientOf(120, 102); // 띠 끝쪽(가운데 핀 · 라벨 자리 밖) +const center120 = clientOf(120, 100); +const empty = clientOf(40, 40); // 리본만 +out.bar = press(edge120); +out.empty = press(empty); +out.again = press(edge120); // 같은 띠 다시 = 풂 +out.center = press(center120); // 숨은 핀 · 라벨이 있던 가운데 +out.memberPick = press(clientOf(160, 160)); +out.memberEmpty = press(empty); +out.memberAgain = press(clientOf(160, 160)); // 같은 부재 다시 = 풂 +press(edge120); +markers.setStationLinesVisible(false); +out.hiddenLines = press(clientOf(100, 102)); // 꺼 둔 측점선은 누름을 안 가로챔 +out.hiddenPressedMarker = markerInput.pressedMarker(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_232_1_3d_press.py b/resources/tester/test_232_1_3d_press.py new file mode 100644 index 000000000..ea08494ef --- /dev/null +++ b/resources/tester/test_232_1_3d_press.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""232-1 3D 띠 강조 바로 꺼짐(요구 104 · 조사 AF a1 · a2) — 3D 누름 길 통째. + +까닭: 띠 누름을 마커 듣개가 켜고(누를 때) 3D 부재 고름이 뗄 때 「빈 곳 누름」으로 읽어 고름 전체를 지움. +고침: ① 마커 · 띠에 맞은 누름은 부재 고름이 건너뜀 ② 숨은 핀 · 꺼 둔 측점선 · 라벨은 누름을 안 가로챔 +③ 3D 빈 곳 누름 = 고름 안 풂(08-05) · 같은 띠 · 같은 부재 다시 누름 = 풂. +진짜 TS 를 `helper_232_1_3d_press.cjs` 가 three + 가짜 캔버스로 한 누름씩(창 캡처 → 캔버스 듣개 차례) 돌림. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + +RED = "#ef4444" +YELLOW = "#facc15" +AT_120 = {"chainageM": 120, "structureId": None} + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_232_1_3d_press.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_띠_누름은_뗀_뒤에도_빨강_핀_기록_유지(out) -> None: + for phase in ("down", "up"): + state = out["bar"][phase] + assert state["s120"] == RED + assert state["pin"] is True + assert state["session"] == AT_120 + assert state["graph"] == "s120" + + +def test_빈_곳_누름은_고름_안_풂(out) -> None: + assert out["empty"]["up"]["s120"] == RED + assert out["empty"]["up"]["session"] == AT_120 + assert out["memberEmpty"]["up"]["member"] is True + assert out["memberEmpty"]["up"]["session"]["chainageM"] == 160 + + +def test_같은_띠_다시_누름은_풂(out) -> None: + up = out["again"]["up"] + assert (up["s120"], up["pin"], up["session"], up["graph"]) == (YELLOW, False, None, None) + + +def test_숨은_핀_라벨이_있던_띠_가운데도_켜짐(out) -> None: + assert out["center"]["up"]["s120"] == RED + assert out["center"]["up"]["session"] == AT_120 + + +def test_부재_고름과_같은_부재_다시_누름(out) -> None: + up = out["memberPick"]["up"] + assert up["member"] is True + assert up["session"] == {"chainageM": 160, "structureId": None, "partKey": "inlet"} + again = out["memberAgain"]["up"] + assert again["member"] is False + assert again["session"] is None + + +def test_꺼_둔_측점선은_누름을_안_가로챔(out) -> None: + up = out["hiddenLines"]["up"] + assert up["s100"] == YELLOW + assert up["session"] == AT_120 # 앞 고름 그대로 · 빈 곳 누름이라 안 풂 + assert out["hiddenPressedMarker"] is False + + +def test_배선() -> None: + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Viewer.ts"), encoding="utf-8" + ) as handle: + viewer = handle.read() + assert "pressedMarker: () => markerInput.pressedMarker()" in viewer From aa2c2a870f14026df23d93ec475fe764f0825df3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:55:50 +0900 Subject: [PATCH 21/92] =?UTF-8?q?feat(design):=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=ED=95=9C=20=EC=B9=B8=20=EA=B5=AC=EB=8F=85=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=EA=B0=95=EC=A1=B0=EB=A5=BC=20=EA=B7=B8=20=EA=B0=92?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=8C=8C=EC=83=9D=20(233-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_pick_profile — 고름 한 칸 구독 → 종단 측점선 · 알약 · 띠를 값으로 다시 세움 - 관 · 구조물 목록이 바뀐 뒤 · 폼이 닫힌 뒤(저장 뒤 관 폼 등)에도 같은 값으로 다시 얹음 - 관 알약 id 는 그릴 때마다 지금 관 목록에서 찾음(관이 옮겨져도 이어짐) - 숨은 보기는 안 그림 · 보기를 떠나면 구독 끊음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_pick_profile.ts | 88 +++++++++++++ A00_Common/design/design_view_profile.ts | 12 +- .../tester/helper_233_1_profile_pick.cjs | 117 ++++++++++++++++++ resources/tester/test_233_1_profile_pick.py | 93 ++++++++++++++ 4 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 A00_Common/design/design_pick_profile.ts create mode 100644 resources/tester/helper_233_1_profile_pick.cjs create mode 100644 resources/tester/test_233_1_profile_pick.py diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts new file mode 100644 index 000000000..48c2bb1bb --- /dev/null +++ b/A00_Common/design/design_pick_profile.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * A00_Common/design/design_pick_profile.ts + * 종단도 고름 강조(측점 세로선 · 알약 · 구간 띠) = 고름 한 칸에서 파생 (233-1 · 조사 AF (나) ②). + * + * 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고, + * 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이 + * 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는 + * 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다. + * ========================================================================== */ + +import { + nearestWithin, + readSelection, + subscribeSelection, + type DesignSelection, +} from "./design_selection"; +import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; +import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; + +/** 종단도가 켤 것 — 측점선(알약은 그 측점 짝을 종단도가 맞춤) 또는 알약(구조물 · 관 `pipe-*`). */ +export type ProfilePick = { stationId: string | null } | { structureId: string }; + +export interface ProfilePickSource { + /** 구조물 측점(수동 + 관 투영) — id 는 `irregularStationId` 를 씌우기 전 값. */ + irregular: () => ReadonlyArray<{ id: string; chainage_m: number; origin?: string }>; + /** 관 지점 정본(배수유역도). */ + pipes: () => ReadonlyArray<{ chainage_m: number }>; + /** 일반 측점 카드. */ + sections: () => ReadonlyArray<{ station_id: string; chainage_m: number }>; +} + +export interface ProfilePickTarget { + setSelectedStation: (stationId: string | null) => void; + setSelectedStructure: (structureId: string | null) => void; +} + +/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */ +const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6; + +/** + * 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1) + * ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1). + */ +export function profilePickOf( + selection: DesignSelection | null, + source: ProfilePickSource, +): ProfilePick { + if (!selection || selection.chainageM === null) return { stationId: null }; + if (selection.structureId) return { structureId: selection.structureId }; + const at = selection.chainageM; + const card = source.sections().find((entry) => sameAt(entry.chainage_m, at)); + if (card) return { stationId: card.station_id }; + const irregular = source.irregular(); + const own = + irregular.find((entry) => sameAt(entry.chainage_m, at)) ?? + nearestWithin( + irregular.filter((entry) => entry.origin === "pipe"), + at, + (entry) => entry.chainage_m, + ); + if (own) return { stationId: irregularStationId(own.id) }; + const target = graphTargetAt(null, at, source.pipes(), source.sections()); + return "pipeChainageM" in target + ? { structureId: `pipe-${target.pipeChainageM.toFixed(2)}` } + : target; +} + +export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void { + if ("structureId" in pick) target.setSelectedStructure(pick.structureId); + else target.setSelectedStation(pick.stationId); +} + +/** + * 종단도를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 같은 값으로 다시 얹음. + * 보기가 숨어 있으면(다른 보기가 종단도를 쥠) 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. + */ +export function bindProfilePick( + projectId: string | null, + target: ProfilePickTarget, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, + source: ProfilePickSource, +): { paint: () => void } { + const paint = (): void => { + if (host.visible()) paintProfilePick(target, profilePickOf(readSelection(projectId), source)); + }; + host.onDispose(subscribeSelection(projectId, paint)); + return { paint }; +} diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 013ccdf01..05821f65d 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -39,6 +39,7 @@ import { selectDesign, } from "./design_selection"; import type { DesignPageHost } from "./design_page"; +import { bindProfilePick } from "./design_pick_profile"; import { bindDesignView, designReady, @@ -247,6 +248,7 @@ export async function mountProfileView( /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ function restorePick(): void { + profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1) if (panel.structures.hasSelection()) return; const pick = viewer.structurePick; restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); @@ -346,7 +348,7 @@ export async function mountProfileView( if (selectionSyncing) return; selectionSyncing = true; try { - profilePanel.setSelectedStructure(structure?.structure_id ?? null); + profilePick.paint(); // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단은 고름 한 칸대로(233-1) viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null); } finally { selectionSyncing = false; @@ -409,6 +411,12 @@ export async function mountProfileView( }, isRestoring: () => restoring, }); + // 종단도 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1) + const profilePick = bindProfilePick(activeProjectId, profilePanel, host, { + irregular: () => bridge.irregularStations(), + pipes: () => lastPipes ?? [], + sections: () => currentSectionDetail?.cross_sections ?? [], + }); // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); @@ -566,7 +574,7 @@ export async function mountProfileView( return; } viewer.markers.selectStation(null); - profilePanel.setSelectedStation(null); + profilePick.paint(); }, // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. onHide: () => { diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs new file mode 100644 index 000000000..a328856e2 --- /dev/null +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -0,0 +1,117 @@ +/* 233-1 종단도 강조 = 고름 한 칸에서 파생 — 진짜 `design_pick_profile` · `design_selection` · + * `graphTargetAt`(B05_Profile_UI_Selection) 를 Node 로 돌림. 종단도는 고른 측점 · 알약 두 칸만 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_233_1_profile_pick.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const IRR = { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", +}; +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": IRR, +}); +const PP = loadTs("A00_Common/design/design_pick_profile.ts", { + "./design_selection": DSel, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); + +const PID = "p233"; +const sections = [80, 100, 120, 140, 300].map((at) => ({ station_id: `s${at}`, chainage_m: at })); +let pipes = [{ chainage_m: 80.17 }, { chainage_m: 300.4 }]; +const irregular = [ + { id: "p80", chainage_m: 80.17, origin: "pipe" }, + { id: "sign", chainage_m: 127.86, origin: "user" }, +]; + +/* 종단도 흉내 — 고른 측점 · 알약 */ +const profile = { station: null, structure: null, paints: 0 }; +const target = { + setSelectedStation: (id) => { + profile.station = id; + profile.structure = null; + profile.paints += 1; + }, + setSelectedStructure: (id) => { + profile.structure = id; + profile.station = null; + profile.paints += 1; + }, +}; +let visible = true; +const disposers = []; +const pick = PP.bindProfilePick( + PID, + target, + { visible: () => visible, onDispose: (fn) => disposers.push(fn) }, + { irregular: () => irregular, pipes: () => pipes, sections: () => sections }, +); + +const snap = () => ({ station: profile.station, structure: profile.structure }); +const sel = (chainageM, structureId = null) => + DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }); + +const out = {}; +sel(120); +out.card = snap(); +sel(127.86); +out.irregularSign = snap(); +sel(80); +out.card80 = snap(); // 관 80.17 옆 카드 80 = 카드 선(관 아님) +sel(80.17); +out.pipe80 = snap(); +sel(300.2); +out.pipePill = snap(); +pipes = [{ chainage_m: 80.17 }, { chainage_m: 300.45 }]; // 관이 옮겨짐 +pick.paint(); +out.pipeMoved = snap(); +sel(100, "wall"); +out.structure = snap(); +// 다른 길이 강조를 끔(저장 뒤 관 폼 닫힘 · 목록 다시 세움) → 값은 그대로 → 다시 얹음 +target.setSelectedStructure(null); +out.clearedByOther = snap(); +pick.paint(); +out.repainted = snap(); +// 숨은 보기는 안 그림 · 보이면 다시 얹음 +visible = false; +sel(140); +out.hidden = snap(); +visible = true; +pick.paint(); +out.shownAgain = snap(); +// 풂(값 비움) = 선 · 알약 모두 끔 +sel(null); +out.released = snap(); +// 보기를 떠나면 구독 끊음 +disposers.forEach((fn) => fn()); +const before = profile.paints; +sel(120); +out.paintsAfterDispose = profile.paints - before; +out.disposers = disposers.length; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_233_1_profile_pick.py b/resources/tester/test_233_1_profile_pick.py new file mode 100644 index 000000000..3801a57e6 --- /dev/null +++ b/resources/tester/test_233_1_profile_pick.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""233-1 고름 한 칸 = 유일 원천 + 바뀜 알림 · 종단도(측점선 · 알약 · 띠)는 그 값으로 그림(조사 AF (나) ① ②). + +까닭: 알림 장치(`subscribeSelection`)는 있었으나 듣는 곳 0 — 화면끼리 손으로 부르는 사슬이라 한 길이 +강조를 끄면(저장 뒤 관 폼 닫힘 → 종단 선 · 알약 끔) 값은 그대로인데 종단만 꺼짐 · 관 알약 id(`pipe-`+측점)가 +옛 관 자리에 묶여 관이 옮겨지면 끊김. +고침: `design_pick_profile` — 종단 보기가 고름 한 칸을 구독해 값 → 종단 강조(`profilePickOf`) · +목록이 바뀐 뒤 · 폼이 닫힌 뒤에도 같은 값으로 다시 얹음(`paint`) · 숨은 보기는 안 그림 · 떠나면 구독 끊음. +진짜 TS 를 `helper_233_1_profile_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_1_profile_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def line(station): + return {"station": station, "structure": None} + + +def pill(structure): + return {"station": None, "structure": structure} + + +def test_측점_값은_그_측점선(out) -> None: + assert out["card"] == line("s120") + assert out["irregularSign"] == line("irregular:sign") # 구조물 측점(관 아님)도 그대로 + assert out["card80"] == line("s80") # 관 80.17 옆 카드 80 은 카드 선 + assert out["pipe80"] == line("irregular:p80") + + +def test_측점선_없는_관은_지금_관_목록으로_알약(out) -> None: + assert out["pipePill"] == pill("pipe-300.40") + assert out["pipeMoved"] == pill("pipe-300.45") # 관이 옮겨져도 끊기지 않음 + + +def test_구조물_값은_그_알약(out) -> None: + assert out["structure"] == pill("wall") + + +def test_다른_길이_꺼도_값대로_다시_얹음(out) -> None: + assert out["clearedByOther"] == pill(None) + assert out["repainted"] == pill("wall") + + +def test_숨은_보기는_안_그리고_보이면_다시_얹음(out) -> None: + assert out["hidden"] == pill("wall") + assert out["shownAgain"] == line("s140") + + +def test_풂과_구독_끊음(out) -> None: + assert out["released"] == line(None) + assert out["disposers"] == 1 + assert out["paintsAfterDispose"] == 0 + + +def test_종단_보기_배선() -> None: + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "bindProfilePick(activeProjectId, profilePanel, host," in view + # 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음 + assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view + assert view.count("profilePick.paint()") >= 3 + assert len(view.splitlines()) <= 700 From 21c14287726fc79d3ad9ae0d2c03d02ccf2a5a4f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:56:14 +0900 Subject: [PATCH 22/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20111~112?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index d2179d721..692fec43e 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -134,3 +134,5 @@ 108. 셋다 너의 제안대로 해주고(104 구멍 둘 먼저 · 고름 한 칸 원천으로 단계별 묶음 · 구조물 기준 측점 정수 맞춤) 노면공은 아닌데 범위를 갖는 구조물은 예전의 자료인것같은데 지금 구현하는 구성을 다시 확인해봐. 109. 추가로 소단에 대한 측점 표기가 3D에서 측점 표현이 안되는것같은데 현재 구조물 리스트에서 내용과 현재 구조물군과 종류의 내용 중 누락된게 뭐가 있는지 조사해봐. (조사) 110. (질문) 지금 조사를 하고 있는 너의 별도 에이전트가 있어? +111. B09에서 설계 내역서의 내용중 공종, 규격, 수량 단위의 값이 텍스트입력을 바로 받게 되어 있어서 드래그가 힘들어. 더블클릭하여 입력을 할수 있게하면 드래그에 문제없을것같아. 그리고 해당 행의 하이라이트는 박스가 쳐지게 바꾸는게 좋을것같아. +112. B09의 테이블 행을 기준으로 항목을 레벨화하는게 어려운거야? 접히지 않는 트리라고 생각하면 쉽잖아. 지금 구조물조합의 마스터/회사/개인도 다 트리형태인거고 그럼 중간 폴더들은 당연히 값이 아니라 수식이들어와야하고 파일이되는 최하위 항목을 입력을 받게 하는데 이때 일위대가나 단가산출을 연결하게 하면되는거고 From 760972bb9aedbbb6f87f1498b1e35357be55f9e1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:59:17 +0900 Subject: [PATCH 23/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20113?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 692fec43e..a5210767e 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -136,3 +136,4 @@ 110. (질문) 지금 조사를 하고 있는 너의 별도 에이전트가 있어? 111. B09에서 설계 내역서의 내용중 공종, 규격, 수량 단위의 값이 텍스트입력을 바로 받게 되어 있어서 드래그가 힘들어. 더블클릭하여 입력을 할수 있게하면 드래그에 문제없을것같아. 그리고 해당 행의 하이라이트는 박스가 쳐지게 바꾸는게 좋을것같아. 112. B09의 테이블 행을 기준으로 항목을 레벨화하는게 어려운거야? 접히지 않는 트리라고 생각하면 쉽잖아. 지금 구조물조합의 마스터/회사/개인도 다 트리형태인거고 그럼 중간 폴더들은 당연히 값이 아니라 수식이들어와야하고 파일이되는 최하위 항목을 입력을 받게 하는데 이때 일위대가나 단가산출을 연결하게 하면되는거고 +113. 추가로 B08과 B09의 내용중에서 아래 설명때문에 페이지 스크롤이 동작해야하는 경우가 있어. 그냥 제작 설명을 제외하고 내용을 줄여주고 페이지에 스크롤이 동작하지 않게 해줘. From a2b3217d80b153c1dd62bd32ef91ae443ec4acb7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:03:14 +0900 Subject: [PATCH 24/92] =?UTF-8?q?feat(design):=203D=20=C2=B7=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=C2=B7=20[=EC=83=81=EC=84=B8]?= =?UTF-8?q?=20=EA=B0=95=EC=A1=B0=EB=A5=BC=20=EA=B3=A0=EB=A6=84=20=ED=95=9C?= =?UTF-8?q?=20=EC=B9=B8=EC=97=90=EC=84=9C=20=ED=8C=8C=EC=83=9D=20(233-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종단 보기 구독이 3D 측점 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이 칠하기만) - B06_Section_UI_Page_Pick_Sync — 횡단 보기 구독 · 다른 곳이 바꾼 값은 넘김 적용 길로 다시 세움 - 풀기 한 길 — 카드 · 폼 관 · 범위 테두리 · 포장층 · 형상 모두 끔(카드 다시 누름도 이 길) - [상세] 선 조각 — 다시 그려도 같은 자리 조각 고름 유지 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_pick_profile.ts | 46 ++- A00_Common/design/design_view_cross.ts | 32 +- A00_Common/design/design_view_profile.ts | 5 +- B05_Profile/B05_Profile_UI_Markers.ts | 5 +- .../B06_Section_UI_Cross_Detail_Select.ts | 10 +- B06_Section/B06_Section_UI_Page_Pick_Sync.ts | 80 +++++ .../tester/helper_233_1_profile_pick.cjs | 2 +- resources/tester/helper_233_2_view_pick.cjs | 316 ++++++++++++++++++ resources/tester/test_233_1_profile_pick.py | 2 +- resources/tester/test_233_2_view_pick.py | 103 ++++++ 10 files changed, 570 insertions(+), 31 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Page_Pick_Sync.ts create mode 100644 resources/tester/helper_233_2_view_pick.cjs create mode 100644 resources/tester/test_233_2_view_pick.py diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index 48c2bb1bb..d8c3a4d07 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -1,6 +1,7 @@ /* ============================================================================= * A00_Common/design/design_pick_profile.ts - * 종단도 고름 강조(측점 세로선 · 알약 · 구간 띠) = 고름 한 칸에서 파생 (233-1 · 조사 AF (나) ②). + * 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) · + * 3D(측점 띠 · 핀 · 부재 · 233-2) (조사 AF (나) ② ③). * * 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고, * 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이 @@ -70,18 +71,53 @@ export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): else target.setSelectedStation(pick.stationId); } +export interface ViewerPickTarget { + markers: { selectStation: (stationId: string | null, notify?: boolean) => void }; + structurePick: { + select: (pick: { chainageM: number; kind: string; key?: string } | null) => void; + }; +} + +/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) · + * 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */ +export function viewerPickOf( + selection: (DesignSelection & { partKey?: string }) | null, + source: ProfilePickSource, +): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } { + if (!selection || selection.chainageM === null) return { stationId: null, member: null }; + const at = selection.chainageM; + const pick = profilePickOf(selection, source); + const own = selection.structureId + ? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m) + : undefined; + const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null; + return { stationId, member: { chainageM: at, kind: "", key: selection.partKey } }; +} + +export function paintViewerPick( + target: ViewerPickTarget, + pick: ReturnType, +): void { + target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게 + target.structurePick.select(pick.member); +} + /** - * 종단도를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 같은 값으로 다시 얹음. - * 보기가 숨어 있으면(다른 보기가 종단도를 쥠) 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. + * 종단 보기(종단도 · 3D)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 · + * 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠) 안 그림 — 그 보기가 + * 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. */ export function bindProfilePick( projectId: string | null, - target: ProfilePickTarget, + target: { profile: ProfilePickTarget; viewer?: ViewerPickTarget }, host: { visible: () => boolean; onDispose: (fn: () => void) => void }, source: ProfilePickSource, ): { paint: () => void } { const paint = (): void => { - if (host.visible()) paintProfilePick(target, profilePickOf(readSelection(projectId), source)); + if (!host.visible()) return; + const selection = readSelection(projectId); + paintProfilePick(target.profile, profilePickOf(selection, source)); + if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source)); }; host.onDispose(subscribeSelection(projectId, paint)); return { paint }; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 985c16f40..77aba5392 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -39,6 +39,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi 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 { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { @@ -387,7 +388,7 @@ export async function mountCrossView( : null; if (!target) { structuresPanel.showAtChainage(null); - selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다. + releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2) return; } const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null; @@ -494,23 +495,20 @@ export async function mountCrossView( if (applied) appliedPick = signature; } - /** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */ + // 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2) + const crossPick = bindCrossPick(projectId, host, { + sections: () => sectionDetail?.cross_sections ?? [], + controls: stationControls, + releasePipe: () => structuresPanel.releasePipe(), + view: sectionView, + reapply: () => { + appliedPick = null; + restoreStructurePick(); + }, + }); + /** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */ function releaseCross(): void { - const { revetOffset, ford, box } = stationControls; - const lit = (sectionDetail?.cross_sections ?? []).filter( - (s) => - revetOffset.highlightFor(s) || - ford.selectedFor(s.chainage_m) || - box.selectedFor(s.chainage_m), - ); - for (const s of lit) { - if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null); - if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null); - if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null); - } - structuresPanel.releasePipe(); - sectionView.focusStation(null); - sectionView.refreshCards(lit.map((s) => s.chainage_m)); + crossPick.release(); appliedPick = null; selectDesign(projectId, NO_SELECTION, { from: "b06" }); } diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 05821f65d..1adab405a 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -411,8 +411,8 @@ export async function mountProfileView( }, isRestoring: () => restoring, }); - // 종단도 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1) - const profilePick = bindProfilePick(activeProjectId, profilePanel, host, { + // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) + const profilePick = bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host, { irregular: () => bridge.irregularStations(), pipes: () => lastPipes ?? [], sections: () => currentSectionDetail?.cross_sections ?? [], @@ -573,7 +573,6 @@ export async function mountProfileView( ); return; } - viewer.markers.selectStation(null); profilePick.paint(); }, // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index e0709d413..a32733979 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -494,7 +494,8 @@ export function createRouteMarkers( if (center) selectionPin.position.copy(center); } - function selectStation(stationId: string | null): void { + /** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(알림 없음 · 233-2). */ + function selectStation(stationId: string | null, notify = true): void { selectedStationId = stationId; stationGroup.children.forEach((object) => { // 측점 띠(stationId 보유 Mesh)만 색을 바꾼다. @@ -503,7 +504,7 @@ export function createRouteMarkers( (object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15); }); syncSelectionPin(); - stationSelectionListener?.(selectedStationId); + if (notify) stationSelectionListener?.(selectedStationId); } return { diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index b3e8540ec..2a5aca824 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -143,7 +143,7 @@ export function selectionSource( /* ── 모달 배선 ─────────────────────────────────────────────────────────── */ export interface DetailSelect { - /** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */ + /** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(같은 자리 조각 고름은 유지 · 233-2). */ redraw: () => void; /** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */ setMode: (kind: "area" | "length" | null) => void; @@ -152,6 +152,10 @@ export interface DetailSelect { detach: () => void; } +/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */ +const pieceKey = (piece: SplitPiece | undefined): string => + piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : ""; + export function attachDetailSelect(input: { cad: HTMLElement; design: () => CrossDesign | undefined; @@ -209,10 +213,12 @@ export function attachDetailSelect(input: { return { redraw() { - selected = []; + // 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2) + const keep = new Set(selected.map((i) => pieceKey(pieces[i]))); const axes = input.axes(); const layer = cad.querySelector("g.b06-chart__plot-layer"); pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; + selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : [])); const toPx = axes ? mToPx(axes) : null; hits = toPx ? pieces.map((piece) => { diff --git a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts new file mode 100644 index 000000000..48d3cad72 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * B06_Section_UI_Page_Pick_Sync.ts + * 횡단 보기 강조를 고름 한 칸에 묶음 (233-2 · 조사 AF (나) ④). + * + * 고름 한 칸(`structure-pick`)이 유일 원천이다. 횡단 보기가 보일 때 다른 곳(풀기 Esc 등)이 값을 + * 바꾸면 구독으로 받아 카드 · 범위 테두리(`--span`) · 포장층 · 벽 · 세월교 · BOX 형상 강조를 그 값으로 + * 다시 세운다 — 값이 비면 모두 풂, 값이 있으면 넘김 적용(`applyStructurePick`)과 같은 길로 다시 얹음. + * 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음. + * 풀기(카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 관 · 범위 테두리 · + * 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4). + * ========================================================================== */ + +import { subscribeSelection } from "../A00_Common/design/design_selection"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; + +export interface CrossPickDeps { + sections: () => ReadonlyArray; + controls: Pick; + releasePipe: () => void; + view: { + focusStation: (stationId: string | null) => void; + refreshCards: (chainages: ReadonlyArray) => void; + selectMark: (structureId: string | null, span?: boolean) => void; + }; + /** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */ + reapply: () => void; +} + +let releasing = false; + +/** 횡단 보기 강조를 모두 끔 — 카드 · 벽 · 세월교 · BOX · 폼 관 · 범위 테두리 · 포장층(값은 안 건드림). + * 카드를 풀면 카드 리스너가 다시 풀기를 부름 — 되돌아온 부름은 건너뜀. */ +export function releaseCrossHighlights(deps: CrossPickDeps): void { + if (releasing) return; + releasing = true; + try { + releaseAll(deps); + } finally { + releasing = false; + } +} + +function releaseAll(deps: CrossPickDeps): void { + const { revetOffset, ford, box } = deps.controls; + const lit = deps + .sections() + .filter( + (s) => + revetOffset.highlightFor(s) || + ford.selectedFor(s.chainage_m) || + box.selectedFor(s.chainage_m), + ); + for (const s of lit) { + if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null); + if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null); + if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null); + } + deps.releasePipe(); + deps.view.focusStation(null); + deps.view.selectMark(null); + deps.view.refreshCards(lit.map((s) => s.chainage_m)); +} + +/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음. + * `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */ +export function bindCrossPick( + projectId: string | null, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, + deps: CrossPickDeps, +): { release: () => void } { + host.onDispose( + subscribeSelection(projectId, (selection, from) => { + if (!host.visible() || from === "b06") return; + if (selection.chainageM === null) releaseCrossHighlights(deps); + else deps.reapply(); + }), + ); + return { release: () => releaseCrossHighlights(deps) }; +} diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs index a328856e2..2d690aab7 100644 --- a/resources/tester/helper_233_1_profile_pick.cjs +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -67,7 +67,7 @@ let visible = true; const disposers = []; const pick = PP.bindProfilePick( PID, - target, + { profile: target }, { visible: () => visible, onDispose: (fn) => disposers.push(fn) }, { irregular: () => irregular, pipes: () => pipes, sections: () => sections }, ); diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs new file mode 100644 index 000000000..95c32d464 --- /dev/null +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -0,0 +1,316 @@ +/* 233-2 3D · 횡단 카드 · [상세] 강조 = 고름 한 칸에서 파생 — 진짜 TS 를 Node 로 돌림. + * ① 3D: `bindProfilePick`(종단 보기 구독) + 진짜 마커(MK3) · 3D 부재 고름(SPK) · three. + * ② 횡단: `bindCrossPick` · `releaseCrossHighlights`(B06_Section_UI_Page_Pick_Sync) + 카드 · 형상 흉내. + * ③ [상세]: `attachDetailSelect` 다시 그림(창 크기 바뀜) 뒤 고른 선 조각 유지. + * 표준출력 JSON 한 벌 · 짝 = test_233_2_view_pick.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const NM = path.join(ROOT, "config", "node_modules"); +const ts = require(path.join(NM, "typescript")); +const THREE = require(path.join(NM, "three", "build", "three.cjs")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (request === "three") return THREE; + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* ── 가짜 DOM(라벨 캔버스 · [상세] 값 칸 · 조각) ── */ +const node = (tag) => { + const self = { + tag, + hidden: false, + textContent: "", + attrs: {}, + classes: new Set(), + parentElement: null, + setAttribute: (k, v) => { + self.attrs[k] = String(v); + if (k === "class") + String(v) + .split(" ") + .forEach((c) => self.classes.add(c)); + }, + getAttribute: (k) => self.attrs[k] ?? null, + addEventListener() {}, + classList: { + toggle: (c, on) => (on ? self.classes.add(c) : self.classes.delete(c)), + contains: (c) => self.classes.has(c), + }, + closest: (sel) => (self.classes.has(sel.slice(1)) ? self : null), + getContext: () => null, + }; + return self; +}; +globalThis.document = { + createElement: node, + createElementNS: (_ns, tag) => node(tag), + addEventListener() {}, + removeEventListener() {}, +}; +globalThis.window = { addEventListener() {}, removeEventListener() {} }; + +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const IRR = { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", +}; +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": IRR, +}); +const PP = loadTs("A00_Common/design/design_pick_profile.ts", { + "./design_selection": DSel, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); +const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { + "@util/common_util_svg": { inferStationInterval: () => 20, stationLabel: (c) => `0+${c}` }, +}); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { + "../A00_Common/design/design_selection": DSel, +}); +const CROSS_PAD = Function( + `return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(fs.readFileSync(path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), "utf8"))[1]}`, +)(); +// [상세] 조각 모듈은 helper_209_1 과 같은 꼴로 딸린 모듈까지 진짜로 읽음(카드 공용만 흉내) +const DEEP_STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolveTs = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadDeep(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (DEEP_STUBS[request]) return DEEP_STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadDeep(resolveTs(path.join(ROOT, "common_util", request.slice(6)))); + return loadDeep(resolveTs(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const DTS = loadDeep(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Select.ts")); + +const out = {}; +const PID = "p233b"; +const sel = (chainageM, structureId = null, options = {}) => + DSel.selectDesign( + PID, + chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, + options, + ); + +/* ── ① 3D ── */ +const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] }; +const scene = new THREE.Scene(); +const markers = MK3.createRouteMarkers(scene, () => bounds); +const STATIONS = [ + ...[100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at, center_x: at })), + { station_id: "irregular:sign", chainage_m: 127.86, center_x: 127.86 }, +].map((s) => ({ ...s, center_y: 100, center_z: 5, frame: { left_xy: [0, 1] }, kind: "regular" })); +markers.renderStationLines(STATIONS, 3); +let notified = 0; +markers.onStationSelectionChange(() => (notified += 1)); +const corridor = new THREE.Group(); +const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +member.userData = { chainageM: 160, kind: "pipe", key: "inlet" }; +const wall = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +wall.userData = { chainageM: 120, kind: "wall", key: "own" }; +corridor.add(member, wall); +const structurePick = SPK.bindStructurePick({ + canvas: { addEventListener() {}, removeEventListener() {} }, + camera: () => null, + group: () => corridor, + blocked: () => false, +}); +const profile = { station: null, structure: null }; +let b05Visible = true; +const disposers = []; +PP.bindProfilePick( + PID, + { + profile: { + setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }), + setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }), + }, + viewer: { markers, structurePick }, + }, + { visible: () => b05Visible, onDispose: (fn) => disposers.push(fn) }, + { + irregular: () => [{ id: "sign", chainage_m: 127.86, origin: "user" }], + pipes: () => [], + sections: () => STATIONS.filter((s) => s.station_id.startsWith("s")), + }, +); +const bar = (id) => markers.group.children[1].children.find((o) => o.userData.stationId === id); +const pin = () => markers.group.children.find((g) => g.renderOrder === 9); +const lit = (mesh) => mesh.material.emissive.getHex() !== 0; +const view3d = () => ({ + red: STATIONS.map((s) => s.station_id).filter( + (id) => bar(id).material.color.getHexString() === "ef4444", + ), + pin: pin().visible, + member: lit(member), + wall: lit(wall), + profile: { ...profile }, +}); +sel(120, null, { from: "b06" }); // 횡단 카드 120 고름 → 종단 보기도 값을 받음 +out.card120 = view3d(); +sel(160, null, { partKey: "inlet", from: "b06" }); +out.part160 = view3d(); +sel(127.86, "sign", { from: "b05" }); // 목록 표지 — 그 자리 구조물 측점 띠 +out.sign = view3d(); +sel(null); +out.released3d = view3d(); +out.notified = notified; // 칠하기만 — 마커 알림으로 사슬이 다시 돌지 않음 +disposers.forEach((fn) => fn()); + +/* ── ② 횡단 카드 · 범위 · 형상 ── */ +const calls = []; +const sections = [100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at })); +const revetLit = new Set([120]); +let cardSelected = "s120"; +let crossVisible = true; +let releaseCross; +const deps = { + sections: () => sections, + controls: { + revetOffset: { + highlightFor: (s) => revetLit.has(s.chainage_m), + selectedFor: (s) => revetLit.has(s.chainage_m), + select: (at) => revetLit.delete(at), + }, + ford: { selectedFor: () => false, select() {} }, + box: { selectedFor: () => false, select() {} }, + }, + releasePipe: () => calls.push("releasePipe"), + view: { + focusStation: (id) => { + calls.push(`focus:${id}`); + if (id === null && cardSelected !== null) { + cardSelected = null; + releaseCross(); // 카드 리스너(null) → 풀기 한 길 다시 부름 — 되돌아온 부름은 건너뜀 + } + }, + refreshCards: (list) => calls.push(`refresh:${list.join(",")}`), + selectMark: (id) => calls.push(`mark:${id}`), + }, + reapply: () => calls.push("reapply"), +}; +const crossDisposers = []; +const crossPick = SYNC.bindCrossPick( + PID, + { visible: () => crossVisible, onDispose: (fn) => crossDisposers.push(fn) }, + deps, +); +releaseCross = () => { + crossPick.release(); + sel(null, null, { from: "b06" }); +}; +sel(120, null, { from: "b06" }); +out.ownPick = calls.splice(0); +sel(140, null, { from: "esc" }); +out.otherPick = calls.splice(0); +releaseCross(); // 카드 다시 누름 · 목록 다시 누름 · 보기 오감 +out.release = calls.splice(0); +out.revetAfter = [...revetLit]; +revetLit.add(100); +cardSelected = "s100"; +sel(null, null, { from: "esc" }); // 다른 곳 풀기(Esc) +out.otherRelease = calls.splice(0); +crossVisible = false; +sel(100, null, { from: "esc" }); +out.hidden = calls.splice(0); +crossDisposers.forEach((fn) => fn()); + +/* ── ③ [상세] 선 조각 — 다시 그린 뒤 고름 유지 ── */ +let axes = { + maxOffset: 10, + pixelsPerMeter: 10, + displayMax: 110, + elevationMid: 100, + exaggeration: 1, +}; +const groundM = [ + [ + [-5, 100], + [0, 101], + ], + [ + [0, 101], + [5, 99], + ], +]; +const toPx = ([o, e]) => + [ + CROSS_PAD.left + (axes.maxOffset - o) * axes.pixelsPerMeter, + CROSS_PAD.top + (axes.displayMax - e) * axes.pixelsPerMeter, + ].join(","); +const layerChildren = []; +const layer = { + querySelectorAll: () => [...layerChildren], + append: (hit) => layerChildren.push(hit), +}; +const drawGround = () => { + layerChildren.length = 0; + for (const pts of groundM) { + const el = node("polyline"); + el.tagName = "polyline"; + el.setAttribute("class", "b06-chart__cross-profile"); + el.setAttribute("points", pts.map(toPx).join(" ")); + el.hasAttribute = () => false; + layerChildren.push(el); + } +}; +let click = null; +const cad = { + addEventListener: (_type, fn) => (click = fn), + querySelector: (s) => (s === "g.b06-chart__plot-layer" ? layer : null), +}; +const select = DTS.attachDetailSelect({ + cad, + design: () => undefined, + axes: () => axes, + picked() {}, + commit() {}, +}); +drawGround(); +select.redraw(); +const hit = layerChildren.find((el) => el.classes.has(DTS.PIECE_HIT_CLASS)); +click({ target: hit, shiftKey: false, ctrlKey: false, metaKey: false, stopPropagation() {} }); +out.detailPicked = select.source("length")?.pieces.length ?? 0; +axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림 +drawGround(); +select.redraw(); +out.detailAfterRedraw = select.source("length")?.pieces.length ?? 0; +groundM.splice(0, 1, [ + [-6, 100], + [0, 102], +]); // 그 조각 자리가 바뀌면 고름도 빠짐 +drawGround(); +select.redraw(); +out.detailAfterChange = select.source("length")?.pieces.length ?? 0; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_233_1_profile_pick.py b/resources/tester/test_233_1_profile_pick.py index 3801a57e6..6e48e9181 100644 --- a/resources/tester/test_233_1_profile_pick.py +++ b/resources/tester/test_233_1_profile_pick.py @@ -86,7 +86,7 @@ def test_풂과_구독_끊음(out) -> None: def test_종단_보기_배선() -> None: view = _read("A00_Common", "design", "design_view_profile.ts") - assert "bindProfilePick(activeProjectId, profilePanel, host," in view + assert "bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host," in view # 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음 assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view assert view.count("profilePick.paint()") >= 3 diff --git a/resources/tester/test_233_2_view_pick.py b/resources/tester/test_233_2_view_pick.py new file mode 100644 index 000000000..134687edd --- /dev/null +++ b/resources/tester/test_233_2_view_pick.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""233-2 3D(띠 · 핀 · 부재) · 횡단 카드(고름 · --span · 형상 · 포장층) · [상세] 선 조각 = 고름 한 칸에서 파생. + +까닭: 3D 는 화면끼리 손으로 부르는 사슬로만 칠해져 길마다 빠짐(구조물 고름 뒤 옛 띠 남음 · 횡단 카드 +고름은 부재를 안 켬) · 횡단 카드를 풀어도 `--span` · 포장층이 남음(a4) · 풀기 길마다 끄는 범위가 다름 · +[상세] 선 조각은 다시 그릴 때(창 크기 · 카드 다시 세움) 고름을 지워 바로 꺼짐. +고침: 종단 보기 구독이 3D 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이) · 횡단 보기 구독(`bindCrossPick`)은 +다른 곳이 바꾼 값을 넘김 적용 길로 다시 세우고 · 풀기는 한 길(`releaseCrossHighlights` — 카드 · 폼 관 · +범위 테두리 · 포장층 · 형상) · [상세] 다시 그림은 같은 자리 조각 고름 유지. +진짜 TS 를 `helper_233_2_view_pick.cjs` 가 Node(three 포함)로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_2_view_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_3D_횡단_카드_고름은_띠_핀_그_측점_부재(out) -> None: + card = out["card120"] + assert (card["red"], card["pin"], card["wall"], card["member"]) == (["s120"], True, True, False) + assert card["profile"]["station"] == "s120" + + +def test_3D_부재키_고름은_그_부재만_띠는_없음(out) -> None: + part = out["part160"] + assert (part["red"], part["pin"], part["member"], part["wall"]) == ([], False, True, False) + + +def test_3D_구조물_고름은_그_자리_구조물_측점_띠(out) -> None: + assert out["sign"]["red"] == ["irregular:sign"] + assert out["sign"]["profile"]["structure"] == "sign" + + +def test_3D_풂과_알림_없음(out) -> None: + released = out["released3d"] + assert (released["red"], released["pin"], released["member"], released["wall"]) == ( + [], + False, + False, + False, + ) + assert out["notified"] == 0 # 칠하기만 — 마커 알림이 사슬을 다시 돌리지 않음 + + +def test_횡단_제_누름은_되받지_않고_다른_곳_값은_다시_세움(out) -> None: + assert out["ownPick"] == [] + assert out["otherPick"] == ["reapply"] + assert out["hidden"] == [] # 숨은 보기는 안 받음 + + +def test_횡단_풀기_한_길은_범위_테두리_형상까지(out) -> None: + for key in ("release", "otherRelease"): + assert out[key] == ["releasePipe", "focus:null", "mark:null", out[key][-1]] + assert out["release"][-1] == "refresh:120" + assert out["revetAfter"] == [] + + +def test_상세_다시_그려도_같은_자리_조각_고름_유지(out) -> None: + assert out["detailPicked"] == 1 + assert out["detailAfterRedraw"] == 1 + assert out["detailAfterChange"] == 0 + + +def test_배선() -> None: + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "bindCrossPick(projectId, host," in cross + assert "crossPick.release()" in cross + # 카드 다시 누름 = 풀기 한 길 + assert "releaseCross(); // 카드 다시 누름" in cross + assert len(cross.splitlines()) <= 685 + profile = _read("A00_Common", "design", "design_view_profile.ts") + assert "{ profile: profilePanel, viewer }" in profile + assert len(profile.splitlines()) <= 700 From 994b91636c6057eac1d8691dd81b11fc424fc040 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:05:52 +0900 Subject: [PATCH 25/92] =?UTF-8?q?feat(B05=C2=B7B06):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=C2=B7=20=EA=B4=80=20=EA=B8=B0=EC=A4=80=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=A0=95=EC=88=98=20m=20=C2=B7=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=20=EC=B8=A1=EC=A0=90=201=20m=20=EC=95=88=20=EB=B6=99?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20[=EC=A0=95=EC=88=98=20=EB=A7=9E=EC=B6=A4]?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94(234-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 맞춤 규칙 한 벌 — common_util_structure_station(파이썬 · TS 짝 · 거울 시험) - 입력 — 좌측 폼 칸(1 m 단계 · 친 값만) · 끌기 · 우클릭 · 방향키(1 m 걸음) · 배수유역도 관 · B06 관 옮김 · 추가 - 구간형 시작 · 끝도 정수 m · 일반 측점 = 횡단 상세를 읽을 때 등록 - 서버 자동 배치 · 노선 재투영 자리도 정수 m(확정본은 단추로만) - [정수 맞춤] — 구조물 목록 머리 · 확인 창 · 관은 하나씩 폼 [수정] 길 · 계획고 편집 따라감 · 결과 n건 알림 - 찾는 폭 한 값 — 손으로 적은 0.51 을 PIPE_MATCH_M 으로 - 시험 — test_234_1 새로 · 1 m 걸음 · 정수 입력에 맞춘 기대값 고침(219_1 · station_roundtrip) · 헬퍼 대역 보탬 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_selection.ts | 2 +- B04_PreProcess/B04_PreProcess_UI_Basins.ts | 3 +- .../B05_Profile_UI_Drainage_Ford_Fill.ts | 7 +- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 22 +- B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 7 +- B05_Profile/B05_Profile_UI_Drainage_Pipes.ts | 20 +- B05_Profile/B05_Profile_UI_Drainage_Report.ts | 3 +- .../B05_Profile_UI_IrregularStations.ts | 13 +- B05_Profile/B05_Profile_UI_Page_Structures.ts | 4 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 7 +- .../B05_Profile_UI_Profile_Structures.ts | 3 +- .../B05_Profile_UI_Structures_Fields.ts | 6 +- .../B05_Profile_UI_Structures_Panel.ts | 30 +-- .../B05_Profile_UI_Structures_Panel_Commit.ts | 14 +- B05_Profile/B05_Profile_UI_Structures_Snap.ts | 160 ++++++++++++ .../B05_Profile_UI_Style_Structures.css | 12 + B06_Section/B06_Section_Section_Store.ts | 3 + .../B06_Section_UI_Page_Structures_Panel.ts | 12 +- .../B06_Section_UI_Section_View_Menu.ts | 3 +- .../B06_Section_UI_Section_View_Profile.ts | 4 +- common_util/common_util_drainage_detail.py | 4 +- common_util/common_util_drainage_pipes.py | 8 +- common_util/common_util_structure_station.py | 80 ++++++ common_util/common_util_structure_station.ts | 69 ++++++ resources/tester/helper_165_pavement_flow.cjs | 4 + resources/tester/helper_231_1_pave_reload.cjs | 4 + .../tester/helper_234_1_structure_station.cjs | 192 +++++++++++++++ .../tester/helper_b05_drainage_report.cjs | 15 +- resources/tester/test_219_1_arrow_owner.py | 7 +- .../tester/test_234_1_structure_station.py | 231 ++++++++++++++++++ .../tester/test_b05_station_roundtrip.py | 3 +- 31 files changed, 874 insertions(+), 78 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Snap.ts create mode 100644 common_util/common_util_structure_station.py create mode 100644 common_util/common_util_structure_station.ts create mode 100644 resources/tester/helper_234_1_structure_station.cjs create mode 100644 resources/tester/test_234_1_structure_station.py diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 0e16185a5..df7a528ef 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -13,7 +13,7 @@ import { readState, writeState } from "../b_page_state"; -/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */ +/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */ export const PIPE_MATCH_M = 0.51; export interface DesignSelection { diff --git a/B04_PreProcess/B04_PreProcess_UI_Basins.ts b/B04_PreProcess/B04_PreProcess_UI_Basins.ts index 251369cf8..39b93d257 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Basins.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Basins.ts @@ -40,6 +40,7 @@ import { resampleRoute, type RoutePoint, } from "./B04_PreProcess_UI_RouteSamples"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -296,7 +297,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl function selectBasinOfPipe(pipeIndex: number): void { const pipe = pipes[pipeIndex]; if (!pipe) return; - const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < PIPE_MATCH_M); selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null; say(summary()); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts index 4e9983648..f412bcb3a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts @@ -12,6 +12,7 @@ import { FORD_PAVEMENT_DEFAULT_WIDTH_M, } from "@config/config_frontend"; import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; const FORD_KINDS: Record = { ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M, @@ -39,7 +40,7 @@ export function fillFordHeights( ): boolean { let filled = false; for (const wait of [...pending]) { - const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < 0.51); + const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < PIPE_MATCH_M); if (!point?.facility || !(point.facility in FORD_KINDS)) { pending.delete(wait); // 관이 사라졌거나 종류가 바뀜 continue; @@ -48,7 +49,9 @@ export function fillFordHeights( pending.delete(wait); // 사용자가 넣은 값 — 안 덮음 continue; } - const flow = basins.find((entry) => Math.abs(entry.chainage_m - point.chainage_m) < 0.51); + const flow = basins.find( + (entry) => Math.abs(entry.chainage_m - point.chainage_m) < PIPE_MATCH_M, + ); const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility]; const need = fordHeightM(flow?.design_flow_m3s ?? null, width); if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 1deeea3c5..d34b96d10 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -30,6 +30,8 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; @@ -571,15 +573,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra movePipe(fromChainage, toChainage) { const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < PIPE_MATCH_M); if (index < 0) return; - pipeEditor.moveTo(index, toChainage); + const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게) + facilityStore.move(pipeEditor.pipes()[index].chainage_m, to); + pipeEditor.moveTo(index, to); }, selectBasinByChainage(chainageM) { const picked = chainageM === null ? null - : (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < 0.51) ?? null); + : (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < PIPE_MATCH_M) ?? null); // 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단). selectBasin(picked ? picked.index : null, false); }, @@ -605,12 +609,16 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra scheduleDraw(); }, addPipe(chainageM, attributes) { + chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. facilityStore.set(chainageM, attributes ?? null); if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM); pipeEditor.addAtChainage(chainageM); }, updatePipeFacility(fromChainageM, toChainageM, attributes) { + // 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리 + if (Math.abs(toChainageM - fromChainageM) > 0.005) + toChainageM = snapStructureStation(toChainageM); // ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임. const previous = facilityStore.get(fromChainageM); facilityStore.set(fromChainageM, null); @@ -621,7 +629,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra // attach가 새 위치로 시설 정보를 승계한다. const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M); if (index >= 0) pipeEditor.moveTo(index, toChainageM); else void analyze(); } else { @@ -634,7 +642,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra removePipe(chainageM) { const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M); if (index < 0) return; pipeEditor.select(index); pipeEditor.deleteSelected(); @@ -643,7 +651,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const index = chainageM === null ? null - : pipeEditor.pipes().findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); + : pipeEditor + .pipes() + .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M); const next = index === -1 ? null : index; if (pipeEditor.selected() === next) return; // select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 3a9d17fab..88036c07d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -21,6 +21,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -352,7 +353,7 @@ export function reconcilePipes( return null; } return next.map((chainage) => { - const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51); + const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < PIPE_MATCH_M); return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" }; }); } @@ -516,7 +517,7 @@ export function createProgressReporter(progress: { /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ export function pipeMarkerColor(basins: ReadonlyArray, chainageM: number): string { - const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < PIPE_MATCH_M); if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb"); return basinColor(basin.index).replace(/0\.45\)$/, "1)"); } @@ -527,7 +528,7 @@ export function basinIndexOfPipe( pipe: { chainage_m: number } | null, ): number | null { if (!pipe) return null; - const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < PIPE_MATCH_M); return basin ? basin.index : null; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 5ac77fbf0..864188844 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -1,6 +1,7 @@ import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; // 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제. // 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선 @@ -74,6 +75,9 @@ export function createPipeEditor( /** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다 * (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */ let dragDownAt: { x: number; y: number } | null = null; + /** 노선 안 · 관 기준 측점 = 정수 m(일반 측점 1 m 안이면 그 측점 · 234-1). */ + const onRoute = (chainageM: number): number => + Math.min(snapStructureStation(Math.max(0, Math.min(totalChainage, chainageM))), totalChainage); /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ function screenToMetric( @@ -199,14 +203,14 @@ export function createPipeEditor( moveTo(index, chainageM) { const pipe = pipeList[index]; if (!pipe) return; - pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM)); + pipe.chainage_m = onRoute(chainageM); pipe.reason = "confirmed"; sortPipes(); onChange(); onCommit(); }, addAtChainage(chainageM) { - const clamped = Math.max(0, Math.min(totalChainage, chainageM)); + const clamped = onRoute(chainageM); pipeList.push({ chainage_m: clamped, reason: "confirmed" }); sortPipes(); selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6); @@ -224,18 +228,17 @@ export function createPipeEditor( if (!metric) return null; const nearest = nearestChainage(metric.x, metric.y); if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null; - return Number(nearest.chainage.toFixed(2)); + return onRoute(nearest.chainage); }, addAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; const nearest = nearestChainage(metric.x, metric.y); if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; - pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" }); + const at = onRoute(nearest.chainage); + pipeList.push({ chainage_m: at, reason: "confirmed" }); sortPipes(); - selectedIndex = pipeList.findIndex( - (pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6, - ); + selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - at) < 1e-6); onChange(); onCommit(); return true; @@ -271,8 +274,7 @@ export function createPipeEditor( if (!metric) return true; const nearest = nearestChainage(metric.x, metric.y); if (!nearest) return true; - const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage)); - pipeList[draggingIndex].chainage_m = clamped; + pipeList[draggingIndex].chainage_m = onRoute(nearest.chainage); pipeList[draggingIndex].reason = "confirmed"; dragMoved = true; onChange(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Report.ts b/B05_Profile/B05_Profile_UI_Drainage_Report.ts index d5596c4ca..ea809b3a2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Report.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Report.ts @@ -7,6 +7,7 @@ import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. * 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, @@ -20,7 +21,7 @@ export function buildPipeReports( ): DrainagePipeReport[] { return pipes.map((pipe) => { const chainage = Math.round(pipe.chainage_m * 100) / 100; - const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); + const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M); const attributes = facilityStore.get(chainage); return { chainage_m: chainage, diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index 798caaf8a..07e7b3e12 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -23,6 +23,7 @@ import { pickPipeDiameter, type StructureType, } from "@config/config_frontend"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; export interface IrregularStation { id: string; @@ -480,7 +481,9 @@ export function createIrregularStationsSection( // 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다. const restoredGhost = entry.structureType === undefined && - incomingChainages.some((chainage) => Math.abs(entry.chainage_m - chainage) < 0.51); + incomingChainages.some( + (chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M, + ); if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1); } pipes.forEach((pipe) => { @@ -488,7 +491,7 @@ export function createIrregularStationsSection( const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null); const { station, remainder } = splitChainage(chainage); const kept = previousPipes.find( - (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < 0.51, + (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M, ); const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE; const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective); @@ -530,7 +533,9 @@ export function createIrregularStationsSection( callbacks.onChange([...stations]); }, removeByChainage(chainageM) { - const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51); + const index = stations.findIndex( + (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, + ); if (index < 0) return null; const [removed] = stations.splice(index, 1); if (editingId === removed.id) loadForm(null); @@ -540,7 +545,7 @@ export function createIrregularStationsSection( }, moveByChainage(fromChainageM, toChainageM) { const index = stations.findIndex( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51, + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); if (index < 0) return false; const { station, remainder } = splitChainage(toChainageM); diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 8518459e2..70d0400fa 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -33,6 +33,7 @@ import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers"; import { followedStation } from "./B05_Profile_UI_Profile_Alignment"; +import { followSnapped } from "./B05_Profile_UI_Structures_Snap"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -114,7 +115,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ); const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); gone.forEach((station) => { - const same = followedStation(station, gone, born); + // 없으면 맞춘 자리의 새 측점 — [정수 맞춤]이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1) + const same = followedStation(station, gone, born) ?? followSnapped(station, born); deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); }); irregularStations = stations; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 64127e8f2..f8a1a2950 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -31,8 +31,9 @@ import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { createFreeDraw } from "../A00_Common/design/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; +import { stepStructureStation } from "../common_util/common_util_structure_station"; -/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ +/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; /** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ @@ -234,8 +235,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해 // 0.1m 만 움직이고 멈춤(2026-09-04 실측). const fromM = holdChainageM ?? station.chainage_m; - const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3)); - if (next < 0) return false; + const next = stepStructureStation(fromM, key === "ArrowRight" ? 1 : -1); + if (next === fromM) return false; ctx.moveStation({ ...station, chainage_m: fromM }, next); holdStation = station; holdChainageM = next; diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 6e2f67b27..5649dbd79 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -17,6 +17,7 @@ import { } from "./B05_Profile_UI_IrregularStations"; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import type { PickGroup } from "./B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 사이드 「구조물 배치」 군 · 종류 드롭다운 목록(`section.pickGroups()`)을 그대로 구조물군 → 종류 * 2단 메뉴 항목으로(129-1 — 종단그래프 · 배수유역도 · 3D · B06 종단 공용 · 가린 군 · 종류는 뺌). @@ -118,7 +119,7 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti } /** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ -export const SAME_CHAINAGE_M = 0.51; +export const SAME_CHAINAGE_M = PIPE_MATCH_M; /** * 측점선 id ↔ 알약(구조물) id 짝짓기. diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 95181eb53..d1bb93c0d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -10,6 +10,7 @@ import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station"; import { DEFAULT_BASIN_SHAPE } from "../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; import { fillPipeHeights } from "../common_util/common_util_culvert_sets"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; export function field(labelText: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); @@ -141,7 +142,7 @@ export function stationFields( ): StationFields { const station = numberInput("1", "0"); station.placeholder = "측점"; - const remainder = numberInput("0.1", "0"); + const remainder = numberInput("1", "0"); // 기준 측점 = 정수 m(234-1) remainder.placeholder = "+m"; const pair = document.createElement("div"); pair.className = "b05-structure__station-pair"; @@ -195,7 +196,8 @@ export function stationFields( const valid = Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0; markInvalid(!valid); - return valid ? stationNo * intervalM + rest : null; + // 친 값은 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 안 고친 칸은 위에서 옛 값 그대로 + return valid ? snapStructureStation(stationNo * intervalM + rest) : null; }, write(chainageM, intervalM) { written = null; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 526d27113..b29c23e40 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -14,7 +14,6 @@ * ========================================================================== */ import { - structureAnchorM, type StructureInstance, type StructuresSectionOptions, type StructurePlacement, @@ -48,6 +47,7 @@ import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define"; import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; +import { movedStructure, snapDock, snapStructureStation } from "./B05_Profile_UI_Structures_Snap"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ @@ -363,7 +363,7 @@ export function createStructuresSection( if (tempPipeChainage === null) return; const chainage = tempPipeChainage; tempPipeChainage = null; - if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.51) { + if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < PIPE_MATCH_M) { selectedPipeChainage = null; } callbacks.onPipeRemove(chainage); @@ -542,7 +542,13 @@ export function createStructuresSection( return { root, facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) - listRoot: list, + listRoot: snapDock(list, { + structures, + pipes: () => pipeFacilities, + emit, + movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes), + closeForm: () => resetForm(), + }), hasSelection: () => editingId !== null || selectedPipeChainage !== null, pickGroups: () => pickGroups(types, canPick), // 우클릭 메뉴 = 이 드롭다운 목록(129-1) setTypes(next, pick = () => true) { @@ -637,6 +643,7 @@ export function createStructuresSection( loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); }, addAt(chainageM, typeId) { + chainageM = snapStructureStation(chainageM); // 정수 m · 일반 측점 붙기(234-1) const type = typeMap().get(typeId); if (!type) return; // 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서 @@ -670,21 +677,8 @@ export function createStructuresSection( moveById(structureId, toChainageM) { const index = structures.findIndex((entry) => entry.structure_id === structureId); if (index < 0) return false; - const target = structures[index]; - if (target.placement === "interval") { - // 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다. - const length = (target.end_m ?? 0) - (target.start_m ?? 0); - const anchorOffset = structureAnchorM(target) - (target.start_m ?? 0); - const start = toChainageM - anchorOffset; - structures[index] = { - ...target, - chainage_m: toChainageM, - start_m: start, - end_m: start + length, - }; - } else { - structures[index] = { ...target, chainage_m: toChainageM }; - } + // 구간은 길이를 유지한 채 통째로 — 기준 · 시작 · 끝 모두 정수 m(234-1) + structures[index] = movedStructure(structures[index], toChainageM); emit(); return true; }, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 5490be851..66e2b3966 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -22,6 +22,10 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures import { asRoad } from "./B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { + isSnappedStation, + snapStructureStation, +} from "../common_util/common_util_structure_station"; import { readGroupItems, readSummaryItem, @@ -32,6 +36,7 @@ import type { PipeFacilityItem, StructuresCallbacks, } from "./B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 패널이 쥔 값 중 폼 왕복·저장에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface StructuresCommitContext { @@ -168,7 +173,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte // 관으로 본다(다른 곳의 관 매칭 기준과 동일). const isTemp = ctx.tempPipeChainage() !== null && - Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < 0.51; + Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < PIPE_MATCH_M; if (isTemp) { ctx.setTempPipeChainage(pipe.chainage_m); ctx.setSelectedPipeChainage(pipe.chainage_m); @@ -304,8 +309,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis beforeInput?.focus(); return; } - start = Math.max(anchor - before, 0); - end = start + length; + // 시작 · 끝도 정수 m(234-1) — 안 고친 옛 소수 기준이면 옛 셈 그대로([정수 맞춤] 몫) + const fit = (value: number): number => + isSnappedStation(anchor as number) ? snapStructureStation(value) : value; + start = fit(Math.max(anchor - before, 0)); + end = Math.max(fit(start + length), start + Math.min(length, 1)); } else if (placement === "interval") { start = ctx.startFields.read(step, true); end = ctx.endFields.read(step, true); diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts new file mode 100644 index 000000000..3f29a57ac --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Snap.ts + * 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과 + * 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌). + * + * 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤 + * 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 + * 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다. + * ========================================================================== */ + +import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { + isSnappedStation, + snapStructureStation, +} from "../common_util/common_util_structure_station"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; + +export { snapStructureStation }; +import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; +import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; +import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types"; + +/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */ +export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance { + const anchor = snapStructureStation(toChainageM); + if (target.placement !== "interval") return { ...target, chainage_m: anchor }; + const length = (target.end_m ?? 0) - (target.start_m ?? 0); + const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0))); + const snappedEnd = snapStructureStation(start + length); + const end = snappedEnd > start ? snappedEnd : start + length; + return { + ...target, + chainage_m: Math.min(Math.max(anchor, start), end), + start_m: start, + end_m: end, + }; +} + +/** 사라진 측점의 계획고 편집이 따라갈 새 측점 — 맞춘 자리(0.006m 안 · 213-2 와 같은 폭)의 것. */ +export function followSnapped( + station: T, + born: ReadonlyArray, +): T | null { + const to = snapStructureStation(station.chainage_m); + return born.find((next) => Math.abs(next.chainage_m - to) < 0.006) ?? null; +} + +const offGrid = (value: number | null | undefined): boolean => + typeof value === "number" && !isSnappedStation(value); + +/** 기준 · 시작 · 끝 중 하나라도 맞춤 밖(소수)인 구조물인가. */ +export const structureOffGrid = (structure: StructureInstance): boolean => + offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m); + +export interface SnapPlan { + /** 목록 자리 · 옮긴 모양. */ + structures: Array<{ index: number; next: StructureInstance }>; + pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>; + /** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */ + skipped: number; +} + +/** 옮길 것 셈 — 구조물은 옮긴 모양, 관은 (옛 자리 → 새 자리) · 자리 겹친 관은 건너뜀. */ +export function snapPlan( + structures: ReadonlyArray, + pipes: ReadonlyArray, +): SnapPlan { + const moved = structures.flatMap((structure, index) => + structureOffGrid(structure) + ? [{ index, next: movedStructure(structure, structureAnchorM(structure)) }] + : [], + ); + const taken = pipes.filter((pipe) => !offGrid(pipe.chainage_m)).map((pipe) => pipe.chainage_m); + const plan: SnapPlan = { structures: moved, pipes: [], skipped: 0 }; + [...pipes] + .filter((pipe) => offGrid(pipe.chainage_m)) + .sort((left, right) => left.chainage_m - right.chainage_m) + .forEach((pipe) => { + const to = snapStructureStation(pipe.chainage_m); + if (taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) { + plan.skipped += 1; + return; + } + taken.push(to); + plan.pipes.push({ from: pipe.chainage_m, to, pipe }); + }); + return plan; +} + +/** 관 한 건의 지금 속성 그대로 — 폼 [수정]과 같은 꼴(비운 칸 = 지움 규칙에 옛 값이 다 실림). */ +export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({ + facility: pipe.facility, + ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), + ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), + options: { ...(pipe.options ?? {}) }, + summary_item: pipe.summary_item ?? null, + group_items: pipe.group_items ?? null, +}); + +export interface SnapDockContext { + /** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */ + structures: StructureInstance[]; + pipes: () => ReadonlyArray; + /** 목록이 바뀜 알림(한 번). */ + emit: () => void; + /** 관 한 건 옮김 — 폼 [수정]과 같은 길. */ + movePipe: (from: number, to: number, attributes: FacilityAttributes) => void; + /** 열린 폼을 닫음 — 옛 측점을 쥔 폼이 다음 [수정]에 되돌리지 않게. */ + closeForm: () => void; +} + +/** [정수 맞춤] 단추를 얹은 목록 뿌리 — 아래 고정 영역에 목록 대신 놓인다. */ +export function snapDock(list: HTMLElement, ctx: SnapDockContext): HTMLElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-route__irregular-btn b05-structure__snap"; + button.textContent = "정수 맞춤"; + button.title = "소수 기준 측점(관 · 구조물)을 정수 m 로 — 일반 측점 1 m 안이면 그 측점"; + button.addEventListener("click", () => void runSnap(ctx)); + const head = document.createElement("div"); + head.className = "b05-structure__snap-row"; + head.append(button); + const dock = document.createElement("div"); + dock.className = "b05-structure__list-dock"; + dock.append(head, list); + return dock; +} + +export async function runSnap(ctx: SnapDockContext): Promise { + const plan = snapPlan(ctx.structures, ctx.pipes()); + const count = plan.structures.length + plan.pipes.length; + if (!count) { + showToast( + plan.skipped + ? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.` + : "소수 기준 측점이 없습니다.", + "info", + ); + return 0; + } + const ok = await showConfirmDialog( + `소수 기준 측점 ${count}건(관 ${plan.pipes.length} · 구조물 ${plan.structures.length})을 ` + + "정수 m(일반 측점 1 m 안이면 그 측점)로 옮깁니다. 계획고 편집도 따라가며, [저장]을 눌러야 남습니다.", + "정수 맞춤", + ); + if (!ok) return 0; + ctx.closeForm(); + if (plan.structures.length) { + plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next)); + ctx.emit(); + } + plan.pipes.forEach(({ from, to, pipe }) => ctx.movePipe(from, to, pipeAttributes(pipe))); + showToast( + `정수 맞춤 ${count}건` + + (plan.skipped ? ` · 자리 겹친 관 ${plan.skipped}건은 그대로` : "") + + " — [저장]을 눌러 남기세요.", + "success", + ); + return count; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index ed8512694..846d3623c 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -650,3 +650,15 @@ .b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area { color: var(--color-text-secondary); } + +/* [정수 맞춤] 단추 줄 — 구조물 목록 머리(234-1) */ +.b05-structure__snap-row { + display: flex; + justify-content: flex-end; + margin-bottom: var(--spacing-4); +} + +.b05-structure__snap { + flex: 0 0 auto; + padding: var(--spacing-4) var(--spacing-8); +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 85fd95747..d194c8c29 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -39,6 +39,7 @@ import { pavementSpecsFrom, type PavementStructureInput, } from "@util/common_util_structure_pavement"; +import { setGeneralStations } from "../common_util/common_util_structure_station"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; @@ -63,6 +64,7 @@ async function withDraftWalls( detail: SectionDetailResponse, projectId: string, ): Promise { + setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) const drafts = readPendingStructures(projectId); // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 @@ -214,6 +216,7 @@ export function replaceSectionDetail( ): void { cache.set(keyOf(projectId, routeId), detail); writeState("section-detail", detail, projectId, routeId); + setGeneralStations(detail.longitudinal?.stations); } /** diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index fc0d5a3ae..29bca82ca 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -48,6 +48,7 @@ import { type DesignSelection, } from "../A00_Common/design/design_selection"; import { showToast } from "@ui/ui_template_elements"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; import { adjustDockRoot, refreshAdjustSlots, @@ -239,7 +240,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast(PIPE_ADD_GUIDE, "error"); return; } - const at = Number(chainageM.toFixed(2)); + const at = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) { showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error"); return; @@ -263,11 +264,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 // (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다. onPipeUpdate: (fromChainageM, toChainageM, attributes) => { - // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 - // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 - // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). - if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M) - movePipeTo(fromChainageM, toChainageM); + // 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1) + if (Math.abs(fromChainageM - toChainageM) > 0.005) movePipeTo(fromChainageM, toChainageM); if (attributes.summary_item !== undefined || attributes.group_items !== undefined) { // 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다. const mine = pipeFacilities.find( @@ -480,7 +478,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast(PIPE_MOVE_GUIDE, "error"); return; } - const to = Number(toChainageM.toFixed(2)); + const to = snapStructureStation(toChainageM); // 정수 m · 일반 측점 붙기(234-1) const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index cfddee991..36f17d038 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -15,6 +15,7 @@ import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { pickGroups } from "../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */ export interface SectionStructureEdit { @@ -57,7 +58,7 @@ export function moveMarkById( if (!station) return; // 측점선 id 로 온 경우 — 그 측점 자리에 선 구조물을 찾는다(관 매칭과 같은 0.51m). const near = input.structures.find( - (entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < 0.51, + (entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < PIPE_MATCH_M, ); if (near) moveStructureMark(input.structures, input.edit, String(near.structure_id), toChainageM); } diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 0df0e0f12..3cac3c62d 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,7 +15,7 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; -import { coversChainage } from "../A00_Common/design/design_selection"; +import { coversChainage, PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -40,7 +40,7 @@ import { } from "./B06_Section_UI_Section_View_Menu"; /** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ -const MATCH_M = 0.51; +const MATCH_M = PIPE_MATCH_M; /** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */ const LEGACY_TYPE_IDS: Record = { 기성막이: "revetment", diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 8e2a2f40b..7ae990503 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -47,6 +47,7 @@ from common_util.common_util_route_geometry import ( interpolate_vertex, is_uphill_at, ) +from common_util.common_util_structure_station import snap_candidates from common_util.common_util_wamis_rainfall import idf_intensity from config.config_system import ( DRAINAGE_BOX_THRESHOLD_MM, @@ -198,7 +199,8 @@ def build_detail( ) for pipe in routing.base_pipes ] - pipes = place_pipes(vertices, base, routing.strength_curve) + # 자동 관 자리 = 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 확정본(위)은 단추로만 + pipes = snap_candidates(place_pipes(vertices, base, routing.strength_curve), vertices) detail = DrainageDetail( route_lonlat=routing.route_lonlat, diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 2a3c67f3e..1b8454b1c 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -35,6 +35,10 @@ from shapely.geometry import LineString, Point from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex +from common_util.common_util_structure_station import ( + route_general_stations, + snap_structure_station, +) from common_util.common_util_summary_item import parse_group_items, parse_summary_item from config.config_system import ( DRAINAGE_CACHE_DIRNAME, @@ -216,10 +220,12 @@ def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> line = LineString([(vertex.x, vertex.y) for vertex in vertices]) if line.length <= 0: return points + # 새 노선 위 자리도 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 시설 속성은 관과 함께 옮겨 감 + stations = route_general_stations(vertices[-1].chainage_m) for point in points: if point.x is None or point.y is None: continue - moved = float(line.project(Point(point.x, point.y))) + moved = snap_structure_station(float(line.project(Point(point.x, point.y))), stations) shift = moved - float(point.chainage_m) point.chainage_m = moved if point.start_m is not None: diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py new file mode 100644 index 000000000..79eb2cdd3 --- /dev/null +++ b/common_util/common_util_structure_station.py @@ -0,0 +1,80 @@ +"""구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1). + +TS 짝 = `common_util/common_util_structure_station.ts`(화면 입력 · 끌기 · 방향키 · 정수 맞춤 단추). +거울 시험 = `resources/tester/test_234_1_structure_station.py`. +일반 측점 = 격자(간격) · 시종점 — 서버 자동 배치 · 노선 재투영이 쓴다. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import replace +from typing import TypeVar + +from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex +from config.config_system_design import SECTION_STATION_INTERVAL_M + +_Candidate = TypeVar("_Candidate") + +# 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). +GENERAL_SNAP_M = 1.0 +_EPS = 1e-6 + + +def general_stations(total_m: float, interval_m: float) -> list[float]: + """격자 측점 + 끝 측점(`Sections_Core._chainages` 와 같은 줄).""" + if not (total_m > 0 and interval_m > 0): + return [0.0] + values = [index * interval_m for index in range(int(total_m // interval_m) + 1)] + if total_m - values[-1] > _EPS: + values.append(float(total_m)) + return values + + +def _near_general(value: float, stations: Sequence[float]) -> float | None: + best: float | None = None + for station in stations: + gap = abs(station - value) + if gap < GENERAL_SNAP_M - _EPS and (best is None or gap < abs(best - value)): + best = float(station) + return best + + +def snap_structure_station(chainage_m: float, stations: Sequence[float]) -> float: + """기준 측점 맞춤 — 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m(.5 는 올림) · 정수가 일반 + 측점 1 m 안이면 그 측점. 두 번 맞춰도 같다.""" + value = float(chainage_m) + if not math.isfinite(value): + return value + direct = _near_general(value, stations) + if direct is not None: + return direct + whole = float(math.floor(value + 0.5)) + near = _near_general(whole, stations) + return whole if near is None else near + + +def route_general_stations(total_m: float) -> list[float]: + """노선 길이의 일반 측점 — 서버 측점 간격(`SECTION_STATION_INTERVAL_M`).""" + return general_stations(total_m, SECTION_STATION_INTERVAL_M) + + +def snap_candidates( + candidates: Sequence[_Candidate], vertices: Sequence[RouteVertex] +) -> list[_Candidate]: + """자동 배치 관 자리 맞춤 — 좌표는 맞춘 자리로 다시 찍고, 같은 자리로 모이면 앞선 것만. + + `candidates` 는 `chainage_m · x · y` 를 가진 dataclass(`StructureCandidate`).""" + total_m = float(vertices[-1].chainage_m) + stations = route_general_stations(total_m) + snapped: list[_Candidate] = [] + taken: set[float] = set() + for candidate in candidates: + at = min(snap_structure_station(candidate.chainage_m, stations), total_m) + if at in taken: + continue + taken.add(at) + x, y, _ = interpolate_vertex(list(vertices), at) + snapped.append(replace(candidate, chainage_m=at, x=x, y=y)) + return snapped diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts new file mode 100644 index 000000000..4c5cc21d2 --- /dev/null +++ b/common_util/common_util_structure_station.ts @@ -0,0 +1,69 @@ +/* ============================================================================= + * common_util/common_util_structure_station.ts + * 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1). + * + * 파이썬 짝 = `common_util/common_util_structure_station.py`(서버 자동 배치 · 노선 재투영). + * 거울 시험 = `resources/tester/test_234_1_structure_station.py`. + * 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 상세를 읽을 때 한 번 둔다. + * ========================================================================== */ + +/** 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). */ +export const GENERAL_SNAP_M = 1; +const EPS = 1e-6; + +let general: number[] = []; + +/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */ +export function setGeneralStations( + stations: ReadonlyArray<{ chainage_m: number; kind?: string }> | null | undefined, +): void { + general = (stations ?? []) + .filter((station) => station.kind !== "irregular") + .map((station) => station.chainage_m); +} + +export const generalStations = (): readonly number[] => general; + +function nearGeneral(value: number, stations: readonly number[]): number | null { + let best: number | null = null; + for (const station of stations) { + const gap = Math.abs(station - value); + if (gap < GENERAL_SNAP_M - EPS && (best === null || gap < Math.abs(best - value))) + best = station; + } + return best; +} + +/** 기준 측점 맞춤 — 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m(.5 는 올림) · 정수가 일반 측점 + * 1 m 안이면 그 측점(끝 119.8 옆 119 → 119.8). 두 번 맞춰도 같다. */ +export function snapStructureStation( + chainageM: number, + stations: readonly number[] = general, +): number { + if (!Number.isFinite(chainageM)) return chainageM; + const direct = nearGeneral(chainageM, stations); + if (direct !== null) return direct; + const whole = Math.floor(chainageM + 0.5); + return nearGeneral(whole, stations) ?? whole; +} + +/** 방향키 한 걸음(1 m) — 붙기가 제자리로 되돌리면 한 걸음 더(끝 119.8 → 118). 노선 밖이면 제자리. */ +export function stepStructureStation( + fromM: number, + direction: 1 | -1, + stations: readonly number[] = general, +): number { + const end = stations.length ? Math.max(...stations) : Infinity; + for (let steps = 1; steps <= 3; steps += 1) { + const next = snapStructureStation(fromM + direction * steps, stations); + if (next < 0 || next > end + EPS) return fromM; + if ((next - fromM) * direction > EPS) return next; + } + return fromM; +} + +/** 이미 맞춘 값인가(정수 맞춤 단추가 옮길 것을 가름). */ +export const isSnappedStation = ( + chainageM: number, + stations: readonly number[] = general, +): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS; diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 209ea2d1f..f9fc900b3 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -148,6 +148,10 @@ const panelModule = loadTs( "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": { readStructurePick: () => null }, "../A00_Common/design/design_selection": selection, "@ui/ui_template_elements": { showToast() {} }, + // 관 기준 측점 정수 맞춤(234-1) — 일반 측점 없으면 정수 m 만 + "../common_util/common_util_structure_station": { + snapStructureStation: (value) => Math.floor(value + 0.5), + }, "./B06_Section_UI_Adjust_Dock": { adjustDockRoot: () => ({}), refreshAdjustSlots() {}, diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 24d17d3e8..0f8153e01 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -167,6 +167,10 @@ const panelModule = loadTs( "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": pickSession, "../A00_Common/design/design_selection": selection, "@ui/ui_template_elements": { showToast() {} }, + // 관 기준 측점 정수 맞춤(234-1) — 일반 측점 없으면 정수 m 만 + "../common_util/common_util_structure_station": { + snapStructureStation: (value) => Math.floor(value + 0.5), + }, "./B06_Section_UI_Adjust_Dock": { adjustDockRoot: () => ({}), refreshAdjustSlots() {}, diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs new file mode 100644 index 000000000..e6717ba84 --- /dev/null +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -0,0 +1,192 @@ +/* 234-1 구조물 기준 측점 = 정수 m · 일반 측점 붙기 · [정수 맞춤]. + * 진짜 `common_util_structure_station` · `_Structures_Snap` · `_Structures_Fields` · `_Drainage_Pipes` · + * `followedStation` 을 Node 로 돌림. 입력은 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* 가짜 DOM — 폼 칸(input · label · span)만. */ +function fakeElement(tag) { + const listeners = {}; + const classes = new Set(); + return { + tagName: tag, + value: "", + className: "", + style: {}, + children: [], + classList: { + toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)), + add: (name) => classes.add(name), + remove: (name) => classes.delete(name), + contains: (name) => classes.has(name), + }, + append(...nodes) { + this.children.push(...nodes); + }, + addEventListener(type, fn) { + (listeners[type] ??= []).push(fn); + }, + fire(type) { + (listeners[type] ?? []).forEach((fn) => fn({ type })); + }, + focus() {}, + }; +} +global.document = { createElement: fakeElement }; + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const station = loadTs("common_util/common_util_structure_station.ts"); +const STATIONS = input.stations; +const out = {}; + +// ① 맞춤 · 걸음 — 파이썬과 같은 값(거울) +out.snap = input.samples.map((value) => station.snapStructureStation(value, STATIONS)); +out.step = input.steps.map(([from, dir]) => station.stepStructureStation(from, dir, STATIONS)); +out.snapTwice = input.samples.every( + (value) => + station.snapStructureStation(station.snapStructureStation(value, STATIONS), STATIONS) === + station.snapStructureStation(value, STATIONS), +); + +// 일반 측점 등록 — 구조물 측점(irregular)은 뺌 · 등록값이 기본으로 쓰임 +station.setGeneralStations( + STATIONS.map((value) => ({ chainage_m: value, kind: "regular" })).concat([ + { chainage_m: 80.17, kind: "irregular" }, + ]), +); +out.general = [...station.generalStations()]; +out.defaultSnap = station.snapStructureStation(80.4); + +// ② 좌측 폼 칸 — 친 값 80.17 → 80 · 안 고친 옛 값은 그대로 +const fields = loadTs("B05_Profile/B05_Profile_UI_Structures_Fields.ts", { + "./B05_Profile_Util_Station": loadTs("B05_Profile/B05_Profile_Util_Station.ts"), + "../B06_Section/B06_Section_UI_Cross_Culvert_Basin": { DEFAULT_BASIN_SHAPE: {} }, + "../common_util/common_util_culvert_sets": { fillPipeHeights: () => {} }, + "../common_util/common_util_structure_station": station, +}); +const anchor = fields.stationFields("기준 측점", () => 20); +out.remainderStep = anchor.remainder.step; +anchor.station.value = "4"; +anchor.remainder.value = "0.17"; +out.typed = anchor.read(20, true); +anchor.remainder.value = "0.6"; +out.typedNear = anchor.read(20, true); +anchor.remainder.value = "1"; +out.typedOne = anchor.read(20, true); +anchor.write(80.17, 20); +out.untouched = anchor.read(20, true); + +// ③ 구조물 옮김 · [정수 맞춤] 셈 · 단추 +const toasts = []; +let confirmText = null; +const snap = loadTs("B05_Profile/B05_Profile_UI_Structures_Snap.ts", { + "@ui/ui_template_elements": { + showToast: (message, kind) => toasts.push({ message, kind }), + showConfirmDialog: async (message) => ((confirmText = message), true), + }, + "../common_util/common_util_structure_station": station, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (entry) => entry.chainage_m ?? entry.start_m ?? 0, + }, +}); +out.movedPoint = snap.movedStructure({ placement: "point", chainage_m: 127.86 }, 127.86); +out.movedWall = snap.movedStructure( + { placement: "interval", chainage_m: 80.17, start_m: 75.17, end_m: 95.67 }, + 80.17, +); +out.movedDrag = snap.movedStructure( + { placement: "interval", chainage_m: 50, start_m: 50, end_m: 62 }, + 133.4, +); + +const structures = input.structures.map((entry) => ({ ...entry })); +const pipes = input.pipes.map((chainage_m) => ({ + chainage_m, + facility: "pipe", + options: { pipe_kind: "흄관" }, + summary_item: null, + group_items: null, +})); +const plan = snap.snapPlan(structures, pipes); +out.plan = { + structures: plan.structures.map(({ index, next }) => ({ index, next })), + pipes: plan.pipes.map(({ from, to }) => [from, to]), + skipped: plan.skipped, +}; +const moves = []; +let emits = 0; +let closed = 0; +snap + .runSnap({ + structures, + pipes: () => pipes, + emit: () => (emits += 1), + movePipe: (from, to, attributes) => moves.push({ from, to, attributes }), + closeForm: () => (closed += 1), + }) + .then(async (count) => { + out.run = { count, emits, closed, moves, structures, confirmText, toasts: [...toasts] }; + // 두 번째 누름 = 옮길 것 없음(관은 위 moves 로 옮겨졌다고 보고 목록을 맞춤) + moves.forEach(({ from, to }) => { + const hit = pipes.find((pipe) => pipe.chainage_m === from); + if (hit) hit.chainage_m = to; + }); + toasts.length = 0; + const again = { structures, pipes: () => pipes, emit() {}, movePipe() {}, closeForm() {} }; + out.second = { count: await snap.runSnap(again), toasts: [...toasts] }; + + // ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘) + const alignment = loadTs("B05_Profile/B05_Profile_UI_Profile_Alignment.ts"); + const gone = [ + { id: "pipe-80.17", chainage_m: 80.17 }, + { id: "pipe-263.31", chainage_m: 263.31 }, + ]; + const born = [ + { id: "pipe-80.00", chainage_m: 80 }, + { id: "pipe-263.00", chainage_m: 263 }, + ]; + out.follow = gone.map((entry) => { + const old = alignment.followedStation(entry, gone, born); + const next = old ?? snap.followSnapped(entry, born); + return { old: old?.chainage_m ?? null, next: next?.chainage_m ?? null }; + }); + + // ⑤ 배수유역도 관 지점 — 옮김 · 우클릭 추가 = 정수 m + const pipesEditor = loadTs("B05_Profile/B05_Profile_UI_Drainage_Pipes.ts", { + "@ui/ui_template_palette": { themeColor: () => "#000" }, + "../common_util/common_util_structure_station": station, + }); + const editor = pipesEditor.createPipeEditor(() => {}); + editor.setContext(null, [ + { x: 0, y: 0, chainage_m: 0 }, + { x: 300, y: 0, chainage_m: 300 }, + ]); + editor.setPipes([{ chainage_m: 80.17, reason: "stream" }]); + editor.moveTo(0, 141.37); + editor.addAtChainage(200.6); + out.editor = editor.pipes().map((pipe) => pipe.chainage_m); + + process.stdout.write(JSON.stringify(out)); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/helper_b05_drainage_report.cjs b/resources/tester/helper_b05_drainage_report.cjs index 402b32bba..c8aac2cfa 100644 --- a/resources/tester/helper_b05_drainage_report.cjs +++ b/resources/tester/helper_b05_drainage_report.cjs @@ -8,23 +8,24 @@ const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); +// 찾는 폭 한 값(234-1 · `PIPE_MATCH_M`) — 세션 저장소까지 끌고 오지 않게 값만 줌 +const localRequire = (request) => + request === "../A00_Common/design/design_selection" ? { PIPE_MATCH_M: 0.51 } : require(request); +new Function("exports", "module", "require", js)(box.exports, box, localRequire); const { buildPipeReports } = box.exports; const store = { get: (chainage) => - chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null, + chainage === 100.5 + ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } + : null, }; const basins = [ { chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false }, { chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true }, ]; const out = buildPipeReports( - [ - { chainage_m: 40.004, reason: "auto" }, - { chainage_m: 100.499 }, - { chainage_m: 200 }, - ], + [{ chainage_m: 40.004, reason: "auto" }, { chainage_m: 100.499 }, { chainage_m: 200 }], basins, store, ); diff --git a/resources/tester/test_219_1_arrow_owner.py b/resources/tester/test_219_1_arrow_owner.py index f9884020d..cd6ae78f9 100644 --- a/resources/tester/test_219_1_arrow_owner.py +++ b/resources/tester/test_219_1_arrow_owner.py @@ -63,9 +63,10 @@ def test_횡단_카드를_누른_뒤_좌우키는_관을_안_옮김(out) -> None @needs_node def test_종단도를_누른_뒤_좌우키는_지금처럼_이동(out) -> None: - assert out["profileThenArrows"]["move"] == [[263.21, 263.31]] - assert out["popoutThenArrows"]["move"] == [[263.21, 263.11]] # 새 창 종단도도 - assert out["chuteThenProfile"]["move"] == [[263.21, 263.31]] + # 좌우 한 걸음 = 1 m · 정수 m(234-1 · 옛 0.1 m) + assert out["profileThenArrows"]["move"] == [[263.21, 264]] + assert out["popoutThenArrows"]["move"] == [[263.21, 262]] # 새 창 종단도도 + assert out["chuteThenProfile"]["move"] == [[263.21, 264]] @needs_node diff --git a/resources/tester/test_234_1_structure_station.py b/resources/tester/test_234_1_structure_station.py new file mode 100644 index 000000000..885ec58af --- /dev/null +++ b/resources/tester/test_234_1_structure_station.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +"""234-1 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추. + +9-29 「저장값 소수 · 표기만 정수」(20장)를 「저장값도 정수 m」로 바꿈 +(사용자 확정 10-10 · 조사 AF 7절 A+B+C). +맞춤 규칙 한 벌 = 파이썬 `common_util_structure_station.py` ↔ +TS `common_util_structure_station.ts`(거울). +화면 쪽 진짜 TS(폼 칸 · 구조물 옮김 · 단추 · 배수유역도 관 · 계획고 편집 따라감)는 +`helper_234_1_structure_station.cjs` 가 Node 로 돌린다. 서버 자동 배치 · 노선 재투영은 파이썬으로. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402 +from common_util.common_util_route_geometry import RouteVertex, StructureCandidate # noqa: E402 +from common_util.common_util_structure_station import ( # noqa: E402 + general_stations, + snap_candidates, + snap_structure_station, +) + +TOTAL = 279.8 +STATIONS = general_stations(TOTAL, 20.0) +# (입력, 기대) — 소수 → 정수 · 일반 측점 1 m 안 붙기 · 끝 측점(소수) 붙기 +CASES = [ + (80.17, 80.0), # 관 80.17 = 카드 80 한 자리 + (80.6, 80.0), # 1 m 안 → 일반 측점 + (80.99, 80.0), + (81.0, 81.0), # 꼭 1 m 는 밖 — 81 에 둘 수 있음(방향키가 갇히지 않음) + (50.5, 51.0), # .5 는 올림(파이썬 · TS 같은 꼴) + (50.4, 50.0), + (263.31, 263.0), + (263.206, 263.0), + (127.86, 128.0), + (0.3, 0.0), + (279.3, 279.8), # 끝 측점 1 m 안 → 끝 측점(정수가 아닌 일반 측점) + (278.6, 279.8), # 반올림 279 가 끝 측점 1 m 안 + (277.4, 277.0), +] +STEPS = [ + ((80.0, 1), 81.0), + ((80.17, 1), 81.0), + ((80.17, -1), 80.0), # 79.17 은 일반 측점 80 의 1 m 안 + ((279.8, -1), 278.0), # 279 는 끝 측점에 붙어 제자리 → 한 걸음 더 + ((279.8, 1), 279.8), # 노선 밖은 제자리 + ((0.0, -1), 0.0), +] +STRUCTURES = [ + {"structure_id": "sign", "placement": "point", "chainage_m": 127.86}, + { + "structure_id": "wall", + "placement": "interval", + "chainage_m": 80.17, + "start_m": 75.17, + "end_m": 95.67, + }, + {"structure_id": "ok", "placement": "point", "chainage_m": 40.0}, + {"structure_id": "near", "placement": "point", "chainage_m": 20.4}, +] +PIPES = [80.17, 120.0, 120.3, 263.31, 140.0] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s234") / "in.json" + source.write_text( + json.dumps( + { + "stations": STATIONS, + "samples": [value for value, _ in CASES], + "steps": [list(args) for args, _ in STEPS], + "structures": STRUCTURES, + "pipes": PIPES, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_234_1_structure_station.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_general_stations() -> None: + assert STATIONS == [float(index * 20) for index in range(14)] + [279.8] + + +@pytest.mark.parametrize(("value", "expected"), CASES) +def test_snap_python(value: float, expected: float) -> None: + assert snap_structure_station(value, STATIONS) == expected + # 두 번 맞춰도 같음 + assert snap_structure_station(expected, STATIONS) == expected + + +def test_mirror_python_ts(ts) -> None: + assert ts["snap"] == [snap_structure_station(value, STATIONS) for value, _ in CASES] + assert ts["snapTwice"] is True + + +def test_arrow_step_one_metre(ts) -> None: + assert ts["step"] == [expected for _, expected in STEPS] + + +def test_general_registry_skips_irregular(ts) -> None: + assert ts["general"] == STATIONS # 구조물 측점 80.17(irregular)은 일반 측점 아님 + assert ts["defaultSnap"] == 80 + + +def test_form_input_integer(ts) -> None: + assert ts["remainderStep"] == "1" + assert ts["typed"] == 80 # 입력 4+0.17 = 80.17 → 80 + assert ts["typedNear"] == 80 # 4+0.6 → 일반 측점 80 에 붙음 + assert ts["typedOne"] == 81 + assert ts["untouched"] == 80.17 # 안 고친 옛 값은 그대로(단추 몫) + + +def test_moved_structure_range_ends_integer(ts) -> None: + assert ts["movedPoint"]["chainage_m"] == 128 + wall = ts["movedWall"] + assert (wall["chainage_m"], wall["start_m"], wall["end_m"]) == (80, 75, 96) + drag = ts["movedDrag"] + assert (drag["chainage_m"], drag["start_m"], drag["end_m"]) == (133, 133, 145) + + +def test_snap_button_plan_and_run(ts) -> None: + plan = ts["plan"] + assert [entry["index"] for entry in plan["structures"]] == [0, 1, 3] + assert plan["pipes"] == [[80.17, 80], [263.31, 263]] + assert plan["skipped"] == 1 # 120.3 → 120 자리에 관이 이미 있음 + run = ts["run"] + assert run["count"] == 5 and run["emits"] == 1 and run["closed"] == 1 + assert [s["chainage_m"] for s in run["structures"]] == [128, 80, 40, 20] + assert (run["structures"][1]["start_m"], run["structures"][1]["end_m"]) == (75, 96) + # 관은 하나씩 폼 [수정]과 같은 길 — 시설 속성이 그대로 실림 + assert [(m["from"], m["to"]) for m in run["moves"]] == [(80.17, 80), (263.31, 263)] + assert run["moves"][0]["attributes"]["options"] == {"pipe_kind": "흄관"} + assert run["moves"][0]["attributes"]["facility"] == "pipe" + assert "5건" in run["confirmText"] + assert run["toasts"][-1]["message"].startswith("정수 맞춤 5건") + assert "1건은 그대로" in run["toasts"][-1]["message"] + # 두 번째 누름 — 옮길 것 없음(겹친 관만 알림) + assert ts["second"]["count"] == 0 + assert "1건은 맞출 자리에 다른 관" in ts["second"]["toasts"][0]["message"] + + +def test_plan_edit_follows_batch_move(ts) -> None: + # 213-2 규칙만으로는 여럿을 한꺼번에 옮기면 못 따라감(null) → 맞춘 자리로 따라감 + assert ts["follow"] == [{"old": None, "next": 80}, {"old": None, "next": 263}] + + +def test_drainage_map_pipe_integer(ts) -> None: + assert ts["editor"] == [141, 200] # 옮김 141.37 → 141 · 추가 200.6 → 일반 측점 200 + + +def _vertices() -> list[RouteVertex]: + return [RouteVertex(x=0.0, y=0.0, z=0.0, chainage_m=0.0), RouteVertex(TOTAL, 0.0, 0.0, TOTAL)] + + +def test_server_auto_placement_integer() -> None: + vertices = _vertices() + placed = [ + StructureCandidate(chainage_m=80.374, x=80.374, y=0.0, reason="stream"), + StructureCandidate(chainage_m=150.6, x=150.6, y=0.0, reason="spacing"), + StructureCandidate(chainage_m=150.9, x=150.9, y=0.0, reason="spacing"), + ] + snapped = snap_candidates(placed, vertices) + assert [(p.chainage_m, p.x, p.reason) for p in snapped] == [ + (80.0, 80.0, "stream"), + (151.0, 151.0, "spacing"), # 같은 자리로 모인 둘째는 버림 + ] + + +def test_server_reprojection_integer() -> None: + points = [PipePoint(chainage_m=70.0, x=80.374, y=3.0, start_m=75.0, end_m=85.0)] + moved = project_pipe_points(points, _vertices()) + assert moved[0].chainage_m == 80.0 + assert (moved[0].start_m, moved[0].end_m) == (85.0, 95.0) # 구간은 같은 만큼 밀림 + + +def test_wiring_source() -> None: + """받는 곳마다 맞춤이 걸렸는지(짝 머리 · 폼 · 끌기 · 방향키 · 관 · 단추).""" + + def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + assert "common_util_structure_station.py" in read( + "common_util/common_util_structure_station.ts" + ) + assert "common_util_structure_station.ts" in read( + "common_util/common_util_structure_station.py" + ) + assert "movedStructure(structures[index], toChainageM)" in read( + "B05_Profile/B05_Profile_UI_Structures_Panel.ts" + ) + assert "snapDock(list" in read("B05_Profile/B05_Profile_UI_Structures_Panel.ts") + assert "stepStructureStation(fromM" in read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + b06 = read("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") + assert "const to = snapStructureStation(toChainageM)" in b06 + assert "Math.abs(fromChainageM - toChainageM) > 0.005" in b06 + assert "setGeneralStations" in read("B06_Section/B06_Section_Section_Store.ts") + assert "snap_candidates(" in read("common_util/common_util_drainage_detail.py") + for rel in ( + "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", + "B05_Profile/B05_Profile_UI_IrregularStations.ts", + "B05_Profile/B05_Profile_UI_Drainage_Parts.ts", + "B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts", + ): + assert "< 0.51" not in read(rel), rel # 찾는 폭 = PIPE_MATCH_M 한 값 diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py index 22d113943..6c8ad7142 100644 --- a/resources/tester/test_b05_station_roundtrip.py +++ b/resources/tester/test_b05_station_roundtrip.py @@ -42,7 +42,8 @@ def test_화면_표기는_거리를_정수로(results): def test_칸을_고치면_친_값(results): - assert results["edited"] == pytest.approx(125.2) + # 친 값 125.2 → 정수 m 125(234-1 · 저장값도 정수 m — 9-29 「소수 그대로」를 바꿈) + assert results["edited"] == 125 assert results["normalized"] == ["2", "10.0", 50] assert results["cleared"] is None From 785307dcd0f4d2244c41d7d1e6d2ee250aa16b14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:07:29 +0900 Subject: [PATCH 26/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=A1=B0=EC=82=AC?= =?UTF-8?q?=20AG(=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=8C=80=20=EC=A0=95=EC=9D=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- .../요구_1010_2/조사_AG_구조물목록대정의.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AG_구조물목록대정의.md diff --git a/docs/raw/요구_1010_2/조사_AG_구조물목록대정의.md b/docs/raw/요구_1010_2/조사_AG_구조물목록대정의.md new file mode 100644 index 000000000..0f93d2b6f --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AG_구조물목록대정의.md @@ -0,0 +1,84 @@ +# 조사 AG — 구조물 목록 대 정의 · 3D 측점 표기 (109 · 108 뒷부분) (10-10 · 읽기 전용 조사 에이전트) + +약칭: DEF `resources/master_template/구조물정의.json` · TBL `docs/raw/구조물정의_표/0N_*.md` · `B05_Profile/B05_Profile_` + API `Api_Structures.ts` · PS `UI_Page_Structures.ts` · PT `UI_Structures_Panel_Types.ts` · PNL `UI_Structures_Panel.ts` · PO `UI_Structures_Panel_Options.ts` · DEFN `UI_Structures_Define.ts` · LST `UI_Structures_List.ts` · MRK `UI_Structures_Marks.ts` · STN `UI_Page_Stations.ts` · MK `UI_Markers.ts` · PR `UI_Profile_Render.ts` · PA `UI_Page_Actions.ts` · CS `UI_Corridor_Structures.ts` · SCH `Structures_Schema.py` · ES `Engine_Sections.py` · RC `Router_Confirm.py` · RL `Router_Lifecycle.py` / `B06_Section/B06_Section_` + SP `UI_Page_Structures_Panel.ts` · VP `UI_Section_View_Profile.ts` · SS `Section_Store.ts` · WL `Engine_Structures_Wall.py` · RS `Router_Stations.py` · RC6 `Router_Confirm.py` / B07P `B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py` · B07O `B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts` · B08S `B08_Quantity/B08_Quantity_Router_Summary.py` · M02D `M02_MasterTemplete/StructOptions/M02_SO_Def.ts` + +판정은 코드 읽기 · storage 6 프로젝트에 C군(옹벽 · 흙막이 · 소단) 자료가 없어 화면 실측은 못 함(노면공 · 위험표시만 있음). + +## 1. 한눈 결론 + +1. 지금 고를 수 있는 「노면공이 아닌데 범위를 갖는 구조물」은 **옹벽 · 흙막이 · 소단(C) + 가드레일(E)** 넷뿐. 107 답의 B군 6종 · 비탈면 녹화(F)는 정의에서 꺼짐 · 대피소 · 정차·작업장은 「대피로(점형) 상세」로 흡수된 옛 자료. 소단은 길이 · 전 · 후 칸이 숨어 **기준 ±5 m(10 m) 고정**. +2. 109 까닭: 3D 노란 띠 · 라벨은 **관 + A군만** 그림. 서버가 소단 시작 · 기준 · 종료 측점을 심어도 3D 와 종단 그래프가 서버 비정규 측점을 일부러 걷어 내고(STN:27 · PR:240), 대신 넣는 화면 목록이 A군만 담음(PS:147-151). +3. 더 큰 구멍(코드 판정): **B05 [저장]이 「관 + A군」 목록으로 종단 비정규 측점을 통째 갈아 끼움** → 심어 둔 C군 측점 · 독립 기슭막이 시작/종료 측점이 지워짐(관이 하나라도 있을 때). +4. 같은 누락: 옹벽 · 흙막이 · 독립 기슭막이 시작/종료 · E군 전부 · 노면공 · 기타 = 3D 측점 표기 없음. B06 종단 측점선은 관만(A군 수동 3종도 없음). +5. 그 밖 어긋남: B06 고르기에 꺼진 F 2종 · 쇄석 포설이 보임 · M02 「안 씀」과 화면 가림(코드 상수)이 따로 놂 · B07 평면 대피소 기호가 옛 `refuge` 만 봄 · DEF comment 낡음. + +## 2. 지금 켜진 구성 표 + +고르기 규칙 — B05 = 정의 켜짐 · 군 B · D 가림 · 종류 돌쌓기(찰 · 메) · 큰돌쌓기 가림 · 옛 대피 3종 뺌(PS:320-323 · PT:41-65 · 85 · DEFN:221-229). B06 = 정의 켜짐을 안 봄(SP:366-370 `setTypes(types)` · 가림 상수만). M02 = 42종 전부 + 「안 씀」 표시(M02D:137-147 · 가림 상수 모름). + +| 군 | type_id | 이름 | placement | 정의 enabled | B05 고름 | B06 고름 | 비고 | +|---|---|---|---|---|---|---|---| +| A | pipe · box_culvert · ford_pavement · ford_bridge | 배관 · BOX암거 · 물넘이포장 · 세월교 | point | 켜짐 | 있음 | 있음 | 관 정본(pipe_points) | +| A | cross_drain_exposed · open_ditch · cross_underdrain | 노출형 횡단수로 · 개거 · 맹암거 | point | 켜짐 | 있음 | 있음 | structures.json | +| B | ditch_side · ditch_ridge · ditch_berm · chute · slope_drain · underdrain | 측구 등 6 | interval | 꺼짐 | 없음 | 없음(군 가림) | 표 07 숨김 | +| C | retaining_wall · soil_guard | 옹벽 · 흙막이 | interval | 켜짐 | 있음 | 있음 | 기준 + 길이 · 전 · 후 | +| C | berm | 소단 | interval | 켜짐 | 있음 | 있음 | 길이 10 · 전 5 · 후 5 숨김(PO:162-175 · DEF 표시 3575) | +| C | masonry_wet · masonry_dry · boulder_masonry | 돌쌓기(찰 · 메) · 큰돌쌓기 | interval | 켜짐 | 없음 | 없음 | 코드 가림(PT:44-48 · 54-31) | +| D | erosion_check · bed_sill · check_dam_small | 골막이 · 바닥막이 · 소형사방댐 | point | 켜짐 | 없음 | 없음 | 군 가림 · bed_sill 은 comment 상 「삭제」인데 남음(DEF:2295) | +| D | revetment | 기슭막이 | point(관 정본) | 켜짐 | 관 시설로 | 관 시설로 | 독립 기슭막이 = 시작~종료 | +| E | evac_road | 대피로 | point | 켜짐 | 있음 | 있음 | 상세 = 대피소 · 정차/작업장 · 차돌림곳 | +| E | refuge · work_yard · turnaround | 대피소 · 정차·작업장 · 차돌림곳 | interval · interval · site | 켜짐 | 없음 | 없음 | 옛 종류 · 고치면 대피로로 저장 | +| E | mirror · curb · hazard_sign · position_sign · water_intake · barrier_gate · completion_sign | 반사경 등 7 | point | 켜짐 | 있음 | 있음 | | +| E | guardrail | 가드레일 | interval | 켜짐 | 있음 | 있음 | 기준 + 길이 · 전 · 후 · 「측점 범위」 줄 보임(PNL:159) | +| E | spoil_bank · borrow_pit | 사토장 · 토취장 | site | 켜짐 | 있음 | 있음 | 한 측점 + 부피(범위 아님 · SCH:217-226) | +| F | wildlife_path · revegetation | 야생동물 이동통로 · 비탈면 녹화 | point · interval | 꺼짐 | 없음 | **있음** | B06 고르기에 새어 나옴 | +| G | pavement_concrete | 콘크리트 포장 | interval | 켜짐 | 있음 | 있음 | 시작 · 종료 직접(byRange) | +| G | gravel_surfacing | 쇄석(자갈) 포설 | interval | 꺼짐 | 없음 | **있음** | B06 고르기에 새어 나옴 | +| 호환 | etc | 기타 | point | 켜짐 | 있음 | 있음 | 수기 이름 | + +- 「노면공이 아닌 범위형 · 지금 켜짐 · 고를 수 있음」 확정 = **옹벽 · 흙막이 · 소단 · 가드레일**. 돌쌓기 3종은 정의상 켜짐이나 화면에서 가림 · 옛 값만 열림. +- TBL 01~09 대조: 표의 종류는 정의에 모두 있음. 정의에만 있는 것(B · D · F군 · 돌쌓기 3 · 옛 대피 3 · 쇄석)은 화면이 가림 — 단 B06 의 F 2 · 쇄석은 새어 나옴. 표 09 「소단 높이」는 178-2 로 숨김(깊이 칸도 194 로 숨기고 횡단도에서 측점마다 고름) — 결정된 변경이라 누락 아님. + +## 3. 종류별 화면 반영 대조 표 + +| 종류 | ① 좌측 목록 | ② 종단 알약 · 측점선 | ③ 3D 띠 · 라벨 / 메쉬 | ④ B06 카드 · 측점 심음 | ⑤ B07 · B08 | +|---|---|---|---|---|---| +| 관 4종 | 있음(기준) | 알약 · 측점선 있음(물넘이 = 알약만) | 띠 · 라벨 있음(물넘이 없음) · 메쉬 있음 | 형상 · 심음 있음 | B07 평면 기호 · 횡단 덧그림 · B08 횡단배수 집계표 | +| 독립 기슭막이 | 있음 | 알약 · 측점선 **기준만** | 띠 · 라벨 **기준만** · 벽 메쉬 있음 | 시작 · 기준 · 종료 심음 · 형상 | B07 횡단 · B08 흙막이 열 | +| A 수동 3종 | 있음 | 알약 · 측점선(B05) · **B06 측점선 없음** | 띠 · 라벨 있음 · 메쉬 없음 | 심음 · 형상 없음(카드 제목만) | B08 개소 1(SIMPLE) | +| 옹벽 · 흙막이 | 있음(시작~종료) | 알약 + 범위 띠 · **측점선 없음** | **띠 · 라벨 없음** · 벽 메쉬 있음(측점 있을 때) | 시작 · 기준 · 종료 심음 · 벽 형상 | B07 횡단 덧그림 · B08 조합 연결 때만 | +| 소단 | 있음(시작~종료) | 알약 + 띠 · **측점선 없음** | **띠 · 라벨 없음** · 메쉬 = 비탈 계단(측점 행에서만) | 심음 · 형상(design.berm · 178-2 한 단 · 194 깊이) | B07 평탄부 판정 · 토공 면적에 섞임 · 조합 연결 때만 | +| 가드레일 | 있음 | 알약 + 띠 | 없음 | 없음(안 심음) | 조합 연결 때만 | +| 대피로 · E 점형 7 | 있음 | 알약 | 없음 | 없음 | **B07 평면 대피소 기호 = 옛 refuge 만** · 조합 | +| 사토장 · 토취장 | 있음 | 알약 | 없음 | 사토 단면(가까운 측점) · 토취장 없음 | B07 평면 사토장 기호 · 조합 | +| 콘크리트 포장 | 있음(시작~종료) | 알약 + 띠 | 없음 | 카드 포장 · 안 심음 | B07 횡단 포장 덧그림 · 조합 | +| 기타 | 있음(이름) | 알약 | 없음 | 없음 | 조합 | + +근거: ① LST:54-57 · 156-243 / ② MRK:176-188 · PS:139-167 · PR:236-245 · VP:83-104 / ③ STN:22-63 · MK:334-352 · 405-464 · CS:285-289 / ④ SCH:143-157 · ES:296-311 · SS:76-101 · WL:35-41 · 123-176 · `B06_Section_Engine_SpoilFill.py`:97 / ⑤ B07P:82 · 431-434 · B07O:485-506 · B08S:79-81 · `M02_MasterTemplete/M02_Table_Fill.py`:104-108 · `B07_DesignDetail_Engine_SlopeGeometry.py`:178-180. + +## 4. 109 — 소단이 3D 에서 측점 표기가 안 되는 까닭 + +- **그리는 규칙**: 3D 띠 = 종단 정본의 규칙 측점 + 화면 목록 비정규 측점. 서버 비정규 측점은 「옮기기 전 자리 남음」을 막으려 버림(STN:24-27). 화면 목록 = 관 투영 + A군 수동만(PS:147-151 · 165-167). ⇒ 소단 측점이 종단 정본에 있어도 3D 띠 · 라벨 없음. 종단 그래프 세로선도 같은 규칙(PR:236-245). 라벨은 비정규면 「측점 + 이름」(MK:345-348)이라 목록에만 들면 바로 「소단」이 붙음. +- **측점 심기**: 178-1(24aba4008)로 C군 구간형 시작 · 기준 · 종료를 심음(SCH:143-157 · ES:296-311). 심는 때 = B06 [측점 만들기] · B06 저장 중 행 없는 측점 수정(RS:209-228 · RC6:90-103) · 전체 재생성. **B05 구조물 저장은 안 심음**. +- **B05 [저장]이 지움**: PA:52-68 이 「관 + A군」 목록을 보냄 → RL:59-61 → RC:341-375 → RC:265-290 이 비정규 측점을 **통째 교체**. 심어 둔 소단 · 옹벽 · 흙막이 측점과 독립 기슭막이 시작/종료가 빠짐. ES:446 「단일 공급자 = resolve_extra_stations」 원칙과 어긋남(B06 재생성 RS:145-200 은 서버 목록을 씀). +- **10 m 고정 범위와 겹침**: 소단 = 기준 ±5 m. 측점간격 20 m 면 범위 안 규칙 측점이 없을 수 있음 → 심은 측점이 없거나 지워지면 카드 · 3D 어디에도 소단 형상이 안 섬. +- **194 측점별 깊이**: `berm_depth_{side}_m` 은 측점 patch → 측점이 지워지면 그 깊이도 받을 행 없이 떠돎. **178-2 한 단**: 각 측점 설계선에만 서므로 3D 계단도 측점 행에서만 생김(측점 사이는 로프트 보간). +- **같은 누락**: 옹벽 · 흙막이(띠 · 라벨 없음 · 벽 메쉬는 있음) · 독립 기슭막이 시작/종료(기준만) · E군 · 노면공 · 기타(처음부터 측점 표기 대상 아님) · B06 종단 측점선(관만). + +## 5. 누락 목록 · 고칠 거리 (고치지 않음) + +| # | 누락 | 고칠 거리 | 크기 | 위험 | +|---|---|---|---|---| +| 1 | 3D · 종단 그래프에 C군 측점 표기 없음 | PS `crossDrainStationsOf` 를 서버 규칙 거울로(A + C · D 구간형 시작 · 기준 · 종료) · 두 파일 머리에 짝 · 거울 시험 · ▲▼ 기울기 단추는 A군만 남김 | 작음~중 | 계획고 편집 키가 새 측점에도 붙음 · 0.1 m 겹침 지움(STN:53) | +| 2 | B05 [저장]이 C군 측점을 지움 | RC `_append_irregular_cross_sections` 도 `resolve_extra_stations`(서버 목록)로 — 화면 목록은 안 믿음 | 작음 | 관 초안이 저장 차례상 먼저 나가 서버 목록이 최신(`A00_Common/design/design_save.ts`:31-38) · 시험 test_192_1 · test_213_1 손봄 | +| 3 | 독립 기슭막이 시작 · 종료 3D 표기 | 1 과 함께 — PS `pipesToStations` 에 시작 · 종료 | 작음 | 1 과 같음 | +| 4 | B06 종단 측점선 = 관만 | VP `pipeStations` 를 B05 와 같은 목록으로 | 작음 | B06 카드 고름 연동 | +| 5 | B06 고르기에 꺼진 F 2 · 쇄석 | SP:370 `setTypes(types, t => t.enabled)` | 한 줄 | 옛 값 열기는 그대로 | +| 6 | M02 「안 씀」 ≠ 화면 가림 | 가림 상수(B · D군 · 돌쌓기 3 · 옛 대피 3)를 정의 `enabled:false` 로 옮기거나 M02 에 「화면 가림」 표시 | 중 | B06 재사용 뜻(DEF comment)과 엮임 · M02 판 올림 | +| 7 | B07 평면 대피소 기호 = 옛 refuge 만 | B07P:431-434 에 대피로 상세 「대피소」도 셈 | 한 줄 | 없음 | +| 8 | DEF comment 낡음(「G 꺼짐」 · 「bed_sill 삭제」) · bed_sill 남음 | comment 고침 · bed_sill 정리 여부 결정 | 작음 | M02 판 올림 | +| 9 | E군 · 노면공 3D 표기 없음 | 필요 여부 결정 — 측점을 안 심는 종류라 띠 대신 3D 알약 · 범위 띠 같은 새 표기 | 중 | 3D 글자 겹침 | + +- ⬜ 3D 표기 범위: (가) C군만 / (나) C군 + 독립 기슭막이 시작 · 종료(= 서버가 실제로 심는 측점과 같게) / (다) 모든 구조물 — 추천 (나). +- ⬜ 5번 B06 F 2 · 쇄석: (가) 가림(표 07 · 08 에 없음) / (나) 그대로 — 추천 (가). +- 묶음 추천: 2 → 1 · 3 → 4 차례(2 가 원천 · 1 은 표기). From 3e70362b14e095b44a0ca4ecc61cf4910a4ca2c3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:09:00 +0900 Subject: [PATCH 27/92] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EC=A0=91=ED=9E=88=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=ED=8A=B8=EB=A6=AC=20=E2=80=94=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EC=A4=84=20=EC=88=98=EC=8B=9D=20=C2=B7=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=A4=84=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EB=8D=94=EB=B8=94=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EC=A4=84=20?= =?UTF-8?q?=EC=83=81=EC=9E=90(235-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 표 editOnDouble — 고칠 칸 = 글 · 더블클릭 · Enter · F2 = 그 칸만 글 상자 · Enter · 초점 떠남 = 입력 끝 - 공용 표 level.tree — ├ └ │ 안내선 + 폴더 · 파일 아이콘(공용 나무와 한 벌 TREE_ICON) - 설계내역서 — 폴더 줄 금액 = 자손 합 수식 풍선 · 고른 줄 = accent 상자 테두리 - test_235_1 새 시험 · test_228_1 들여쓰기 식 개수 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Page.css | 29 ++ B09_Estimation/B09_Estimation_UI_Shell.ts | 5 +- B09_Estimation/B09_Estimation_UI_Tabs.ts | 2 + B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 8 + .../B09_Estimation_UI_Tabs_Bill_Level.ts | 17 +- .../tester/test_228_1_table_level_font.py | 3 +- resources/tester/test_235_1_b09_bill_tree.py | 354 ++++++++++++++++++ ui_template/ui_template_data_table.css | 54 +++ ui_template/ui_template_data_table.ts | 125 ++++++- ui_template/ui_template_tree.ts | 10 +- 10 files changed, 580 insertions(+), 27 deletions(-) create mode 100644 resources/tester/test_235_1_b09_bill_tree.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 86cf8fc3c..0b6118118 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -81,6 +81,35 @@ text-align: left; } +/* 235-1 설계내역서 고른 줄 — 줄 전체 상자 테두리(공용 is-focus 밑줄 · 배경 대신) */ +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td { + box-shadow: + inset 0 2px 0 var(--color-accent), + inset 0 -2px 0 var(--color-accent); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:first-child { + box-shadow: + inset 0 2px 0 var(--color-accent), + inset 0 -2px 0 var(--color-accent), + inset 2px 0 0 var(--color-accent); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:last-child { + box-shadow: + inset 0 2px 0 var(--color-accent), + inset 0 -2px 0 var(--color-accent), + inset -2px 0 0 var(--color-accent); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus:not(:hover) td:not(.is-edited) { + background-color: var(--color-canvas); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-focus.is-alt:not(:hover) td:not(.is-edited) { + background-color: var(--dt-zebra); +} + /* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ .b09-estimation .ui-data-table__table tbody tr.is-pick td { --pick-t: inset 0 0 0 0 transparent; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index bd47dacfb..ba5f595ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -204,6 +204,7 @@ export function renderB09Estimation(root: HTMLElement): void { empty: "", caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, + editOnDouble: spec.editOnDouble, onEdit: spec.onEdit, rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null @@ -260,10 +261,10 @@ export function renderB09Estimation(root: HTMLElement): void { }), ); } - // 200-2 설계내역서 = 고른 줄 강조 · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 + // 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력 const bill = (spec as BillTable).menu ? (spec as BillTable) : null; const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined); - const level = bill ? levelBar(table.root, bill) : null; + const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; if (level) bars.setTools(level.tools); panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : [])); if (focus !== undefined) { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1a3405e96..1b5a903b7 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -99,6 +99,8 @@ export interface TabTable { /** 칸 누름 → 갈 줄(호표 칸) */ link?: (index: number, key: string) => Link | null; editable?: (index: number, key: string) => boolean; + /** 고칠 칸 = 더블클릭 · Enter · F2 때만 글 상자(235-1 · 공용 표 editOnDouble) */ + editOnDouble?: boolean; onEdit?: (index: number, key: string, text: string) => void; /** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */ onCell?: (index: number, key: string) => void; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 8363c7930..c768c7998 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -9,6 +9,8 @@ * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) * 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level` * 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴 + * 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) · + * 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -24,6 +26,8 @@ import { } from "./B09_Estimation_UI_Tabs_Bill_Edit"; const TEXT_KEYS = ["name", "spec", "unit"]; +/** 폴더 줄 수식 칸 — 자손 합(셈) */ +const SUM_KEYS = ["sum", "labor", "material", "expense"]; export const buildBill: TabBuild = (ctx): BillTable => { const { sheets, user, setUser } = ctx; @@ -61,9 +65,11 @@ export const buildBill: TabBuild = (ctx): BillTable => { frozen: 1, hint: "기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " + + "칸 누름 = 줄 고름 · 입력 = 더블클릭 · Enter · F2 · 폴더 줄 = 이름만(금액 = 아래 합) · 파일 줄 = 값 입력 · 연결 · " + "금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " + "조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " + `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, + editOnDouble: true, editable: (i, key) => { const r = rows[i]; if (!r) return false; @@ -86,6 +92,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null), tip: (i, key) => { const r = rows[i]; + if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)"; if (!r || !edited(r, key)) return null; const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`]; return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`; @@ -101,6 +108,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { key: "name", of: (i) => rows[i]?.level as number | undefined, group: (i) => rows[i]?.kind !== "item", + tree: true, }, menu: (i) => billMenu(ctx, raw, i), drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 3ff2cab15..49d16fb3b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -5,7 +5,8 @@ * * 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) · 안내 글 = 표 아래(202-1) * 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음 - * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) + * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) · + * 꼴 = 줄 전체 상자 테두리(235-1 · 페이지 CSS) · 글 칸도 한 번 누름 = 고름 · 더블클릭 · Enter · F2 = 입력(공용 표) * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) * 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄) * 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름 @@ -26,7 +27,8 @@ const ACTS: [string, string][] = [ ]; const GUIDE = - "레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기"; + "레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기 · " + + "입력 = 더블클릭 · Enter · F2(Esc · Enter = 끝)"; const GUIDE_MORE = "레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " + "들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " + @@ -43,6 +45,8 @@ let live: { tools: HTMLElement[]; run: (label: string) => void; sync: () => void; + edit: (index: number) => void; + index: () => number; } | null = null; let keysOn = false; @@ -96,6 +100,12 @@ function listenKeys(): void { (at as HTMLElement).blur(); return live.root.focus(); } + // Enter · F2 = 고른 줄 입력 시작(235-1) — 도구 단추 위 Enter 는 단추 몫 + if ((event.key === "Enter" || event.key === "F2") && !editing(at)) { + if (live.tools.includes(at as HTMLElement) || event.isComposing) return; + event.preventDefault(); + return live.edit(live.index()); + } const label = keyAct(event, editing(at)); if (!label) return; event.preventDefault(); @@ -107,6 +117,7 @@ function listenKeys(): void { export function levelBar( root: HTMLElement, table: BillTable, + edit: (index: number) => void = () => {}, ): { tools: HTMLElement[]; guide: HTMLElement } { const index = (): number => picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked); @@ -140,7 +151,7 @@ export function levelBar( }); attachDrag(root, table, (key) => (picked = key)); sync(); - live = { root, tools: buttons, run, sync }; + live = { root, tools: buttons, run, sync, edit, index }; listenKeys(); return { tools: buttons, guide }; } diff --git a/resources/tester/test_228_1_table_level_font.py b/resources/tester/test_228_1_table_level_font.py index cd230f001..f098d0ae4 100644 --- a/resources/tester/test_228_1_table_level_font.py +++ b/resources/tester/test_228_1_table_level_font.py @@ -111,7 +111,8 @@ def test_CSS_레벨_글꼴_규칙(): assert rule(".is-lv5") == "font-size: var(--dt-lv5-size);" # 항목 Lv1 · 2 · Lv3 = 표 기본 14px(크기 규칙 없음) · 굵게 = 묶음만 assert not rule(".is-lv1") and not rule(".is-lv2") and not rule(".is-lv3") - assert css.count("var(--dt-lv, 0) * var(--dt-indent)") == 2 and "* 1em" not in css + # 들여쓰기 = 글 칸 · 글 상자 · 나무 글 상자(235-1 · 아이콘 자리 더함) + assert css.count("var(--dt-lv, 0) * var(--dt-indent)") == 3 and "* 1em" not in css # 줄 높이 28px 안 — 가장 큰 글자(16px × 줄 간격 1.3) assert "--dt-row-h: 28px;" in css and 16 * 1.3 < 28 diff --git a/resources/tester/test_235_1_b09_bill_tree.py b/resources/tester/test_235_1_b09_bill_tree.py new file mode 100644 index 000000000..a7e94117d --- /dev/null +++ b/resources/tester/test_235_1_b09_bill_tree.py @@ -0,0 +1,354 @@ +"""235-1 B09 설계내역서 = 접히지 않는 트리 · 더블클릭 입력 · 고른 줄 상자. + +- 폴더 줄(자식 있는 줄 · 제목 · 묶음) = 이름만 고침 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용 · 연결 없음) +- 파일 줄(항목) = 이름 · 규격 · 단위 · 수량 입력 + 「연결 ▾」 +- 트리 안내선(├ is-tee · └ is-end · │ is-bar) · 폴더 · 파일 아이콘(공용 나무 꼴) = 공용 표 level.tree +- 공용 표 editOnDouble = 고칠 칸도 글 · 한 번 누름 = 줄 고름 · 더블클릭 · edit() = 그 칸만 글 상자 · Enter · 초점 떠남 = 끝 +- 레벨 키 = 고른 줄에서 Enter · F2 = 입력 시작(편집 중 · 도구 단추 · 고른 줄 없음 = 안 먹음) +- 고른 줄 = 줄 전체 상자 테두리(테마 토큰 · 배경 대신) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_182_b09_bill_level import BOOKS, INPUTS, USER # noqa: E402 +from test_184_3_b09_bill_menu import _node, _ts # noqa: E402 +from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402 +from test_184_3_b09_bill_menu import ROWS as FAKE_ROWS # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) +TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")) +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"] +SUMS = ["sum", "labor", "material", "expense"] +FOLDER_SVG, LEAF_SVG = "M1.5 3.5h4", "M3.5 1.5h6" + + +def _rows(): + return compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"] + + +# --- 설계내역서 칸: 폴더 = 수식 · 파일 = 입력 ------------------------------------------------ + +SPEC_JS = """ +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} }; +const spec = buildBill(ctx); +const keys = ["name", "spec", "unit", "qty", "price_sum", "sum", "labor", "material", "expense", "connect"]; +console.log(JSON.stringify({ + double: spec.editOnDouble, tree: spec.level.tree, + rows: spec.rows.map((r, i) => ({ key: r.key, kind: r.kind, connect: r.connect, + edit: keys.filter((k) => spec.editable(i, k)), + tips: Object.fromEntries(keys.map((k) => [k, spec.tip(i, k) ?? null])) })), +})); +""" + + +def _spec(rows): + return json.loads( + _node(_ts(SRC) + SPEC_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))) + ) + + +def test_폴더_줄_이름만_수량_단가_빈칸_금액_자손_합(): + rows = _rows() + by = {r["key"]: r for r in rows} + folders = [r for r in rows if r["kind"] != "item"] + assert {"sec:1", "sec:1.1", "hand:p", "hand:q"} <= {r["key"] for r in folders} + for f in folders: + assert f.get("qty") is None and all(f.get(f"price_{p}") is None for p in [*SUMS[1:], "sum"]) + kids = [r for r in rows if r["kind"] == "item" and _under(by, r, f["key"])] + for p in SUMS: # 금액 = 자손 항목 합(셈이 냄) + assert f[p] == sum(r[p] or 0 for r in kids), (f["key"], p) + out = _spec(rows) + assert out["double"] is True and out["tree"] is True + for r in out["rows"]: + if r["kind"] == "item": + continue + assert r["edit"] == ["name"], r["key"] # 이름만 + assert r["connect"] == "" # 연결 없음 + for p in SUMS: + assert r["tips"][p] == "수식 = 아래 항목 합(읽기 전용)" + assert r["tips"]["qty"] is None and r["tips"]["name"] is None + + +def _under(by, row, key): + at = by.get(row.get("parent") or "") + while at: + if at["key"] == key: + return True + at = by.get(at.get("parent") or "") + return False + + +def test_파일_줄_입력_연결(): + out = _spec(_rows()) + items = [r for r in out["rows"] if r["kind"] == "item" and r["key"] != "fee"] + assert items + for r in items: + assert r["edit"] == ["name", "spec", "unit", "qty"], r["key"] + assert all(r["tips"][p] is None for p in SUMS) + linked = [r for r in items if not r["key"].startswith("mat:")] + assert linked and all(r["connect"].endswith("▾") for r in linked) + + +# --- 트리 안내선 · 아이콘 --------------------------------------------------------------------- + + +def _guides_py(levels): + """거울 — 아래에 같은 레벨 형제(더 얕은 줄 전까지)가 있으면 ├ · 없으면 └ · 윗대 형제가 있으면 │.""" + + def more(i, d): + for lv in levels[i + 1 :]: + if lv < d: + return False + if lv == d: + return True + return False + + out = [] + for i, lv in enumerate(levels): + g = [("is-bar", d - 2) for d in range(2, lv) if more(i, d)] + if lv >= 2: + g.append(("is-tee" if more(i, lv) else "is-end", lv - 2)) + out.append(g) + return out + + +GUIDE_JS = """ +const parse = (h) => [...h.matchAll(/ui-data-table__guide (is-\\w+)" style="--g:(\\d+)"/g)].map((m) => [m[1], Number(m[2])]); +const levels = __LEVELS__; +const unit = treeGuides(levels).map(parse); +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} }; +const spec = buildBill(ctx); +const html = renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: spec.level, + editable: spec.editable, editOnDouble: true }); +const trs = html.split("")[1].split("")[0].split("").filter(Boolean); +const lvCell = (tr) => (tr.match(/]*>(.*?)<\\/td>/) || [])[1] ?? ""; +console.log(JSON.stringify({ + unit, + bill: trs.map((tr) => parse(lvCell(tr))), + icons: trs.map((tr) => lvCell(tr).includes("__FOLDER__") ? "folder" : lvCell(tr).includes("__LEAF__") ? "leaf" : null), + tree: trs.map((tr) => /has-tree/.test(tr)), + levels: spec.rows.map((r) => r.level), kinds: spec.rows.map((r) => r.kind), + plain: renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: { ...spec.level, tree: false } }) + .includes("ui-data-table__guide"), +})); +""" + + +def _guides(levels, rows): + body = ( + GUIDE_JS.replace("__LEVELS__", json.dumps(levels)) + .replace("__ROWS__", json.dumps(rows, ensure_ascii=False)) + .replace("__FOLDER__", FOLDER_SVG) + .replace("__LEAF__", LEAF_SVG) + ) + return json.loads(_node(_ts(SRC) + TABLE + body)) + + +def test_트리_안내선_한_칸씩(): + levels = [1, 2, 3, 3, 2, 3, 1, 2] + out = _guides(levels, _rows()) + assert out["unit"] == [ + [], + [["is-tee", 0]], + [["is-bar", 0], ["is-tee", 1]], + [["is-bar", 0], ["is-end", 1]], + [["is-end", 0]], + [["is-end", 1]], + [], + [["is-end", 0]], + ] + assert out["unit"] == [[list(g) for g in row] for row in _guides_py(levels)] + + +def test_설계내역서_안내선_아이콘_공용_나무_꼴(): + out = _guides([1], _rows()) + assert out["bill"] == [[list(g) for g in row] for row in _guides_py(out["levels"])] + assert any(out["bill"]) # 실제로 선이 그려짐 + assert out["icons"] == ["leaf" if k == "item" else "folder" for k in out["kinds"]] + assert all(out["tree"]) and out["plain"] is False # 나무 옵션 없으면 안내선 없음 + table = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") + tree = (ROOT / "ui_template" / "ui_template_tree.ts").read_text("utf-8") + assert FOLDER_SVG in table and LEAF_SVG in table # 아이콘 한 벌 — 공용 나무가 가져다 씀 + assert 'import { TREE_ICON } from "./ui_template_data_table";' in tree + assert "TREE_ICON.folder : TREE_ICON.leaf" in tree and FOLDER_SVG not in tree + + +def test_안내선_CSS_테마_토큰(): + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + block = css.split(".ui-data-table__guide::before {")[1].split("}")[0] + assert "border-left: 1px solid var(--color-border);" in block + assert ".ui-data-table__guide.is-end::before {\n bottom: 50%;" in css + assert ".ui-data-table__guide.is-tee::after,\n.ui-data-table__guide.is-end::after {" in css + assert "left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent));" in css + assert ".ui-data-table__table td.has-tree:not(.is-frozen) {\n position: relative;" in css + + +# --- 공용 표 더블클릭 입력 --------------------------------------------------------------------- + +DOM_JS = """ +const doc = { activeElement: null }; +globalThis.document = doc; +globalThis.CSS = { escape: (s) => s }; +class FakeInput { + constructor(owner, i, k) { this.owner = owner; this.dataset = { i, k }; this.value = "v"; } + focus() { doc.activeElement = this; } + select() { this.selected = true; } +} +globalThis.HTMLInputElement = FakeInput; +class Root { + constructor() { this.on = {}; this.html = ""; this.clientHeight = 600; this.scrollTop = 0; this.dataset = {}; } + set innerHTML(h) { this.html = h; } + get innerHTML() { return this.html; } + addEventListener(t, f) { (this.on[t] ??= []).push(f); } + fire(t, ev) { for (const f of this.on[t] ?? []) f(ev); } + contains(x) { return x === this || x?.owner === this; } + focus() { doc.activeElement = this; } + querySelector(sel) { + const m = sel.match(/^input\\[data-i="(\\d+)"\\]\\[data-k="(\\w+)"\\]$/); + if (!m || !this.html.includes(`data-i="${m[1]}" data-k="${m[2]}"`)) return null; + return new FakeInput(this, m[1], m[2]); + } +} +doc.createElement = () => new Root(); +const target = (root, i, c) => { + const td = { dataset: {}, cellIndex: c }; + const tr = { dataset: { i: String(i) }, owner: root }; + return { closest: (s) => (s.startsWith("tr") ? tr : td) }; +}; +const inputs = (root) => [...root.html.matchAll(/]*data-i="(\\d+)" data-k="(\\w+)"/g)].map((m) => `${m[1]}:${m[2]}`); +const columns = [{ key: "name", label: "명칭" }, { key: "qty", label: "수량", kind: "number" }]; +const rows = [{ name: "폴더", qty: null }, { name: "파일", qty: 3 }]; +const log = []; +const t = createDataTable({ columns, rows, editOnDouble: true, editable: (i) => i === 1, + onCellClick: (i, k) => log.push(`click ${i}:${k}`), onEdit: (i, k, v) => log.push(`edit ${i}:${k}=${v}`) }); +const r = t.root; +const out = { start: inputs(r) }; +r.fire("click", { target: target(r, 1, 0) }); +out.click = [inputs(r), [...log]]; +r.fire("dblclick", { target: target(r, 0, 0) }); +out.folderDbl = inputs(r); +r.fire("dblclick", { target: target(r, 1, 1) }); +const input = doc.activeElement; +out.dbl = [inputs(r), input?.dataset ?? null, !!input?.selected]; +let stopped = 0; +const key = (k, more = {}) => r.fire("keydown", { key: k, target: input, isComposing: false, ...more, + preventDefault() {}, stopPropagation() { stopped += 1; } }); +key("Enter", { isComposing: true }); +out.composing = [doc.activeElement === input, stopped]; +key("Enter"); +out.enter = [doc.activeElement === r, stopped]; +r.fire("change", { target: input }); +r.fire("focusout", { target: input }); +setTimeout(() => { + out.after = [inputs(r), log.at(-1)]; + out.editName = [t.edit(1), inputs(r)]; // 마지막 누른 열 = 명칭 + out.editQty = [t.edit(1, "qty"), inputs(r)]; + out.editFolder = t.edit(0); + const plain = createDataTable({ columns, rows, editable: (i) => i === 1 }); + out.plain = [inputs(plain.root), plain.edit(1)]; + console.log(JSON.stringify(out)); +}, 5); +""" + + +def test_한_번_누름_고름_더블클릭_입력_Enter_끝(): + out = json.loads(_node(TABLE + DOM_JS)) + assert out["start"] == [] # 고칠 칸도 처음엔 글 + assert out["click"] == [[], ["click 1:name"]] # 한 번 누름 = 줄 고름(글 상자 안 생김) + assert out["folderDbl"] == [] # 못 고치는 칸 더블클릭 = 그대로 + assert out["dbl"] == [["1:qty"], {"i": "1", "k": "qty"}, True] # 그 칸만 · 초점 · 글 전체 고름 + assert out["composing"] == [True, 0] # 한글 조합 중 Enter = 조합 끝 + assert out["enter"] == [True, 1] # Enter = 표에 초점 · 문서로 안 감 + assert out["after"] == [[], "edit 1:qty=v"] # 초점 떠남 = 입력 끝 · onEdit + assert out["editName"] == [True, ["1:name"]] + assert out["editQty"] == [True, ["1:qty"]] + assert out["editFolder"] is False + assert out["plain"] == [["1:name", "1:qty"], False] # 옵션 없는 표 = 예전대로 바로 글 상자 + + +# --- 레벨 키: Enter · F2 = 입력 시작 -------------------------------------------------------- + +KEYS_JS = """ +const edits = []; +const out = {}; +draw(); +out.none = [key("Enter"), edits.length]; +pick("earth:5"); +root.focus(); +out.enter = [key("Enter"), [...edits]]; +out.f2 = [key("F2"), [...edits]]; +doc.activeElement = tr("earth:5").kids[0].kids[0]; +out.editing = [key("Enter"), edits.length]; +doc.activeElement = bar.tools[0]; +out.tool = [key("Enter"), edits.length]; +root.focus(); +out.composing = [key("Enter", { isComposing: true }), edits.length]; +out.order = (user.bill?.rows ?? []).length; +console.log(JSON.stringify(out)); +""" + + +def test_고른_줄_Enter_F2_입력_시작(): + setup = BAR_JS.split("const out = {};")[0].replace( + "bar = levelBar(root, table);", "bar = levelBar(root, table, (i) => edits.push(i));" + ) + body = (setup + KEYS_JS).replace("__ROWS__", json.dumps(FAKE_ROWS, ensure_ascii=False)) + names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", + "UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip + out = json.loads(_node(_ts(names) + body)) + at = [r["key"] for r in FAKE_ROWS].index("earth:5") + assert out["none"] == [False, 0] # 고른 줄 없음 + assert out["enter"] == [True, [at]] + assert out["f2"] == [True, [at, at]] + assert out["editing"] == [False, 2] # 글 상자 안 Enter = 공용 표 몫(입력 끝) + assert out["tool"] == [False, 2] # 도구 단추 Enter = 단추 몫 + assert out["composing"] == [False, 2] + assert out["order"] == 0 # 줄 차례 · 레벨은 그대로 + + +# --- 고른 줄 상자 · 배선 ----------------------------------------------------------------------- + + +def test_고른_줄_상자_테두리_테마_토큰(): + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + sel = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td' + top = "inset 0 2px 0 var(--color-accent),\n inset 0 -2px 0 var(--color-accent)" + assert f"{sel} {{\n box-shadow:\n {top};" in css + assert ( + f"{sel}:first-child {{\n box-shadow:\n {top},\n inset 2px 0 0 var(--color-accent);" + in css + ) + assert ( + f"{sel}:last-child {{\n box-shadow:\n {top},\n inset -2px 0 0 var(--color-accent);" + in css + ) + # 배경 대신 — 고른 줄 배경 = 보통 줄 배경(고친 칸 색은 그대로) + bill = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus' + assert ( + f"{bill}:not(:hover) td:not(.is-edited) {{\n background-color: var(--color-canvas);" in css + ) + assert ( + f"{bill}.is-alt:not(:hover) td:not(.is-edited) {{\n background-color: var(--dt-zebra);" + in css + ) + + +def test_Shell_배선(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "editOnDouble: spec.editOnDouble," in shell + assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell + for name in [ + "ui_template/ui_template_data_table.ts", + "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts", + ]: + assert len((ROOT / name).read_text("utf-8").splitlines()) <= 700, name diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 76f9682eb..42126928b 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -195,6 +195,60 @@ text-align: left; } +/* 나무(PLAN 235-1 · level.tree) — 접히지 않는 나무 안내선 · 폴더 · 파일 아이콘(공용 나무 ui_template_tree 꼴) + * 안내선 칸 한 단 = 들여쓰기 한 칸 · 선 = 칸 가운데(윗대 아이콘 가운데) · 고정 열 칸은 sticky 가 자리 기준 */ +.ui-data-table__table td.has-tree:not(.is-frozen) { + position: relative; +} + +.ui-data-table__guide { + position: absolute; + z-index: 1; + top: 0; + bottom: 0; + left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent)); + width: var(--dt-indent); + pointer-events: none; +} + +.ui-data-table__guide::before, +.ui-data-table__guide::after { + content: ""; + position: absolute; + left: 50%; +} + +/* │ 윗대 형제가 아래에 더 있음 · ├ 형제가 아래에 더 있음 · └ 끝 형제 */ +.ui-data-table__guide::before { + top: 0; + bottom: 0; + border-left: 1px solid var(--color-border); +} + +.ui-data-table__guide.is-end::before { + bottom: 50%; +} + +.ui-data-table__guide.is-tee::after, +.ui-data-table__guide.is-end::after { + top: 50%; + width: 50%; + border-top: 1px solid var(--color-border); +} + +.ui-data-table__icon { + display: inline-flex; + margin-right: var(--spacing-4); + color: var(--color-text-secondary); + vertical-align: -2px; +} + +.ui-data-table__table td.has-tree .ui-data-table__input { + padding-left: calc( + var(--spacing-8) + var(--dt-lv, 0) * var(--dt-indent) + 14px + var(--spacing-4) + ); +} + /* 레벨 글꼴(PLAN 228-1) — 묶음 줄(is-node) 굵게 · 크기는 레벨 · 항목 줄은 14px 보다 크지 않게(Lv1 · 2 항목 = 기본) */ .ui-data-table__table td.ui-data-table__lv.is-node { font-weight: var(--font-weight-bold); diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 02c865ad1..8cd212f94 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -14,6 +14,9 @@ * 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도) * 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게 * 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(px) · 항목 줄은 14 보다 크지 않게 + * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) + * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · + * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ @@ -76,7 +79,13 @@ export interface DataTableOptions { key: string; of: (index: number) => number | null | undefined; group?: (index: number) => boolean; + /** 접히지 않는 나무 안내선 · 폴더(묶음) · 파일 아이콘(235-1) */ + tree?: boolean; }; + /** 참이면 고칠 칸 = 글 · 더블클릭 · edit() 때만 글 상자(235-1) */ + editOnDouble?: boolean; + /** 지금 글 상자인 칸(editOnDouble · 표가 정함) */ + editing?: { index: number; key: string } | null; /** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */ mergeSame?: string[]; } @@ -86,8 +95,17 @@ export interface DataTable { setRows: (rows: DataRow[], total?: DataRow | null) => void; /** 그 줄로 스크롤하고 강조 */ focusRow: (index: number) => void; + /** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */ + edit: (index: number, key?: string) => boolean; } +/** 나무 아이콘 — 폴더 · 항목(글꼴색을 따름) · 공용 나무(`ui_template_tree` 기본 줄)와 같은 꼴 */ +export const TREE_ICON = { + folder: + '', + leaf: '', +}; + const EMPTY = "-"; const ESC: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]); @@ -169,14 +187,16 @@ function rowHtml( if (!span) continue; // 위 칸에 합쳐짐 const lvc = lv?.key === key - ? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}` + ? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}${lv.tree === undefined ? "" : " has-tree"}` : ""; + // 나무(235-1) = 안내선 + 폴더 · 파일 아이콘 — 글 상자 칸은 안내선만 + const guide = lv?.key === key ? (lv.tree ?? "") : ""; const c = spans ? ` data-c="${j}"` : ""; if (edit !== null && editable?.(edit, key)) { const v = r[key]; const raw = v === null || v === undefined ? "" : String(v); const tip = esc(cols[j].title ?? cols[j].label); - tr += ``; + tr += `${guide}`; continue; } const [txt, empty] = formatCell(r[key], fmts[j], blank); @@ -197,7 +217,11 @@ function rowHtml( (fmts[j] ? ' data-num=""' : "") + (span > 1 ? ` rowspan="${span}"` : "") + c; - tr += `${esc(txt)}`; + const icon = + lv?.key === key && lv.tree !== undefined + ? `${lv.node ? TREE_ICON.folder : TREE_ICON.leaf}` + : ""; + tr += `${guide}${icon}${esc(txt)}`; } const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra] .filter(Boolean) @@ -209,9 +233,10 @@ function rowHtml( : `${tr}`; } -/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ +/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · 나무 안내선 HTML(나무 아니면 없음) · + * spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ interface RowMore { - lv?: { key: string; n: number; node: boolean }; + lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; } @@ -248,6 +273,25 @@ export function mergeSpans(o: DataTableOptions, from: number, to: number): numbe return out; } +/** 나무 안내선(235-1) — 줄마다 칸(한 단 = 들여쓰기 한 칸 · 레벨 2 부터) span HTML. + * 자기 칸 = 아래에 같은 레벨 형제가 더 있으면 ├(is-tee) · 끝이면 └(is-end) · 윗대 칸 = 그 윗대 형제가 아래에 더 있으면 │(is-bar) */ +export function treeGuides(levels: number[]): string[] { + const out: string[] = new Array(levels.length); + const more: boolean[] = []; // 레벨 d 형제가 아래에 더 있음(더 얕은 줄 전까지) + const span = (cls: string, slot: number) => + ``; + for (let i = levels.length - 1; i >= 0; i--) { + const lv = Math.max(1, levels[i]); + let html = ""; + for (let d = 2; d < lv; d++) if (more[d]) html += span("is-bar", d - 2); + if (lv >= 2) html += span(more[lv] ? "is-tee" : "is-end", lv - 2); + out[i] = html; + more[lv] = true; + more.length = lv + 1; // 더 깊은 레벨 = 이 줄에서 끊김 + } + return out; +} + /** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */ export interface RowWindow { from: number; @@ -284,6 +328,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const blank = o.empty ?? EMPTY; const pick = o.onRowClick !== undefined || o.onCellClick !== undefined; const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null; + const tree = o.level?.tree ? treeGuides(o.rows.map((_r, i) => o.level!.of(i) ?? 1)) : null; + // 더블클릭 입력(235-1) — 글 상자 = 지금 고치는 칸만 + const inputAt = o.editOnDouble + ? (i: number, key: string) => + o.editing?.index === i && o.editing.key === key && !!o.editable?.(i, key) + : o.editable; for (let i = from; i < to; i++) { const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0; const node = o.level ? isNode(o.level, i, n) : false; @@ -296,12 +346,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin blank, pick ? i : null, i, - o.editable, + inputAt, o.focus === i, o.rowClass?.(i), o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, { - ...(o.level ? { lv: { key: o.level.key, n: lvN, node } } : {}), + ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), }, ); @@ -419,14 +469,62 @@ export function createDataTable(opts: DataTableOptions): DataTable { const key = input.dataset.k; setTimeout(() => state.onEdit?.(index, key, input.value)); }); - root.addEventListener("click", (event) => { - const row = (event.target as Element).closest("tr[data-i]"); - if (!row || !root.contains(row)) return; - const cell = (event.target as Element).closest("td"); + /** 누른 칸 [줄, 열 키] — 본문 줄 밖 = null */ + const cellAt = (target: EventTarget | null): [number, string] | null => { + const row = (target as Element).closest("tr[data-i]"); + if (!row || !root.contains(row)) return null; + const cell = (target as Element).closest("td"); const column = cell ? state.columns[cellColumn(cell)] : undefined; - if (column) state.onCellClick?.(Number(row.dataset.i), column.key); - state.onRowClick?.(Number(row.dataset.i)); + return [Number(row.dataset.i), column?.key ?? ""]; + }; + let lastKey = ""; + root.addEventListener("click", (event) => { + const at = cellAt(event.target); + if (!at) return; + lastKey = at[1]; + if (at[1]) state.onCellClick?.(at[0], at[1]); + state.onRowClick?.(at[0]); }); + const edit = (index: number, key?: string): boolean => { + if (!state.editOnDouble || !state.editable) return false; + const can = state.columns.map((c) => c.key).filter((k) => state.editable!(index, k)); + const k = [key, lastKey, can[0]].find((x) => x !== undefined && can.includes(x)); + if (k === undefined) return false; + state.editing = { index, key: k }; + draw(true); + const input = root.querySelector( + `input[data-i="${index}"][data-k="${CSS.escape(k)}"]`, + ); + input?.focus(); + input?.select(); + return true; + }; + if (opts.editOnDouble) { + root.addEventListener("dblclick", (event) => { + const at = cellAt(event.target); + if (at && state.editable?.(at[0], at[1])) edit(at[0], at[1]); + }); + // Enter = 입력 끝(한글 조합 중 Enter 는 조합 끝) — 표에 초점 · 키가 문서까지 가지 않게(다시 입력 시작 방지) + root.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.isComposing) return; + if (!(event.target instanceof HTMLInputElement)) return; + event.preventDefault(); + event.stopPropagation(); + root.focus(); + }); + // 초점 떠남 = 입력 끝 — 다시 그린 같은 칸으로 초점이 돌아왔으면 그대로 + root.addEventListener("focusout", (event) => { + const input = event.target as HTMLInputElement; + if (!state.editing || !input.dataset?.k) return; + setTimeout(() => { + const now = document.activeElement as HTMLElement | null; + const back = now && root.contains(now) && now.dataset.k === input.dataset.k; + if (back && now.dataset.i === input.dataset.i) return; + state.editing = null; + draw(true); + }); + }); + } draw(true); if (opts.fillViewport) fillViewport(root, () => draw(false)); return { @@ -441,5 +539,6 @@ export function createDataTable(opts: DataTableOptions): DataTable { root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3); draw(true); }, + edit, }; } diff --git a/ui_template/ui_template_tree.ts b/ui_template/ui_template_tree.ts index 0da561078..a2aa5041a 100644 --- a/ui_template/ui_template_tree.ts +++ b/ui_template/ui_template_tree.ts @@ -8,6 +8,7 @@ import "./ui_template_tree.css"; import { enterMenu, leaveMenu } from "./ui_template_context_menu"; +import { TREE_ICON } from "./ui_template_data_table"; import { el } from "./ui_template_elements"; export interface TreeNode { @@ -28,13 +29,6 @@ export interface TreeNode { folder?: boolean; } -/** 기본 줄 아이콘 — 폴더 · 항목(글꼴색을 따름) */ -const ICON = { - folder: - '', - leaf: '', -}; - export interface TreeMenuItem { label: string; run: () => void; @@ -130,7 +124,7 @@ export function createTreeRow( ): HTMLButtonElement { const branch = !!node.children?.length || !!node.folder; const icon = el("span", { className: "ui-tree__icon" }); - icon.innerHTML = branch ? ICON.folder : ICON.leaf; // 고정 SVG 글만 + icon.innerHTML = branch ? TREE_ICON.folder : TREE_ICON.leaf; // 고정 SVG 글만 const button = el("button", { className: `ui-tree__row ui-sidebar-row${branch ? " is-branch" : ""}`, attrs: { type: "button" }, From 88dd941d73030eeae6d4b5b6166fdd81786aaf13 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:09:56 +0900 Subject: [PATCH 28/92] =?UTF-8?q?fix(B08=C2=B7B09):=20=ED=91=9C=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=20=EC=95=88=EB=82=B4=20=EA=B8=80=20=EC=A0=9C?= =?UTF-8?q?=EC=9E=91=20=C2=B7=20=EC=85=88=20=EC=84=A4=EB=AA=85=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EB=8A=98=20=ED=95=9C=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?0(236-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B09 표 탭 안내 글 = 쓸모 있는 한 줄(lineHint) · 묶음 탭과 같은 줄임표 - B08 표 아래 안내 · 경고 줄 = footNote 한 줄 · 서버 알림은 한 줄로 이어 붙임 - 새 시험 test_236_1 · 창 높이별 창 끝까지 셈 헬퍼 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B08_Quantity/B08_Quantity_Router_Material.py | 2 +- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 13 +- .../B08_Quantity_UI_Earthwork_Sheets.ts | 18 +- B08_Quantity/B08_Quantity_UI_Foot.ts | 15 ++ B08_Quantity/B08_Quantity_UI_Material.ts | 26 ++- B08_Quantity/B08_Quantity_UI_Page.css | 10 ++ B08_Quantity/B08_Quantity_UI_Summary.ts | 3 +- B09_Estimation/B09_Estimation_Sangeun.py | 2 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 4 +- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 14 +- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 8 +- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 2 +- .../B09_Estimation_UI_Tabs_Groups.ts | 4 +- .../B09_Estimation_UI_Tabs_Labor.ts | 7 +- .../B09_Estimation_UI_Tabs_Sangeun.ts | 7 +- .../B09_Estimation_UI_Tabs_Summary.ts | 7 +- .../B09_Estimation_UI_Tabs_Total.ts | 8 +- resources/tester/helper_236_1_fill_sizes.cjs | 72 ++++++++ .../tester/test_160_3_b08_unmatched_total.py | 4 +- .../tester/test_171_1_manual_input_label.py | 4 +- .../tester/test_183_2_b09_compare_head.py | 2 +- resources/tester/test_184_3_b09_bill_menu.py | 2 +- resources/tester/test_236_1_hint_one_line.py | 160 ++++++++++++++++++ 23 files changed, 316 insertions(+), 78 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_Foot.ts create mode 100644 resources/tester/helper_236_1_fill_sizes.cjs create mode 100644 resources/tester/test_236_1_hint_one_line.py diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index d8e6626cb..e0bb13ba7 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -219,7 +219,7 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) " ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"]))) for k in lost ) - notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}") + notes.append(f"2공구 열 맞춤 없음 {len(lost)}종 — {listed}") return { "source": source, "overrides": overrides, diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index f8c318187..ac64372d5 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -15,6 +15,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard } from "@ui/ui_template_elements"; import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { stationLabel } from "../common_util/common_util_station"; +import { footNote } from "./B08_Quantity_UI_Foot"; import { stationInterval } from "./B08_Quantity_UI_Summary"; import { renderEarthworkSummary, @@ -216,17 +217,13 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); return; } - const c = (kind: string): string => table.conversion_factors[kind]?.compacted.toFixed(2) ?? ""; const flagged = table.rows.filter((row) => row.notes.length); const info = [ - note( - `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + - `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 암 ` + - `${table.conversion_factors.ripping_rock?.compacted.toFixed(3) ?? ""}(연암 리핑 ` + - `${table.rock_c.ripping_rock} · 경암 발파 ${table.rock_c.blasting_rock} 비율 가중) · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + - `단위 m · ㎡ · ㎥`, + footNote( + `노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + + `측점 ${table.station_count} · 단위 m · ㎡ · ㎥`, ), - ...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), + ...(flagged.length ? [footNote(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), ]; const grid = createDataTable({ columns: COLUMNS, diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index e7acf989c..ee9e58c22 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -9,6 +9,7 @@ import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork"; +import { footNote } from "./B08_Quantity_UI_Foot"; const fmt = (value: number | null | undefined): string => value == null || !Number.isFinite(value) @@ -26,9 +27,8 @@ const SUMMARY_COLUMNS: DataColumn[] = [ /** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { - const info = note( - "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + - "연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)", + const info = footNote( + "연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움", ); const grid = createDataTable({ columns: SUMMARY_COLUMNS, @@ -50,7 +50,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) function haulNote(table: EarthworkTable): HTMLElement[] { return table.haul_saved ? [] - : [note("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; + : [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; } /** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */ @@ -58,7 +58,7 @@ function haulSheet( host: HTMLElement, table: EarthworkTable, groups: Array<[string, HaulList]>, - info: string, + caption: string, ): void { const columns: DataColumn[] = [ { key: "no", label: "번호", width: 56 }, @@ -98,10 +98,10 @@ function haulSheet( rows, frozen: 1, total, - caption: info, + caption, fillViewport: true, }); - host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages)); + host.replaceChildren(tableCard(haulNote(table), grid.root, averages)); } export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { @@ -112,7 +112,7 @@ export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): vo ["덤프트럭", table.haul.dump_truck], ["불도우저", table.haul.dozer], ], - "유토곡선 배분의 덤프 · 도저 띠와 떨어진 구간 운반 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m", ); } @@ -121,6 +121,6 @@ export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void { host, table, [["종방향무대", table.haul.free_haul]], - "유토곡선 배분의 무대(20 m 안) 띠 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + "종방향 무대운반거리 — 단위 ㎥ · m", ); } diff --git a/B08_Quantity/B08_Quantity_UI_Foot.ts b/B08_Quantity/B08_Quantity_UI_Foot.ts new file mode 100644 index 000000000..1376cc3ad --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Foot.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * B08_Quantity_UI_Foot.ts + * B08 표 아래 안내 · 경고 줄(PLAN 202-1 · 236-1) — 늘 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문. + * 한 줄만 남겨 창 끝까지 셈(199-1)에 한 줄 높이만 듦 → 페이지 스크롤 0. + * ========================================================================== */ + +import { attachHoverInfo } from "@ui/ui_template_hover_info"; + +export function footNote(text: string): HTMLElement { + const p = document.createElement("p"); + p.className = "ui-general-block__empty b08-quantity__foot"; + p.textContent = text; + attachHoverInfo(p, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); + return p; +} diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 53d2223ed..819d20049 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -34,6 +34,7 @@ import { type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; +import { footNote } from "./B08_Quantity_UI_Foot"; import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options"; export type MaterialSheetId = "material" | "concrete" | "quantity"; @@ -51,11 +52,9 @@ interface Payload { const BASIS = "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; const HINTS: Record = { - material: - "합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널", - concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", - quantity: - "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요", + material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널", + concrete: "㎥당 배합은 칸에서 고침", + quantity: "단위수량은 칸에 수기 입력", }; const HIDDEN = "값 없는 줄은 숨김"; @@ -190,9 +189,7 @@ function excelDiffs(s: MaterialSheets): string { if (want != null && (row.surcharge ?? 0) !== want) out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`); } - return out.length - ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` - : ""; + return out.length ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")}` : ""; } /** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */ @@ -213,7 +210,7 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { const blank = s.quantity.rows.filter((row) => [...keys].every((k) => row.units[k] == null)); const copied = blank.filter((row) => source.snapshot?.units?.[row.id]).length; return blank.length - ? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 비었음 ${blank.length}줄(사본 자재에 콘크리트 · 모르터 없음 ${copied} · 사본 자재 없음 · 미연결 ${blank.length - copied}) — 자재수량 탭 그 열에 넣음` + ? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 빈 ${blank.length}줄(사본에 없음 ${copied} · 미연결 ${blank.length - copied}) — 자재수량 탭에서 넣음` : ""; } @@ -340,10 +337,7 @@ export async function renderMaterialSheet( const toolbar = document.createElement("div"); toolbar.className = "b08-material__toolbar"; const notes = document.createElement("div"); - notes.append( - note(BASIS), - note(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`), - ); + notes.append(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`)); if (sheet !== "quantity") { // 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로 const blank = blankUnitRows(sheets); @@ -352,12 +346,12 @@ export async function renderMaterialSheet( ...(payload.notes ?? []), ]; if (why.length) { - const reason = note(`빈 칸 까닭 — ${why.join(" · ")}`); + const reason = footNote(`빈 칸 까닭 — ${why.join(" · ")}`); reason.dataset.b08MaterialWhy = ""; notes.append(reason); } } - const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 + const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 concreteNote.dataset.b08MaterialConcreteWhy = ""; if (sheet === "concrete") notes.append(concreteNote); toolbar.append(save); @@ -390,7 +384,7 @@ export async function renderMaterialSheet( }, }); table.root.dataset.b08Material = sheet; - const excel = note(""); + const excel = footNote(""); excel.dataset.b08MaterialExcel = ""; const redraw = (): void => { built = BUILD[sheet](sheets, overrides); diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index cf2275cd9..8de5e6c50 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -24,6 +24,16 @@ visibility: hidden; } +/* 236-1 표 아래 안내 · 경고 줄 — 늘 한 줄(줄임표 · 잘리면 공용 호버로 전문) */ +.b08-quantity__foot { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-caption); +} + /* 자재 집계표 셋(121) — 안내 글 · [저장] 한 줄 */ .b08-material__toolbar { display: flex; diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index ba9a28b1e..a8a13ef89 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -46,6 +46,7 @@ import { } from "./B08_Quantity_Summary_Data"; import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; import type { SheetsRequest } from "./B08_Quantity_UI_Drawing"; +import { footNote } from "./B08_Quantity_UI_Foot"; import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { @@ -258,7 +259,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin const summary = live(); const parts: HTMLElement[] = []; if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); - for (const text of summary?.notes ?? []) parts.push(note(text)); + if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄 if (!columns.length && !summary?.columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); const draw = (): HTMLElement => diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 0ee98c311..cf25e1acf 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -56,7 +56,7 @@ PARTS = ("labor", "material", "expense") COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} DEPTH = 4 # 하위 산근 깊이 상한 _Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) -NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)" +NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음" EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음" diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index bd47dacfb..68ed5b3f5 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -61,7 +61,7 @@ import { } from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; -import { isGroupTab, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; +import { isGroupTab, lineHint, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -243,7 +243,7 @@ export function renderB09Estimation(root: HTMLElement): void { ); return made; }; - const hint = spec.hint ? note(spec.hint) : null; + const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄 // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표 if (isGroupTab(tab)) { const redraw = (): void => show(tab); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index fdd74a838..4eb8eb286 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -52,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) => priceList( sheets.labor || [], "단가(원/인)", - `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`, + `굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`, ); export const buildExpense: TabBuild = ({ sheets, payload }) => priceList( sheets.expense || [], "기준가(천원)", - `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`, + `굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`, ); function madeText(payload: Row): string { @@ -104,7 +104,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { rows, frozen: 2, mergeSame: ["part"], - hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표", + hint: "인건비 줄을 누르면 노무비목록표로 이동", link: (i) => { const row = rows[i]; return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null; @@ -196,7 +196,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)", + hint: "「적용 단가」 칸을 누르면 출처 고르기 · 「관급 · 별도」 칸을 누르면 구분 바꿈 · 견적 단가는 수기 입력", tip: (i, key) => key.startsWith("p:") && rows[i] ? sourceInfo( @@ -297,7 +297,7 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => { ? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) } : null, frozen: 2, - hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`, + hint: LINK_HINT, link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), }; }; @@ -317,7 +317,7 @@ export const buildMachine: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, - hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`, + hint: LINK_HINT, link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null), }; }; @@ -388,7 +388,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 1, - hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭", + hint: LINK_HINT, link: (i) => links[i] ?? null, level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")), }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 8363c7930..1893d4ddc 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -60,10 +60,10 @@ export const buildBill: TabBuild = (ctx): BillTable => { total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " + - "금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " + - "조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " + - `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, + "줄을 우클릭하면 추가 · 지움 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + + (direct.blank + ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` + : ""), editable: (i, key) => { const r = rows[i]; if (!r) return false; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 3ff2cab15..8112b6b76 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -122,7 +122,7 @@ export function levelBar( return button; }); const guide = document.createElement("p"); - guide.className = "ui-general-block__empty"; + guide.className = "ui-general-block__empty b09-estimation__line-hint"; guide.textContent = GUIDE; attachHoverInfo(guide, GUIDE_MORE); const sync = (): void => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 313a30d7a..bc57acacb 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -178,8 +178,8 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine /* ── 그리기 ───────────────────────────────────────────────────────────── */ /** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */ -function lineHint(hint: HTMLElement | null): HTMLElement | null { - if (!hint) return null; +export function lineHint(hint: HTMLElement | null): HTMLElement | null { + if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint; hint.classList.add("b09-estimation__line-hint"); attachHoverInfo(hint, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); return hint; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 50b500e2f..26490385e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -27,8 +27,7 @@ const EMPTY = "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; /** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */ const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1) -const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0"; -const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김"; +const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)"; /** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */ const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : ""); const blankTip = (b: Row | undefined): string | null => @@ -65,7 +64,7 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => { ], rows, frozen: 3, - hint: `호표 = 일위대가표 블록 합계 · ${BLANK} · 줄을 누르면 그 블록으로`, + hint: `줄을 누르면 그 블록으로 이동 · ${BLANK}`, link: (i: number): Link | null => (rows[i] ? { tab: "dg", key: String(rows[i].key) } : null), tip: (i, key) => key === "spec" @@ -152,7 +151,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { ], rows, frozen: 1, - hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, + hint: `줄을 누르면 단가를 낸 탭으로 이동 · ${BLANK}`, link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), level: blockLevel("name", (i) => titles.has(i)), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 1776afdf3..fc51ab5d0 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -56,9 +56,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, - hint: - "산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + - notes(sheets), + hint: "줄을 누르면 그 블록으로 이동" + notes(sheets), link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null), tip: (i, key) => (key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null), }; @@ -159,8 +157,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { rows, frozen: 1, hint: - "산출내역 = 값이 든 식(B09 굳힌 단가 * 수량 = 금액 · 작업량 Q = ROUND 2) · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" + - notes(sheets), + "줄을 누르면 단가를 낸 탭으로 이동 · 자재 적용 단가를 고치면 [저장] 뒤 반영" + notes(sheets), link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), level: blockLevel("text", (i) => titles.has(i)), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 5fd4fba08..f3cef1dd2 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -18,8 +18,7 @@ import { type TabTable, } from "./B09_Estimation_UI_Tabs"; -const HINT = - "총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표"; +const HINT = "줄을 누르면 목록표로 이동"; /** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */ const hint = (payload: Row, extra = ""): string => { @@ -28,7 +27,7 @@ const hint = (payload: Row, extra = ""): string => { }; /** 줄이 없을 때 표 위 맨 앞 안내 */ -const EXPENSE_NONE = "경비 갈래 자원 없음 — 중기 · 자재로 셈 · 경비 로직이 들어오면 채워짐"; +const EXPENSE_NONE = "경비 자원 없음"; function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild { return ({ payload }): TabTable => { @@ -86,7 +85,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => { } : null, frozen: 2, - hint: hint(payload, " · 비목마다 ROUND(시간 × 시간당, 0)"), + hint: hint(payload), link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null), }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 96a3691aa..d319eed11 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -211,11 +211,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["part"], - hint: - "요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " + - "2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " + - "수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " + - "공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음", + hint: "요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || (key === "hand" && String(rows[i]?.key) in HAND), @@ -292,7 +288,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["group", "part"], - hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄", + hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), link: (i) => { diff --git a/resources/tester/helper_236_1_fill_sizes.cjs b/resources/tester/helper_236_1_fill_sizes.cjs new file mode 100644 index 000000000..64c240b5d --- /dev/null +++ b/resources/tester/helper_236_1_fill_sizes.cjs @@ -0,0 +1,72 @@ +/* 236-1 — 표 아래 안내 글이 한 줄일 때 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)이 + * 창 크기마다 문서 맨 아래 = 창 끝(페이지 스크롤 0)이 되는지 단위검증 헬퍼. JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +const ONE_LINE = 18; // 안내 글 한 줄(caption 글꼴) 높이 +const win = { innerHeight: 0, scrollY: 0, addEventListener() {}, removeEventListener() {} }; +const observers = []; +class RO { + constructor(fn) { + observers.push(fn); + } + observe() {} + disconnect() {} +} +const el = (css = {}, height = 0) => ({ + css, + height, + top: 0, + parentElement: null, + nextElementSibling: null, + isConnected: true, + style: {}, + getBoundingClientRect() { + return { top: this.top, height: this.height }; + }, +}); +const sty = (n) => ({ + rowGap: n.css.rowGap ?? "normal", + paddingBottom: n.css.paddingBottom ?? "0px", + borderBottomWidth: n.css.borderBottomWidth ?? "0px", + marginBottom: n.css.marginBottom ?? "0px", +}); +const box = { exports: {} }; +new Function( + "exports", + "module", + "require", + "window", + "document", + "getComputedStyle", + "ResizeObserver", + js, +)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO); + +const out = []; +for (const h of [1080, 768, 600]) { + win.innerHeight = h; + observers.length = 0; + // 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [표 칸][안내 한 줄] + const body = el({ paddingBottom: "8px" }); + const card = el({ paddingBottom: "12px", rowGap: "8px" }); + const root = el(); + const hint = el({}, ONE_LINE); + card.parentElement = body; + root.parentElement = card; + hint.parentElement = card; + root.nextElementSibling = hint; + root.top = 200; + box.exports.fillViewport(root); + observers.forEach((fn) => fn()); + const grow = parseFloat(root.style.maxHeight); + const below = box.exports.spaceBelow(root); + out.push({ viewport: h, bottom: root.top + grow + below, grow, below }); +} +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_160_3_b08_unmatched_total.py b/resources/tester/test_160_3_b08_unmatched_total.py index 2bb24e775..eacc1d617 100644 --- a/resources/tester/test_160_3_b08_unmatched_total.py +++ b/resources/tester/test_160_3_b08_unmatched_total.py @@ -68,9 +68,7 @@ def test_알림에_맞춤_없는_이름(tmp_path, monkeypatch): monkeypatch.setattr(router, "structure_summary", summary) monkeypatch.setattr(router, "snapshot_units", lambda root, s: SUMMARY["snapshot"]) got = asyncio.run(router._sheets("p", tmp_path, clean_overrides({}))) - assert got["notes"] == [ - "2공구 열 맞춤 없음 2종(총괄 끝 줄 그대로) — 이형철근 D13 ton · 토목용부직포 ㎡" - ] + assert got["notes"] == ["2공구 열 맞춤 없음 2종 — 이형철근 D13 ton · 토목용부직포 ㎡"] SC = {"층": "project", "주인": None, "키": "SC-000001"} diff --git a/resources/tester/test_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py index de80196ea..9cd4645bc 100644 --- a/resources/tester/test_171_1_manual_input_label.py +++ b/resources/tester/test_171_1_manual_input_label.py @@ -14,8 +14,8 @@ SPOTS = { "B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Sangeun.py": ['"수기 줄" if'], - "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"], - "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'], + "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가는 수기 입력"], + "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"'], "ui_template/ui_template_locale_b2.ts": [ "수량 원천: 수기 입력(직접비)", "수량 원천: 수기 입력 공종", diff --git a/resources/tester/test_183_2_b09_compare_head.py b/resources/tester/test_183_2_b09_compare_head.py index 21fd23dcb..97ff1246e 100644 --- a/resources/tester/test_183_2_b09_compare_head.py +++ b/resources/tester/test_183_2_b09_compare_head.py @@ -27,4 +27,4 @@ def test_견적은_단가_견적서_그_밖은_단가_하나(): def test_견적_면_수기_칸_없앰(): body = _compare() assert 'key === "g:견적"' not in body and "quote_page" not in body - assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body + assert "견적 단가는 수기 입력" in body and "견적 단가 · 면" not in body diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index 653d8a64d..78f99583a 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -279,7 +279,7 @@ def test_표_칸_강조_번호_연결_칸(): assert out["cls"] == ["is-edited", None, None] assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]" assert out["edit"] == [True, False, True, True, False] - assert "엑셀 꼴 고정" not in out["hint"] and "내역 초기화" in out["hint"] + assert "엑셀 꼴 고정" not in out["hint"] and "우클릭" in out["hint"] assert out["renamed"] == "절토공" # 번호는 떼고 이름만 diff --git a/resources/tester/test_236_1_hint_one_line.py b/resources/tester/test_236_1_hint_one_line.py new file mode 100644 index 000000000..edd8941c1 --- /dev/null +++ b/resources/tester/test_236_1_hint_one_line.py @@ -0,0 +1,160 @@ +"""236-1 B08 · B09 표 아래 안내 글 — 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0. + +- 안내 글 낱말에 제작 · 셈 설명(ROUND · 「 = 」 · 장 번호 · M01 · 곱셈 나눗셈 기호)이 없음 +- 안내 글 한 조각 70 자 이하(긴 글은 줄임표 + 공용 호버) +- B09 안내 글(lineHint) · 레벨 안내 · B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표) +- 창 크기(1080 · 768 · 600)마다 한 줄 안내 + 표 칸 = 창 끝(199-1 fillViewport 셈) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B08 = ROOT / "B08_Quantity" +B09 = ROOT / "B09_Estimation" + +STRING = re.compile(r'"([^"\\\n]*)"|`([^`\n]*)`') +FORBID = ["ROUND", " = ", "÷", "×", "Σ", "M01", "역전개", "원문"] +CHAPTER = re.compile(r"\(\d{2,3}-\d\)|\b\d{1,3}-\d-\d\b|\b\d{2,3}-\d\b") +MAX_PIECE = 70 + + +def _read(path: Path) -> str: + return path.read_text("utf-8") + + +def _pieces(expr: str) -> list[str]: + out = [] + for m in STRING.finditer(expr): + text = m.group(1) if m.group(1) is not None else m.group(2) + out.append(re.sub(r"\$\{[^}]*\}", "", text).strip()) + return [p for p in out if p] + + +def _b09_hints() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for path in sorted(B09.glob("B09_Estimation_UI_Tabs_*.ts")): + src = _read(path) + for m in re.finditer(r"\bhint:\s*(.*?),\n\s+\w+[:(]", src, re.S): + found += [(path.name, p) for p in _pieces(m.group(1))] + for m in re.finditer( + r"^const (?:LINK_HINT|BLANK|HINT|EXPENSE_NONE|GUIDE)\s*=\s*(.*?);\n", src, re.S | re.M + ): + found += [(path.name, p) for p in _pieces(m.group(1))] + return found + + +def _b08_hints() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for name in ( + "B08_Quantity_UI_Earthwork.ts", + "B08_Quantity_UI_Earthwork_Sheets.ts", + "B08_Quantity_UI_Material.ts", + ): + src = _read(B08 / name) + for m in re.finditer(r"footNote\(((?:\s|\+|\"[^\"\n]*\"|`[^`\n]*`)+)\)", src): + found += [(name, p) for p in _pieces(m.group(1))] + for m in re.finditer(r"^const HINTS[^=]*=\s*\{(.*?)\n\};", src, re.S | re.M): + found += [(name, p) for p in _pieces(m.group(1))] + for m in re.finditer(r"^const HIDDEN\s*=\s*(.*?);\n", src, re.S | re.M): + found += [(name, p) for p in _pieces(m.group(1))] + return found + + +def _server_notes() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for name in ("Router_Summary", "Router_Material"): + src = _read(B08 / f"B08_Quantity_{name}.py") + for m in re.finditer(r"notes\.append\(\s*(.*?)\)\n", src, re.S): + found += [(name, p) for p in _pieces(m.group(1).replace('f"', '"'))] + sangeun = _read(B09 / "B09_Estimation_Sangeun.py") + for m in re.finditer(r'^(?:NO_SOURCE|EMPTY) = "([^"]*)"', sangeun, re.M): + found.append(("Sangeun", m.group(1))) + return found + + +def _all() -> list[tuple[str, str]]: + return _b09_hints() + _b08_hints() + _server_notes() + + +def test_안내_글_모음이_비지_않음(): + assert len(_b09_hints()) >= 20 + assert len(_b08_hints()) >= 4 + assert len(_server_notes()) >= 7 + + +def test_제작_셈_설명_낱말_없음(): + bad = [] + for where, text in _all(): + bad += [(where, token, text) for token in FORBID if token in text] + if CHAPTER.search(text): + bad.append((where, "장 번호", text)) + assert not bad, bad + + +def test_안내_글_조각_짧음(): + long = [(w, t) for w, t in _all() if len(t) > MAX_PIECE] + assert not long, long + + +# ── 늘 한 줄 ─────────────────────────────────────────────────────────────── + + +def _block(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_B09_안내_글_한_줄(): + css = _read(B09 / "B09_Estimation_UI_Page.css") + block = _block(css, ".b09-estimation__line-hint") + assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block + shell = _read(B09 / "B09_Estimation_UI_Shell.ts") + assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 한 줄 + assert "export function lineHint" in _read(B09 / "B09_Estimation_UI_Tabs_Groups.ts") + level = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts") + assert 'guide.className = "ui-general-block__empty b09-estimation__line-hint"' in level + + +def test_B08_안내_줄_한_줄(): + css = _read(B08 / "B08_Quantity_UI_Page.css") + block = _block(css, ".b08-quantity__foot") + assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block + assert "display" not in block # hidden 속성(콘크리트 빈 까닭)을 덮지 않음 + foot = _read(B08 / "B08_Quantity_UI_Foot.ts") + assert "b08-quantity__foot" in foot and "attachHoverInfo" in foot + for name in ("Earthwork", "Earthwork_Sheets", "Material", "Summary"): + assert "footNote" in _read(B08 / f"B08_Quantity_UI_{name}.ts"), name + + +def test_B08_표_아래_안내는_footNote만(): + material = _read(B08 / "B08_Quantity_UI_Material.ts") + assert "note(BASIS)" not in material # 「구조물 수량 기준 …」 제작 설명 줄 없음 + assert "const excel = footNote(" in material + assert "const concreteNote = footNote(" in material + summary = _read(B08 / "B08_Quantity_UI_Summary.ts") + assert 'footNote(summary.notes.join(" · "))' in summary # 서버 알림 = 한 줄로 이어 붙임 + sheets = _read(B08 / "B08_Quantity_UI_Earthwork_Sheets.ts") + assert "tableCard(haulNote(table), grid.root, averages)" in sheets # 제작 설명 줄 없음 + + +# ── 창 끝까지 셈 ──────────────────────────────────────────────────────────── + + +def test_창_크기마다_한_줄_안내_뒤_창_끝(): + proc = subprocess.run( + ["node", str(ROOT / "resources" / "tester" / "helper_236_1_fill_sizes.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + rows = json.loads(proc.stdout) + assert [r["viewport"] for r in rows] == [1080, 768, 600] + for r in rows: + assert r["below"] == 18 + 8 + 12 + 8 # 안내 한 줄 + 줄 사이 + 카드 + 본문 여백 + assert r["bottom"] == r["viewport"], r # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0 From e6315f8581b05419ca4dbc7e96f845362dd24776 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:10:37 +0900 Subject: [PATCH 29/92] =?UTF-8?q?test(B05=C2=B7M02):=20=EB=B9=A8=EA=B0=95?= =?UTF-8?q?=20=EB=91=98=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20213-1=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EC=82=AC=EB=B3=B8=20=C2=B7=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B9=B4=EB=93=9C=20=EC=8B=9C=ED=97=98=20=EB=82=A1?= =?UTF-8?q?=EC=9D=80=20=EA=B8=B0=EB=8C=80=EA=B0=92(234-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_213_1 spike — 엄상준_검증 storage 원본 대신 고정 사본(fixtures/b05_213_1_hidden_station_longitudinal.json · initial_snapshot 종단 160~360m · 측점 263.21 · 꼭짓점 · 편집 키 263.206 · 263.210) · 옛 코드 15009% 재현 · 지금 100% 아래 - test_item_picker_detail — 카드 도면이 105-3(36fc3d6c3 · 10-08)부터 mountStructureSheets(여러 장 탭) — 낡은 기대값 mountDrawingTemplate 를 고침 · 코드 흠 아님 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- ...b05_213_1_hidden_station_longitudinal.json | 2261 +++++++++++++++++ .../tester/test_213_1_hidden_station_pvi.py | 12 +- resources/tester/test_item_picker_detail.py | 4 +- 3 files changed, 2270 insertions(+), 7 deletions(-) create mode 100644 resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json diff --git a/resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json b/resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json new file mode 100644 index 000000000..38caa4025 --- /dev/null +++ b/resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json @@ -0,0 +1,2261 @@ +{ + "_note": "213-1 시험 고정 자료 — 엄상준_검증 d2245e3b initial_snapshot 종단 160~360m 사본, 관 측점 263.206 → 263.21(화면 0.01m 반올림) · 꼭짓점은 옛 글자 263.206 · 편집 키 263.206 · 263.210 둘(213-1 이전 재현 상태)", + "length_m": 1196.88459, + "samples": [ + { + "chainage_m": 160.0, + "x": 208393.562479, + "y": 367768.040752, + "z": 880.274875, + "elevation_m": 880.274875, + "valid": true + }, + { + "chainage_m": 161.0, + "x": 208394.009662, + "y": 367767.14631, + "z": 880.066701, + "elevation_m": 880.066701, + "valid": true + }, + { + "chainage_m": 162.0, + "x": 208394.456845, + "y": 367766.251867, + "z": 879.974874, + "elevation_m": 879.974874, + "valid": true + }, + { + "chainage_m": 163.0, + "x": 208394.904028, + "y": 367765.357425, + "z": 879.939525, + "elevation_m": 879.939525, + "valid": true + }, + { + "chainage_m": 164.0, + "x": 208395.351211, + "y": 367764.462982, + "z": 879.783039, + "elevation_m": 879.783039, + "valid": true + }, + { + "chainage_m": 165.0, + "x": 208395.798394, + "y": 367763.56854, + "z": 879.522523, + "elevation_m": 879.522523, + "valid": true + }, + { + "chainage_m": 166.0, + "x": 208396.245578, + "y": 367762.674097, + "z": 879.721375, + "elevation_m": 879.721375, + "valid": true + }, + { + "chainage_m": 167.0, + "x": 208396.692761, + "y": 367761.779655, + "z": 879.909006, + "elevation_m": 879.909006, + "valid": true + }, + { + "chainage_m": 168.0, + "x": 208397.139944, + "y": 367760.885212, + "z": 880.000277, + "elevation_m": 880.000277, + "valid": true + }, + { + "chainage_m": 169.0, + "x": 208397.587127, + "y": 367759.99077, + "z": 880.150005, + "elevation_m": 880.150005, + "valid": true + }, + { + "chainage_m": 170.0, + "x": 208398.067013, + "y": 367759.113789, + "z": 880.207492, + "elevation_m": 880.207492, + "valid": true + }, + { + "chainage_m": 171.0, + "x": 208398.598118, + "y": 367758.268125, + "z": 879.901536, + "elevation_m": 879.901536, + "valid": true + }, + { + "chainage_m": 172.0, + "x": 208399.192951, + "y": 367757.465031, + "z": 879.853283, + "elevation_m": 879.853283, + "valid": true + }, + { + "chainage_m": 173.0, + "x": 208399.861358, + "y": 367756.721235, + "z": 879.748083, + "elevation_m": 879.748083, + "valid": true + }, + { + "chainage_m": 174.0, + "x": 208400.562, + "y": 367756.008, + "z": 879.519311, + "elevation_m": 879.519311, + "valid": true + }, + { + "chainage_m": 175.0, + "x": 208401.269107, + "y": 367755.300893, + "z": 879.336245, + "elevation_m": 879.336245, + "valid": true + }, + { + "chainage_m": 176.0, + "x": 208401.976214, + "y": 367754.593786, + "z": 879.025666, + "elevation_m": 879.025666, + "valid": true + }, + { + "chainage_m": 177.0, + "x": 208402.683321, + "y": 367753.886679, + "z": 878.89015, + "elevation_m": 878.89015, + "valid": true + }, + { + "chainage_m": 178.0, + "x": 208403.390427, + "y": 367753.179573, + "z": 878.965976, + "elevation_m": 878.965976, + "valid": true + }, + { + "chainage_m": 179.0, + "x": 208404.097534, + "y": 367752.472466, + "z": 879.083304, + "elevation_m": 879.083304, + "valid": true + }, + { + "chainage_m": 180.0, + "x": 208404.804641, + "y": 367751.765359, + "z": 878.960423, + "elevation_m": 878.960423, + "valid": true + }, + { + "chainage_m": 181.0, + "x": 208405.526937, + "y": 367751.074275, + "z": 879.03746, + "elevation_m": 879.03746, + "valid": true + }, + { + "chainage_m": 182.0, + "x": 208406.279346, + "y": 367750.416559, + "z": 878.932674, + "elevation_m": 878.932674, + "valid": true + }, + { + "chainage_m": 183.0, + "x": 208407.090763, + "y": 367749.832091, + "z": 879.027649, + "elevation_m": 879.027649, + "valid": true + }, + { + "chainage_m": 184.0, + "x": 208407.950598, + "y": 367749.323011, + "z": 879.307757, + "elevation_m": 879.307757, + "valid": true + }, + { + "chainage_m": 185.0, + "x": 208408.833749, + "y": 367748.854692, + "z": 879.54669, + "elevation_m": 879.54669, + "valid": true + }, + { + "chainage_m": 186.0, + "x": 208409.728224, + "y": 367748.407575, + "z": 879.538158, + "elevation_m": 879.538158, + "valid": true + }, + { + "chainage_m": 187.0, + "x": 208410.6227, + "y": 367747.960457, + "z": 879.281805, + "elevation_m": 879.281805, + "valid": true + }, + { + "chainage_m": 188.0, + "x": 208411.517175, + "y": 367747.513339, + "z": 878.973901, + "elevation_m": 878.973901, + "valid": true + }, + { + "chainage_m": 189.0, + "x": 208412.419339, + "y": 367747.082534, + "z": 878.77534, + "elevation_m": 878.77534, + "valid": true + }, + { + "chainage_m": 190.0, + "x": 208413.342822, + "y": 367746.701142, + "z": 878.869338, + "elevation_m": 878.869338, + "valid": true + }, + { + "chainage_m": 191.0, + "x": 208414.294782, + "y": 367746.396907, + "z": 879.691937, + "elevation_m": 879.691937, + "valid": true + }, + { + "chainage_m": 192.0, + "x": 208415.269068, + "y": 367746.172536, + "z": 880.470388, + "elevation_m": 880.470388, + "valid": true + }, + { + "chainage_m": 193.0, + "x": 208416.259321, + "y": 367746.033253, + "z": 880.612699, + "elevation_m": 880.612699, + "valid": true + }, + { + "chainage_m": 194.0, + "x": 208417.257753, + "y": 367745.981861, + "z": 881.503248, + "elevation_m": 881.503248, + "valid": true + }, + { + "chainage_m": 195.0, + "x": 208418.257573, + "y": 367745.974, + "z": 882.19521, + "elevation_m": 882.19521, + "valid": true + }, + { + "chainage_m": 196.0, + "x": 208419.257573, + "y": 367745.974, + "z": 882.327939, + "elevation_m": 882.327939, + "valid": true + }, + { + "chainage_m": 197.0, + "x": 208420.257573, + "y": 367745.974, + "z": 882.119903, + "elevation_m": 882.119903, + "valid": true + }, + { + "chainage_m": 198.0, + "x": 208421.257573, + "y": 367745.974, + "z": 880.314188, + "elevation_m": 880.314188, + "valid": true + }, + { + "chainage_m": 199.0, + "x": 208422.257573, + "y": 367745.974, + "z": 878.282163, + "elevation_m": 878.282163, + "valid": true + }, + { + "chainage_m": 200.0, + "x": 208423.257573, + "y": 367745.974, + "z": 877.309908, + "elevation_m": 877.309908, + "valid": true + }, + { + "chainage_m": 201.0, + "x": 208424.257573, + "y": 367745.974, + "z": 877.12456, + "elevation_m": 877.12456, + "valid": true + }, + { + "chainage_m": 202.0, + "x": 208425.257573, + "y": 367745.974, + "z": 877.052589, + "elevation_m": 877.052589, + "valid": true + }, + { + "chainage_m": 203.0, + "x": 208426.257573, + "y": 367745.974, + "z": 876.634767, + "elevation_m": 876.634767, + "valid": true + }, + { + "chainage_m": 204.0, + "x": 208427.257573, + "y": 367745.974, + "z": 876.139693, + "elevation_m": 876.139693, + "valid": true + }, + { + "chainage_m": 205.0, + "x": 208428.257573, + "y": 367745.974, + "z": 876.149218, + "elevation_m": 876.149218, + "valid": true + }, + { + "chainage_m": 206.0, + "x": 208429.257573, + "y": 367745.974, + "z": 876.096325, + "elevation_m": 876.096325, + "valid": true + }, + { + "chainage_m": 207.0, + "x": 208430.257573, + "y": 367745.974, + "z": 875.870445, + "elevation_m": 875.870445, + "valid": true + }, + { + "chainage_m": 208.0, + "x": 208431.257573, + "y": 367745.974, + "z": 875.785863, + "elevation_m": 875.785863, + "valid": true + }, + { + "chainage_m": 209.0, + "x": 208432.257573, + "y": 367745.974, + "z": 875.788061, + "elevation_m": 875.788061, + "valid": true + }, + { + "chainage_m": 210.0, + "x": 208433.257573, + "y": 367745.974, + "z": 875.853658, + "elevation_m": 875.853658, + "valid": true + }, + { + "chainage_m": 211.0, + "x": 208434.257573, + "y": 367745.974, + "z": 875.661856, + "elevation_m": 875.661856, + "valid": true + }, + { + "chainage_m": 212.0, + "x": 208435.257573, + "y": 367745.974, + "z": 875.290313, + "elevation_m": 875.290313, + "valid": true + }, + { + "chainage_m": 213.0, + "x": 208436.257573, + "y": 367745.974, + "z": 875.127237, + "elevation_m": 875.127237, + "valid": true + }, + { + "chainage_m": 214.0, + "x": 208437.257573, + "y": 367745.974, + "z": 874.938191, + "elevation_m": 874.938191, + "valid": true + }, + { + "chainage_m": 215.0, + "x": 208438.257573, + "y": 367745.974, + "z": 874.889529, + "elevation_m": 874.889529, + "valid": true + }, + { + "chainage_m": 216.0, + "x": 208439.257573, + "y": 367745.974, + "z": 875.186213, + "elevation_m": 875.186213, + "valid": true + }, + { + "chainage_m": 217.0, + "x": 208440.257573, + "y": 367745.974, + "z": 875.477006, + "elevation_m": 875.477006, + "valid": true + }, + { + "chainage_m": 218.0, + "x": 208441.257573, + "y": 367745.974, + "z": 875.443878, + "elevation_m": 875.443878, + "valid": true + }, + { + "chainage_m": 219.0, + "x": 208442.257573, + "y": 367745.974, + "z": 875.186807, + "elevation_m": 875.186807, + "valid": true + }, + { + "chainage_m": 220.0, + "x": 208443.257573, + "y": 367745.974, + "z": 875.077858, + "elevation_m": 875.077858, + "valid": true + }, + { + "chainage_m": 221.0, + "x": 208444.257573, + "y": 367745.974, + "z": 875.625482, + "elevation_m": 875.625482, + "valid": true + }, + { + "chainage_m": 222.0, + "x": 208445.257573, + "y": 367745.974, + "z": 875.758555, + "elevation_m": 875.758555, + "valid": true + }, + { + "chainage_m": 223.0, + "x": 208446.257573, + "y": 367745.974, + "z": 875.482925, + "elevation_m": 875.482925, + "valid": true + }, + { + "chainage_m": 224.0, + "x": 208447.257573, + "y": 367745.974, + "z": 875.712811, + "elevation_m": 875.712811, + "valid": true + }, + { + "chainage_m": 225.0, + "x": 208448.257053, + "y": 367745.996679, + "z": 875.756986, + "elevation_m": 875.756986, + "valid": true + }, + { + "chainage_m": 226.0, + "x": 208449.252676, + "y": 367746.078262, + "z": 875.40165, + "elevation_m": 875.40165, + "valid": true + }, + { + "chainage_m": 227.0, + "x": 208450.238368, + "y": 367746.241833, + "z": 875.503535, + "elevation_m": 875.503535, + "valid": true + }, + { + "chainage_m": 228.0, + "x": 208451.207667, + "y": 367746.485362, + "z": 875.628622, + "elevation_m": 875.628622, + "valid": true + }, + { + "chainage_m": 229.0, + "x": 208452.153942, + "y": 367746.808156, + "z": 875.624947, + "elevation_m": 875.624947, + "valid": true + }, + { + "chainage_m": 230.0, + "x": 208453.068087, + "y": 367747.213544, + "z": 875.447687, + "elevation_m": 875.447687, + "valid": true + }, + { + "chainage_m": 231.0, + "x": 208453.963849, + "y": 367747.657925, + "z": 874.69746, + "elevation_m": 874.69746, + "valid": true + }, + { + "chainage_m": 232.0, + "x": 208454.858277, + "y": 367748.105138, + "z": 873.796567, + "elevation_m": 873.796567, + "valid": true + }, + { + "chainage_m": 233.0, + "x": 208455.752704, + "y": 367748.552352, + "z": 873.630186, + "elevation_m": 873.630186, + "valid": true + }, + { + "chainage_m": 234.0, + "x": 208456.647131, + "y": 367748.999565, + "z": 873.355806, + "elevation_m": 873.355806, + "valid": true + }, + { + "chainage_m": 235.0, + "x": 208457.541558, + "y": 367749.446779, + "z": 872.944746, + "elevation_m": 872.944746, + "valid": true + }, + { + "chainage_m": 236.0, + "x": 208458.435985, + "y": 367749.893993, + "z": 872.594761, + "elevation_m": 872.594761, + "valid": true + }, + { + "chainage_m": 237.0, + "x": 208459.330413, + "y": 367750.341206, + "z": 872.323602, + "elevation_m": 872.323602, + "valid": true + }, + { + "chainage_m": 238.0, + "x": 208460.22484, + "y": 367750.78842, + "z": 871.908658, + "elevation_m": 871.908658, + "valid": true + }, + { + "chainage_m": 239.0, + "x": 208461.119267, + "y": 367751.235633, + "z": 871.521665, + "elevation_m": 871.521665, + "valid": true + }, + { + "chainage_m": 240.0, + "x": 208462.013694, + "y": 367751.682847, + "z": 871.348918, + "elevation_m": 871.348918, + "valid": true + }, + { + "chainage_m": 241.0, + "x": 208462.908121, + "y": 367752.130061, + "z": 871.178815, + "elevation_m": 871.178815, + "valid": true + }, + { + "chainage_m": 242.0, + "x": 208463.802548, + "y": 367752.577274, + "z": 870.5225, + "elevation_m": 870.5225, + "valid": true + }, + { + "chainage_m": 243.0, + "x": 208464.696976, + "y": 367753.024488, + "z": 869.978886, + "elevation_m": 869.978886, + "valid": true + }, + { + "chainage_m": 244.0, + "x": 208465.591403, + "y": 367753.471701, + "z": 869.385986, + "elevation_m": 869.385986, + "valid": true + }, + { + "chainage_m": 245.0, + "x": 208466.48583, + "y": 367753.918915, + "z": 868.741635, + "elevation_m": 868.741635, + "valid": true + }, + { + "chainage_m": 246.0, + "x": 208467.380257, + "y": 367754.366129, + "z": 868.389385, + "elevation_m": 868.389385, + "valid": true + }, + { + "chainage_m": 247.0, + "x": 208468.274684, + "y": 367754.813342, + "z": 867.839514, + "elevation_m": 867.839514, + "valid": true + }, + { + "chainage_m": 248.0, + "x": 208469.169112, + "y": 367755.260556, + "z": 867.749827, + "elevation_m": 867.749827, + "valid": true + }, + { + "chainage_m": 249.0, + "x": 208470.063539, + "y": 367755.707769, + "z": 867.725932, + "elevation_m": 867.725932, + "valid": true + }, + { + "chainage_m": 250.0, + "x": 208470.957966, + "y": 367756.154983, + "z": 867.237421, + "elevation_m": 867.237421, + "valid": true + }, + { + "chainage_m": 251.0, + "x": 208471.852393, + "y": 367756.602197, + "z": 867.328371, + "elevation_m": 867.328371, + "valid": true + }, + { + "chainage_m": 252.0, + "x": 208472.74682, + "y": 367757.04941, + "z": 866.683088, + "elevation_m": 866.683088, + "valid": true + }, + { + "chainage_m": 253.0, + "x": 208473.641248, + "y": 367757.496624, + "z": 865.303387, + "elevation_m": 865.303387, + "valid": true + }, + { + "chainage_m": 254.0, + "x": 208474.535675, + "y": 367757.943837, + "z": 864.735997, + "elevation_m": 864.735997, + "valid": true + }, + { + "chainage_m": 255.0, + "x": 208475.430102, + "y": 367758.391051, + "z": 864.7865, + "elevation_m": 864.7865, + "valid": true + }, + { + "chainage_m": 256.0, + "x": 208476.324529, + "y": 367758.838265, + "z": 864.81515, + "elevation_m": 864.81515, + "valid": true + }, + { + "chainage_m": 257.0, + "x": 208477.218956, + "y": 367759.285478, + "z": 864.240971, + "elevation_m": 864.240971, + "valid": true + }, + { + "chainage_m": 258.0, + "x": 208478.113384, + "y": 367759.732692, + "z": 864.200528, + "elevation_m": 864.200528, + "valid": true + }, + { + "chainage_m": 259.0, + "x": 208479.007811, + "y": 367760.179905, + "z": 864.034127, + "elevation_m": 864.034127, + "valid": true + }, + { + "chainage_m": 260.0, + "x": 208479.902238, + "y": 367760.627119, + "z": 863.832762, + "elevation_m": 863.832762, + "valid": true + }, + { + "chainage_m": 261.0, + "x": 208480.796665, + "y": 367761.074333, + "z": 863.496394, + "elevation_m": 863.496394, + "valid": true + }, + { + "chainage_m": 262.0, + "x": 208481.691092, + "y": 367761.521546, + "z": 862.687865, + "elevation_m": 862.687865, + "valid": true + }, + { + "chainage_m": 263.0, + "x": 208482.585519, + "y": 367761.96876, + "z": 862.708177, + "elevation_m": 862.708177, + "valid": true + }, + { + "chainage_m": 264.0, + "x": 208483.479947, + "y": 367762.415973, + "z": 862.616929, + "elevation_m": 862.616929, + "valid": true + }, + { + "chainage_m": 265.0, + "x": 208484.374374, + "y": 367762.863187, + "z": 862.370077, + "elevation_m": 862.370077, + "valid": true + }, + { + "chainage_m": 266.0, + "x": 208485.268801, + "y": 367763.310401, + "z": 862.183629, + "elevation_m": 862.183629, + "valid": true + }, + { + "chainage_m": 267.0, + "x": 208486.163228, + "y": 367763.757614, + "z": 862.068982, + "elevation_m": 862.068982, + "valid": true + }, + { + "chainage_m": 268.0, + "x": 208487.057655, + "y": 367764.204828, + "z": 862.077233, + "elevation_m": 862.077233, + "valid": true + }, + { + "chainage_m": 269.0, + "x": 208487.952083, + "y": 367764.652041, + "z": 861.94402, + "elevation_m": 861.94402, + "valid": true + }, + { + "chainage_m": 270.0, + "x": 208488.863804, + "y": 367765.062569, + "z": 861.552265, + "elevation_m": 861.552265, + "valid": true + }, + { + "chainage_m": 271.0, + "x": 208489.803805, + "y": 367765.401435, + "z": 861.348876, + "elevation_m": 861.348876, + "valid": true + }, + { + "chainage_m": 272.0, + "x": 208490.768228, + "y": 367765.662155, + "z": 861.811783, + "elevation_m": 861.811783, + "valid": true + }, + { + "chainage_m": 273.0, + "x": 208491.750693, + "y": 367765.842721, + "z": 862.30889, + "elevation_m": 862.30889, + "valid": true + }, + { + "chainage_m": 274.0, + "x": 208492.744713, + "y": 367765.941972, + "z": 862.347235, + "elevation_m": 862.347235, + "valid": true + }, + { + "chainage_m": 275.0, + "x": 208493.743964, + "y": 367765.974, + "z": 862.18813, + "elevation_m": 862.18813, + "valid": true + }, + { + "chainage_m": 276.0, + "x": 208494.743964, + "y": 367765.974, + "z": 862.329525, + "elevation_m": 862.329525, + "valid": true + }, + { + "chainage_m": 277.0, + "x": 208495.743964, + "y": 367765.974, + "z": 862.652236, + "elevation_m": 862.652236, + "valid": true + }, + { + "chainage_m": 278.0, + "x": 208496.743964, + "y": 367765.974, + "z": 862.779696, + "elevation_m": 862.779696, + "valid": true + }, + { + "chainage_m": 279.0, + "x": 208497.743964, + "y": 367765.974, + "z": 862.697969, + "elevation_m": 862.697969, + "valid": true + }, + { + "chainage_m": 280.0, + "x": 208498.743964, + "y": 367765.974, + "z": 862.784431, + "elevation_m": 862.784431, + "valid": true + }, + { + "chainage_m": 281.0, + "x": 208499.743964, + "y": 367765.974, + "z": 863.016989, + "elevation_m": 863.016989, + "valid": true + }, + { + "chainage_m": 282.0, + "x": 208500.743964, + "y": 367765.974, + "z": 863.06037, + "elevation_m": 863.06037, + "valid": true + }, + { + "chainage_m": 283.0, + "x": 208501.743964, + "y": 367765.974, + "z": 863.169186, + "elevation_m": 863.169186, + "valid": true + }, + { + "chainage_m": 284.0, + "x": 208502.743964, + "y": 367765.974, + "z": 863.309619, + "elevation_m": 863.309619, + "valid": true + }, + { + "chainage_m": 285.0, + "x": 208503.743964, + "y": 367765.974, + "z": 863.551038, + "elevation_m": 863.551038, + "valid": true + }, + { + "chainage_m": 286.0, + "x": 208504.743964, + "y": 367765.974, + "z": 863.764148, + "elevation_m": 863.764148, + "valid": true + }, + { + "chainage_m": 287.0, + "x": 208505.743964, + "y": 367765.974, + "z": 863.89876, + "elevation_m": 863.89876, + "valid": true + }, + { + "chainage_m": 288.0, + "x": 208506.743964, + "y": 367765.974, + "z": 864.164324, + "elevation_m": 864.164324, + "valid": true + }, + { + "chainage_m": 289.0, + "x": 208507.743964, + "y": 367765.974, + "z": 864.336218, + "elevation_m": 864.336218, + "valid": true + }, + { + "chainage_m": 290.0, + "x": 208508.742891, + "y": 367765.928137, + "z": 864.070881, + "elevation_m": 864.070881, + "valid": true + }, + { + "chainage_m": 291.0, + "x": 208509.734387, + "y": 367765.801764, + "z": 864.008954, + "elevation_m": 864.008954, + "valid": true + }, + { + "chainage_m": 292.0, + "x": 208510.711727, + "y": 367765.593928, + "z": 864.503795, + "elevation_m": 864.503795, + "valid": true + }, + { + "chainage_m": 293.0, + "x": 208511.668704, + "y": 367765.307081, + "z": 864.617185, + "elevation_m": 864.617185, + "valid": true + }, + { + "chainage_m": 294.0, + "x": 208512.598739, + "y": 367764.942594, + "z": 864.949289, + "elevation_m": 864.949289, + "valid": true + }, + { + "chainage_m": 295.0, + "x": 208513.504625, + "y": 367764.519687, + "z": 865.163788, + "elevation_m": 865.163788, + "valid": true + }, + { + "chainage_m": 296.0, + "x": 208514.399052, + "y": 367764.072474, + "z": 865.441189, + "elevation_m": 865.441189, + "valid": true + }, + { + "chainage_m": 297.0, + "x": 208515.29348, + "y": 367763.62526, + "z": 865.797692, + "elevation_m": 865.797692, + "valid": true + }, + { + "chainage_m": 298.0, + "x": 208516.187907, + "y": 367763.178047, + "z": 866.464496, + "elevation_m": 866.464496, + "valid": true + }, + { + "chainage_m": 299.0, + "x": 208517.075808, + "y": 367762.718621, + "z": 867.214534, + "elevation_m": 867.214534, + "valid": true + }, + { + "chainage_m": 300.0, + "x": 208517.944672, + "y": 367762.22357, + "z": 867.864402, + "elevation_m": 867.864402, + "valid": true + }, + { + "chainage_m": 301.0, + "x": 208518.757896, + "y": 367761.641914, + "z": 868.228745, + "elevation_m": 868.228745, + "valid": true + }, + { + "chainage_m": 302.0, + "x": 208519.523465, + "y": 367761.000501, + "z": 868.587501, + "elevation_m": 868.587501, + "valid": true + }, + { + "chainage_m": 303.0, + "x": 208520.252968, + "y": 367760.317032, + "z": 868.710721, + "elevation_m": 868.710721, + "valid": true + }, + { + "chainage_m": 304.0, + "x": 208520.960075, + "y": 367759.609925, + "z": 870.026336, + "elevation_m": 870.026336, + "valid": true + }, + { + "chainage_m": 305.0, + "x": 208521.667182, + "y": 367758.902818, + "z": 870.609514, + "elevation_m": 870.609514, + "valid": true + }, + { + "chainage_m": 306.0, + "x": 208522.374288, + "y": 367758.195712, + "z": 871.522133, + "elevation_m": 871.522133, + "valid": true + }, + { + "chainage_m": 307.0, + "x": 208523.081395, + "y": 367757.488605, + "z": 871.571255, + "elevation_m": 871.571255, + "valid": true + }, + { + "chainage_m": 308.0, + "x": 208523.788502, + "y": 367756.781498, + "z": 871.704316, + "elevation_m": 871.704316, + "valid": true + }, + { + "chainage_m": 309.0, + "x": 208524.495609, + "y": 367756.074391, + "z": 872.173686, + "elevation_m": 872.173686, + "valid": true + }, + { + "chainage_m": 310.0, + "x": 208525.202715, + "y": 367755.367285, + "z": 872.223643, + "elevation_m": 872.223643, + "valid": true + }, + { + "chainage_m": 311.0, + "x": 208525.871593, + "y": 367754.623956, + "z": 872.384133, + "elevation_m": 872.384133, + "valid": true + }, + { + "chainage_m": 312.0, + "x": 208526.482219, + "y": 367753.833569, + "z": 872.308009, + "elevation_m": 872.308009, + "valid": true + }, + { + "chainage_m": 313.0, + "x": 208527.030651, + "y": 367752.999042, + "z": 871.762187, + "elevation_m": 871.762187, + "valid": true + }, + { + "chainage_m": 314.0, + "x": 208527.51956, + "y": 367752.126878, + "z": 871.648424, + "elevation_m": 871.648424, + "valid": true + }, + { + "chainage_m": 315.0, + "x": 208527.966769, + "y": 367751.232448, + "z": 871.724875, + "elevation_m": 871.724875, + "valid": true + }, + { + "chainage_m": 316.0, + "x": 208528.413977, + "y": 367750.338019, + "z": 871.83015, + "elevation_m": 871.83015, + "valid": true + }, + { + "chainage_m": 317.0, + "x": 208528.861185, + "y": 367749.443589, + "z": 871.535179, + "elevation_m": 871.535179, + "valid": true + }, + { + "chainage_m": 318.0, + "x": 208529.308394, + "y": 367748.549159, + "z": 871.494106, + "elevation_m": 871.494106, + "valid": true + }, + { + "chainage_m": 319.0, + "x": 208529.755602, + "y": 367747.654729, + "z": 871.29612, + "elevation_m": 871.29612, + "valid": true + }, + { + "chainage_m": 320.0, + "x": 208530.20281, + "y": 367746.760299, + "z": 871.872531, + "elevation_m": 871.872531, + "valid": true + }, + { + "chainage_m": 321.0, + "x": 208530.650019, + "y": 367745.86587, + "z": 872.09275, + "elevation_m": 872.09275, + "valid": true + }, + { + "chainage_m": 322.0, + "x": 208531.097227, + "y": 367744.97144, + "z": 872.006481, + "elevation_m": 872.006481, + "valid": true + }, + { + "chainage_m": 323.0, + "x": 208531.544436, + "y": 367744.07701, + "z": 871.972749, + "elevation_m": 871.972749, + "valid": true + }, + { + "chainage_m": 324.0, + "x": 208531.991644, + "y": 367743.18258, + "z": 871.819964, + "elevation_m": 871.819964, + "valid": true + }, + { + "chainage_m": 325.0, + "x": 208532.438852, + "y": 367742.28815, + "z": 871.483305, + "elevation_m": 871.483305, + "valid": true + }, + { + "chainage_m": 326.0, + "x": 208532.886061, + "y": 367741.393721, + "z": 870.837869, + "elevation_m": 870.837869, + "valid": true + }, + { + "chainage_m": 327.0, + "x": 208533.333269, + "y": 367740.499291, + "z": 870.770714, + "elevation_m": 870.770714, + "valid": true + }, + { + "chainage_m": 328.0, + "x": 208533.780477, + "y": 367739.604861, + "z": 870.903201, + "elevation_m": 870.903201, + "valid": true + }, + { + "chainage_m": 329.0, + "x": 208534.227686, + "y": 367738.710431, + "z": 870.158915, + "elevation_m": 870.158915, + "valid": true + }, + { + "chainage_m": 330.0, + "x": 208534.674894, + "y": 367737.816001, + "z": 870.2466, + "elevation_m": 870.2466, + "valid": true + }, + { + "chainage_m": 331.0, + "x": 208535.122103, + "y": 367736.921571, + "z": 870.481656, + "elevation_m": 870.481656, + "valid": true + }, + { + "chainage_m": 332.0, + "x": 208535.569311, + "y": 367736.027142, + "z": 870.637386, + "elevation_m": 870.637386, + "valid": true + }, + { + "chainage_m": 333.0, + "x": 208536.016519, + "y": 367735.132712, + "z": 870.740964, + "elevation_m": 870.740964, + "valid": true + }, + { + "chainage_m": 334.0, + "x": 208536.463728, + "y": 367734.238282, + "z": 870.315482, + "elevation_m": 870.315482, + "valid": true + }, + { + "chainage_m": 335.0, + "x": 208536.910936, + "y": 367733.343852, + "z": 870.455116, + "elevation_m": 870.455116, + "valid": true + }, + { + "chainage_m": 336.0, + "x": 208537.358144, + "y": 367732.449422, + "z": 870.773586, + "elevation_m": 870.773586, + "valid": true + }, + { + "chainage_m": 337.0, + "x": 208537.805353, + "y": 367731.554993, + "z": 871.170461, + "elevation_m": 871.170461, + "valid": true + }, + { + "chainage_m": 338.0, + "x": 208538.252561, + "y": 367730.660563, + "z": 870.856249, + "elevation_m": 870.856249, + "valid": true + }, + { + "chainage_m": 339.0, + "x": 208538.69977, + "y": 367729.766133, + "z": 870.772348, + "elevation_m": 870.772348, + "valid": true + }, + { + "chainage_m": 340.0, + "x": 208539.146978, + "y": 367728.871703, + "z": 870.732001, + "elevation_m": 870.732001, + "valid": true + }, + { + "chainage_m": 341.0, + "x": 208539.594186, + "y": 367727.977273, + "z": 870.954294, + "elevation_m": 870.954294, + "valid": true + }, + { + "chainage_m": 342.0, + "x": 208540.041395, + "y": 367727.082844, + "z": 871.692873, + "elevation_m": 871.692873, + "valid": true + }, + { + "chainage_m": 343.0, + "x": 208540.488603, + "y": 367726.188414, + "z": 871.204913, + "elevation_m": 871.204913, + "valid": true + }, + { + "chainage_m": 344.0, + "x": 208540.935811, + "y": 367725.293984, + "z": 870.361649, + "elevation_m": 870.361649, + "valid": true + }, + { + "chainage_m": 345.0, + "x": 208541.38302, + "y": 367724.399554, + "z": 870.112888, + "elevation_m": 870.112888, + "valid": true + }, + { + "chainage_m": 346.0, + "x": 208541.830228, + "y": 367723.505124, + "z": 870.160481, + "elevation_m": 870.160481, + "valid": true + }, + { + "chainage_m": 347.0, + "x": 208542.277437, + "y": 367722.610695, + "z": 870.356587, + "elevation_m": 870.356587, + "valid": true + }, + { + "chainage_m": 348.0, + "x": 208542.724645, + "y": 367721.716265, + "z": 870.213329, + "elevation_m": 870.213329, + "valid": true + }, + { + "chainage_m": 349.0, + "x": 208543.171853, + "y": 367720.821835, + "z": 869.701063, + "elevation_m": 869.701063, + "valid": true + }, + { + "chainage_m": 350.0, + "x": 208543.619062, + "y": 367719.927405, + "z": 869.28203, + "elevation_m": 869.28203, + "valid": true + }, + { + "chainage_m": 351.0, + "x": 208544.06627, + "y": 367719.032975, + "z": 869.502922, + "elevation_m": 869.502922, + "valid": true + }, + { + "chainage_m": 352.0, + "x": 208544.513478, + "y": 367718.138546, + "z": 869.69136, + "elevation_m": 869.69136, + "valid": true + }, + { + "chainage_m": 353.0, + "x": 208544.960687, + "y": 367717.244116, + "z": 869.508172, + "elevation_m": 869.508172, + "valid": true + }, + { + "chainage_m": 354.0, + "x": 208545.407895, + "y": 367716.349686, + "z": 869.283434, + "elevation_m": 869.283434, + "valid": true + }, + { + "chainage_m": 355.0, + "x": 208545.855103, + "y": 367715.455256, + "z": 869.157154, + "elevation_m": 869.157154, + "valid": true + }, + { + "chainage_m": 356.0, + "x": 208546.302312, + "y": 367714.560826, + "z": 868.784861, + "elevation_m": 868.784861, + "valid": true + }, + { + "chainage_m": 357.0, + "x": 208546.74952, + "y": 367713.666397, + "z": 868.386015, + "elevation_m": 868.386015, + "valid": true + }, + { + "chainage_m": 358.0, + "x": 208547.196729, + "y": 367712.771967, + "z": 867.871342, + "elevation_m": 867.871342, + "valid": true + }, + { + "chainage_m": 359.0, + "x": 208547.643937, + "y": 367711.877537, + "z": 867.712103, + "elevation_m": 867.712103, + "valid": true + }, + { + "chainage_m": 360.0, + "x": 208548.091145, + "y": 367710.983107, + "z": 867.67227, + "elevation_m": 867.67227, + "valid": true + } + ], + "stations": [ + { + "station_id": "station_000000160000", + "chainage_m": 160.0, + "label": "STA.0+160.000", + "kind": "regular", + "center_x": 208393.562479, + "center_y": 367768.040752, + "center_z": 880.274875, + "azimuth_deg": 153.436904, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 1.3949, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208393.562479, + "y": 367768.040752, + "z": 880.274875 + }, + "tangent_xy": [ + 0.447183081, + -0.894442447 + ], + "left_xy": [ + 0.894442447, + 0.447183081 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 4.13, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000178290", + "chainage_m": 178.29, + "label": "STA.0+178.290", + "kind": "irregular", + "center_x": 208403.595488, + "center_y": 367752.974512, + "center_z": 878.972372, + "azimuth_deg": 135.0, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 1.7334, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208403.595488, + "y": 367752.974512, + "z": 878.972372 + }, + "tangent_xy": [ + 0.707106781, + -0.707106781 + ], + "left_xy": [ + 0.707106781, + 0.707106781 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "structure": "세월교", + "pavement_suggested": false, + "pavement_grade_pct": 5.95, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000180000", + "chainage_m": 180.0, + "label": "STA.0+180.000", + "kind": "regular", + "center_x": 208404.804641, + "center_y": 367751.765359, + "center_z": 878.960423, + "azimuth_deg": 134.367573, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 2.1181, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208404.804641, + "y": 367751.765359, + "z": 878.960423 + }, + "tangent_xy": [ + 0.714868541, + -0.699258871 + ], + "left_xy": [ + 0.699258871, + 0.714868541 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 6.15, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000200000", + "chainage_m": 200.0, + "label": "STA.0+200.000", + "kind": "regular", + "center_x": 208423.257573, + "center_y": 367745.974, + "center_z": 877.309908, + "azimuth_deg": 90.0, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 0.9385, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208423.257573, + "y": 367745.974, + "z": 877.309908 + }, + "tangent_xy": [ + 1.0, + 0.0 + ], + "left_xy": [ + -0.0, + 1.0 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 9.54, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000220000", + "chainage_m": 220.0, + "label": "STA.0+220.000", + "kind": "regular", + "center_x": 208443.257573, + "center_y": 367745.974, + "center_z": 875.077858, + "azimuth_deg": 90.0, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 1.2363, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208443.257573, + "y": 367745.974, + "z": 875.077858 + }, + "tangent_xy": [ + 1.0, + 0.0 + ], + "left_xy": [ + -0.0, + 1.0 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 14.18, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000240000", + "chainage_m": 240.0, + "label": "STA.0+240.000", + "kind": "regular", + "center_x": 208462.013694, + "center_y": 367751.682847, + "center_z": 871.348918, + "azimuth_deg": 63.434949, + "plan_radius_m": null, + "curve_outer_side": "right", + "curve_widening_m": 0.0151, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208462.013694, + "y": 367751.682847, + "z": 871.348918 + }, + "tangent_xy": [ + 0.894427191, + 0.447213595 + ], + "left_xy": [ + -0.447213595, + 0.894427191 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 29.16, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000260000", + "chainage_m": 260.0, + "label": "STA.0+260.000", + "kind": "regular", + "center_x": 208479.902238, + "center_y": 367760.627119, + "center_z": 863.832762, + "azimuth_deg": 63.434949, + "plan_radius_m": null, + "curve_outer_side": "left", + "curve_widening_m": 0.1973, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208479.902238, + "y": 367760.627119, + "z": 863.832762 + }, + "tangent_xy": [ + 0.894427191, + 0.447213595 + ], + "left_xy": [ + -0.447213595, + 0.894427191 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 39.87, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000263206", + "chainage_m": 263.21, + "label": "STA.0+263.206", + "kind": "irregular", + "center_x": 208482.769771, + "center_y": 367762.060886, + "center_z": 862.691564, + "azimuth_deg": 63.434949, + "plan_radius_m": null, + "curve_outer_side": "left", + "curve_widening_m": 0.9187, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208482.769771, + "y": 367762.060886, + "z": 862.691564 + }, + "tangent_xy": [ + 0.894427191, + 0.447213595 + ], + "left_xy": [ + -0.447213595, + 0.894427191 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "structure": "파형강관 D800", + "pavement_suggested": true, + "pavement_grade_pct": 22.37, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000280000", + "chainage_m": 280.0, + "label": "STA.0+280.000", + "kind": "regular", + "center_x": 208498.743964, + "center_y": 367765.974, + "center_z": 862.784431, + "azimuth_deg": 90.0, + "plan_radius_m": null, + "curve_outer_side": "left", + "curve_widening_m": 1.054, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208498.743964, + "y": 367765.974, + "z": 862.784431 + }, + "tangent_xy": [ + 1.0, + 0.0 + ], + "left_xy": [ + -0.0, + 1.0 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 14.46, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000300000", + "chainage_m": 300.0, + "label": "STA.0+300.000", + "kind": "regular", + "center_x": 208517.944672, + "center_y": 367762.22357, + "center_z": 867.864402, + "azimuth_deg": 122.623368, + "plan_radius_m": 12.0, + "curve_outer_side": "left", + "curve_widening_m": 2.25, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208517.944672, + "y": 367762.22357, + "z": 867.864402 + }, + "tangent_xy": [ + 0.842232593, + -0.539114328 + ], + "left_xy": [ + 0.539114328, + 0.842232593 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": true, + "pavement_grade_pct": 25.84, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000320000", + "chainage_m": 320.0, + "label": "STA.0+320.000", + "kind": "regular", + "center_x": 208530.20281, + "center_y": 367746.760299, + "center_z": 871.872531, + "azimuth_deg": 153.435283, + "plan_radius_m": null, + "curve_outer_side": "left", + "curve_widening_m": 0.8735, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208530.20281, + "y": 367746.760299, + "z": 871.872531 + }, + "tangent_xy": [ + 0.447208374, + -0.894429802 + ], + "left_xy": [ + 0.894429802, + 0.447208374 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 7.89, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000340000", + "chainage_m": 340.0, + "label": "STA.0+340.000", + "kind": "regular", + "center_x": 208539.146978, + "center_y": 367728.871703, + "center_z": 870.732001, + "azimuth_deg": 153.435283, + "plan_radius_m": null, + "curve_outer_side": null, + "curve_widening_m": 0.0, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208539.146978, + "y": 367728.871703, + "z": 870.732001 + }, + "tangent_xy": [ + 0.447208374, + -0.894429802 + ], + "left_xy": [ + 0.894429802, + 0.447208374 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 8.56, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000344122", + "chainage_m": 344.122, + "label": "STA.0+344.122", + "kind": "irregular", + "center_x": 208540.990371, + "center_y": 367725.184864, + "center_z": 870.282781, + "azimuth_deg": 153.435283, + "plan_radius_m": null, + "curve_outer_side": null, + "curve_widening_m": 0.0, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208540.990371, + "y": 367725.184864, + "z": 870.282781 + }, + "tangent_xy": [ + 0.447208374, + -0.894429802 + ], + "left_xy": [ + 0.894429802, + 0.447208374 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "structure": "파형강관 D800", + "pavement_suggested": true, + "pavement_grade_pct": 15.26, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + }, + { + "station_id": "station_000000360000", + "chainage_m": 360.0, + "label": "STA.0+360.000", + "kind": "regular", + "center_x": 208548.091145, + "center_y": 367710.983107, + "center_z": 867.67227, + "azimuth_deg": 153.435283, + "plan_radius_m": null, + "curve_outer_side": null, + "curve_widening_m": 0.0, + "uphill_side": "right", + "frame": { + "origin": { + "x": 208548.091145, + "y": 367710.983107, + "z": 867.67227 + }, + "tangent_xy": [ + 0.447208374, + -0.894429802 + ], + "left_xy": [ + 0.894429802, + 0.447208374 + ], + "up_xyz": [ + 0.0, + 0.0, + 1.0 + ] + }, + "pavement_suggested": false, + "pavement_grade_pct": 9.6, + "pavement_grade_limit_pct": 14.0, + "uphill_side_source": "user" + } + ], + "design_profiles": [ + { + "schema_version": 1, + "id": "design_grade_line", + "name": "계획선", + "basis": "ground_polyline", + "criteria": { + "max_grade_pct": 14.0, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 100.0, + "min_curve_length_m": 20.0, + "min_tangent_length_m": 20.0, + "vertical_curve_skip_delta_pct": 5.0, + "design_speed_kph": 20, + "terrain_type": "special", + "paved": false, + "main_direction": "auto", + "balance_segment_length_m": null, + "start_elevation_offset_m": 0.0, + "end_elevation_offset_m": 0.0, + "enforce_pipe_clearance": false, + "resolved_main_direction": "descending" + } + } + ], + "profile_alignment": { + "schema_version": 1, + "policy": { + "station_interval_m": 20.0, + "curve_radius_ratio": 0.4, + "default_curve_length_m": 15.0, + "curve_tangent_max_ratio": 0.45, + "curve_skip_legal_exception": false, + "default_curve_radius_m": 8.0, + "balance_tolerance_percent": 10.0, + "edit_step_m": 0.1, + "grade_violation_policy": "warn", + "max_grade_pct": 14.0, + "curve_skip_delta_pct": 5.0, + "paved": false, + "min_plan_radius_m": 0.0, + "hairpin_min_radius_m": 0.0 + }, + "only_explicit_curves": false, + "base_pvi": [ + { + "chainage_m": 160.0, + "elevation_m": 878.698 + }, + { + "chainage_m": 178.29, + "elevation_m": 877.9714 + }, + { + "chainage_m": 180.0, + "elevation_m": 877.925 + }, + { + "chainage_m": 200.0, + "elevation_m": 876.3436 + }, + { + "chainage_m": 220.0, + "elevation_m": 873.5503 + }, + { + "chainage_m": 240.0, + "elevation_m": 870.7218 + }, + { + "chainage_m": 260.0, + "elevation_m": 868.183 + }, + { + "chainage_m": 263.206, + "elevation_m": 867.9361 + }, + { + "chainage_m": 280.0, + "elevation_m": 866.4094 + }, + { + "chainage_m": 300.0, + "elevation_m": 865.7391 + }, + { + "chainage_m": 320.0, + "elevation_m": 866.0486 + }, + { + "chainage_m": 340.0, + "elevation_m": 867.2154 + }, + { + "chainage_m": 344.122, + "elevation_m": 867.2302 + }, + { + "chainage_m": 360.0, + "elevation_m": 867.3354 + } + ], + "edits": { + "station_offsets": { + "263.206": 1.5, + "263.210": 0.9 + }, + "curve_radii": {} + } + } +} \ No newline at end of file diff --git a/resources/tester/test_213_1_hidden_station_pvi.py b/resources/tester/test_213_1_hidden_station_pvi.py index 4e5480c8c..088edaba4 100644 --- a/resources/tester/test_213_1_hidden_station_pvi.py +++ b/resources/tester/test_213_1_hidden_station_pvi.py @@ -5,7 +5,10 @@ 편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단). 고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의 꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도 -옛 글자를 같은 측점으로 봄. 사용자 storage 는 읽기만 — 사본(tmp_path). +옛 글자를 같은 측점으로 봄. +실자료 = 고정 사본(fixtures/b05_213_1_hidden_station_longitudinal.json · +d2245e3b initial_snapshot 종단 160~360m · 213-1 이전 재현 상태 · 234-2) +— 사용자 storage 에 기대지 않음. """ from __future__ import annotations @@ -32,12 +35,9 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profi from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" -SOURCE = ( - PROJECT_ROOT - / "storage/1/3/d2245e3b-0468-4a90-ac5c-50b2adcb56e7/B06_Section/longitudinal/longitudinal.json" -) +SOURCE = PROJECT_ROOT / "resources/tester/fixtures/b05_213_1_hidden_station_longitudinal.json" REL = "B06_Section/longitudinal/longitudinal.json" -needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="엄상준_검증 storage 없음") +needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="213-1 고정 사본 없음") # 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998) STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998] diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index 91320af14..33eda500d 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -124,7 +124,9 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀(): assert "mountDrawingTemplate(" not in text and "createSpreadsheet(" not in text, name assert cards.count("mountDrawingCard(") == 1 # 도면 미리보기 = 카드 파일에서 뗀 부품 하나(31-4) drawing_card = (M02 / "M02_MasterTemplete_DrawingCard.ts").read_text(encoding="utf-8") - assert drawing_card.count("mountDrawingTemplate(") == 1 + # 카드 도면 = 여러 장 탭 부품 한 번(105-3 · 36fc3d6c3 부터 mountDrawingTemplate 대신 · 234-2) + assert drawing_card.count("mountStructureSheets(") == 1 + assert "mountDrawingTemplate(" not in drawing_card def test_넣을_값_카드는_sub2_파일_한_벌(out): From b3c17622293ab3edcbfedc26a75888fc8c29a85a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:13:39 +0900 Subject: [PATCH 30/92] =?UTF-8?q?feat(design):=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=ED=8F=BC=20=C2=B7=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=EB=8F=84=20=EA=B0=95=EC=A1=B0=EB=A5=BC=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=ED=95=9C=20=EC=B9=B8=EC=97=90=EC=84=9C=20=C2=B7=20?= =?UTF-8?q?=ED=92=80=EA=B8=B0=20=3D=20Esc=20=ED=95=9C=20=EA=B8=B8=20(233-3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종단 보기 구독이 배수유역도 관 마커 · 유역 · 마름모까지 알림 없이 칠함 · 재계산 뒤 다시 얹음 - 지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에 적고 칠하기는 구독 몫 - 목록 관 줄 폭 PIPE_MATCH_M · 조금 옮겨진 관도 고름 따라감 · 저장 뒤 목록 주입이 관 폼 안 닫음 - design_pick_escape — Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_pick_escape.ts | 31 ++++ A00_Common/design/design_pick_profile.ts | 40 +++- A00_Common/design/design_view_cross.ts | 2 +- A00_Common/design/design_view_profile.ts | 68 +++---- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 10 +- .../B05_Profile_UI_Drainage_Panel_Types.ts | 3 +- B05_Profile/B05_Profile_UI_Panel.ts | 3 + B05_Profile/B05_Profile_UI_Structures_List.ts | 4 +- .../B05_Profile_UI_Structures_Panel.ts | 18 +- B06_Section/B06_Section_UI_Page_Pick_Sync.ts | 9 +- .../tester/helper_233_1_profile_pick.cjs | 3 + resources/tester/helper_233_2_view_pick.cjs | 5 +- resources/tester/helper_233_3_release.cjs | 171 ++++++++++++++++++ resources/tester/test_233_1_profile_pick.py | 4 +- resources/tester/test_233_2_view_pick.py | 4 +- resources/tester/test_233_3_release.py | 108 +++++++++++ 16 files changed, 410 insertions(+), 73 deletions(-) create mode 100644 A00_Common/design/design_pick_escape.ts create mode 100644 resources/tester/helper_233_3_release.cjs create mode 100644 resources/tester/test_233_3_release.py diff --git a/A00_Common/design/design_pick_escape.ts b/A00_Common/design/design_pick_escape.ts new file mode 100644 index 000000000..691c328e5 --- /dev/null +++ b/A00_Common/design/design_pick_escape.ts @@ -0,0 +1,31 @@ +/* ============================================================================= + * A00_Common/design/design_pick_escape.ts + * 풀기 한 길 — Esc = 고름 한 칸 비움 (233-3 · 조사 AF (나)). + * + * 값을 비우면 구독한 화면이 모두 따라 풂 — 종단(측점선 · 알약) · 3D(띠 · 핀 · 부재) · 배수유역도 · + * 좌측 폼 · 횡단 카드 · 범위 테두리 · 포장층 · 형상. 입력 칸 · 창(모달) · 메뉴 · 종단 도구가 먼저 + * 받은 Esc(입력 취소 · 창 닫기 · 도구 끝)는 그쪽 몫이라 건드리지 않음. + * ========================================================================== */ + +import { NO_SELECTION, selectDesign } from "./design_selection"; + +/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */ +export const ESCAPE_FROM = "esc"; + +const OWN_ESCAPE = + "input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop"; + +/** 보기마다 건다 — 보이는 보기 하나만 받음 · 보기를 떠나면 뗌. */ +export function bindEscapeRelease( + projectId: string | null, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, +): void { + const onKey = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || event.defaultPrevented || !host.visible()) return; + const target = event.target as HTMLElement | null; + if (target?.closest?.(OWN_ESCAPE) || document.querySelector(".ui-modal__backdrop")) return; + selectDesign(projectId, NO_SELECTION, { from: ESCAPE_FROM }); + }; + window.addEventListener("keydown", onKey); + host.onDispose(() => window.removeEventListener("keydown", onKey)); +} diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index d8c3a4d07..13c71b83e 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -1,7 +1,7 @@ /* ============================================================================= * A00_Common/design/design_pick_profile.ts * 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) · - * 3D(측점 띠 · 핀 · 부재 · 233-2) (조사 AF (나) ② ③). + * 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤). * * 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고, * 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이 @@ -15,6 +15,7 @@ import { subscribeSelection, type DesignSelection, } from "./design_selection"; +import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape"; import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; @@ -33,6 +34,11 @@ export interface ProfilePickSource { export interface ProfilePickTarget { setSelectedStation: (stationId: string | null) => void; setSelectedStructure: (structureId: string | null) => void; + /** 배수유역도 — 관 마커 · 유역(알림 없이) · 측점 마름모. */ + drainage?: { + selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void; + markStation: (chainageM: number | null) => void; + } | null; } /** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */ @@ -71,6 +77,16 @@ export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): else target.setSelectedStation(pick.stationId); } +/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(a3 · a6). */ +export function paintMapPick( + map: NonNullable, + selection: DesignSelection | null, +): void { + const at = selection?.chainageM ?? null; + map.selectPipeAtChainage(at, false); + map.markStation(at); +} + export interface ViewerPickTarget { markers: { selectStation: (stationId: string | null, notify?: boolean) => void }; structurePick: { @@ -103,13 +119,17 @@ export function paintViewerPick( } /** - * 종단 보기(종단도 · 3D)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 · - * 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠) 안 그림 — 그 보기가 - * 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. + * 종단 보기(종단도 · 3D · 배수유역도)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 + * 바뀐 뒤 · 재계산 뒤 · 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠) + * 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. Esc(풀기 한 길)는 좌측 폼까지 닫음. */ export function bindProfilePick( projectId: string | null, - target: { profile: ProfilePickTarget; viewer?: ViewerPickTarget }, + target: { + profile: ProfilePickTarget; + viewer?: ViewerPickTarget; + form?: { hasSelection: () => boolean; selectById: (id: null) => void }; + }, host: { visible: () => boolean; onDispose: (fn: () => void) => void }, source: ProfilePickSource, ): { paint: () => void } { @@ -118,7 +138,15 @@ export function bindProfilePick( const selection = readSelection(projectId); paintProfilePick(target.profile, profilePickOf(selection, source)); if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source)); + if (target.profile.drainage) paintMapPick(target.profile.drainage, selection); }; - host.onDispose(subscribeSelection(projectId, paint)); + host.onDispose( + subscribeSelection(projectId, (_selection, from) => { + paint(); + if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection()) + target.form.selectById(null); + }), + ); + bindEscapeRelease(projectId, host); return { paint }; } diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 77aba5392..1f4239ca2 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -499,7 +499,7 @@ export async function mountCrossView( const crossPick = bindCrossPick(projectId, host, { sections: () => sectionDetail?.cross_sections ?? [], controls: stationControls, - releasePipe: () => structuresPanel.releasePipe(), + releaseForm: () => structuresPanel.showAtChainage(null), view: sectionView, reapply: () => { appliedPick = null; diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 1adab405a..2c2113c78 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,13 +31,7 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { - coversAtStation, - NO_SELECTION, - nearestWithin, - readSelection, - selectDesign, -} from "./design_selection"; +import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindProfilePick } from "./design_pick_profile"; import { @@ -174,8 +168,8 @@ export async function mountProfileView( panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. - onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), - onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), + onBasinSelected: (chainageM) => pickFromMap(chainageM), + onPipeSelected: (chainageM) => pickFromMap(chainageM), // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). onStructureSelect: (structureId) => { @@ -221,7 +215,7 @@ export async function mountProfileView( * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */ const uphillOverrides = loadUphillOverrides(activeProjectId); - const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ + const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({ stations: () => bridge.irregularStations(), isSyncing: () => selectionSyncing, setSyncing: (value) => { @@ -245,6 +239,12 @@ export async function mountProfileView( remember: (chainageM) => rememberPick(chainageM, null), }); + /** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */ + const pickFromMap = (chainageM: number | null): void => { + if (!selectionSyncing) rememberPick(chainageM, null); + selectStationOfPipe(chainageM); + }; + /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ function restorePick(): void { @@ -320,39 +320,18 @@ export async function mountProfileView( forwardPipeOptions(shared, from, attributes.options as Record); }, onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), + // 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3) 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); - selectGraphAt(id, chainage); - syncBasinHighlight(id); - // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). - profilePanel.drainage.selectPipeAtChainage(chainage); - viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2) - } finally { - selectionSyncing = false; - } + rememberPick(chainage, null); + if (!selectionSyncing) profilePick.paint(); }, + onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3) onStructureSelect: (structure) => { // 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); - if (selectionSyncing) return; - selectionSyncing = true; - try { - profilePick.paint(); // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단은 고름 한 칸대로(233-1) - viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null); - } finally { - selectionSyncing = false; - } + // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2) + if (!selectionSyncing) profilePick.paint(); }, }, shared, @@ -412,11 +391,16 @@ export async function mountProfileView( isRestoring: () => restoring, }); // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) - const profilePick = bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host, { - irregular: () => bridge.irregularStations(), - pipes: () => lastPipes ?? [], - sections: () => currentSectionDetail?.cross_sections ?? [], - }); + const profilePick = bindProfilePick( + activeProjectId, + { profile: profilePanel, viewer, form: panel.structures }, + host, + { + irregular: () => bridge.irregularStations(), + pipes: () => lastPipes ?? [], + sections: () => currentSectionDetail?.cross_sections ?? [], + }, + ); // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 1deeea3c5..251c0233f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -287,18 +287,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */ - function syncPipeSelection(): void { + function syncPipeSelection(notify = true): void { const index = pipeEditor.selected(); deleteButton.disabled = index === null; const picked = index === null ? null : pipeEditor.pipes()[index]; - selectBasin(basinIndexOfPipe(basins, picked ?? null)); + selectBasin(basinIndexOfPipe(basins, picked ?? null), notify); // 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은 // Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply) // 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게. const pickedChainage = picked ? picked.chainage_m : null; if (pickedChainage !== lastNotifiedPipeChainage) { lastNotifiedPipeChainage = pickedChainage; - callbacks.onPipeSelected?.(pickedChainage); + if (notify) callbacks.onPipeSelected?.(pickedChainage); } } @@ -639,7 +639,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeEditor.select(index); pipeEditor.deleteSelected(); }, - selectPipeAtChainage(chainageM) { + selectPipeAtChainage(chainageM, notify = true) { const index = chainageM === null ? null @@ -648,7 +648,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (pipeEditor.selected() === next) return; // select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다. pipeEditor.select(next); - syncPipeSelection(); + syncPipeSelection(notify); scheduleDraw(); }, async savePipes() { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 140f8d9e8..7ed1fe8b7 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -45,7 +45,8 @@ export interface DrainagePanel { selectBasinByChainage: (chainageM: number | null) => void; /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. * 이미 같으면 무시(2026-08-17 전역 선택 동기화). */ - selectPipeAtChainage: (chainageM: number | null) => void; + /** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(밖으로 안 알림 · 재계산 뒤 다시 얹음 · 233-3). */ + selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void; /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ markStation: (chainageM: number | null) => void; /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index ca7b2e808..8f748552f 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -69,6 +69,8 @@ interface PanelCallbacks { onPipeFacilityRemove: (chainageM: number) => void; /** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */ onPipeFacilitySelect: (chainageM: number | null) => void; + /** 목록 다시 누름 · 새로 놓기 = 풀기 한 길(233-3). */ + onListRelease?: () => void; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; } @@ -104,6 +106,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeRemove: callbacks.onPipeFacilityRemove, onPipeSelect: callbacks.onPipeFacilitySelect, + onListRelease: callbacks.onListRelease, }, }); diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index f50e8df9a..5151d8e16 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -23,6 +23,7 @@ import { type SummaryItem, } from "../common_util/common_util_summary_item"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import { containerTitle, @@ -229,7 +230,8 @@ export function renderStructureList(params: StructureListParams): void { } else { const { pipe } = row; const picked = - selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05; + selectedPipeChainage !== null && + Math.abs(selectedPipeChainage - pipe.chainage_m) < PIPE_MATCH_M; // 고름 찾는 거리 한 값(233-3) item.classList.toggle("is-selected", picked); station.textContent = formatStation(pipe.chainage_m, intervalM); item.addEventListener("click", () => params.onSelectPipe(pipe)); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 526d27113..29cf68046 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -560,10 +560,10 @@ export function createStructuresSection( // 이 배열을 참조로 받아 두므로, 새 배열로 바꾸면 그쪽이 옛 배열을 고쳐 // 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인). structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry }))); - // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. - // [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2). + // 실시간 반영 목록이면 폼 안 닫음(고치던 칸) · [저장] 뒤 같은 id · 관 폼은 그대로(53-2 · 233-3) const kept = structures.find((entry) => entry.structure_id === editingId) ?? null; - if (!applyingLive) loadForm(editingId === null ? null : kept); + if (!applyingLive && selectedPipeChainage === null) + loadForm(editingId === null ? null : kept); renderList(); }, getStructures: () => [...structures], @@ -587,12 +587,12 @@ export function createStructuresSection( syncButtons(); } } - if ( - selectedPipeChainage !== null && - !pipeFacilities.some( - (pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < PIPE_MATCH_M, - ) - ) { + const kept = pipeFacilities.find( + (pipe) => Math.abs(pipe.chainage_m - (selectedPipeChainage ?? NaN)) < PIPE_MATCH_M, + ); + if (kept) + selectedPipeChainage = kept.chainage_m; // 조금 옮겨진 관도 고름 따라감(233-3) + else if (selectedPipeChainage !== null) { // 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다. selectedPipeChainage = null; facilityOptions.setFacility(null); diff --git a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts index 48d3cad72..239649672 100644 --- a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts @@ -6,18 +6,20 @@ * 바꾸면 구독으로 받아 카드 · 범위 테두리(`--span`) · 포장층 · 벽 · 세월교 · BOX 형상 강조를 그 값으로 * 다시 세운다 — 값이 비면 모두 풂, 값이 있으면 넘김 적용(`applyStructurePick`)과 같은 길로 다시 얹음. * 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음. - * 풀기(카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 관 · 범위 테두리 · + * 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 · * 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4). * ========================================================================== */ import { subscribeSelection } from "../A00_Common/design/design_selection"; +import { bindEscapeRelease } from "../A00_Common/design/design_pick_escape"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; export interface CrossPickDeps { sections: () => ReadonlyArray; controls: Pick; - releasePipe: () => void; + /** 좌측 폼(구조물 · 관) 닫기. */ + releaseForm: () => void; view: { focusStation: (stationId: string | null) => void; refreshCards: (chainages: ReadonlyArray) => void; @@ -56,7 +58,7 @@ function releaseAll(deps: CrossPickDeps): void { if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null); if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null); } - deps.releasePipe(); + deps.releaseForm(); deps.view.focusStation(null); deps.view.selectMark(null); deps.view.refreshCards(lit.map((s) => s.chainage_m)); @@ -76,5 +78,6 @@ export function bindCrossPick( else deps.reapply(); }), ); + bindEscapeRelease(projectId, host); // 풀기 한 길(233-3) return { release: () => releaseCrossHighlights(deps) }; } diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs index 2d690aab7..22811c127 100644 --- a/resources/tester/helper_233_1_profile_pick.cjs +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -27,6 +27,8 @@ const DSel = loadTs("A00_Common/design/design_selection.ts", { writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), }, }); +globalThis.window = { addEventListener() {}, removeEventListener() {} }; +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); const IRR = { irregularStationId: (id) => `irregular:${id}`, isPipeStation: (s) => s.origin === "pipe", @@ -37,6 +39,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, }); diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs index 95c32d464..0158756a5 100644 --- a/resources/tester/helper_233_2_view_pick.cjs +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -67,6 +67,7 @@ const DSel = loadTs("A00_Common/design/design_selection.ts", { writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), }, }); +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); const IRR = { irregularStationId: (id) => `irregular:${id}`, isPipeStation: (s) => s.origin === "pipe", @@ -77,6 +78,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, }); @@ -86,6 +88,7 @@ const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { "../A00_Common/design/design_selection": DSel, + "../A00_Common/design/design_pick_escape": ESC, }); const CROSS_PAD = Function( `return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(fs.readFileSync(path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), "utf8"))[1]}`, @@ -205,7 +208,7 @@ const deps = { ford: { selectedFor: () => false, select() {} }, box: { selectedFor: () => false, select() {} }, }, - releasePipe: () => calls.push("releasePipe"), + releaseForm: () => calls.push("releaseForm"), view: { focusStation: (id) => { calls.push(`focus:${id}`); diff --git a/resources/tester/helper_233_3_release.cjs b/resources/tester/helper_233_3_release.cjs new file mode 100644 index 000000000..ff6114e70 --- /dev/null +++ b/resources/tester/helper_233_3_release.cjs @@ -0,0 +1,171 @@ +/* 233-3 풀기 한 길(Esc) · 배수유역도 다시 얹음 — 진짜 `design_pick_escape` · `design_pick_profile` · + * `B06_Section_UI_Page_Pick_Sync` · `design_selection` 을 Node 로 돌림. 창 키 · 폼 · 지도 · 카드는 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_233_3_release.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* 창 키 듣기 · 창(모달) 흉내 */ +const keyListeners = new Set(); +globalThis.window = { + addEventListener: (type, fn) => type === "keydown" && keyListeners.add(fn), + removeEventListener: (type, fn) => keyListeners.delete(fn), +}; +let modalOpen = false; +globalThis.document = { querySelector: () => (modalOpen ? {} : null) }; +const press = (key, { inInput = false, prevented = false } = {}) => { + const event = { + key, + defaultPrevented: prevented, + target: { closest: () => (inInput ? {} : null) }, + }; + for (const fn of [...keyListeners]) fn(event); +}; + +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); +const IRR = { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", +}; +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": IRR, +}); +const PP = loadTs("A00_Common/design/design_pick_profile.ts", { + "./design_selection": DSel, + "./design_pick_escape": ESC, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); +const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { + "../A00_Common/design/design_selection": DSel, + "../A00_Common/design/design_pick_escape": ESC, +}); + +const PID = "p233c"; +const sel = (chainageM, structureId = null, from = "b05") => + DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, { + from, + }); + +/* ── 종단 보기(B05) — 종단도 · 배수유역도 · 좌측 폼 흉내 ── */ +const map = { pipe: null, mark: null, notifies: 0 }; +const profile = { station: null, structure: null }; +const form = { open: "wall", resets: 0 }; +let b05Visible = true; +const b05 = PP.bindProfilePick( + PID, + { + profile: { + setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }), + setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }), + drainage: { + // 진짜 판 꼴 — 0.51 m 안 관을 고름 · notify 거짓이면 밖으로 안 알림 + selectPipeAtChainage: (at, notify = true) => { + map.pipe = at === null ? null : Math.abs(at - 80.17) < 0.51 ? 80.17 : null; + if (notify) map.notifies += 1; + }, + markStation: (at) => (map.mark = at), + }, + }, + form: { + hasSelection: () => form.open !== null, + selectById: () => { + form.open = null; + form.resets += 1; + }, + }, + }, + { visible: () => b05Visible, onDispose() {} }, + { + irregular: () => [{ id: "p80", chainage_m: 80.17, origin: "pipe" }], + pipes: () => [{ chainage_m: 80.17 }], + sections: () => [80, 100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at })), + }, +); + +/* ── 횡단 보기(B06) — 카드 · 형상 · 폼 흉내 ── */ +const cross = []; +let b06Visible = false; +SYNC.bindCrossPick( + PID, + { visible: () => b06Visible, onDispose() {} }, + { + sections: () => [], + controls: { + revetOffset: { highlightFor: () => false, selectedFor: () => false, select() {} }, + ford: { selectedFor: () => false, select() {} }, + box: { selectedFor: () => false, select() {} }, + }, + releaseForm: () => cross.push("releaseForm"), + view: { + focusStation: (id) => cross.push(`focus:${id}`), + refreshCards: () => undefined, + selectMark: (id) => cross.push(`mark:${id}`), + }, + reapply: () => cross.push("reapply"), + }, +); + +const snap = () => ({ + session: DSel.readSelection(PID), + profile: { ...profile }, + map: { ...map }, + form: { ...form }, +}); +const out = {}; +sel(80.17); +out.pipePick = snap(); +// 배수유역도 재계산이 관 · 유역 고름을 지움 → 같은 값으로 다시 얹음(알림 없이) +map.pipe = null; +map.mark = null; +b05.paint(); +out.afterRecompute = snap(); +press("Escape", { inInput: true }); +out.escInInput = snap(); +modalOpen = true; +press("Escape"); +modalOpen = false; +out.escModal = snap(); +press("Escape", { prevented: true }); +out.escPrevented = snap(); +press("Escape"); +out.esc = snap(); +out.crossWhileHidden = cross.splice(0); +// 목록 다시 누름 같은 B05 풀기(값 비움)는 폼을 그 길이 이미 닫음 — 구독은 폼을 또 닫지 않음 +form.open = "sign"; +sel(100, "sign"); +sel(null); +out.listRelease = snap(); +// 횡단 보기가 보일 때 Esc = 카드 · 폼 · 범위 · 형상 모두 풂 +b05Visible = false; +b06Visible = true; +sel(120, null, "b06"); +press("Escape"); +out.crossEsc = cross.splice(0); +out.crossSession = DSel.readSelection(PID); +out.listeners = keyListeners.size; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_233_1_profile_pick.py b/resources/tester/test_233_1_profile_pick.py index 6e48e9181..7ce7a537e 100644 --- a/resources/tester/test_233_1_profile_pick.py +++ b/resources/tester/test_233_1_profile_pick.py @@ -80,13 +80,13 @@ def test_숨은_보기는_안_그리고_보이면_다시_얹음(out) -> None: def test_풂과_구독_끊음(out) -> None: assert out["released"] == line(None) - assert out["disposers"] == 1 + assert out["disposers"] == 2 # 구독 + Esc 듣기(233-3) assert out["paintsAfterDispose"] == 0 def test_종단_보기_배선() -> None: view = _read("A00_Common", "design", "design_view_profile.ts") - assert "bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host," in view + assert "bindProfilePick(" in view and "{ profile: profilePanel, viewer," in view # 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음 assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view assert view.count("profilePick.paint()") >= 3 diff --git a/resources/tester/test_233_2_view_pick.py b/resources/tester/test_233_2_view_pick.py index 134687edd..241f5a561 100644 --- a/resources/tester/test_233_2_view_pick.py +++ b/resources/tester/test_233_2_view_pick.py @@ -80,7 +80,7 @@ def test_횡단_제_누름은_되받지_않고_다른_곳_값은_다시_세움(o def test_횡단_풀기_한_길은_범위_테두리_형상까지(out) -> None: for key in ("release", "otherRelease"): - assert out[key] == ["releasePipe", "focus:null", "mark:null", out[key][-1]] + assert out[key] == ["releaseForm", "focus:null", "mark:null", out[key][-1]] assert out["release"][-1] == "refresh:120" assert out["revetAfter"] == [] @@ -99,5 +99,5 @@ def test_배선() -> None: assert "releaseCross(); // 카드 다시 누름" in cross assert len(cross.splitlines()) <= 685 profile = _read("A00_Common", "design", "design_view_profile.ts") - assert "{ profile: profilePanel, viewer }" in profile + assert "{ profile: profilePanel, viewer," in profile assert len(profile.splitlines()) <= 700 diff --git a/resources/tester/test_233_3_release.py b/resources/tester/test_233_3_release.py new file mode 100644 index 000000000..246703daf --- /dev/null +++ b/resources/tester/test_233_3_release.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""233-3 좌측 목록 · 폼 · 배수유역도 · 남은 바로 꺼짐 자리 · 풀기 = Esc 한 길(조사 AF (나) ⑤ · a3 · a5 · a6). + +까닭: 배수유역도 재계산이 관 마커 · 유역 고름을 지움(지도만 꺼짐) · 지도 · 유역 고름은 고름 한 칸에 안 적혀 +마름모가 안 옮겨짐 · 목록 관 줄은 폭 0.05 · 관이 조금 옮겨지면 줄 강조 빠짐 · 목록 관 고름은 화면마다 손으로 +불러 길마다 빠짐 · 풀기가 화면마다 다름(어디까지 푸는지 제각각). +고침: 종단 보기 구독이 배수유역도(관 마커 · 유역 · 마름모)까지 값으로 칠함(알림 없이 · 재계산 뒤 다시 얹음) · +지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에 적고 칠하기는 구독 몫 · 목록 줄 폭 = `PIPE_MATCH_M` · +옮겨진 관도 고름 따라감 · Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂 +(입력 칸 · 창 · 먼저 받은 Esc 는 그쪽 몫). +진짜 TS 를 `helper_233_3_release.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_3_release.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_관_고름은_지도_관_마커_유역_마름모까지_알림_없이(out) -> None: + assert out["pipePick"]["map"] == {"pipe": 80.17, "mark": 80.17, "notifies": 0} + assert out["pipePick"]["profile"]["station"] == "irregular:p80" + + +def test_재계산이_지운_지도_고름을_같은_값으로_다시_얹음(out) -> None: + assert out["afterRecompute"]["map"] == {"pipe": 80.17, "mark": 80.17, "notifies": 0} + + +def test_입력칸_창_먼저_받은_Esc는_안_풂(out) -> None: + for key in ("escInInput", "escModal", "escPrevented"): + assert out[key]["session"] == {"chainageM": 80.17, "structureId": None}, key + assert out[key]["form"]["resets"] == 0, key + + +def test_Esc는_값_종단_지도_폼_모두_풂(out) -> None: + esc = out["esc"] + assert esc["session"] is None + assert esc["profile"] == {"station": None, "structure": None} + assert (esc["map"]["pipe"], esc["map"]["mark"]) == (None, None) + assert esc["form"] == {"open": None, "resets": 1} + assert out["crossWhileHidden"] == [] # 숨은 횡단 보기는 안 받음 + + +def test_B05_목록_풀기는_그_길이_폼을_닫고_구독은_폼을_또_안_닫음(out) -> None: + release = out["listRelease"] + assert release["session"] is None + assert release["profile"] == {"station": None, "structure": None} + assert release["form"]["resets"] == 1 # Esc 때 한 번뿐 + + +def test_횡단_보기_Esc는_카드_폼_범위_형상_모두(out) -> None: + assert out["crossEsc"] == ["releaseForm", "focus:null", "mark:null"] + assert out["crossSession"] is None + assert out["listeners"] == 2 # 보기마다 하나 — 보이는 보기만 받음 + + +def test_배선() -> None: + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "onBasinSelected: (chainageM) => pickFromMap(chainageM)" in view + assert "onPipeSelected: (chainageM) => pickFromMap(chainageM)" in view + assert "if (!selectionSyncing) rememberPick(chainageM, null);" in view + assert "onListRelease: () => rememberPick(null, null)" in view + assert "form: panel.structures" in view + assert len(view.splitlines()) <= 700 + panel = _read("B05_Profile", "B05_Profile_UI_Panel.ts") + assert "onListRelease: callbacks.onListRelease" in panel + drainage = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts") + assert "if (notify) callbacks.onPipeSelected?.(pickedChainage);" in drainage + assert "selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);" in drainage + listing = _read("B05_Profile", "B05_Profile_UI_Structures_List.ts") + assert "< PIPE_MATCH_M" in listing and "< 0.05" not in listing + structures = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + assert "selectedPipeChainage = kept.chainage_m;" in structures + # 저장 · 새로 읽기 뒤 목록 주입이 관 폼을 닫지 않음(닫히며 종단 선까지 꺼졌음) + assert "if (!applyingLive && selectedPipeChainage === null)" in structures + assert len(structures.splitlines()) <= 700 + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "releaseForm: () => structuresPanel.showAtChainage(null)" in cross + assert len(cross.splitlines()) <= 685 From 3cb67d17621ba36d0e12702260126f5e79c0420e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:13:43 +0900 Subject: [PATCH 31/92] =?UTF-8?q?fix(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EB=A0=88=EB=B2=A8=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EA=B8=80=20=EC=A7=A7=EC=9D=80=20=ED=95=9C=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EC=9E=98=EB=A6=B4=20=EB=95=8C=EB=A7=8C=20=ED=98=B8=EB=B2=84?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=ED=97=98=20=EA=B8=B0=EB=8C=80=EA=B0=92=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC(236-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레벨 안내 글에서 「=」 꼴 설명 · 긴 호버 설명 지움 - test_181_2 danger 색 수 5(204-1 끌기 못 놓음 점선) · test_200_2 안내 글 기대값 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 14 ++------------ resources/tester/test_181_2_b09_box_pick.py | 4 +++- resources/tester/test_200_2_b09_bill_level_bar.py | 6 ++++-- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 19ed60576..742314f4e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -26,17 +26,7 @@ const ACTS: [string, string][] = [ ["↓ 아래로", "아래로 ↓"], ]; -const GUIDE = - "레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기 · " + - "입력 = 더블클릭 · Enter · F2(Esc · Enter = 끝)"; -const GUIDE_MORE = - "레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " + - "들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " + - "내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " + - "위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " + - "끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " + - "키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " + - "묶음 줄로 바꾸기 · 추가 · 지우기 = 줄 우클릭"; +const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력"; let picked: string | null = null; /** 지금 그린 도구 줄 — 키가 부름 */ @@ -135,7 +125,7 @@ export function levelBar( const guide = document.createElement("p"); guide.className = "ui-general-block__empty b09-estimation__line-hint"; guide.textContent = GUIDE; - attachHoverInfo(guide, GUIDE_MORE); + attachHoverInfo(guide, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); // 236-2 잘릴 때만 const sync = (): void => { const acts = menuActs(table, index()); ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label))); diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py index f078bc23f..0233c6458 100644 --- a/resources/tester/test_181_2_b09_box_pick.py +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -71,6 +71,8 @@ def test_글_접기_없음_CSS_잇기(): page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") for sel in ("tr.is-pick td", "tr.is-pick.is-pick-top td", "tr.is-pick.is-pick-end td"): assert sel in page - assert page.count("var(--color-danger)") == 4 # 위 · 아래 · 왼 · 오른 2px + assert ( + page.count("var(--color-danger)") == 5 + ) # 위 · 아래 · 왼 · 오른 2px + 204-1 끌기 못 놓음 점선 shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert "extra?: (index: number) => string | null" in shell diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index c37ad1136..743aa8eaf 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -141,8 +141,10 @@ def test_단추_넷_고른_줄_못_하면_흐림(): out = _bar() assert out["labels"] == [["→ 들여쓰기", IN, "ghost"], ["← 내어쓰기", OUT, "ghost"], ["↑ 위로", UP, "ghost"], ["↓ 아래로", DOWN, "ghost"]] # fmt: skip - assert "Tab" in out["guide"] and "Shift+Tab" in out["guide"] and "Alt+↑↓" in out["guide"] - assert out["hover"] == 1 # 안내 한 줄 = 공용 호버로 자세히 + assert ( + "방향키" in out["guide"] and "더블클릭" in out["guide"] and "=" not in out["guide"] + ) # 236-2 + assert out["hover"] == 1 # 안내 한 줄 = 잘릴 때만 공용 호버(236-2) assert out["none"] == [True] * 4 # 고른 줄 없음 = 모두 흐림 # earth:4 = 1.1 첫 줄 — 들여(위 형제 없음) · 위로 = 못 함 · 내어 · 아래로 = 됨(184-3 메뉴와 같음) assert out["earth4"] == [True, False, True, False] From 78bab9eeda8d4ee86e383977a304df228a1a5f50 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:18:19 +0900 Subject: [PATCH 32/92] =?UTF-8?q?fix(design):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=C2=B7=20=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=B9=88=20?= =?UTF-8?q?=EA=B3=B3=20=EB=88=84=EB=A6=84=EB=8F=84=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=EC=95=88=20=ED=92=82=20(233-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 곳 누름 해제 듣개 뗌 — 3D 와 같은 한 규칙 - 풀기는 Esc · 같은 것 다시 누름 한 길 · 도구 중 빈 곳 누름은 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B05_Profile_UI_Profile_MassHaul.ts | 10 -- B05_Profile/B05_Profile_UI_Profile_Render.ts | 16 +- B06_Section/B06_Section_UI_Section_View.ts | 5 +- resources/tester/helper_233_4_empty_click.cjs | 144 ++++++++++++++++++ resources/tester/test_233_4_empty_click.py | 72 +++++++++ 5 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 resources/tester/helper_233_4_empty_click.cjs create mode 100644 resources/tester/test_233_4_empty_click.py diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 2ffa158bc..0600b48e1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -135,8 +135,6 @@ export interface RouteMassHaulDrawParams { widthPx: number; selectedStationId: string | null; onSelectStation: (stationId: string) => void; - /** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */ - onClearSelection?: () => void; /** * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: @@ -250,13 +248,6 @@ export function createRouteMassHaulPanel( pendingChip.hidden = true; overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); - // 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시). - let clearSelection: (() => void) | undefined; - scroll.addEventListener("click", (event) => { - if ((event.target as HTMLElement).closest(".b06-chart__station")) return; - clearSelection?.(); - }); - // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. attachWheelHorizontalScroll(scroll); @@ -368,7 +359,6 @@ export function createRouteMassHaulPanel( function draw(params: RouteMassHaulDrawParams): void { lastParams = params; - clearSelection = params.onClearSelection; overlay.hidden = !open; syncHandlePosition(); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index af5042efd..b940a155a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -199,8 +199,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { const chartWrap = document.createElement("div"); chartWrap.className = "b05-profile__chart"; chartWrap.style.height = `${chartHeight}px`; - // 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시). - // 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation). + // 빈 곳 누름은 고름을 안 풂 — 풀기는 Esc · 같은 것 다시 누름 한 길(3D 와 같은 규칙 · 233-4). + // 도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 그 도구 몫 그대로. chartWrap.addEventListener("click", (event) => { // [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면 // 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02). @@ -219,15 +219,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; } } - if ((event.target as HTMLElement).closest(".b06-chart__station")) return; - if (ctx.selectedStationId() !== null) ctx.selectStation(null); - // 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지 - // 리셋되는 해제 신호가 여기서만 나갈 수 있다(2026-08-18 사용자 보고). - if (ctx.selectedStructureId() !== null) { - ctx.setSelectedStructureId(null); - callbacks?.onStructureSelect?.(null); - ctx.redraw(); - } }); const designProfiles = alignment ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] @@ -454,9 +445,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { widthPx: width, selectedStationId, onSelectStation: ctx.selectStation, - onClearSelection: () => { - if (ctx.selectedStationId() !== null) ctx.selectStation(null); - }, }; massHaul.draw(massHaulParams); // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index bc95835f8..88f3a9e64 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -418,10 +418,7 @@ export function createSectionView( // 적혀 있어 무엇을 보는지는 이미 분명하다. const grid = document.createElement("div"); grid.className = "b06-section__grid"; - // 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다. - grid.addEventListener("click", (event) => { - if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); - }); + // 카드 사이 · 밖 빈 자리 누름은 고름을 안 풂 — 풀기는 Esc · 같은 카드 다시 누름(233-4) // 노면공 형상 누름 = 목록 고름(범위 카드 전부 · 형상 강조) — 카드 고름보다 먼저 가로챔(189-1) grid.addEventListener( "click", diff --git a/resources/tester/helper_233_4_empty_click.cjs b/resources/tester/helper_233_4_empty_click.cjs new file mode 100644 index 000000000..b474e6ed5 --- /dev/null +++ b/resources/tester/helper_233_4_empty_click.cjs @@ -0,0 +1,144 @@ +/* 233-4 빈 곳 누름 = 어디서도 안 풂 — 진짜 소스의 누름 처리 본문을 떼어(TS 그 자리 트랜스파일) 돌림. + * ① 종단 그래프 빈 곳 누름(`_Profile_Render` chartWrap click) ② 횡단 그리드 빈 곳 누름(`_Section_View` grid click) + * ③ 같은 것 다시 누름 = 풂(종단 측점선 `design_profile` selectStation · 횡단 카드 `_Section_View` selectStation) + * ④ Esc = 풂(`design_pick_escape` + `design_selection`). 표준출력 JSON 한 벌 · 짝 = test_233_4_empty_click.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const read = (...parts) => fs.readFileSync(path.join(ROOT, ...parts), "utf8").replace(/\r/g, ""); +const run = (code, names, values) => { + const js = ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + return new Function(...names, js)(...values); +}; +/** `start` 줄부터 같은 들여쓰기로 닫는 `end` 줄까지 떼어 냄. */ +const block = (src, start, end) => { + const from = src.indexOf(start); + if (from < 0) throw new Error(`본문 없음: ${start}`); + const to = src.indexOf(end, from); + return src.slice(from, to + end.length); +}; + +const out = {}; +const target = (inStation = false, inCard = false) => ({ + closest: (sel) => + (sel.includes("b06-chart__station") && inStation) || (sel.includes("b06-cross-card") && inCard) + ? {} + : null, +}); + +/* ① 종단 그래프 빈 곳 누름 */ +const render = read("B05_Profile", "B05_Profile_UI_Profile_Render.ts"); +const chartClick = block(render, ` chartWrap.addEventListener("click", (event) => {`, "\n });"); +const calls = []; +let tool = false; +const ctx = { + toolActive: () => tool, + handleToolPick: (at) => (calls.push(`tool:${at}`), true), + selectedStationId: () => "s120", + selectedStructureId: () => "wall", + selectStation: (id) => calls.push(`station:${id}`), + setSelectedStructureId: (id) => calls.push(`structure:${id}`), + redraw: () => calls.push("redraw"), +}; +const callbacks = { onStructureSelect: (id) => calls.push(`onStructureSelect:${id}`) }; +let handler = null; +const chartWrap = { + addEventListener: (_type, fn) => (handler = fn), + getBoundingClientRect: () => ({ left: 0 }), + scrollLeft: 0, +}; +run( + chartClick, + ["ctx", "callbacks", "chartWrap", "chainageInverter", "longitudinal", "width", "layout"], + [ctx, callbacks, chartWrap, () => (x) => x, null, 100, { originOffset: 0 }], +); +handler({ target: target(), clientX: 42 }); +out.profileEmpty = calls.splice(0); +tool = true; // 도구 중 빈 곳 누름은 도구 몫 그대로 +handler({ target: target(), clientX: 42 }); +out.profileEmptyTool = calls.splice(0); + +/* ② 횡단 그리드 빈 곳 누름 — 그리드에 걸린 누름 듣개를 모두 돌림 */ +const view = read("B06_Section", "B06_Section_UI_Section_View.ts"); +const gridStart = view.indexOf(' grid.className = "b06-section__grid";'); +const gridCode = view.slice(gridStart, view.indexOf(" // 열 수 = 사용자 값", gridStart)); // 그리드 누름 듣개 전부 +const gridCalls = []; +const grid = { listeners: [], addEventListener: (_t, fn) => grid.listeners.push(fn) }; +run( + gridCode.replace(' grid.className = "b06-section__grid";', ""), + ["grid", "clearSelection", "pavementAt", "spanMarks", "isPavement", "pavementSelectListener"], + [grid, () => gridCalls.push("clearSelection"), () => null, [], () => false, null], +); +for (const fn of grid.listeners) + fn({ target: target(), stopPropagation() {}, stopImmediatePropagation() {} }); +out.gridListeners = grid.listeners.length; +out.gridEmpty = gridCalls.splice(0); + +/* ③ 같은 것 다시 누름 = 풂 */ +const dpSrc = read("A00_Common", "design", "design_profile.ts"); +const dpSelect = block( + dpSrc, + " function selectStation(stationId: string | null): void {", + "\n }", +); +const profileState = run( + `let selectedStationId = null; let selectedStructureId = null; + ${dpSelect} + return { selectStation, line: () => selectedStationId };`, + ["structureIdAtStation", "draw", "onSelectStation"], + [() => null, () => undefined, () => undefined], +); +profileState.selectStation("s120"); +out.profileFirst = profileState.line(); +profileState.selectStation("s120"); +out.profileAgain = profileState.line(); + +const svSelect = block( + view, + " const selectStation = (stationId: string, scroll: boolean): void => {", + "\n };", +); +const cardState = run( + `let selectedStationId = null; let activeAreaKey = null; + ${svSelect} + return { selectStation, card: () => selectedStationId };`, + ["applySelection", "revealCard"], + [() => undefined, () => undefined], +); +cardState.selectStation("s120", true); +out.cardFirst = cardState.card(); +cardState.selectStation("s120", true); +out.cardAgain = cardState.card(); + +/* ④ Esc = 풂 */ +const store = new Map(); +const loadTs = (file, stubs) => { + const box = { exports: {} }; + const js = ts.transpileModule(read(...file.split("/")), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r]); + return box.exports; +}; +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (k, p) => store.get(`${k}:${p}`) ?? null, + writeState: (k, v, p) => store.set(`${k}:${p}`, v), + }, +}); +let onKey = null; +globalThis.window = { addEventListener: (_t, fn) => (onKey = fn), removeEventListener() {} }; +globalThis.document = { querySelector: () => null }; +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); +ESC.bindEscapeRelease("p", { visible: () => true, onDispose() {} }); +DSel.selectDesign("p", { chainageM: 120, structureId: null }, { from: "b06" }); +out.beforeEsc = DSel.readSelection("p"); +onKey({ key: "Escape", defaultPrevented: false, target: { closest: () => null } }); +out.afterEsc = DSel.readSelection("p"); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_233_4_empty_click.py b/resources/tester/test_233_4_empty_click.py new file mode 100644 index 000000000..5c224ef55 --- /dev/null +++ b/resources/tester/test_233_4_empty_click.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""233-4 빈 곳 누름 = 어디서도 안 풂 — 3D(232-1)와 같은 한 규칙. + +까닭: 종단 그래프 · 유토곡선 빈 곳 누름(08-04)과 횡단 그리드(카드 사이 · 밖) 빈 곳 누름(08-02)은 고름을 풀었고 +3D 빈 곳 누름(232-1)만 안 풀어 화면마다 「해제」 뜻이 달랐음(조사 AF 5절 5). +고침: 빈 곳 누름 듣개를 뗌 · 풀기는 Esc(`design_pick_escape`) 또는 같은 것 다시 누름 한 길 · +도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 도구 몫 그대로. +진짜 소스의 누름 처리 본문을 `helper_233_4_empty_click.cjs` 가 떼어 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_4_empty_click.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_종단_빈_곳_누름은_고름_유지(out) -> None: + assert out["profileEmpty"] == [] # 옛 코드 = 측점 · 알약 · 폼 풂 + + +def test_종단_도구_중_빈_곳_누름은_도구_몫_그대로(out) -> None: + assert out["profileEmptyTool"] == ["tool:42"] + + +def test_횡단_그리드_빈_곳_누름은_고름_유지(out) -> None: + assert out["gridEmpty"] == [] # 옛 코드 = clearSelection + assert out["gridListeners"] == 1 # 노면공 형상 누름만 남음(189-1) + + +def test_같은_것_다시_누름은_풂(out) -> None: + assert (out["profileFirst"], out["profileAgain"]) == ("s120", None) + assert (out["cardFirst"], out["cardAgain"]) == ("s120", None) + + +def test_Esc는_풂(out) -> None: + assert out["beforeEsc"] == {"chainageM": 120, "structureId": None} + assert out["afterEsc"] is None + + +def test_유토곡선_빈_곳_누름도_안_풂() -> None: + mass = _read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts") + render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") + assert "onClearSelection" not in mass and "onClearSelection" not in render From 493f861e06a4656e372042ea1b74633c54e2a58d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:19:31 +0900 Subject: [PATCH 33/92] =?UTF-8?q?chore(plan):=2010-10=20229~236=EC=9E=A5?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-10_plan_229-236.md | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/raw/plans/2026-10-10_plan_229-236.md diff --git a/docs/raw/plans/2026-10-10_plan_229-236.md b/docs/raw/plans/2026-10-10_plan_229-236.md new file mode 100644 index 000000000..b781a5010 --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_229-236.md @@ -0,0 +1,59 @@ +# 2026-10-10 계획 229~236장 기록 — 내역서 레벨 단추 · 새 창 휠 · 노면공 범위 · 3D 띠 · 강조 한 칸 · 정수 측점 · 내역서 트리 · 안내 글 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 101~113 · 조사 AD · AE · AF · AG. + +## 229. B09 설계내역서 레벨 단추 = 보조 탭 줄 (sub1) +정함(브레인) = 사용자 101번 · 설계내역서는 하위 탭이 없으니 보조 탭 줄(181 · 202 오른쪽 자리 꼴)에 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] 옮김 · 표 위 도구 줄(200-2)은 비움(안내 글은 202 대로 표 아래) · 못 하면 흐림 그대로 +- [x] 229-1 레벨 단추 보조 탭 줄로 — test_200_2 · 202_1 고침 · 새 시험 + - 검증완료 — test_229_1(새) · 200_2 · 202_1 · 204_1 · 228_1 25 통과 · typecheck 통과 · 단추 넷 = 보조 줄 setTools · 안내 글 = 표 아래 · 표 위 줄 없음 + +## 230. B05 종단 새 창 + 브라우저 최대화 = 휠로 종단 안 움직임 (sub7) +정함(브레인) = 사용자 102번 · 까닭 판정(213-3 design_profile_viewport 크기 듣개 · 새 창 window 대 부모 window · 220 돋보기 휠 잡기 · 227 · resize 뒤 영역 셈) · 고침 — 새 창 최대화 · 보통 둘 다 휠 동작 +- [x] 230-1 까닭 · 고침 · 새 시험 + - 검증완료 — 까닭: 휠 이동이 메인 창 화면 틀(requestAnimationFrame)을 씀 · 새 창 최대화로 메인 창이 가려지면 틀이 멈춰 휠은 막히고 이동 0 · 고침: 스크롤러가 놓인 창의 틀 · 시계(휠 · 끌기 · 스크롤 뒤 다시 그리기) · test_230_1 5건(옛 코드 이동 0px → 새 창 보통 360px · 최대화 1920 뒤 240px · 원래 창 360px) · 종단 관련 37 passed · typecheck 통과 + +## 231. 노면공 범위 고름 고침 — 조사 AD 1안 + 강조 연동 폭넓게 (sub8) +정함(브레인) = 사용자 103번 · 1안(B06 폼 다시 세울 때 load() → selectAt 에 노면공 거름 coverable) · 강조는 여러 군데 이어져 있으니 먼저 폭넓게 조사(횡단 카드 --span · 포장층 · 종단 알약 · 측점선 · 3D 띠 · 좌측 목록 · 폼 · 고름 기록 188 · B05 3D 고름 Structure_Pick_Session 같은 구멍) → 같은 규칙으로 한 벌 고침 · 189 세 갈래 규칙 그대로 +- [x] 231-1 강조 연동 조사 표 · 1안 고침 · 같은 구멍(B05 3D) · 새 시험 + - 검증완료 — test_231_1 7건(옛 코드 = 카드 120 오감 뒤 노면공 · 3D 노면공 id · 카드 100 · 80 재현 → 고침 뒤 모두 풂 · 목록 노면공은 그대로 범위) · 165 · 177_3 · 177_4 · 188_1 · 189_1 · 216_1 · 152_5 · 106 · 118 · 152_4 · selection_single 통과 · typecheck +- [x] 230-2 (sub7) 같은 꼴 나머지 — design_profile rebuild · Heights · MassHaul · TableOverlay 의 메인 창 전역 requestAnimationFrame → 놓인 창 틀(공용 함수 하나) · 새 창 최대화 중 편집 · 다시 그리기 즉시 — 새 시험 + - 검증완료 — 공용 common_util_view_window(viewOf · frameOf) · 고친 곳: design_profile(rebuild · 접기 · render) · viewport · Wheel · Heights · TableOverlay · MassHaul · mass_haul_view(세로창 미끄러짐) · Drag(120ms 그리기) · Edit(▲▼ 길게 누르기) · Panel_Tools(방향키 길게 누르기) · Drainage_Panel · Structures_Marks(말풍선) · B06 횡단 카드는 새 창으로 안 가 해당 없음 · test_230_2 11건(메인 틀 멈춤 흉내 — 옛 코드 다시 그리기 0회 → 모두 1회 · 길게 누르기 1초 5회) + 메인 창 틀 · 시계 0줄 훑기 · 종단 관련 135 passed(test_b05_structures_form_summary 15 errors 는 B06_Section_Structure_Layouts 의 @util 경로 — 이번 변경 밖 · 전부터) · typecheck 통과 · design_profile 642줄 +- [x] 231-2 (sub8) 조사 AD 2안 — 목록에서 구조물을 고르면(노면공 아니어도) 늘 고름 기록에 그 구조물 id(같은 카드 안 목록 고름도 오감 · 새로 고침 뒤 그 폼 유지) — 새 시험 + - 검증완료 — test_231_2 6건(카드 120 → 목록 관 = 오감 · F5 뒤 관 폼 · 카드 80.17 / 같은 카드 표지 = 기록 sign · 오감 · F5 뒤 표지 폼 / 목록 노면공 F5 = 범위 그대로 / 카드 고름은 측점만) · 231_1 · 189_1 · 188_1 · 216_1 · 165 · 177_3 · 177_4 · 152_5 · 106 · selection_single 82 통과 · typecheck +- [x] 230-3 (sub7) test_b05_structures_form_summary 15 errors(헬퍼가 @util/common_util_cross_structure_areas 못 찾음 · 178-1 뒤) — 헬퍼 별칭 · 가짜 모듈 고침(코드 안 고침) — 시험 통과 + - 검증완료 — 헬퍼 @util → common_util 별칭 · 가짜 요소에 style.setProperty · select.options · Option 보탬 · 기대값 한 줄 54-26(숨긴 길이 = 칸 없이 값만 쥠)에 맞춤(그린 칸 height_m 만 확인 추가) · 코드 안 고침 · structures_form_summary 15 · 177_1 · 178_1 합 21 passed + +## 232. 3D 띠 강조 바로 꺼짐(104) — 구멍 둘 (sub8) +정함(브레인) = 사용자 108 「제안대로」 · 조사 AF a1 + a2 · a1 = 3D 띠(측점 노란 띠)를 누른 누름은 3D 구조물 고름이 「빈 곳 누름」으로 읽지 않고 건너뜀 · a2 = 숨은 핀 · 꺼 둔 측점선은 누름을 가로채지 않음 · 3D 빈 곳 누름 = 고름 안 풂(08-05 사용자 결정 쪽) · 풀기는 Esc(또는 같은 것 다시 누름) 한 길로 · 3D 누름 길을 통째로 도는 시험 새로 +- [x] 232-1 a1 · a2 · 빈 곳 누름 안 풂 · 3D 누름 통째 시험 — 새 시험 + - 검증완료 — test_232_1 7건(진짜 MK3 · MI · SPK · SY · PKS · DSel + three 한 누름씩 · 옛 코드 = 띠 누름 뒤 노랑 · 핀 끔 · 기록 null → 고침 뒤 뗌 뒤도 빨강 · 핀 · 기록 120 · 빈 곳 누름 안 풂 · 같은 띠 · 부재 다시 누름 = 풂 · 숨은 핀 · 라벨 · 꺼 둔 측점선 안 가로챔) · 231_1 · 231_2 · 188_1 · 189_1 · 216_1 · 219_1 · 177_3 · 177_4 · 165 · selection_single · 176_3 · design_page_single 99 통과 · typecheck + +## 233. 고름 원천 한 칸 · 강조는 거기서 파생 — 단계별 (sub8 · 232 뒤) +정함(브레인) = 사용자 108 · 조사 AF (나) · ① 고름 한 칸(세션 · 188 remember)을 유일 원천 + 바뀜 알림(구독) 하나 · ② 종단(세로선 · 알약 · 띠) ③ 3D(띠 · 핀) ④ B06 카드(고름 · --span · 형상 · 포장층) · [상세] ⑤ 좌측 목록 · 폼 · 지도 — 화면마다 손으로 서로 부르던 것을 구독해 그 값으로 그림 · 풀기 = Esc 한 길로 세션 · 폼 · 범위 강조 모두 · 다시 계산 · 다시 그림이 강조를 버리지 않음 · 단계마다 커밋 · 시험(바로 꺼지는 자리 넷 포함 · 조사 AF 6절 a3~a6) +- [x] 233-1 ① 원천 + 구독 · ② 종단 + - 검증완료 — 새 design_pick_profile(고름 한 칸 구독 → 종단 측점선 · 알약 · 띠 · 목록 바뀐 뒤 · 폼 닫힌 뒤 다시 얹음 · 숨은 보기 안 그림) · test_233_1 7건(카드 120 · 표지 127.86 · 관 옆 카드 80 = 카드 선 · 관 80.17 · 측점선 없는 관 300.4 → 옮김 300.45 알약 따라감 · 남이 꺼도 값대로 다시 · 풂 · 떠나면 구독 0) · 232_1 · 231_1 · 231_2 · 188_1 · 189_1 · 216_1 · 219_1 · 177_3 · 177_4 · 165 · selection_single · 116 · 76 · design_view_parity 등 130 통과 · typecheck · design_view_profile 700줄 +- [x] 233-2 ③ 3D · ④ B06 카드 · [상세] + - 검증완료 — 종단 보기 구독이 3D 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이) · 새 B06_Section_UI_Page_Pick_Sync(횡단 보기 구독 · 다른 곳 값 = 넘김 적용 길 · 풀기 한 길 = 카드 · 폼 관 · --span · 포장층 · 형상 · 카드 다시 누름도 이 길 · a4) · [상세] 다시 그림 뒤 같은 자리 조각 유지 · test_233_2 8건(카드 120 → 3D 띠 · 핀 · 벽 부재 · 부재키 = 그 부재만 · 표지 = 그 측점 띠 · 풂 · 마커 알림 0 · 횡단 제 누름 안 되받음 · 다른 곳 값 reapply · 풀기 = releasePipe · focus null · mark null · refresh · [상세] 창 크기 뒤 1 → 1 · 자리 바뀌면 0) · 관련 226 통과 · typecheck · design_view_cross 683줄 · design_view_profile 699줄 +- [x] 233-3 ⑤ 좌측 목록 · 폼 · 지도 · 남은 바로 꺼짐 자리 + - 검증완료 — 종단 보기 구독이 배수유역도(관 마커 · 유역 · 마름모)까지 알림 없이 칠함 · 재계산 뒤 다시 얹음(a3) · 지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에(a6 · 칠하기는 구독) · 목록 관 줄 폭 PIPE_MATCH_M · 옮겨진 관 고름 따라감(a5) · 저장 뒤 목록 주입이 관 폼 안 닫음 · 새 design_pick_escape — Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂(입력 칸 · 창 · 도구가 먼저 받은 Esc 제외) · test_233_3 7건(관 80.17 → 지도 관 · 마름모 · 알림 0 · 재계산 뒤 다시 · Esc 입력칸 · 창 · 먼저 받음 = 안 풂 · Esc = 값 · 종단 · 지도 · 폼 풂 · 숨은 보기 안 받음 · 횡단 Esc = 폼 · 카드 · 범위) · 관련 시험 합 378 통과(item_picker_detail 1건 실패 = M02 DrawingCard · 이번 변경 밖) · typecheck · design_view_profile 683 · design_view_cross 683 · Structures_Panel 700(그대로) + +## 234. 구조물 기준 측점 = 정수 m · 일반 측점 붙기 · [정수 맞춤] (sub7) +정함(브레인) = 사용자 108 · 조사 AF 7절 A + B + C · 9-29 「저장값 소수 · 표기만 정수」(20장)를 「저장값도 정수 m」로 바꿈(사용자 확정 10-10) · 입력(좌측 폼 · 끌기 · 방향키 · 자동설계가 놓는 자리 · 관 지점) = 1 m 단계 · 일반 측점에서 1 m 안이면 그 측점에 붙음 · 구간형 시작 · 끝도 정수 m · 고름 찾는 폭 한 값(213 0.006 m 측점 폭과 정리) · 기존 소수 자료 = 좌측(또는 페이지 머리) [정수 맞춤] 단추(하나씩 옮기며 213-2 계획고 편집 따라감) · 225 관보호공 0.1 m 이동은 벽 위치라 그대로 · 깨지는 시험 다섯 기대값 고침 +- [x] 234-1 입력 1 m · 붙기 · 구간 끝 정수 · 찾는 폭 · [정수 맞춤] — 새 시험 + - 검증완료 — 맞춤 한 벌 common_util_structure_station(파이썬 · TS 짝) · test_234_1 25건(80.17 → 80 · 80.6 → 80 · 81 그대로 · 끝 279.3 → 279.8 · 거울 13값 같음 · 폼 친 4+0.17 → 80 · 안 고친 80.17 그대로 · 방향키 1 m · 벽 80.17/75.17~95.67 → 80/75~96 · 단추 5건(관 2 · 구조물 3 · 겹친 관 1 그대로) · 한꺼번에 옮겨도 편집 따라감 80 · 263 · 지도 관 141.37 → 141 · 서버 자동 80.374 → 80 · 재투영 80) · 기대값 고침 219_1(1 m) · station_roundtrip(125) · 깨질 다섯 중 213_1 spike 만 실패 = 엄상준 storage 편집 키가 이미 263.310(전부터) · 관련 403 통과 · item_picker_detail 실패 = M02(무관 · 전부터) · typecheck · 찾는 폭 = PIPE_MATCH_M 한 값(213 0.006 은 반올림 같은 측점 폭이라 그대로) + +## 235. B09 설계내역서 = 접히지 않는 트리 · 더블클릭 입력 · 고른 줄 상자 (sub3) +정함(브레인) = 사용자 111 · 112번 · 표 = 접히지 않는 트리(구조물조합 마스터 · 회사 · 개인 나무와 같은 생각) · 중간 줄(폴더 = 자식 있는 줄) = 값 입력 없음 · 금액 · 재료비 · 노무비 · 경비 = 자손 합 수식(읽기 전용 · 연결 없음 · 수량 · 단가 칸 비움) · 최하위 줄(파일 = 항목) = 이름 · 규격 · 단위 · 수량 입력 + 일위대가 · 단가산출 연결 · 트리 안내선(├ └ │ · 폴더/파일 표시 · 구조물조합 나무 꼴 재사용) · 글 칸은 한 번 누름 = 줄 고름(끌기 됨) · 더블클릭(또는 Enter · F2) = 입력 시작 · 고른 줄 = 줄 전체 상자 테두리(밑줄 대신) · 204 끌기 · 방향키 · 228 글꼴 그대로 +- [x] 235-1 폴더 줄 수식 읽기 전용 · 파일 줄 입력 · 연결 · 트리 안내선 · 더블클릭 입력 · 상자 고름 — test_184_3 · 204_1 · 228_1 · 229_1 고침 · 새 시험 + - 검증완료 — 공용 표 editOnDouble(고칠 칸 = 글 · 더블클릭 · Enter · F2 = 그 칸만 글 상자 · Enter · 초점 떠남 = 끝 · 한글 조합 Enter 안 끊음) · level.tree(├ └ │ 안내선 + 공용 나무 폴더 · 파일 아이콘 한 벌 = TREE_ICON) · 고른 줄 = accent 상자 테두리(배경 대신) · test_235_1 9건(폴더 = 이름만 · 수량 · 단가 빈칸 · 금액 넷 = 자손 합 · 수식 풍선 · 연결 없음 · 파일 = 이름 · 규격 · 단위 · 수량 + 연결 ▾ · 안내선 = 파이썬 거울과 같음 · 한 번 누름 = 글 상자 0 · 더블클릭 = 그 칸 1 · Enter · F2 = 고른 줄 입력 · 편집 중 · 도구 단추 · 조합 중 = 안 먹음) · 184_3 · 204_1 · 228_1(들여쓰기 식 3 곳으로 고침) · 229_1 · 200_2 · 182 · 166_2 · 공용 표 시험(ui_data_table · 199_1 · 61_1 · 201_1 · 121 · 183_3 · 185_1 · 198_1 · 202_1) 125 통과 · item_picker_detail 실패 = M02(무관 · 전부터) · typecheck + +## 236. B08 · B09 표 아래 안내 글 — 제작 설명 지움 · 짧게 · 페이지 스크롤 없음 (sub1) +정함(브레인) = 사용자 113번 · 202 로 표 아래로 옮긴 안내 글 중 제작 · 셈 설명(코드 · 장 번호 · 「= … · …」 꼴 긴 설명)은 지움 · 사용자에게 쓸모 있는 한 줄만(예 「줄을 누르면 그 탭 그 줄로」) · 늘 한 줄(줄임표 · 길면 공용 호버로 전문은 두지 않음 — 지운 설명은 화면에 안 둠) · 199 fillViewport 셈에 한 줄 높이만 · 어떤 창 크기에서도 페이지 스크롤 0 +- [x] 236-1 B08 · B09 안내 글 정리 · 한 줄 · 페이지 스크롤 0 — 새 시험 + - 검증완료 — test_236_1(7) + 202_1 · 199_1 · 200_1 · 229_1 · 160_3 · 160_4 · 163 · 171_1 · 183_2 · 184_3 · 관련 B08 · B09 시험 59 파일 통과(181_2 빨강 1 = 236 밖 · B09 Page.css 드롭 선 danger 색 수 5 ≠ 4) · typecheck 통과 · 창 높이 1080 · 768 · 600 에서 표 칸 끝 = 창 끝 +- [x] 234-2 (sub7) 빨강 둘 — test_213_1 spike(엄상준 storage 원본에 기댐 → 고정 사본) · test_item_picker_detail(M02 모달 같은 카드 — 224-1 · 222 뒤 바뀐 까닭 판정 · 시험 낡음이면 기대값 · 흠이면 고침) — 두 시험 통과 + - 검증완료 — 판정: ① 원본 편집 키가 이미 263.310 이라 재현 조건 빠짐 → 고정 사본 fixtures/b05_213_1_hidden_station_longitudinal.json(initial_snapshot 종단 160~360m · 측점 263.21 · 꼭짓점 · 편집 키 263.206 · 263.210 · 옛 코드 15009% 재현 · 지금 100% 아래) ② 시험 낡음 — 105-3(36fc3d6c3 · 10-08)부터 카드 도면 = mountStructureSheets(여러 장 탭) · 224-1 · 222 와 무관 · 코드 흠 없음 → 기대값 고침 · 213_1 · item_picker_detail · m02_drawing_card · 192_1 · 213_2 · cad_host_tabs 49 통과 +- [x] 236-2 (sub1) 235-1 과 합친 뒤 어긋남 — Bill_Level.ts 레벨 안내 글(235 가 더한 「입력 = 더블클릭 …」 · 긴 레벨 설명)을 236 규칙(「=」 없음 · 짧게 · 한 줄)으로 · test_181_2 danger 색 수 5(204-1 끌기 못 놓음 빨간 점선 = 맞는 쓰임이면 기대값 5) — 세 시험 통과 + - 검증완료 — test_236_1 · 181_2 · 235_1 · 229_1 · 200_2 · 204_1 · 228_1 37건 통과 · typecheck · danger 5번째 쓰임 = `.b09-estimation__drop.is-bad` 점선(204-1)이라 기대값 5 · 안내 글 「줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력」 · 긴 레벨 설명 호버는 지움(잘릴 때만 전문) +- [x] 233-4 (sub8) 빈 곳 누름 = 어디서도 안 풂(종단 · B06 그리드도 3D 처럼) · 풀기 = Esc · 같은 것 다시 누름 한 길 — 새 시험 + - 검증완료 — 종단 그래프 · 유토곡선 · 횡단 그리드 빈 곳 누름 듣개 뗌(도구 중 빈 곳 누름은 그대로) · test_233_4 6건(진짜 소스 본문 — 종단 빈 곳 = 호출 0(옛 = 측점 · 알약 · 폼 풂) · 도구 중 = 도구 몫 · 그리드 빈 곳 = 0(옛 = clearSelection) · 같은 측점선 · 카드 다시 = 풂 · Esc = 풂) · 233_1~3 · 232_1 · 188 · 189 · 216 · 219 · 231 · 107 · 215 등 229 통과 · typecheck From be1c707da0f67488225d48581661f68122484b28 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:56:53 +0900 Subject: [PATCH 34/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20114~120?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index a5210767e..de52eb7d8 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -137,3 +137,13 @@ 111. B09에서 설계 내역서의 내용중 공종, 규격, 수량 단위의 값이 텍스트입력을 바로 받게 되어 있어서 드래그가 힘들어. 더블클릭하여 입력을 할수 있게하면 드래그에 문제없을것같아. 그리고 해당 행의 하이라이트는 박스가 쳐지게 바꾸는게 좋을것같아. 112. B09의 테이블 행을 기준으로 항목을 레벨화하는게 어려운거야? 접히지 않는 트리라고 생각하면 쉽잖아. 지금 구조물조합의 마스터/회사/개인도 다 트리형태인거고 그럼 중간 폴더들은 당연히 값이 아니라 수식이들어와야하고 파일이되는 최하위 항목을 입력을 받게 하는데 이때 일위대가나 단가산출을 연결하게 하면되는거고 113. 추가로 B08과 B09의 내용중에서 아래 설명때문에 페이지 스크롤이 동작해야하는 경우가 있어. 그냥 제작 설명을 제외하고 내용을 줄여주고 페이지에 스크롤이 동작하지 않게 해줘. + +## 다섯째 붙임 (10-10 저녁) + +114. 엑셀 프로그램에서 글 입력의 셀 맞춤 방식은 상하, 좌우 둘다 가운데 값을 기본값으로 변경 +115. 저장때 측점이 지워지는 구멍은 막을것. +116. 나머지는 일단 보류 (조사 AG 3D 표기 범위 · B06 꺼진 종류 · 표 레벨 번호 칸 · 되돌리기 · 로직 기본값 · 제비율 · 관급 등) +117. 앞으로 설명할때는 어느 페이지의 어떤 영역인지 말할것. 설명이 이해안됨. +118. B09의 총괄설계내역서는 내용중에 순공사비를 뽑기 위해 설계내역서의 레벨 2까지의 내용을 총괄설계내역서의 상단에 작성하는 내용이 빠져있음. 다시말해 레벨 2까지의 집계 내용을 상단에 정리해서 넣기만 하면 되고 해당항목을 선택하였을때 설계내역서를 이동하고 붉은색으로 하이라이트 하면 됨. +119. 설계내역서의 폴더구조는 좋은데 레벨에 따라 폴더가 되고 폴더가 되면 폰트를 같은 레벨의 다른 것들과 같은 폰트 사이즈와 색상을 선정하는게 어려운거야.? 너가 직접 확인하고 코드작업해줘. +120. B08의 에서 메인탭으로 거리이정표를 만들어주고 하위탭으로 거리이정표 하나더 추가 거리에 따라 별도로 추가 탭이 생성이 되면 됨. 생성되는 기준으로는 거리 이정표를 구현하여야함. (첨부 = 거리이정표 견본: 태원 · 주상기업 · 거창군청 · 병곡리 968-31 · 시점 · 중점 · 김천역 상자와 도로구분 km, 아래 표 = 구분(장비 거창군청→시점 20톤 · 8톤 / 혼합골재 골재원→임도중점 / 전석운반 골재원→임도중점 / 철근 김천역→임도중점) · 도로구분(2차포장 · 1차포장 · 비포장(1)) · 계(포장 · 비포장 · 합계) · 비고) 기본적으로 시점의 위치는 공사지의 시작 위치임. 중점은 사용자가 거리를 입력하는 위치 이두개는 하나의 컨테이너로 묶어서 공사지가 되고 공사지를 기준으로 특정 기준점이 있을수도 있고 없을 수도 있음. 이는 첨부사진의 병곡리가 됨. 특정기준점이나 공사지를 기준으로 거래업체를 다음 지도 API로 맵을 보여주고 길찾기를 하여 거리를 확보하고 이때 거리찾기한 항목을 각각의 하위탭으로 컨테이너 하나에 사진하나씩 배치해주면 됨. 여러개가 있을수 있으니 추가와 삭제 버튼은 있어야함. From 4e5bbac50d3180aa9b56073fd6894563761401be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:00:52 +0900 Subject: [PATCH 35/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index de52eb7d8..2354b52c0 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -147,3 +147,4 @@ 118. B09의 총괄설계내역서는 내용중에 순공사비를 뽑기 위해 설계내역서의 레벨 2까지의 내용을 총괄설계내역서의 상단에 작성하는 내용이 빠져있음. 다시말해 레벨 2까지의 집계 내용을 상단에 정리해서 넣기만 하면 되고 해당항목을 선택하였을때 설계내역서를 이동하고 붉은색으로 하이라이트 하면 됨. 119. 설계내역서의 폴더구조는 좋은데 레벨에 따라 폴더가 되고 폴더가 되면 폰트를 같은 레벨의 다른 것들과 같은 폰트 사이즈와 색상을 선정하는게 어려운거야.? 너가 직접 확인하고 코드작업해줘. 120. B08의 에서 메인탭으로 거리이정표를 만들어주고 하위탭으로 거리이정표 하나더 추가 거리에 따라 별도로 추가 탭이 생성이 되면 됨. 생성되는 기준으로는 거리 이정표를 구현하여야함. (첨부 = 거리이정표 견본: 태원 · 주상기업 · 거창군청 · 병곡리 968-31 · 시점 · 중점 · 김천역 상자와 도로구분 km, 아래 표 = 구분(장비 거창군청→시점 20톤 · 8톤 / 혼합골재 골재원→임도중점 / 전석운반 골재원→임도중점 / 철근 김천역→임도중점) · 도로구분(2차포장 · 1차포장 · 비포장(1)) · 계(포장 · 비포장 · 합계) · 비고) 기본적으로 시점의 위치는 공사지의 시작 위치임. 중점은 사용자가 거리를 입력하는 위치 이두개는 하나의 컨테이너로 묶어서 공사지가 되고 공사지를 기준으로 특정 기준점이 있을수도 있고 없을 수도 있음. 이는 첨부사진의 병곡리가 됨. 특정기준점이나 공사지를 기준으로 거래업체를 다음 지도 API로 맵을 보여주고 길찾기를 하여 거리를 확보하고 이때 거리찾기한 항목을 각각의 하위탭으로 컨테이너 하나에 사진하나씩 배치해주면 됨. 여러개가 있을수 있으니 추가와 삭제 버튼은 있어야함. +121. B09의 설계내역 테이블을 확인했는데 제어가 어려웠던 이유는 레벨이동의 앞에 들여쓰기가 너무 좁아서 적당한 위치에 놓기 어려웠던것같아. 그러니 들여쓰기 폭을 조금 더 띄워주고 추가로 어디에 추가되는지 어디가 들어가는지가 불분명해. 조금더 잘보이게 표시해줘. From a764cd75cca9e9c4e9b5d255a105fe2b971344d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:01:56 +0900 Subject: [PATCH 36/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20122?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 2354b52c0..f97cfd322 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -148,3 +148,4 @@ 119. 설계내역서의 폴더구조는 좋은데 레벨에 따라 폴더가 되고 폴더가 되면 폰트를 같은 레벨의 다른 것들과 같은 폰트 사이즈와 색상을 선정하는게 어려운거야.? 너가 직접 확인하고 코드작업해줘. 120. B08의 에서 메인탭으로 거리이정표를 만들어주고 하위탭으로 거리이정표 하나더 추가 거리에 따라 별도로 추가 탭이 생성이 되면 됨. 생성되는 기준으로는 거리 이정표를 구현하여야함. (첨부 = 거리이정표 견본: 태원 · 주상기업 · 거창군청 · 병곡리 968-31 · 시점 · 중점 · 김천역 상자와 도로구분 km, 아래 표 = 구분(장비 거창군청→시점 20톤 · 8톤 / 혼합골재 골재원→임도중점 / 전석운반 골재원→임도중점 / 철근 김천역→임도중점) · 도로구분(2차포장 · 1차포장 · 비포장(1)) · 계(포장 · 비포장 · 합계) · 비고) 기본적으로 시점의 위치는 공사지의 시작 위치임. 중점은 사용자가 거리를 입력하는 위치 이두개는 하나의 컨테이너로 묶어서 공사지가 되고 공사지를 기준으로 특정 기준점이 있을수도 있고 없을 수도 있음. 이는 첨부사진의 병곡리가 됨. 특정기준점이나 공사지를 기준으로 거래업체를 다음 지도 API로 맵을 보여주고 길찾기를 하여 거리를 확보하고 이때 거리찾기한 항목을 각각의 하위탭으로 컨테이너 하나에 사진하나씩 배치해주면 됨. 여러개가 있을수 있으니 추가와 삭제 버튼은 있어야함. 121. B09의 설계내역 테이블을 확인했는데 제어가 어려웠던 이유는 레벨이동의 앞에 들여쓰기가 너무 좁아서 적당한 위치에 놓기 어려웠던것같아. 그러니 들여쓰기 폭을 조금 더 띄워주고 추가로 어디에 추가되는지 어디가 들어가는지가 불분명해. 조금더 잘보이게 표시해줘. +122. 추가로 B09의 좌측패널에서 데이터 점검 컨테이너와 업데이트 버튼 사이가 너무 협소해. 업데이트 좌측 여유만큼 상단에도 여유가 있었으면 함. From f262007f56006c8c1bb29526ff10cca7c6ecfae1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:03:02 +0900 Subject: [PATCH 37/92] =?UTF-8?q?fix(profile):=20B05=20[=EC=A0=80=EC=9E=A5?= =?UTF-8?q?]=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=3D=20=EC=84=9C=EB=B2=84=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EA=B7=9C=EC=B9=99=20=C2=B7=20=EC=86=8C=EB=8B=A8=20?= =?UTF-8?q?=C2=B7=20=EC=98=B9=EB=B2=BD=20=C2=B7=20=ED=9D=99=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=C2=B7=20=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=A7=80=EC=9B=80=20=EB=A7=89=EC=9D=8C=20?= =?UTF-8?q?(238-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B05_Profile/B05_Profile_Router_Confirm.py | 37 ++-- ...est_238_1_save_keeps_structure_stations.py | 163 ++++++++++++++++++ 2 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_238_1_save_keeps_structure_stations.py diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 8e5117630..97668b447 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -15,7 +15,12 @@ import aiomysql from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile -from B05_Profile.B05_Profile_Engine_Sections import generate_irregular_sections +from B05_Profile.B05_Profile_Engine_Sections import ( + _load_pipe_points, + _load_route_polyline, + generate_irregular_sections, + resolve_extra_stations, +) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg from B05_Profile.B05_Profile_Schema import RouteConfirmRequest @@ -350,17 +355,25 @@ async def _append_irregular_cross_sections( save_sampling_snapshot(project_root, request) stored_options = await get_latest_section_options(connection, project_id) crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id) - irregular_stations = await asyncio.to_thread( - generate_irregular_sections, - project_root, - str(route["route_data_path"]), - request.filter_key, - request.method, - request.smooth, - extra_stations=request.extra_stations(), - options=_section_options_from_stored(stored_options), - crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, - ) + route_data_path = str(route["route_data_path"]) + + def _generate() -> list[dict[str, Any]]: + # 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은 + # 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1). + polyline = _load_route_polyline(project_root, route_data_path) + extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline)) + return generate_irregular_sections( + project_root, + route_data_path, + request.filter_key, + request.method, + request.smooth, + extra_stations=extras, + options=_section_options_from_stored(stored_options), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, + ) + + irregular_stations = await asyncio.to_thread(_generate) if not irregular_stations: return longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) diff --git a/resources/tester/test_238_1_save_keeps_structure_stations.py b/resources/tester/test_238_1_save_keeps_structure_stations.py new file mode 100644 index 000000000..d2ae972af --- /dev/null +++ b/resources/tester/test_238_1_save_keeps_structure_stations.py @@ -0,0 +1,163 @@ +"""238-1 B05 [저장]이 구조물 측점을 지우지 않음. + +[저장](경로 확정 길 · `_append_irregular_cross_sections`)은 종단 비정규 측점을 통째로 갈아 끼운다. +그 목록을 화면이 보낸 「관 + A군」으로 쓰면 서버가 심어 둔 소단 · 옹벽 · 흙막이 · 독립 기슭막이 +시작 · 종료 측점이 빠진다 → 서버 측점 규칙(`resolve_extra_stations`)으로 만든 목록을 씀. + +- 사본: 임시 폴더 프로젝트(직선 노선 · 관 하나 + 기슭막이 · 소단 · 옹벽 · 흙막이) +- 앞: B06 [측점 만들기]와 같은 길(서버 목록으로 심음) · 뒤: 화면 목록(관만)을 실은 [저장] +- 판정: 앞뒤 구조물 측점 같음 · 소단 측점(깊이 patch 가 붙는 자리) 남음 · 관 옮김은 반영 +""" + +import asyncio +import json +from pathlib import Path + +import numpy as np + +import B05_Profile.B05_Profile_Engine_Sections as sections +import B05_Profile.B05_Profile_Router_Confirm as confirm +from B05_Profile.B05_Profile_Schema import RouteConfirmRequest +from common_util.common_util_drainage_pipes import route_signature +from common_util.common_util_route_geometry import RouteVertex + +LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]] +ROUTE = "B05_Profile/route/route.geojson" +LONG = "B06_Section/longitudinal/longitudinal.json" +PIPE = {"chainage_m": 150.0, "source": "user", "options": {"pipe_diameter_mm": 800}} +REVET = { + "chainage_m": 301.0, + "source": "user", + "facility": "revetment", + "start_m": 296.0, + "end_m": 316.0, +} +STRUCTURES = [ + {"type_id": "soil_guard", "chainage_m": 45.0, "start_m": 41.0, "end_m": 51.0}, + {"type_id": "berm", "chainage_m": 201.0, "start_m": 196.0, "end_m": 206.0}, + {"type_id": "retaining_wall", "chainage_m": 251.0, "start_m": 246.0, "end_m": 256.0}, +] + + +class _Flat: + def sample_xy(self, xy): + count = len(np.asarray(xy, dtype=np.float64)) + return np.full(count, 100.0), np.ones(count, dtype=bool) + + +def _write(root: Path, rel: str, data) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + +def _write_pipes(root: Path, points) -> None: + vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in LINE] + _write( + root, + "B04_PreProcess/drainage/edits/pipe_points.json", + {"route_signature": route_signature(vertices), "points": points}, + ) + + +def _project(root: Path) -> None: + _write(root, ROUTE, {"geometry": {"type": "LineString", "coordinates": LINE}}) + _write_pipes(root, [PIPE, REVET]) + for item in STRUCTURES: + item["placement"] = "interval" + _write(root, "B05_Profile/route/structures.json", {"revision": 1, "structures": STRUCTURES}) + regular = [{"chainage_m": float(c), "kind": "regular"} for c in range(0, 351, 20)] + _write(root, LONG, {"stations": regular}) + + +def _irregular(root: Path) -> list[tuple[str, str]]: + data = json.loads((root / LONG).read_text(encoding="utf-8")) + return [ + (f"{float(s['chainage_m']):.2f}", s.get("structure")) + for s in data["stations"] + if s.get("kind") == "irregular" + ] + + +def _patch(monkeypatch, root: Path) -> None: + async def _value(value, *_a, **_k): + return value + + monkeypatch.setattr(sections, "build_surface_sampler", lambda *_a, **_k: _Flat()) + monkeypatch.setattr(confirm, "get_project_storage_relative_path", lambda *_a: _value("p")) + monkeypatch.setattr(confirm, "resolve_stored_project_path", lambda _p: str(root)) + monkeypatch.setattr(confirm, "get_latest_section_options", lambda *_a: _value(None)) + monkeypatch.setattr(confirm, "get_surface_crs_epsg", lambda *_a: _value(None)) + monkeypatch.setattr( + confirm, + "get_longitudinal_section", + lambda *_a: _value({"longitudinal_file_path": LONG}), + ) + + +def _plant_like_b06(root: Path) -> None: + """B06 [측점 만들기]와 같은 길 — 서버 목록으로 뜨고 종단에 병합.""" + pipes = sections._load_pipe_points(root, LINE) + stations = sections.generate_irregular_sections( + root, + ROUTE, + "csf", + "tin", + False, + extra_stations=sections.resolve_extra_stations(root, pipes), + ) + confirm._merge_irregular_into_longitudinal(root, LONG, stations) + + +def _save(root: Path, screen: list[tuple[float, str]]) -> None: + """B05 [저장] — 화면은 관 · A군 목록만 실어 보냄.""" + request = RouteConfirmRequest( + filter_key="csf", + method="tin", + surface_model_id=1, + irregular_stations=[{"chainage_m": c, "structure": s} for c, s in screen], + ) + assert request.can_regenerate() + asyncio.run( + confirm._append_irregular_cross_sections( + None, "p", {"id": 1, "route_data_path": ROUTE}, request + ) + ) + + +def test_save_keeps_server_planted_stations(tmp_path, monkeypatch): + _project(tmp_path) + _patch(monkeypatch, tmp_path) + _plant_like_b06(tmp_path) + before = _irregular(tmp_path) + labels = {label for _key, label in before} + assert {"소단", "옹벽", "흙막이", "기슭막이"} <= labels + assert [k for k, label in before if label == "기슭막이"] == ["296.00", "301.00", "316.00"] + + _save(tmp_path, [(150.0, "파형강관 D800")]) + + assert _irregular(tmp_path) == before + # 소단 깊이(194 · 측점 patch)가 붙는 자리 = 소단 측점 — 그대로 섬 + assert [k for k, label in _irregular(tmp_path) if label == "소단"] == [ + "196.00", + "201.00", + "206.00", + ] + + +def test_save_follows_moved_pipe(tmp_path, monkeypatch): + _project(tmp_path) + _patch(monkeypatch, tmp_path) + _plant_like_b06(tmp_path) + before = _irregular(tmp_path) + + # 관 150 → 165 (관 초안이 [저장] 차례상 먼저 정본에 들어감 · design_save.ts) + _write_pipes(tmp_path, [{**PIPE, "chainage_m": 165.0}, REVET]) + _save(tmp_path, [(165.0, "파형강관 D800")]) + + after = _irregular(tmp_path) + expected = sorted( + [item for item in before if item[0] != "150.00"] + [("165.00", "파형강관 D800")], + key=lambda item: float(item[0]), + ) + assert after == expected From b099add03686855a76535089cabbb194eca460a6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:03:39 +0900 Subject: [PATCH 38/92] =?UTF-8?q?feat(spreadsheet):=20=EC=B9=B8=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=EA=B8=B0=EB=B3=B8=20=3D=20=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20=C2=B7=20=EC=84=B8?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20(237-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 안 준 칸(가로 없음 · 일반 · 세로 없음)의 칸 · 도구줄 · 서식 창 · 편집창 · 영역 표 · 복사 HTML 기본을 가운데로 · 준 칸은 그대로 · 새 시험 추가 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../spreadsheet/spreadsheet_area_table.ts | 4 +- .../spreadsheet/spreadsheet_cellstyle.ts | 20 ++++-- .../spreadsheet/spreadsheet_clipboard.ts | 10 +-- A00_Common/spreadsheet/spreadsheet_editor.ts | 2 +- .../spreadsheet/spreadsheet_format_dialog.ts | 6 +- .../spreadsheet/spreadsheet_grid_cells.ts | 5 +- A00_Common/spreadsheet/spreadsheet_toolbar.ts | 5 +- .../spreadsheet_align_default_entry.ts | 65 +++++++++++++++++++ resources/tester/test_m02_area_table.py | 24 ++++++- .../tester/test_spreadsheet_align_default.py | 57 ++++++++++++++++ 10 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 resources/tester/spreadsheet/spreadsheet_align_default_entry.ts create mode 100644 resources/tester/test_spreadsheet_align_default.py diff --git a/A00_Common/spreadsheet/spreadsheet_area_table.ts b/A00_Common/spreadsheet/spreadsheet_area_table.ts index 660642655..ec86a2d38 100644 --- a/A00_Common/spreadsheet/spreadsheet_area_table.ts +++ b/A00_Common/spreadsheet/spreadsheet_area_table.ts @@ -8,7 +8,7 @@ * ========================================================================== */ import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address"; -import { cellStyleAt } from "./spreadsheet_cellstyle"; +import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle"; import { createCalcEngine } from "./spreadsheet_graph"; import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers"; import { resolveName } from "./spreadsheet_names"; @@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) { : { 글: "", 정렬: "left" as const }; const key = `${sheet.id}!${toA1(r, c)}`; 값[key] = shown.글; - const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]); + const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1) return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key }; }; const cells = rows.map((r) => diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts index 05c8fc686..31a380977 100644 --- a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -139,9 +139,9 @@ function borderCss(side: BorderSide | undefined, scale: number): string { } } -/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */ +/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). 안 준 칸(일반) = 가운데(237-1). */ const HALIGN_CSS: Record = { - general: "left", + general: "center", left: "left", right: "right", center: "center", @@ -149,13 +149,23 @@ const HALIGN_CSS: Record = { centerContinuous: "center", }; +/** 칸에 맞춤을 안 준(없음 · 일반) 보이는 가로 맞춤 = 가운데 — 화면 · 도구줄 · 대화상자 · 내보내기 공통(237-1). */ +export function shownHAlign(h: HAlign | undefined): HAlign { + return !h || h === "general" ? "center" : h; +} + +/** 칸에 맞춤을 안 준 보이는 세로 맞춤 = 가운데(237-1). */ +export function shownVAlign(v: VAlign | undefined): VAlign { + return v ?? "center"; +} + const VALIGN_CSS: Record = { top: "flex-start", center: "center", bottom: "flex-end", }; -/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). +/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데). * `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */ export function applyCellStyle( cell: HTMLElement, @@ -178,7 +188,7 @@ export function applyCellStyle( s.borderLeft = borderCss(borders.왼, scale); s.borderBottom = borderCss(borders.아래, scale); s.borderRight = borderCss(borders.오른, scale); - s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"]; + s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)]; s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap"; let text = cell.firstElementChild as HTMLElement | null; @@ -187,7 +197,7 @@ export function applyCellStyle( text.className = "aislo-cell__text"; cell.replaceChildren(text); } - text.style.textAlign = HALIGN_CSS[hAlign]; + text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)]; text.style.textAlignLast = hAlign === "distributed" ? "justify" : ""; } diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index 1eef6e5c0..9164ce8c4 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -14,6 +14,7 @@ import { showToast } from "@ui/ui_template_elements"; import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; +import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { clipWholeToUsed } from "./spreadsheet_grid_cells"; import { formatValue } from "./spreadsheet_numfmt"; import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift"; @@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string { 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.세로}`); + // 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양 + const h = shownHAlign(style.가로); + parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`); + const v = shownVAlign(style.세로); + parts.push(`vertical-align:${v === "center" ? "middle" : v}`); if (style.줄바꿈) parts.push("white-space:pre-wrap"); if (style.테두리) { (["위", "아래", "왼", "오른"] as const).forEach((side) => { diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index 8cf4f81a8..b7a23f3a0 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor { 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.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center"; // 글이 길면 오른쪽 · 아래로 늘림(엑셀) ta.style.width = "0"; ta.style.height = "0"; diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts index 51a2b5769..66d199ee7 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts @@ -23,7 +23,7 @@ import { type NegStyle, } from "./spreadsheet_format_code"; import { dialogKeys } from "./spreadsheet_actions"; -import { isAutoInk } from "./spreadsheet_cellstyle"; +import { isAutoInk, shownHAlign, shownVAlign } 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"; @@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { ["centerContinuous", "선택 영역 가운데"], ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), }) as HTMLSelectElement; - hSelect.value = draft.가로 ?? "general"; + hSelect.value = shownHAlign(draft.가로); hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign)); const vSelect = el("select", { @@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { ["bottom", "아래"], ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), }) as HTMLSelectElement; - vSelect.value = draft.세로 ?? "bottom"; + vSelect.value = shownVAlign(draft.세로); vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign)); const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; diff --git a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts index a98943c5c..21160b525 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts @@ -13,6 +13,7 @@ import { isAutoInk, resolveBoxBorders, setCellText, + shownHAlign, type ResolvedBorders, } from "./spreadsheet_cellstyle"; import type { LineGeometry, OutlineRun } from "./spreadsheet_headers"; @@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map { } const SPILL_ALIGN: Partial> = { - general: "left", + general: "center", left: "left", right: "right", center: "center", @@ -218,7 +219,7 @@ function 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 hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1) const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : ""; const isNumeric = !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 5c450eade..d213b4a61 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -13,6 +13,7 @@ import { createButton, el } from "@ui/ui_template_elements"; import { confirmMerge } from "./spreadsheet_actions"; import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; +import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; import { cellToText, textToCell } from "./spreadsheet_editor"; import { st } from "./spreadsheet_text"; import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types"; @@ -528,8 +529,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { strikeBtn.classList.toggle("is-on", !!style.취소선); textColor.value = style.글자색 ?? "#000000"; fillColor.value = style.채움 ?? "#ffffff"; - hAlignSelect.value = style.가로 ?? "general"; - vAlignSelect.value = style.세로 ?? "bottom"; + hAlignSelect.value = shownHAlign(style.가로); + vAlignSelect.value = shownVAlign(style.세로); wrapBtn.classList.toggle("is-on", !!style.줄바꿈); numfmtInput.value = style.형식 ?? ""; const range = ctx.selection.범위[0]; diff --git a/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts b/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts new file mode 100644 index 000000000..54417bd46 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts @@ -0,0 +1,65 @@ +/* 맞춤 기본 = 가운데 · 가운데(237-1) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. + * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_align_default.py` 가 읽음) */ +import { + applyCellStyle, + shownHAlign, + shownVAlign, + type ResolvedBorders, +} from "../../../A00_Common/spreadsheet/spreadsheet_cellstyle"; +import { blockToClipboard } from "../../../A00_Common/spreadsheet/spreadsheet_clipboard"; +import type { CellStyle, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +let pass = 0; +const failures: string[] = []; +const eq = (name: string, got: unknown, want: unknown): void => { + if (JSON.stringify(got) === JSON.stringify(want)) pass += 1; + else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`); +}; + +// 도구줄 · 대화상자가 보이는 값 — 안 준 칸(없음 · 일반) = 가운데 · 준 칸 그대로 +eq("가로 없음", shownHAlign(undefined), "center"); +eq("가로 일반", shownHAlign("general"), "center"); +eq("가로 왼쪽", shownHAlign("left"), "left"); +eq("가로 오른쪽", shownHAlign("right"), "right"); +eq("가로 균등", shownHAlign("distributed"), "distributed"); +eq("세로 없음", shownVAlign(undefined), "center"); +eq("세로 위", shownVAlign("top"), "top"); +eq("세로 아래", shownVAlign("bottom"), "bottom"); + +// 칸 CSS — 가짜 칸(DOM 없이 style 만 봄) +function paint(style: CellStyle, hAlign: Parameters[3]) { + const text = { classList: { contains: () => true }, style: {} as Record }; + const cell = { + style: {} as Record, + firstElementChild: text, + replaceChildren: () => undefined, + }; + applyCellStyle(cell as unknown as HTMLElement, style, {} as ResolvedBorders, hAlign); + return { v: cell.style.justifyContent, h: text.style.textAlign }; +} +eq("칸 안 준 = 가운데 · 가운데", paint({}, "general"), { v: "center", h: "center" }); +eq("칸 왼쪽 · 아래 그대로", paint({ 세로: "bottom" }, "left"), { v: "flex-end", h: "left" }); +eq("칸 오른쪽 · 위 그대로", paint({ 세로: "top" }, "right"), { v: "flex-start", h: "right" }); + +// 내보내기(복사 HTML) — 안 준 칸 = 가운데 · 가운데 · 준 칸 그대로 +const book = { + 종류: "통합문서", + 판: 1, + 열: "rv", + 서식: [{}, { 가로: "left", 세로: "top" }], + 시트: [{ id: "s1", 이름: "시트1", 칸: { A1: { 값: "가" }, B1: { 값: "나", 서식: 1 } } }], + 활성: "s1", +} as unknown as Workbook; +const { html } = blockToClipboard(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 1 }, (_r, c) => + c === 0 ? "가" : "나", +); +const tds = [...html.matchAll(/]*style="([^"]*)"/g)].map((m) => m[1]); +eq("내보내기 칸 수", tds.length, 2); +eq( + "내보내기 안 준 칸", + /text-align:center/.test(tds[0]) && /vertical-align:middle/.test(tds[0]), + true, +); +eq("내보내기 준 칸", /text-align:left/.test(tds[1]) && /vertical-align:top/.test(tds[1]), true); + +console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py index 8a699f868..5826398ac 100644 --- a/resources/tester/test_m02_area_table.py +++ b/resources/tester/test_m02_area_table.py @@ -33,6 +33,7 @@ SHEET = { "A2": {"값": "합"}, "B2": {"식": "B1*2", "서식": 1}, "A3": {"값": "숨김행"}, + "F1": {"값": "왼쪽", "서식": 3}, "C1": {"값": "x"}, }, "병합": ["A1:A2", "C1:E5"], @@ -43,7 +44,7 @@ BOOK = { "종류": "통합문서", "판": 1, "열": "rv", - "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}], + "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}, {"가로": "left"}], "시트": [SHEET], "활성": "s1", } @@ -56,7 +57,12 @@ def _one(**ask) -> dict: def test_표시_글_형식_굵게_정렬_칸_key(): got = _one(시트="면적산출", 범위="A1:B2") cells = got["표"]["shapeData"]["cells"] - assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"} + assert cells[0][1] == { + "text": "1,234.50", + "align": "center", + "bold": True, + "key": "s1!B1", + } # 안 준 수 칸 = 가운데(237-1) assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식 assert cells[0][0]["align"] == "center" # 가로 서식 assert (got["시트"], got["범위"]) == ("s1", "A1:B2") @@ -64,6 +70,14 @@ def test_표시_글_형식_굵게_정렬_칸_key(): assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""} +def test_맞춤_안_준_칸은_가운데_준_칸은_그대로_237_1(): + cells = _one(시트="s1", 범위="A1:F1")["표"]["shapeData"]["cells"][0] + by_key = {c["key"]: c["align"] for c in cells if c} + assert by_key["s1!A1"] == "center" # 가로 center 를 준 칸 + assert by_key["s1!B1"] == "center" # 안 준 수 칸 (옛: right) + assert by_key["s1!F1"] == "left" # 가로 left 를 준 칸 그대로 + + def test_병합_잘라냄_숨긴_행열_뺌_빈_칸(): shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"] cells = shape["cells"] @@ -71,7 +85,11 @@ def test_병합_잘라냄_숨긴_행열_뺌_빈_칸(): assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김) assert cells[1][2] is None and cells[2][2] is None - assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸 + assert cells[2][0] == { + "text": "", + "align": "center", + "key": "s1!A4", + } # 빈 칸 · 안 준 칸 = 가운데 assert shape["style"]["fontSize"] == 5 assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율 assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3 diff --git a/resources/tester/test_spreadsheet_align_default.py b/resources/tester/test_spreadsheet_align_default.py new file mode 100644 index 000000000..e32979c5e --- /dev/null +++ b/resources/tester/test_spreadsheet_align_default.py @@ -0,0 +1,57 @@ +"""맞춤 기본 = 가운데 · 가운데(237-1) — 안 준 칸 · 준 칸 · 도구줄 기본 표시 · 내보내기(복사 HTML). + +`spreadsheet_align_default_entry.ts` 를 vite `--ssr` 로 번들해 Node 로 돌림. 영역 표 맞춤은 +`test_m02_area_table.py`. 화면(도구줄 · 칸 · 편집창)은 사용자가 확인. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_align_default_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_맞춤_기본_가운데_237_1(): + with tempfile.TemporaryDirectory(prefix="spreadsheet_align_default_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "spreadsheet_align_default_entry.js")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), run.stderr + result = json.loads(run.stdout) + assert result["fail"] == 0, result["failures"] + assert result["pass"] == 14 From dd360094b3cb7ac7f742efd8d373a9bf3c66dde9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:05:05 +0900 Subject: [PATCH 39/92] =?UTF-8?q?feat(b09):=20=EC=B4=9D=EA=B4=84=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20=EB=A7=A8=20=EC=9C=84=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=201=20=C2=B7=202=20=EC=A7=91=EA=B3=84=20=C2=B7=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=20=3D=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=84=9C=20=EA=B7=B8=20=EC=A4=84=20=EB=B9=A8=EA=B0=95=20(239-1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 총괄 표 맨 위 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목 포함) · 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계 - 줄 누름 = 설계내역서 탭 그 줄로 · 이동해 온 줄 = is-pick 빨강 외곽선(고른 줄 상자와 따로) - 새 시험 test_239_1 · 200_2 셸 배선 문구 갱신 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Shell.ts | 16 ++- .../B09_Estimation_UI_Tabs_Total.ts | 43 ++++++- .../tester/test_200_2_b09_bill_level_bar.py | 4 +- .../test_239_1_b09_total_bill_summary.py | 117 ++++++++++++++++++ 4 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 resources/tester/test_239_1_b09_total_bill_summary.py diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index ccf09eeb6..1701a2517 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -61,7 +61,7 @@ import { } from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; -import { isGroupTab, lineHint, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; +import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -262,15 +262,19 @@ export function renderB09Estimation(root: HTMLElement): void { ); } // 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력 + // 239-1 이동해 온 줄 = 빨강 외곽선(목록표 → 탭 길 is-pick · 고른 줄 상자와 따로) const bill = (spec as BillTable).menu ? (spec as BillTable) : null; - const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined); + const at = focus === undefined ? -1 : rowIndex(spec.rows, focus); + const jump: [number, number] | null = at >= 0 ? [at, at] : null; + const table = makeTable( + bill + ? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)].filter(Boolean).join(" ") || null + : undefined, + ); const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; if (level) bars.setTools(level.tools); panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : [])); - if (focus !== undefined) { - const index = rowIndex(spec.rows, focus); - if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); - } + if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); }; const apply = (next: Row, fromServer: boolean): void => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index d319eed11..c95485580 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -2,7 +2,8 @@ * B09_Estimation_UI_Tabs_Total.ts * B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`. * - * 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바) + * 총괄 = 맨 위 내역 줄(239-1 · 설계내역서 레벨 1 · 2 집계 + 순공사비 계 · 누름 = 설계내역서 그 줄 빨강) + + * 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바) * 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1) * 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로 * 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · @@ -84,6 +85,42 @@ const nameLevel = (rows: DataRow[]) => ({ of: (i: number) => rows[i]?.level as number | undefined, }); +/** 239-1 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도 — 합이 순공사비) · + * 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계 · 누름 = 설계내역서 그 줄(`bill`) */ +function billRows(bill: Row | undefined): DataRow[] { + const top = (bill?.rows || []).filter( + (r: Row) => r.cost === "direct" && (r.level === 1 || (r.level === 2 && r.kind !== "item")), + ); + if (!top.length) return []; + const direct: Row = bill?.totals?.direct || {}; + const amounts = (r: Row) => ({ + amount: r.sum ?? null, + labor: r.labor ?? null, + material: r.material ?? null, + expense: r.expense ?? null, + }); + const blank = direct.blank ? ` · 빈 줄 ${direct.blank} — 아는 금액만` : ""; + return [ + ...top.map((r: Row) => ({ + key: `bill:${r.key}`, + part: "내역", + name: r.no ? `${r.no} ${r.name}` : r.name, + ...amounts(r), + note: "누르면 설계내역서 그 줄로", + level: r.level, + bill: r.key, + })), + { + key: "bill_sum", + part: "내역", + name: "순공사비 계", + ...amounts(direct), + note: `설계내역서 레벨 1 합(관급 제외)${blank}`, + level: 1, + }, + ]; +} + function rateText(formula: string, rate: Num): string { return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); } @@ -139,6 +176,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { const sums: Row = t.sums || {}; const env = est.environment_kind === "road" ? "road" : "other"; const rows: DataRow[] = [ + ...billRows(ctx.sheets.bill), row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", { hand: hand("in_price"), }), @@ -211,12 +249,13 @@ export const buildTotal: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["part"], - hint: "요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값", + hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || (key === "hand" && String(rows[i]?.key) in HAND), onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from), + link: (i) => (rows[i]?.bill ? { tab: "bill", key: String(rows[i].bill) } : null), tip: (i, key) => { const r = rows[i]; if (!r?.rt || (key !== "rate" && key !== "candidate")) return null; diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 743aa8eaf..3c41ac20f 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -196,7 +196,9 @@ def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표(): def test_셸_배선(): src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src + assert ( + "? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)]" in src + ) # 239-1 이동 줄 빨강 곁 assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") diff --git a/resources/tester/test_239_1_b09_total_bill_summary.py b/resources/tester/test_239_1_b09_total_bill_summary.py new file mode 100644 index 000000000..51fd8e161 --- /dev/null +++ b/resources/tester/test_239_1_b09_total_bill_summary.py @@ -0,0 +1,117 @@ +"""239-1 B09 총괄설계내역서 맨 위 = 설계내역서 레벨 1 · 2 집계 · 누름 = 설계내역서 그 줄 빨강. + +- 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도) · 설계내역서 차례 · 레벨 그대로 +- 금액(합계 · 노무 · 재료 · 경비) = 설계내역서 그 줄 금액(Calc_Bill) · 끝 「순공사비 계」 = 총괄 가. 순공사비계 +- 줄 누름 = link {tab: bill, key} → Shell 이 설계내역서 그 줄에 is-pick(빨강 외곽선 · 고른 줄 상자와 따로) +- 아래 원가계산 줄 = 그대로(요율 입력 줄부터 같은 차례) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_182_b09_bill_level import BOOKS, HAND, INPUTS, USER # noqa: E402 +from test_184_3_b09_bill_menu import _node, _ts # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 + +SRC = ["Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total"] +#: Shell 이 부르는 줄 class(목록표 → 탭 길 · 181-2) — 묶음 탭 파일에서 그 함수만 +GROUPS = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8") +PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0) +PARTS = ["sum", "labor", "material", "expense"] +#: 빈 줄 없는 판(연결 없는 earth:5 · hand:c 뺌) — 총괄 순공사비계가 값을 받음 +FULL_INPUTS = {**INPUTS, "bill": {"items": [i for i in INPUTS["bill"]["items"] if i["ref"]]}} +FULL_USER = {"bill": {"rows": [h for h in HAND if h["key"] != "hand:c"]}} + +JS = """ +const sheets = __SHEETS__; +const ctx = { sheets, payload: {}, user: { estimate: {} }, setUser: () => {} }; +const spec = buildTotal(ctx); +const plain = buildTotal({ ...ctx, sheets: { total: sheets.total } }); +console.log(JSON.stringify({ + rows: spec.rows.map((r, i) => ({ ...r, link: spec.link(i, "name"), cls: spec.rowClass(i), + edit: ["amount", "rate", "hand"].some((k) => spec.editable(i, k)) })), + plain: plain.rows.map((r) => r.key), + pick: [pickClass([3, 3], 2), pickClass([3, 3], 3), pickClass(null, 3)], +})); +""" + + +def _run(inputs, user): + bill = compute_estimation(inputs, user, None, BOOKS)["bill"] + total = compute_total(bill["sums"], {}) + sheets = {"bill": bill, "total": total} + out = json.loads(_node(_ts(SRC) + PICK + JS.replace("__SHEETS__", json.dumps(sheets)))) + return bill, total, out + + +def test_맨_위_줄_레벨_1_2_폴더_차례_금액_같음(): + bill, _, out = _run(INPUTS, USER) + want = [ + r + for r in bill["rows"] + if r["cost"] == "direct" and (r["level"] == 1 or (r["level"] == 2 and r["kind"] != "item")) + ] + top = [r for r in out["rows"] if r["key"].startswith("bill:")] + assert [r["key"] for r in top] == [f"bill:{r['key']}" for r in want] + assert {r["level"] for r in top} == {1, 2} + assert "bill:sec:G" not in {r["key"] for r in top} # 관급 = 순공사비 밖 + assert "bill:haul:1" not in {r["key"] for r in top} # 레벨 2 항목(파일) 빠짐 + for got, src in zip(top, want): + assert got["name"] == f"{src['no']} {src['name']}" + assert [got["amount"], got["labor"], got["material"], got["expense"]] == [ + src[p] for p in PARTS + ] + assert got["cls"] == ("is-title" if src["level"] == 1 else None) + assert got["edit"] is False + # 맨 위부터 내역 줄 → 순공사비 계 → 요율 입력 줄 + keys = [r["key"] for r in out["rows"]] + assert keys[: len(top) + 2] == [r["key"] for r in top] + ["bill_sum", "in_price"] + + +def test_합_순공사비_레벨1_합_총괄_같음(): + bill, total, out = _run(FULL_INPUTS, FULL_USER) + assert bill["totals"]["direct"]["blank"] == 0 + rows = {r["key"]: r for r in out["rows"]} + s = rows["bill_sum"] + lv1 = [r for r in out["rows"] if r["key"].startswith("bill:") and r["level"] == 1] + for col, p in zip(["amount", "labor", "material", "expense"], PARTS): + assert s[col] == bill["totals"]["direct"][p] + assert s[col] == sum(r[col] for r in lv1) + assert s["amount"] == rows["direct"]["amount"] == total["sums"]["direct"] > 0 + assert s["level"] == 1 and s["link"] is None + + +def test_빈_줄_있으면_계_안내(): + bill, _, out = _run(INPUTS, USER) + s = next(r for r in out["rows"] if r["key"] == "bill_sum") + assert bill["totals"]["direct"]["blank"] == 2 + assert s["amount"] == bill["totals"]["direct"]["sum"] and "빈 줄 2" in s["note"] + + +def test_누름_설계내역서_그_줄_빨강(): + bill, _, out = _run(INPUTS, USER) + keys = {r["key"] for r in bill["rows"]} + for r in out["rows"]: + if r["key"].startswith("bill:"): + assert r["link"] == {"tab": "bill", "key": r["key"][5:]} and r["link"]["key"] in keys + else: + assert r["link"] is None # 원가계산 줄 = 이동 없음(그대로) + assert out["pick"] == [None, "is-pick is-pick-top is-pick-end", None] + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "const jump: [number, number] | null = at >= 0 ? [at, at] : null;" in shell + assert "[pickedClass(bill.rows[i]), pickClass(jump, i)]" in shell + + +def test_설계내역서_없으면_원가계산_줄_그대로(): + _, _, out = _run(INPUTS, USER) + assert out["plain"][0] == "in_price" + assert out["plain"] == [r["key"] for r in out["rows"] if not r["key"].startswith("bill")] From fe34f561a0b6edfa17c371bc50f2e3f621017295 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:13:30 +0900 Subject: [PATCH 40/92] =?UTF-8?q?fix(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=ED=8F=B4=EB=8D=94=20=EC=A4=84=20=EA=B8=80?= =?UTF-8?q?=EA=BC=B4=20=ED=95=9C=20=EB=B2=8C=20=C2=B7=20=EB=93=A4=EC=97=AC?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EB=84=93=ED=9E=98=20=C2=B7=20=EB=81=8C?= =?UTF-8?q?=EA=B8=B0=20=EB=86=93=EC=9D=84=20=EC=9E=90=EB=A6=AC=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=C2=B7=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=20=ED=8B=88=20(240=20=C2=B7=20121=20=C2=B7=20122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레벨 고쳐 생긴 폴더 줄도 처음 폴더 줄과 같은 꼴(is-title · 크기 = 레벨) - 들여쓰기 한 단 22px · 끌기 좌우 한 단 = 들여쓰기 폭 - 끌기 미리 보기 = 레벨 시작 점 · 「○○ 안 · 레벨 N」 · 들어갈 폴더 줄 테두리 · 끄는 줄 흐림 - 데이터 점검 [업데이트] 위 틈 = 카드 왼쪽 여유 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B09_Estimation/B09_Estimation_UI_Page.css | 52 +++++++++++++++--- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 3 +- .../B09_Estimation_UI_Tabs_Bill_Drag.ts | 31 ++++++++++- resources/tester/test_182_b09_bill_level.py | 2 +- .../tester/test_228_1_table_level_font.py | 2 +- .../test_240_1_b09_bill_folder_font_drag.py | 55 +++++++++++++++++++ ui_template/ui_template_data_table.css | 4 +- 7 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 resources/tester/test_240_1_b09_bill_folder_font_drag.py diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 0b6118118..56f923c40 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -66,11 +66,6 @@ font-size: var(--text-caption); } -/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */ -.b09-estimation .ui-data-table__table tbody tr.is-group td { - font-weight: var(--font-weight-bold); -} - /* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */ .b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child { background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); @@ -163,6 +158,11 @@ background: var(--color-warning); } +/* 122 데이터 점검 [업데이트] — 제목 아래 틈 = 카드 왼쪽 여유와 같게 */ +.b09-options [data-b09-refresh] { + margin-top: calc(var(--spacing-8) + var(--spacing-4)); +} + /* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */ .b09-check__head, .b09-check__note { @@ -179,13 +179,51 @@ /* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */ .b09-estimation__drop { + --drop-c: var(--color-primary); position: fixed; z-index: 1000; height: 0; - border-top: 2px solid var(--color-primary); + border-top: 2px solid var(--drop-c); + color: var(--drop-c); pointer-events: none; } .b09-estimation__drop.is-bad { - border-top: 2px dashed var(--color-danger); + --drop-c: var(--color-danger); + border-top-style: dashed; +} + +/* 121 선 머리 = 레벨 시작 점 + 「어디 안 · 몇 레벨」 · 들어갈 폴더 줄 = 테두리 · 끄는 줄 = 흐리게 */ +.b09-estimation__drop::before { + content: ""; + position: absolute; + top: -6px; + left: -5px; + width: 8px; + height: 8px; + border: 2px solid; + border-radius: 50%; + background: var(--color-canvas); +} + +.b09-estimation__drop-tag { + position: absolute; + top: -11px; + padding: 1px var(--spacing-8); + border: 1px solid; + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + font-size: var(--text-caption); + white-space: nowrap; +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-drop-into td { + box-shadow: + inset 0 2px 0 var(--color-primary), + inset 0 -2px 0 var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 14%, transparent); +} + +.b09-estimation [data-b09-table="bill"] tbody tr.is-drag-src td { + opacity: 0.45; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index b5f0af7df..2fac2632e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -99,7 +99,8 @@ export const buildBill: TabBuild = (ctx): BillTable => { rowClass: (i) => { const r = rows[i]; if (!r) return null; - const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : ""; + // 240 폴더 줄 = 처음 · 레벨 고쳐 생긴 것 모두 같은 줄 꼴(글자 크기 = 레벨 · 공용 is-lvN) + const title = r.kind === "item" ? "" : "is-title"; return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null; }, // 228-1 레벨 = 셈 나무 깊이(들여 · 내어쓰기 · 끌기 = 다시 셈 · 다시 그림) · 글꼴 = 레벨 · 묶음(빈 공종도) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts index 786ccc285..951c43559 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts @@ -5,6 +5,7 @@ * 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음 * 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만 * 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed) + * 미리 보기(121) = 선 머리 글 「○○ 안 · 레벨 N」 + 들어갈 폴더 줄 테두리 + 끄는 줄 흐리게 · 좌우 한 단 = 들여쓰기 폭(--dt-indent) * 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름) * ========================================================================== */ @@ -64,9 +65,15 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri const tr = target.closest("tr[data-i]"); if (!tr || !root.contains(tr)) return; const from = Number(tr.dataset.i); - const step = parseFloat(getComputedStyle(tr).fontSize) || 14; + const step = parseFloat(getComputedStyle(tr).getPropertyValue("--dt-indent")) || 14; const line = document.createElement("div"); line.className = "b09-estimation__drop"; + const tag = document.createElement("span"); + tag.className = "b09-estimation__drop-tag"; + line.append(tag); + const rowEl = (i: number): HTMLElement | null => root.querySelector(`tr[data-i="${i}"]`); + let into: HTMLElement | null = null; + let lifted: HTMLElement[] = []; let on = false; let run: (() => void) | null = null; let seen = ""; @@ -76,6 +83,13 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri if (!on) { on = true; document.body.append(line); + const lv = Number(table.rows[from]?.level ?? 1); + for (let i = from; i === from || Number(table.rows[i]?.level ?? 1) > lv; i++) { + const el = rowEl(i); + if (!el) break; + el.classList.add("is-drag-src"); + lifted.push(el); + } document.body.style.userSelect = "none"; window.getSelection()?.removeAllRanges(); } @@ -95,14 +109,24 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri if (key !== seen) { seen = key; run = at ? table.drop!(from, at.parent, at.before) : null; + const p = at?.parent ? table.rows.findIndex((r) => String(r.key) === at.parent) : -1; + into?.classList.remove("is-drop-into"); + into = p >= 0 ? rowEl(p) : null; + into?.classList.add("is-drop-into"); + tag.textContent = !run + ? "못 놓음" + : `${p >= 0 ? `「${String(table.rows[p].name ?? "").trim()}」 안` : "맨 위"} · 레벨 ${at!.level}`; } + const cell = tr.querySelector("td.ui-data-table__lv") ?? tr; const box = root.getBoundingClientRect(); - const left = box.left + ((at?.level ?? want) - 1) * step + 8; + const left = cell.getBoundingClientRect().left + ((at?.level ?? want) - 1) * step + 8; Object.assign(line.style, { top: `${y - 1}px`, left: `${left}px`, width: `${Math.max(box.right - left, 40)}px`, }); + // 머리 글 = 명칭 칸 바로 오른쪽 · 선 위에 걸침(줄 이름을 덮지 않게) + tag.style.left = `${Math.max(12, cell.getBoundingClientRect().right - left + 8)}px`; line.classList.toggle("is-bad", !run); document.body.style.cursor = run ? "grabbing" : "not-allowed"; }; @@ -111,6 +135,9 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop); line.remove(); + into?.classList.remove("is-drop-into"); + for (const el of lifted) el.classList.remove("is-drag-src"); + lifted = []; document.body.style.userSelect = ""; document.body.style.cursor = ""; if (!on) return; diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index cccc9ecf4..1bac0f18f 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -311,7 +311,7 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함 assert out["cls"] == [ "is-title", - "is-group is-hand", + "is-title is-hand", "is-hand", None, ] # fmt: skip 201-1 레벨 = 공용 표 level diff --git a/resources/tester/test_228_1_table_level_font.py b/resources/tester/test_228_1_table_level_font.py index f098d0ae4..56bab2b46 100644 --- a/resources/tester/test_228_1_table_level_font.py +++ b/resources/tester/test_228_1_table_level_font.py @@ -88,7 +88,7 @@ def test_CSS_레벨_글꼴_규칙(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") tokens = dict(re.findall(r"(--dt-(?:lv\d-size|indent)): ([^;]+);", css)) assert tokens == { - "--dt-indent": "var(--text-body-sm)", + "--dt-indent": "22px", "--dt-lv1-size": "var(--text-body)", "--dt-lv2-size": "15px", "--dt-lv4-size": "13px", diff --git a/resources/tester/test_240_1_b09_bill_folder_font_drag.py b/resources/tester/test_240_1_b09_bill_folder_font_drag.py new file mode 100644 index 000000000..eda6e364f --- /dev/null +++ b/resources/tester/test_240_1_b09_bill_folder_font_drag.py @@ -0,0 +1,55 @@ +"""PLAN 240 · 121 · 122 — B09 설계내역서 폴더 줄 글꼴 한 벌 · 들여쓰기 폭 · 끌기 미리 보기 · 데이터 점검 틈.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" + + +def _read(p: Path) -> str: + return p.read_text(encoding="utf-8") + + +def test_폴더_줄은_처음이든_레벨_고쳐_생겼든_같은_줄_꼴(): + src = _read(B09 / "B09_Estimation_UI_Tabs_Bill.ts") + assert 'const title = r.kind === "item" ? "" : "is-title";' in src + assert '"is-group"' not in src + css = _read(B09 / "B09_Estimation_UI_Page.css") + assert "tr.is-group" not in css # 옛 묶음 줄 꼴(색 다름) 없음 + + +def test_들여쓰기_한_단_넓힘_끌기_한_단과_같음(): + css = _read(ROOT / "ui_template" / "ui_template_data_table.css") + assert re.search(r"--dt-indent: 22px;", css) + drag = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Drag.ts") + assert 'getPropertyValue("--dt-indent")' in drag + + +def test_끌기_미리_보기_어디_안_몇_레벨_폴더_테두리_끄는_줄_흐림(): + drag = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Drag.ts") + for part in ( + "b09-estimation__drop-tag", + "is-drop-into", + "is-drag-src", + "못 놓음", + "안", + "맨 위", + ): + assert part in drag, part + css = _read(B09 / "B09_Estimation_UI_Page.css") + for sel in (".b09-estimation__drop-tag", "tr.is-drop-into td", "tr.is-drag-src td"): + assert sel in css, sel + # 끝나면 표시를 모두 걷음 + assert 'into?.classList.remove("is-drop-into")' in drag + assert 'el.classList.remove("is-drag-src")' in drag + + +def test_데이터_점검_업데이트_위_틈_카드_왼쪽_여유와_같음(): + css = _read(B09 / "B09_Estimation_UI_Page.css") + m = re.search(r"\.b09-options \[data-b09-refresh\] \{\s*margin-top: ([^;]+);", css) + assert m and m.group(1) == "calc(var(--spacing-8) + var(--spacing-4))" + card = _read(ROOT / "ui_template" / "ui_template_overlay.css") + assert re.search( + r"\.ui-sidebar-card \{\s*padding: calc\(var\(--spacing-8\) \+ var\(--spacing-4\)\);", card + ) diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 42126928b..f726d0827 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -10,8 +10,8 @@ --dt-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface)); --dt-line: var(--color-border); --dt-edge: color-mix(in srgb, var(--color-text-secondary) 45%, var(--color-border)); - /* 들여쓰기 한 단 = 표 기본 글자 1em(레벨 글자 크기와 무관 · 끌기 한 단과 같음) */ - --dt-indent: var(--text-body-sm); + /* 들여쓰기 한 단(레벨 글자 크기와 무관 · 끌기 한 단과 같음 · 121 넓힘) */ + --dt-indent: 22px; /* 레벨 글자 크기(PLAN 228-1) — 줄 높이 28px 안 · Lv3 = 표 기본(14px) */ --dt-lv1-size: var(--text-body); --dt-lv2-size: 15px; From d416ac5922b736041617c54136ac1320b25de2ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:16:10 +0900 Subject: [PATCH 41/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20123?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index f97cfd322..8e0c50cb6 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -149,3 +149,4 @@ 120. B08의 에서 메인탭으로 거리이정표를 만들어주고 하위탭으로 거리이정표 하나더 추가 거리에 따라 별도로 추가 탭이 생성이 되면 됨. 생성되는 기준으로는 거리 이정표를 구현하여야함. (첨부 = 거리이정표 견본: 태원 · 주상기업 · 거창군청 · 병곡리 968-31 · 시점 · 중점 · 김천역 상자와 도로구분 km, 아래 표 = 구분(장비 거창군청→시점 20톤 · 8톤 / 혼합골재 골재원→임도중점 / 전석운반 골재원→임도중점 / 철근 김천역→임도중점) · 도로구분(2차포장 · 1차포장 · 비포장(1)) · 계(포장 · 비포장 · 합계) · 비고) 기본적으로 시점의 위치는 공사지의 시작 위치임. 중점은 사용자가 거리를 입력하는 위치 이두개는 하나의 컨테이너로 묶어서 공사지가 되고 공사지를 기준으로 특정 기준점이 있을수도 있고 없을 수도 있음. 이는 첨부사진의 병곡리가 됨. 특정기준점이나 공사지를 기준으로 거래업체를 다음 지도 API로 맵을 보여주고 길찾기를 하여 거리를 확보하고 이때 거리찾기한 항목을 각각의 하위탭으로 컨테이너 하나에 사진하나씩 배치해주면 됨. 여러개가 있을수 있으니 추가와 삭제 버튼은 있어야함. 121. B09의 설계내역 테이블을 확인했는데 제어가 어려웠던 이유는 레벨이동의 앞에 들여쓰기가 너무 좁아서 적당한 위치에 놓기 어려웠던것같아. 그러니 들여쓰기 폭을 조금 더 띄워주고 추가로 어디에 추가되는지 어디가 들어가는지가 불분명해. 조금더 잘보이게 표시해줘. 122. 추가로 B09의 좌측패널에서 데이터 점검 컨테이너와 업데이트 버튼 사이가 너무 협소해. 업데이트 좌측 여유만큼 상단에도 여유가 있었으면 함. +123. 다음으로 추가로 B08과 B09의 테이블의 내용중에 사용자가 입력가능한 셀영역을 셀색상을 바꾸는데 이것대신에 셀 외곽부에 테두리를 넣게 변경해줘. From 1268179cc8ec02c64eb47d7372e8241341dd5fe0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:17:00 +0900 Subject: [PATCH 42/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20123?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 8e0c50cb6..abff25cb0 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -150,3 +150,4 @@ 121. B09의 설계내역 테이블을 확인했는데 제어가 어려웠던 이유는 레벨이동의 앞에 들여쓰기가 너무 좁아서 적당한 위치에 놓기 어려웠던것같아. 그러니 들여쓰기 폭을 조금 더 띄워주고 추가로 어디에 추가되는지 어디가 들어가는지가 불분명해. 조금더 잘보이게 표시해줘. 122. 추가로 B09의 좌측패널에서 데이터 점검 컨테이너와 업데이트 버튼 사이가 너무 협소해. 업데이트 좌측 여유만큼 상단에도 여유가 있었으면 함. 123. 다음으로 추가로 B08과 B09의 테이블의 내용중에 사용자가 입력가능한 셀영역을 셀색상을 바꾸는데 이것대신에 셀 외곽부에 테두리를 넣게 변경해줘. +124. 입력셀 테두리 색상선정할때 블랙과 화이트테마를 잘 고려해줘. From e8c6e35443da44a61ce81ab929c01412a8966c6d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:18:38 +0900 Subject: [PATCH 43/92] =?UTF-8?q?style(table):=20B08=20=C2=B7=20B09=20?= =?UTF-8?q?=ED=91=9C=20=EC=9E=85=EB=A0=A5=20=EC=B9=B8=20=3D=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80=20=EC=83=89=20=EB=8C=80=EC=8B=A0=20=EC=B9=B8=20?= =?UTF-8?q?=EC=95=88=EC=AA=BD=20=ED=85=8C=EB=91=90=EB=A6=AC=20(241-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../tester/test_241_1_input_cell_border.py | 36 +++++++++++++++++++ ui_template/ui_template_data_table.css | 5 +-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_241_1_input_cell_border.py diff --git a/resources/tester/test_241_1_input_cell_border.py b/resources/tester/test_241_1_input_cell_border.py new file mode 100644 index 000000000..b40553c34 --- /dev/null +++ b/resources/tester/test_241_1_input_cell_border.py @@ -0,0 +1,36 @@ +"""PLAN 241-1 · 123 — B08 · B09 표 입력 칸 = 채움 색 대신 칸 안쪽 테두리.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = ROOT / "ui_template" / "ui_template_data_table.css" + + +def _rule(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_입력_칸_채움_색_없고_안쪽_테두리(): + body = _rule(CSS.read_text(encoding="utf-8"), ".ui-data-table__input") + assert "background: transparent;" in body + assert "color-mix" not in body # 옛 accent 5% 채움 없음 + assert "box-shadow: inset 0 0 0 1px var(--color-accent);" in body + assert "border: 0;" in body # 테두리는 inset — 칸 크기 · 줄 높이 불변 + assert "height: var(--dt-row-h);" in body + + +def test_초점_테두리_그대로(): + body = _rule(CSS.read_text(encoding="utf-8"), ".ui-data-table__input:focus") + assert "outline: 2px solid var(--color-focus-ring);" in body + assert "outline-offset: -2px;" in body + + +def test_고친_값_색_칸_그대로(): + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8") + body = _rule(css, ".b09-estimation .ui-data-table__table tbody td.is-edited") + assert "color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas))" in body + ts = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill.ts").read_text(encoding="utf-8") + assert '"is-edited"' in ts diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index f726d0827..0d20ed184 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -158,7 +158,7 @@ box-shadow: inset 0 -2px 0 var(--color-accent); } -/* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */ +/* 고칠 칸(PLAN 121 · 241-1) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 · 입력 칸 표시 = 채움 색 없이 칸 안쪽 테두리(칸 크기 불변) */ .ui-data-table__table td.is-edit { padding: 0; } @@ -168,7 +168,8 @@ height: var(--dt-row-h); padding: 0 var(--spacing-8); border: 0; - background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); + background: transparent; + box-shadow: inset 0 0 0 1px var(--color-accent); color: inherit; font: inherit; font-variant-numeric: tabular-nums; From 5102f775aecea9da61d469a4d2fa24e477ae7124 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:20:44 +0900 Subject: [PATCH 44/92] =?UTF-8?q?style(table):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=B9=B8=20=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=83=89=20=3D=20?= =?UTF-8?q?=EB=91=90=20=ED=85=8C=EB=A7=88=20=EB=B3=80=EC=88=98=20=ED=98=BC?= =?UTF-8?q?=ED=95=A9(accent=2060%=20=C2=B7=20canvas)=20(241-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- resources/tester/test_241_1_input_cell_border.py | 9 +++++++-- ui_template/ui_template_data_table.css | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_241_1_input_cell_border.py b/resources/tester/test_241_1_input_cell_border.py index b40553c34..6ab516573 100644 --- a/resources/tester/test_241_1_input_cell_border.py +++ b/resources/tester/test_241_1_input_cell_border.py @@ -16,8 +16,13 @@ def _rule(css: str, selector: str) -> str: def test_입력_칸_채움_색_없고_안쪽_테두리(): body = _rule(CSS.read_text(encoding="utf-8"), ".ui-data-table__input") assert "background: transparent;" in body - assert "color-mix" not in body # 옛 accent 5% 채움 없음 - assert "box-shadow: inset 0 0 0 1px var(--color-accent);" in body + assert "background: color-mix" not in body # 옛 accent 5% 채움 없음 + # 241-2 두 테마 — 진한 accent 60% + 캔버스(밝음 · 어두움 모두 캔버스 대비 3:1 넘음 · 초점 2px 와 갈림) + assert ( + "box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));" + in body + ) + assert "#" not in body # 하드코딩 색 없음 — 테마 변수만 assert "border: 0;" in body # 테두리는 inset — 칸 크기 · 줄 높이 불변 assert "height: var(--dt-row-h);" in body diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 0d20ed184..6cc109a0f 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -169,7 +169,7 @@ padding: 0 var(--spacing-8); border: 0; background: transparent; - box-shadow: inset 0 0 0 1px var(--color-accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas)); color: inherit; font: inherit; font-variant-numeric: tabular-nums; From 402700519344603af802562b2d1a46ca34455f48 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:26:51 +0900 Subject: [PATCH 45/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20125?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index abff25cb0..41c5593ae 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -151,3 +151,4 @@ 122. 추가로 B09의 좌측패널에서 데이터 점검 컨테이너와 업데이트 버튼 사이가 너무 협소해. 업데이트 좌측 여유만큼 상단에도 여유가 있었으면 함. 123. 다음으로 추가로 B08과 B09의 테이블의 내용중에 사용자가 입력가능한 셀영역을 셀색상을 바꾸는데 이것대신에 셀 외곽부에 테두리를 넣게 변경해줘. 124. 입력셀 테두리 색상선정할때 블랙과 화이트테마를 잘 고려해줘. +125. B09에서 총괄설계내역서의 내용을 선택하게 되면 설계내역서의 해당항목을 하이라이트 되는데 이때 하이라이트는 해당 항목의 하위 내용까지 같이 범위로 지정해줘야해. 다음으로 그냥 행선택과 하이라이트가 중복으로 표기되는데 하나만 표기되게 해줘. 하이라이트 되었더라도 다른 행을 선택하면 하이라이트가 취소되어야하고 이전에 선택된 행이 있더라도 총괄설계내역서에서 선택하면 선택행이 취소되고 하이라이트되어야함. From 4689576d7dbb2b20fed5f0e4486083e964115ebf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:29:45 +0900 Subject: [PATCH 46/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20126?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 41c5593ae..116ad3ce2 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -152,3 +152,4 @@ 123. 다음으로 추가로 B08과 B09의 테이블의 내용중에 사용자가 입력가능한 셀영역을 셀색상을 바꾸는데 이것대신에 셀 외곽부에 테두리를 넣게 변경해줘. 124. 입력셀 테두리 색상선정할때 블랙과 화이트테마를 잘 고려해줘. 125. B09에서 총괄설계내역서의 내용을 선택하게 되면 설계내역서의 해당항목을 하이라이트 되는데 이때 하이라이트는 해당 항목의 하위 내용까지 같이 범위로 지정해줘야해. 다음으로 그냥 행선택과 하이라이트가 중복으로 표기되는데 하나만 표기되게 해줘. 하이라이트 되었더라도 다른 행을 선택하면 하이라이트가 취소되어야하고 이전에 선택된 행이 있더라도 총괄설계내역서에서 선택하면 선택행이 취소되고 하이라이트되어야함. +126. 다음으로 B08과B09의 테이블 내용에서 수기입력 방식은 더블클릭으로 변경해줘. 모든 값들은 쉽게 변경되면 안되거든. From 5c1035a5e8ddf2251fb28ed74bca5d6275533354 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:31:43 +0900 Subject: [PATCH 47/92] =?UTF-8?q?fix(b09):=20=EC=B4=9D=EA=B4=84=20?= =?UTF-8?q?=E2=86=92=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=95=20=3D=20=EA=B7=B8=20=EC=A4=84=20+=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EC=A0=84=EB=B6=80=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=ED=91=9C=EC=8B=9C=EC=99=80=20=ED=95=9C=EB=95=8C=20?= =?UTF-8?q?=ED=95=98=EB=82=98=20=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20?= =?UTF-8?q?=C2=B7=20=EB=B0=A9=ED=96=A5=ED=82=A4=20=C2=B7=20Esc=20=3D=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=95=20=ED=92=82=20(242-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Shell.ts | 12 +- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 38 ++++- .../tester/test_200_2_b09_bill_level_bar.py | 4 +- .../test_239_1_b09_total_bill_summary.py | 4 +- .../tester/test_242_1_b09_bill_jump_range.py | 130 ++++++++++++++++++ ui_template/ui_template_data_table.ts | 8 +- 6 files changed, 179 insertions(+), 17 deletions(-) create mode 100644 resources/tester/test_242_1_b09_bill_jump_range.py diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 1701a2517..af6d31de2 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -52,7 +52,7 @@ import { } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; -import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; +import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { buildSumExpense, buildSumLabor, @@ -262,19 +262,17 @@ export function renderB09Estimation(root: HTMLElement): void { ); } // 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력 - // 239-1 이동해 온 줄 = 빨강 외곽선(목록표 → 탭 길 is-pick · 고른 줄 상자와 따로) + // 239-1 · 242-1 이동해 온 줄 = 그 줄 + 자손 빨강 외곽선(is-pick) · 고른 줄 상자와 한때 하나(오면 고름 풂) const bill = (spec as BillTable).menu ? (spec as BillTable) : null; const at = focus === undefined ? -1 : rowIndex(spec.rows, focus); - const jump: [number, number] | null = at >= 0 ? [at, at] : null; + if (bill) jumpTo(bill.rows, at); const table = makeTable( - bill - ? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)].filter(Boolean).join(" ") || null - : undefined, + bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined, ); const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; if (level) bars.setTools(level.tools); panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : [])); - if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); + if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강) }; const apply = (next: Row, fromServer: boolean): void => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 742314f4e..aff2f53c6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -10,6 +10,8 @@ * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) * 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄) * 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름 + * 242-1 이동해 온 범위(총괄 등 → 그 줄 + 자손 · 빨강 is-pick) = 고름과 한때 하나 — 오면 고름 풂 · + * 줄 누름 · 방향키 · Esc · 끌어 놓음 = 빨강 풂 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -29,6 +31,8 @@ const ACTS: [string, string][] = [ const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력"; let picked: string | null = null; +/** 242-1 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */ +let jump: [number, number] | null = null; /** 지금 그린 도구 줄 — 키가 부름 */ let live: { root: HTMLElement; @@ -44,6 +48,23 @@ let keysOn = false; export const pickedClass = (row: DataRow | undefined): string | null => row && picked !== null && String(row.key) === picked ? "is-focus" : null; +/** 그 줄 + 자손(뒤 줄 중 레벨이 더 깊은 연속 줄) 끝 줄 번호 */ +export function subtreeEnd(rows: DataRow[], at: number): number { + const lv = Number(rows[at]?.level ?? 1); + let end = at; + while (end + 1 < rows.length && Number(rows[end + 1]?.level ?? 1) > lv) end++; + return end; +} + +/** 242-1 이동해 온 줄(-1 = 없음) — 범위 = 그 줄 + 자손 · 오면 고름 풂(Shell 이 표 그리기 전에) */ +export function jumpTo(rows: DataRow[], at: number): void { + jump = at >= 0 ? [at, subtreeEnd(rows, at)] : null; + if (jump) picked = null; +} + +/** 지금 빨강 범위 — Shell 줄 class 곁값(pickClass) */ +export const jumpRange = (): [number, number] | null => jump; + /** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */ export function menuActs(table: BillTable, index: number): Map void> { const items = index >= 0 && table.menu ? table.menu(index) : []; @@ -72,7 +93,7 @@ function listenKeys(): void { if (keysOn) return; keysOn = true; document.addEventListener("keydown", (event) => { - if (!live?.root.isConnected || picked === null) return; + if (!live?.root.isConnected || (picked === null && !jump)) return; const at = document.activeElement; if ( at && @@ -81,6 +102,12 @@ function listenKeys(): void { !live.tools.includes(at as HTMLElement) ) return; + // 242-1 빨강 중 = 방향키 · Esc 가 빨강만 풂(고른 줄 없음) + if (picked === null) { + if (event.key !== "Escape" && !keyAct(event, editing(at))) return; + jump = null; + return live.sync(); + } // Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂 if (event.key === "Escape") { if (!editing(at)) { @@ -130,6 +157,9 @@ export function levelBar( const acts = menuActs(table, index()); ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label))); for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus"); + if (!jump) + for (const tr of root.querySelectorAll("tr.is-pick")) + tr.classList.remove("is-pick", "is-pick-top", "is-pick-end"); root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus"); }; root.addEventListener("click", (event) => { @@ -137,9 +167,13 @@ export function levelBar( const row = tr ? table.rows[Number(tr.dataset.i)] : undefined; if (!row) return; picked = String(row.key); + jump = null; sync(); }); - attachDrag(root, table, (key) => (picked = key)); + attachDrag(root, table, (key) => { + picked = key; + jump = null; + }); sync(); live = { root, tools: buttons, run, sync, edit, index }; listenKeys(); diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 3c41ac20f..8da7177e0 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -197,8 +197,8 @@ def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표(): def test_셸_배선(): src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)]" in src - ) # 239-1 이동 줄 빨강 곁 + "bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src + ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") diff --git a/resources/tester/test_239_1_b09_total_bill_summary.py b/resources/tester/test_239_1_b09_total_bill_summary.py index 51fd8e161..4e9958014 100644 --- a/resources/tester/test_239_1_b09_total_bill_summary.py +++ b/resources/tester/test_239_1_b09_total_bill_summary.py @@ -107,8 +107,8 @@ def test_누름_설계내역서_그_줄_빨강(): assert r["link"] is None # 원가계산 줄 = 이동 없음(그대로) assert out["pick"] == [None, "is-pick is-pick-top is-pick-end", None] shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "const jump: [number, number] | null = at >= 0 ? [at, at] : null;" in shell - assert "[pickedClass(bill.rows[i]), pickClass(jump, i)]" in shell + assert "if (bill) jumpTo(bill.rows, at);" in shell # 242-1 범위 = 그 줄 + 자손 + assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell def test_설계내역서_없으면_원가계산_줄_그대로(): diff --git a/resources/tester/test_242_1_b09_bill_jump_range.py b/resources/tester/test_242_1_b09_bill_jump_range.py new file mode 100644 index 000000000..cf65f27a4 --- /dev/null +++ b/resources/tester/test_242_1_b09_bill_jump_range.py @@ -0,0 +1,130 @@ +"""242-1 B09 총괄 → 설계내역서 빨강 = 그 줄 + 하위 전부 · 고름 표시와 한때 하나. + +- 이동해 온 줄(Shell show focus) = jumpTo — 범위 = 그 줄 + 뒤 줄 중 레벨이 더 깊은 연속 줄 · + 줄 class = is-pick(첫 줄 is-pick-top · 끝 줄 is-pick-end) · 바깥 테두리 한 벌(181-2 CSS) +- 오면 고른 줄(is-focus) 풂 · 공용 표 focusRow(at, false) = 스크롤만(공용 강조 안 붙임) +- 설계내역서에서 줄 누름(범위 안 · 밖) · 방향키 · Esc · 끌어 놓음 = 빨강 풂 · 누른 줄만 고름 상자 +- 표 · 문서 = 가짜 DOM(200-2 시험 틀) · 줄 class = Shell 과 같은 곁값(pickedClass ?? pickClass) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import ROWS, _node, _ts # noqa: E402 +from test_200_2_b09_bill_level_bar import BAR_JS, SRC # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" +GROUPS = (B09 / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8") +PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0) + +SETUP = ( + "let focusAt = null;\n" + + BAR_JS.split("const out = {};")[0] + # 가짜 DOM 보강 — class 여럿 지움 · tr. 고르기 + .replace( + "remove: (c) => s.delete(c) }", + "remove: (...c) => c.forEach((x) => s.delete(x)) }", + ) + .replace( + 'querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); }', + "querySelectorAll(sel) { const c = sel.match(/tr\\.([\\w-]+)/)[1];" + ' return this.all().filter((n) => n.tagName === "TR" && n.cls.has(c)); }', + ) + # Shell 차례 — jumpTo → 줄 그림(줄 class 곁값) → levelBar + .replace( + ' root = new FakeEl("div");', + " jumpTo(table.rows, focusAt ? table.rows.findIndex((r) => r.key === focusAt) : -1);\n" + ' root = new FakeEl("div");', + ) + .replace( + "td.append(input); tr.append(td); root.append(tr);", + "td.append(input); tr.append(td); root.append(tr);" + " const c = pickedClass(r) ?? pickClass(jumpRange(), i); if (c) tr.className = c;", + ) +) + +for patch in ["remove: (...c)", "querySelectorAll(sel)", "jumpTo(table.rows", "jumpRange(), i)"]: + assert patch in SETUP, patch # 200-2 틀이 바뀌면 여기서 멈춤 + +RUN_JS = """ +const marks = () => Object.fromEntries(root.kids.flatMap((tr, i) => { + const c = [...tr.cls].filter((x) => x.startsWith("is-")).sort().join(" "); + return c ? [[table.rows[i].key, c]] : []; +})); +const out = {}; +const go = (k) => { focusAt = k; user = {}; draw(); focusAt = null; }; +go("sec:1.2"); +out.range = [jumpRange(), marks()]; +out.ends = ["sec:1", "earth:4", "hand:g", "fee"].map((k) => subtreeEnd(table.rows, at(k))); +pick("earth:5"); +out.inside = [jumpRange(), marks()]; +go(null); pick("earth:4"); +out.before = marks(); +go("sec:1"); +out.came = [jumpRange(), marks(), table.rows.map((r) => pickedClass(r)).filter(Boolean)]; +pick("mat:M1"); +out.outside = [jumpRange(), marks()]; +go(null); +out.redraw = marks(); +go("sec:1.2"); root.focus(); +out.arrow = [key("ArrowDown"), jumpRange(), marks(), order()]; +go("sec:4"); root.focus(); +out.esc = [key("Escape"), jumpRange(), marks()]; +go("sec:4"); root.focus(); +out.other = [key("a"), jumpRange()]; +console.log(JSON.stringify(out)); +""" + +PICKED = {"sec:1.2": "is-pick is-pick-top", "earth:4": "is-pick", "earth:5": "is-pick", + "hand:g": "is-pick", "hand:a": "is-pick", "hand:b": "is-pick is-pick-end"} # fmt: skip + + +def _run(): + body = (SETUP + RUN_JS).replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + return json.loads(_node(_ts(SRC) + PICK + body)) + + +def test_범위_그_줄_자손_전부_바깥_테두리_한_벌(): + out = _run() + assert out["range"] == [[1, 6], PICKED] + # sec:1 = 레벨 1 아래 전부 · 항목 = 자기만 · 묶음 = 손 줄까지 · 끝 줄 = 자기 + assert out["ends"] == [6, 2, 5, 11] + + +def test_표시_하나_총괄에서_오면_고름_풂(): + out = _run() + assert out["before"] == {"earth:4": "is-focus"} + rng, marks, picked = out["came"] + assert rng == [0, 6] and picked == [] + assert all("is-focus" not in c for c in marks.values()) + assert marks["sec:1"] == "is-pick is-pick-top" and marks["hand:b"] == "is-pick is-pick-end" + + +def test_다른_줄_누름_빨강_풂_그_줄만_고름(): + out = _run() + assert out["inside"] == [None, {"earth:5": "is-focus"}] # 범위 안 줄 + assert out["outside"] == [None, {"mat:M1": "is-focus"}] # 범위 밖 줄 + assert out["redraw"] == {"mat:M1": "is-focus"} # 다시 그려도 고름만 + + +def test_방향키_Esc_빨강만_풂_다른_키_그대로(): + out = _run() + assert out["arrow"] == [False, None, {}, []] # 줄 안 옮김(고른 줄 없음) + assert out["esc"] == [False, None, {}] + assert out["other"] == [False, [7, 8]] + + +def test_Shell_배선_공용_표_스크롤만(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (bill) jumpTo(bill.rows, at);" in shell + assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell + assert "table.focusRow(at, !bill)" in shell + common = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") + assert "focusRow: (index: number, mark?: boolean) => void;" in common + assert "state.focus = mark ? index : null;" in common + level = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") + assert 'tr.classList.remove("is-pick", "is-pick-top", "is-pick-end")' in level diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 8cd212f94..99c6fde91 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -93,8 +93,8 @@ export interface DataTableOptions { export interface DataTable { root: HTMLElement; setRows: (rows: DataRow[], total?: DataRow | null) => void; - /** 그 줄로 스크롤하고 강조 */ - focusRow: (index: number) => void; + /** 그 줄로 스크롤하고 강조(mark = false → 스크롤만 · 강조 풂) */ + focusRow: (index: number, mark?: boolean) => void; /** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */ edit: (index: number, key?: string) => boolean; } @@ -534,8 +534,8 @@ export function createDataTable(opts: DataTableOptions): DataTable { if (total !== undefined) state.total = total; draw(true); }, - focusRow(index) { - state.focus = index; + focusRow(index, mark = true) { + state.focus = mark ? index : null; root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3); draw(true); }, From f1f988b9f9de29c0832578d0ac780413e13cf2f3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:31:45 +0900 Subject: [PATCH 48/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=A1=B0=EC=82=AC?= =?UTF-8?q?=20AH(=EA=B1=B0=EB=A6=AC=EC=9D=B4=EC=A0=95=ED=91=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/조사_AH_거리이정표.md | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AH_거리이정표.md diff --git a/docs/raw/요구_1010_2/조사_AH_거리이정표.md b/docs/raw/요구_1010_2/조사_AH_거리이정표.md new file mode 100644 index 000000000..957983878 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AH_거리이정표.md @@ -0,0 +1,92 @@ +# 조사 AH — B08 거리이정표 메인 탭 · 경로 하위 탭 · 다음(카카오) 지도 (120) (10-10 · 읽기 전용 조사 에이전트) + +약칭: PG `B08_Quantity/B08_Quantity_UI_Page.ts` · OP `B08_Quantity/B08_Quantity_UI_Options.ts` · TB `B09_Estimation/B09_Estimation_UI_TabBar.ts` · BI `B09_Estimation/B09_Estimation_Bill_Items.py` · SG `B09_Estimation/B09_Estimation_Sangeun.py` · RG `common_util/common_util_route_geometry.py` · CRS `common_util/common_util_crs.py` · MAP `B01_Dashboard/B01_Dashboard_Map.py` · L10 `resources/master_data/로직_산림품셈_10장_자재․장비_운반.json` · 견본 = `resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서/…내역+수량.xlsx`(병곡리 968-31 견본의 원본 · 「거리이정표」 시트 A2:AI30) + +## 1. 한눈 결론 + +1. B08 탭은 공용 탭 양식 없이 페이지마다 `createButton` 줄로 만듦(PG:30-35 · 118-134). 메인 탭 하나 = 배열에 한 줄 + `renderTab` 가지 한 줄 + 글자 키 하나. 사용자가 늘리고 줄이는 하위 탭 선례는 프로그램 어디에도 없음 — 새로 만듦. +2. 거리 km · 도로 구분(포장 · 비포장)을 받는 칸은 프로그램에 없음. B09 운반 줄 6개는 「운반거리 원천 없음」으로 빈칸(BI:21 · 50-57), B09 산근은 운반 산근을 만들지 않음(SG:22). 거리이정표가 그 원천 자리. +3. 시점 좌표 = 설계 노선 CSV 첫 점(프로젝트 작업 좌표계 · m). 중점 = 측점 거리 → 좌표 보간 함수가 이미 있음(RG:572 · 593). 위경도 변환 = pyproj(설치됨 · 선례 있음). +4. 지도는 지금 VWorld 하나뿐(서버가 타일 · 주소검색을 대리). 카카오 · 다음은 코드 · `.env` 어디에도 없음 → 키부터 새로 받아야 함. +5. 카카오 길찾기 응답 = 총 거리 · 도로명별 거리 · 좌표뿐. 도로 종류 · 차로 · 포장 정보 없음 → 2차포장 · 1차포장 · 비포장 나누기는 사용자 입력이 맞음. +6. 견본 엑셀은 1차 · 2차를 「포장」 한 칸으로 합쳐 단가산출에 씀(L1 포장 · L2 비포장 · 속도 V1~V4). 단가에 쓰이는 축은 포장 · 비포장 둘. + +## 2. 지금 B08 화면 짜임 · 붙일 자리 + +- 메인 탭 = `TABS` 넷(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · PG:30-35) · `select()`가 단추 줄을 다시 그리고 `renderTab()`(PG:86-105)이 탭마다 새 몸통을 붙임(늦은 응답이 남의 탭을 덮지 않게 함). +- 하위 탭 = 같은 꼴의 sm 단추 줄 — 자재 `SHEETS`(PG:40-83) · 토공 `SHEETS` 넷(`B08_Quantity_UI_Earthwork.ts:240-245 · 292-320`). 하위 탭이 없는 탭은 높이만 채우는 빈 줄(PG:90-100). +- B09 는 두 층 탭 줄을 묶은 `createTabBars`(TB:35-77)가 있으나 B09 전용 · 탭 목록 고정. +- 좌측 패널 = 「메인 탭과 같은 컨테이너」(OP:2 · `card()` OP:84-93) + [저장] 한 줄 · 캐시 `b08-options`(sessionStorage · `A00_Common/b_page_state.ts` `readState`·`writeState`) → [저장] PUT. +- 붙일 자리: ① PG `TABS` 다섯째 `milestone` + `renderTab` 가지 + 글자 키(`ui_template_locale_b4.ts` B08 줄 옆) ② 새 파일 하나에 하위 탭 줄 — 첫 탭 「거리이정표」(고정) · 다음부터 경로 탭(업체마다) · 줄 끝 [+ 추가] · [삭제] ③ OP 에 「거리이정표」 컨테이너(공사지 · 기준점 칸). PG 는 154줄이라 넉넉함. + +## 3. 지금 프로그램 안 거리 · 운반 값 + +| 자리 | 단위 · 값 | 받는 곳 | 이정표와 관계 | +|---|---|---|---| +| B08 토공 평균운반거리 · 무대운반 | m · 유토곡선 배분(`mass_haul.haul_plan`) | B05~B07 자동 | 현장 안 흙 운반 — 별개 | +| `project_settings.json › quantity` `spoil_site_distance_m` · `topsoil_haul_distance_m` | m · 기본 None | 칸만 있고 읽는 곳 없음 | 사토장 · 표토 거리 — 별개 | +| B09 운반 줄(중기 · 기계 · 모래 · 자갈 · 시멘트 · 철근운반 · BI:50-57) | 수량만 B08 총괄자재 · 단가 「연결 없음」(BI:21) | — | **잇는 자리 후보 1** | +| B09 단가산출근거(산근) | 「조합 밖 운반 산근 = 원천 없음」(SG:22) | — | **후보 2** — 산근 입력 L(포장) · L(비포장) | +| M01 로직(L10) | GF000136 혼합골재 운반 = 입력 Cm(분) · GF000137 중기운반 = 입력 t2(분) · GF000284 덤프운반 = 운반거리 ㎞ 하나 | 사용자 · 견본 예시값 | **후보 3** — 포장 · 비포장 둘로 나눈 입력이 없음(견본 꼴로 가려면 입력 늘림) | +| 구조물정의 · M02 조합 | 거리 칸 없음 | — | 없음 | + +- 견본 단가산출(1315-1339행) 혼합골재 운반: L1 포장 25.1 km(= 이정표 O25) · L2 비포장 0.8 km(= R25) · V1 30 · V2 35 · V3 10 · V4 15 km/h · t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4)×60 = 101.23 분. 시트끼리 수식 연결 없음 — 손으로 옮겨 적음. +- 속도 근거 = 건설표준품셈 8-2-8 덤프트럭 「운반도로와 평균주행속도」(`resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계/8-02-08_덤프트럭.md:44-66`) — 2차로 이상 공사용도로 30 · 35 / 교차 힘든 산간지도로 10 · 15. 임도 안 토사 덤프는 QF000337 V1 5 · V2 6. +- 견본 표 수식: 장비(거창군청→시점) = 2차 23.8 + 1차 0.6 + 비포장 0.8 · 자재(골재원 · 김천역→임도중점) 비포장 = 0.8 + X10(시점→중점 · 견본은 빈칸) · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장. + +## 4. 시점 · 중점 좌표 + +- 설계 노선 정본 순서(RG:300-305): `B05_Profile/route/planned_route.csv`(수정본) → `planned_route_initial.csv` → 예상노선 → `initial_snapshot/planned_route.csv`. 열 = sequence · x · y · 값은 프로젝트 작업 좌표계(m · storage 견본 x≈208,403 · y≈367,920). +- 좌표계 = `project_epsg_from_prj(root)` · 사다리 창구 `resolve_project_crs`(CRS:231-261 · 폴백 EPSG:5186). DB `projects.crs_epsg`(기본 5178)는 라벨일 뿐 · 실좌표와 다른 사례 기록 있음. +- 위경도 = `pyproj.Transformer.from_crs(작업 좌표계, "EPSG:4326", always_xy=True)` — 선례 `B04_PreProcess_Engine_Extent.py:189` · `B04_PreProcess_Engine_VWorld.py:96`. 전용 함수는 없으나 한 줄. +- 중점: `build_route_vertices`(RG:572) + `interpolate_vertex(정점, 누가거리 m)`(RG:593)로 측점 거리 → x · y. 「사용자가 거리 입력」 그대로 가능 · 기본 후보 = 노선 연장 ÷ 2(「임도중점」). +- 시점→중점 km = 그 측점 거리 그대로(임도 위 = 비포장). 견본 X10 칸의 자리. +- 기준점(병곡리 968-31) 검색 씨앗 = `projects.region`(지역명) · 지번 주소 검색은 VWorld · 카카오 둘 다 됨. + +## 5. 지금 지도 쓰임 · 키 + +- B01 회사 주소 지도: VWorld 주소검색 · 후보 검색 · WMTS 배경 타일을 서버가 대리(MAP · `GET /dashboard/map/tile/{z}/{x}/{y}` `B01_Dashboard_Router.py:213`) · 화면 = 라이브러리 없이 타일 3×3 img(`B01_Dashboard_UI_MapPreview.ts` · Web Mercator 타일 수식 `tileIndex` :21) · 주소 칸 `B01_Dashboard_UI_AddressField.ts`. +- B04 · B05 지도: 자체 Canvas 그리기 + VWorld(`B04_PreProcess_UI_MapViewer.ts` · `B05_Profile_UI_RouteEdit_Recommend_Map.ts`). +- `maplibre-gl` = `package.json` 의존성 · vite alias 뿐 · import 0. Leaflet · OpenLayers · 카카오 · 다음 = 0. +- `.env` 지도 키 이름 = `VWORLD_LOGIN_ID` · `VWORLD_LOGIN_PW` · `VWORLD_SESSION_COOKIE` 뿐 · KAKAO · DAUM 없음. VWorld API 키는 `config_system` 에 없고 `B04_PreProcess_Engine_VWorld.py:22-29` 코드 안 폴백 값을 씀(값 생략). +- CSP 머리 없음 → 외부 `sdk.js` 로드 막힘 없음. Pillow 12.3 · pyproj 3.7 · httpx 설치됨. + +## 6. 다음(카카오) 지도 API 외부 조사 (2026-10 기준 문서) + +| 쓰임 | 길 · 조건 | 무료 · 요금 | 막힘 | +|---|---|---|---| +| 지도 보기 | JS SDK `dapi.kakao.com/v2/maps/sdk.js?appkey=` · JavaScript 키 · 키의 「JavaScript SDK 도메인」에 `http://localhost:5173` · 운영 주소 등록(프로토콜 · 포트까지 · 어긋나면 401) · 앱에서 카카오맵 사용 설정 ON · 선 = `kakao.maps.Polyline` | 일 300,000 | JS 키는 브라우저에 보이는 것이 전제 · 도메인 제한이 보호 | +| 주소 → 좌표 | `dapi.kakao.com/v2/local/search/address`(지번 · 도로명) · `…/search/keyword` · 좌표계 변환 `…/geo/transcoord`(WGS84 · WTM · TM 등) · 머리 `Authorization: KakaoAK {REST 키}` | 일 100,000 | FAQ 「로컬 API 응답 별도 저장 금지」 — 업체 좌표를 파일에 두면 회색 | +| 길찾기 | `apis-navi.kakaomobility.com/v1/directions` GET · origin · destination = 「경도,위도」 · 경유지 ≤ 5 · 응답 `summary.distance`(m) · `duration`(초) · `fare`(택시 · 통행료) · `sections[].roads[]` = name · distance · duration · traffic_speed · traffic_state · vertexes(경도 · 위도 평면 배열) · `guides[]` | 일 10,000 · 초과 8원/건 | 도로 종류 · 등급 · 차로 · 포장 필드 없음(`road_details` 는 추가 필드가 문서에 없음) · 상업 이용은 제휴 필요라는 답변이 있음(데브톡 · 엇갈림) | +| 지도 그림 | 정적 지도 `dapi.kakao.com/v2/maps/staticmap` · center · lv 1~15 · size ≤ 2048×1024 · markers ≤ 5 · 응답 = 이미지 바이트 · 로고 못 지움 | 일 1,000 · 초과 2원 | **선 · 경로 파라미터 없음** → 길 선은 서버가 덧그려야 함 · 레벨별 축척 문서 없음(맞춤 실측 필요) | +| 화면 캡처 | JS 지도 타일 = 다른 출처 이미지 | — | canvas 오염으로 `toDataURL` 막힐 공산(데브톡 미해결 문의) · 지도 이미지 저장 · 배포는 약관 회색 | +| 정책 | 2026-07-21 개편 — 계정에서 처음 켠 앱만 무료 쿼터 · 그 밖 · 초과 = 비즈월렛 유료 · 초과 시 429 | — | — | + +- 키 숨김: REST 키(주소 · 길찾기 · 정적 지도) = 서버만(`.env` 새 키) · JS 키 = 화면에 넘김(서버 설정 길 또는 Vite env). +- 길찾기 도착점이 임도 시점이면 API 가 가장 가까운 등록 도로에 붙임 → 마지막 비포장 · 임도 구간은 응답 거리에 안 들거나 어긋남 → 사용자 km 칸 필요. + +## 7. 도로 구분(2차포장 · 1차포장 · 비포장) + +- 응답으로 자동 판정 불가: roads 에는 도로명 · 거리 · 실시간 속도만 있음 · 「○○로」 이름으로 차로 수 · 포장 여부를 알 수 없음 · traffic_speed 는 교통 상태라 도로 등급 대용 아님. +- 맞는 방식 = 경로마다 km 칸 셋(2차포장 · 1차포장 · 비포장) 사용자 입력 · 길찾기 총 km 는 도움값(기본으로 2차포장 칸에 넣고 사용자가 나눔) · 도로명별 거리 목록을 곁에 보여 나누기 도움. +- 견본 짜임이 이를 받침: 업체→기준점 = 2차포장 하나 · 기준점→시점 = 1차포장(0.6) · 시점 쪽 = 비포장(0.8) — 1차 · 비포장은 업체가 달라도 같은 공통 구간 → 기준점이 있으면 공통 구간 km 를 한 번만 적음. + +## 8. 설계 갈래 · 추천 + +- 데이터 모양(한 파일): 공사지 = 시점 측점(0) · 중점 측점(m · 사용자) / 기준점 = 없음 또는 {이름 · 주소 · 기준점→시점 km 셋} / 경로 목록 = [{업체 이름 · 주소 · 도착(기준점 · 시점) · 길찾기 총 km · km 셋 · 쓰임 줄[{구분 · 도착 시점 · 중점 · 비고}] · 그림 파일 이름}]. +- 표 한 줄 = 경로 km 셋 + (기준점 경유면 기준점→시점 km) + (도착 = 중점이면 시점→중점 km 를 비포장에) · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 — 견본 수식과 같음. 장비 20톤 · 8톤처럼 한 업체 두 줄 = 쓰임 줄 둘. +- 정본 자리 = 프로젝트 storage `B08_Quantity/milestone/`(json 한 벌 + 경로 그림 png · 폴더가 목록 — B09 견적서 파일 길 `B09_Estimation_Router_Quotes.py` 꼴) · DB 표 없음. 초기값 층 없음(자동설계가 낳는 값 아님 · 시점 좌표는 읽을 때마다 노선에서). 캐시 `b08-milestone` → [저장]. 업체 좌표는 저장하지 않고 주소 글만(카카오 로컬 저장 금지 회피 · 열 때 다시 찾음). +- 계산 자리: 주소 검색 · 길찾기 · 시점 · 중점 위경도 · 경로 그림 = ③ 서버 단독(REST 키 숨김 · [길찾기] 누를 때만 왕복 · 조작 중 계산 아님) / 표 합 = ① 파이썬 · TS 짝(두 파일 머리 표기 + 거울 시험 · 화면 즉시 · [저장] 때 서버가 다시 셈) / 화면 지도 = 카카오 JS(그리기만). +- 경로 그림 갈래: **A 서버 그림** — VWorld 배경 타일(이미 서버 대리 · Web Mercator 수식 있음) + 길찾기 vertexes 선 + 업체 · 기준점 · 시점 표지 · Pillow · 같은 출처라 막힘 없음 · 카카오 저장 약관 비껴감 · 바탕이 다음 지도 아님 / **B 카카오 정적 지도 + 서버 덧그림** — 바탕 = 다음 지도 · 선 맞춤을 실측으로 · 저장 약관 회색 / **C 화면 캡처** — CORS 로 깨질 공산 / **D 사진 올리기** — 가장 단순. 추천 = 보기는 카카오 JS · 저장 그림은 A · D 를 늘 곁에. +- 화면: 메인 「거리이정표」 → 하위 탭 [거리이정표](상자 그림 SVG + 공용 표 자동 · 업체 → 기준점 → 시점 → 중점 · 선마다 도로 구분 · km) · [경로1 · 업체명] … · [+ 추가] · [삭제] / 경로 탭 = 컨테이너 하나(위 = 업체 · 주소 검색 · 도착 · [길찾기] · km 셋 · 쓰임 줄 · 아래 = 지도 + 그림 한 장) / 좌측 컨테이너 = 공사지(중점 측점) · 기준점(있음 · 없음 · 이름 · 주소 · 공통 km 셋). +- 새 파일 후보: 화면(하위 탭 · 경로 컨테이너) · 상자 그림 · 표 · 라우터(GET · PUT · 주소 · 길찾기 · 그림) · 합 짝(.py · .ts) · 시험 `resources/tester/`. 카카오 키가 오기 전에도 km 손 입력 · 사진 올리기로 먼저 쓸 수 있게 길찾기는 덧붙이는 칸으로. + +## 9. 사용자에게 물을 것 + +1. 경로 그림 바탕 — A 서버 그림(VWorld 바탕 + 길 선) vs B 다음 정적 지도 바탕. 추천 A + 사진 올리기(다음 지도는 화면 보기용). +2. 카카오 키 — 카카오 개발자 앱 하나(첫 앱이어야 무료) · JavaScript 키(도메인 `http://localhost:5173` + 운영 주소 등록) · REST API 키 발급은 사용자 몫. 판매 프로그램이면 카카오모빌리티 제휴 문의 필요 공산. 추천: 키 전에는 손 입력판부터. +3. 도로 구분 나누기 — 추천: 업체→기준점 길찾기 km 전부 「2차포장」 기본 + 사용자가 고침 · 기준점→시점 공통 구간(1차포장 · 비포장)은 기준점 칸에 한 번만 입력. +4. 표 「구분」 줄 — 견본 다섯(장비 20톤 · 8톤 · 혼합골재 · 전석운반 · 철근) 고정 vs 자유. 추천: 경로 탭 안 쓰임 줄 자유 + 견본 다섯을 고르기 목록으로. +5. 중점 기본값 — 추천: 노선 연장 ÷ 2 측점 기본 · 사용자가 고침 · 시점→중점 = 비포장으로 자동 더함. + +출처: [카카오 쿼터](https://developers.kakao.com/docs/ko/getting-started/quota) · [카카오맵 개요](https://developers.kakao.com/docs/ko/kakaomap/common) · [카카오맵 REST(정적 지도 · 좌표 변환 · 주소)](https://developers.kakao.com/docs/ko/kakaomap/rest-api) · [로컬 개요](https://developers.kakao.com/docs/ko/local/common) · [자동차 길찾기](https://developers.kakaomobility.com/guide/navi-api/directions) · [길찾기 가격](https://developers.kakaomobility.com/price) · [2026-07-21 개편 공지](https://devtalk.kakao.com/t/api-notice-on-new-kakao-map-api-features-and-free-quota-policy/150222) · [길찾기 무료 쿼터 답변](https://devtalk.kakao.com/t/api-directions-api/151192) · [JS SDK 도메인 401](https://devtalk.kakao.com/t/js-sdk-401-domain-mismatched-https-4fugfgg-anonymous-8081-exp-direct-caller-30/151184) · [지도 캡처 CORS 문의](https://devtalk.kakao.com/t/api/137347) From e3266a4eaa7b9e694e480281cc0d780c55bdb440 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:35:08 +0900 Subject: [PATCH 49/92] =?UTF-8?q?feat(table):=20B08=20=C2=B7=20B09=20?= =?UTF-8?q?=ED=91=9C=20=EC=88=98=EA=B8=B0=20=EC=9E=85=EB=A0=A5=20=3D=20?= =?UTF-8?q?=EB=8D=94=EB=B8=94=ED=81=B4=EB=A6=AD=20=C2=B7=20Enter=20=C2=B7?= =?UTF-8?q?=20F2=20=C2=B7=20=EA=B3=A0=EC=B9=A0=20=EC=B9=B8=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20=EB=8A=98=20=ED=91=9C=EC=8B=9C=20(243-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 표: 고칠 수 있는 칸(editOnDouble) td 에 is-can-edit 입력 칸 테두리 · 칸 누른 뒤 Enter · F2 = 그 칸 입력(keyEdit) - B09 Shell 표 만드는 길 한 곳 = editOnDouble 기본 · 설계내역서는 레벨 키 몫 - B08 자재 집계표 셋 = editOnDouble - 새 시험 test_243_1 · 235_1 Shell 배선 기대값 갱신 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B08_Quantity/B08_Quantity_UI_Material.ts | 1 + B09_Estimation/B09_Estimation_UI_Shell.ts | 4 +- resources/tester/test_235_1_b09_bill_tree.py | 2 +- .../tester/test_243_1_double_click_tables.py | 136 ++++++++++++++++++ ui_template/ui_template_data_table.css | 6 + ui_template/ui_template_data_table.ts | 25 +++- 6 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_243_1_double_click_tables.py diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 819d20049..849d7b6ea 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -367,6 +367,7 @@ export async function renderMaterialSheet( caption: BASIS, fillViewport: true, editable: (i, key) => built.editable(i, key), + editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침 onEdit: (i, key, text) => { const value = parse(text); if (value !== undefined) { diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 1701a2517..798d2e40a 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -204,7 +204,9 @@ export function renderB09Estimation(root: HTMLElement): void { empty: "", caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, - editOnDouble: spec.editOnDouble, + // 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫) + editOnDouble: spec.editOnDouble ?? true, + keyEdit: !(spec as BillTable).menu, onEdit: spec.onEdit, rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null diff --git a/resources/tester/test_235_1_b09_bill_tree.py b/resources/tester/test_235_1_b09_bill_tree.py index a7e94117d..46b549af0 100644 --- a/resources/tester/test_235_1_b09_bill_tree.py +++ b/resources/tester/test_235_1_b09_bill_tree.py @@ -345,7 +345,7 @@ def test_고른_줄_상자_테두리_테마_토큰(): def test_Shell_배선(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "editOnDouble: spec.editOnDouble," in shell + assert "editOnDouble: spec.editOnDouble ?? true," in shell # 243-1 기본 = 더블클릭 assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell for name in [ "ui_template/ui_template_data_table.ts", diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py new file mode 100644 index 000000000..08ea4367a --- /dev/null +++ b/resources/tester/test_243_1_double_click_tables.py @@ -0,0 +1,136 @@ +"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 늘 보임. + +- 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자 +- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(입력 칸 테두리) · 못 고치는 칸 · 옛 방식 표 = 없음 +- B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble +- 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로 +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import _node # noqa: E402 +from test_235_1_b09_bill_tree import DOM_JS, TABLE # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +HARNESS = DOM_JS.split("const columns")[0] + +SCENE_JS = """ +const columns = [{ key: "name", label: "명칭" }, { key: "qty", label: "수량", kind: "number" }]; +const rows = [{ name: "폴더", qty: null }, { name: "파일", qty: 3 }]; +const can = (html) => [...html.matchAll(/]*>([^<]*) m[1].includes("is-can-edit")).map((m) => m[2]); +const log = []; +const make = (extra = {}) => createDataTable({ columns, rows, editOnDouble: true, + editable: (i, k) => i === 1 && k === "qty", onCellClick: (i, k) => log.push(`click ${i}:${k}`), + onEdit: (i, k, v) => log.push(`edit ${i}:${k}=${v}`), ...extra }); +const key = (r, k, target, more = {}) => r.fire("keydown", { key: k, target, isComposing: false, ...more, + preventDefault() {}, stopPropagation() {} }); +const out = {}; +const t = make(); +const r = t.root; +out.can = can(r.html); +out.start = inputs(r); +r.fire("click", { target: target(r, 1, 1) }); +out.click = [inputs(r), [...log]]; +key(r, "F2", r); +out.f2 = inputs(r); +const typed = doc.activeElement; +doc.activeElement = r; // 초점 떠남 +r.fire("change", { target: typed }); +r.fire("focusout", { target: typed }); +const enter = () => { + r.fire("click", { target: target(r, 1, 1) }); + key(r, "Enter", r); +}; +setTimeout(() => { + out.afterEnd = inputs(r); + enter(); + out.enter = inputs(r); + const input = doc.activeElement; + key(r, "Enter", input, { isComposing: true }); + out.composing = doc.activeElement === input; + key(r, "Enter", input); + out.ends = doc.activeElement === r; + r.fire("focusout", { target: input }); + setTimeout(() => { + r.fire("click", { target: target(r, 0, 0) }); + key(r, "F2", r); + out.noEditCell = inputs(r); + const off = make({ keyEdit: false }); + off.root.fire("click", { target: target(off.root, 1, 1) }); + key(off.root, "F2", off.root); + out.keyEditOff = inputs(off.root); + off.root.fire("dblclick", { target: target(off.root, 1, 1) }); + out.keyEditOffDbl = inputs(off.root); + const old = createDataTable({ columns, rows, editable: (i, k) => i === 1 && k === "qty" }); + out.old = [inputs(old.root), can(old.root.html)]; + console.log(JSON.stringify(out)); + }, 5); +}, 5); +""" + + +def test_공용_표_더블클릭_Enter_F2_고칠_칸_테두리(): + out = json.loads(_node(TABLE + HARNESS + SCENE_JS)) + assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시 + assert out["start"] == [] # 처음 = 글 상자 없음 + assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로 + assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력 + assert out["afterEnd"] == [] # 초점 떠남 = 끝 + assert out["enter"] == ["1:qty"] # Enter = 누른 칸 입력 + assert out["composing"] is True # 한글 조합 중 Enter = 조합 끝(입력 유지) + assert out["ends"] is True # 조합 뒤 Enter = 입력 끝 + assert out["noEditCell"] == [] # 못 고치는 칸 = 안 열림 + assert out["keyEditOff"] == [] # keyEdit false = 표가 키를 안 받음 + assert out["keyEditOffDbl"] == ["1:qty"] # 더블클릭은 그대로 + assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음) + + +def test_고칠_칸_테두리_CSS_테마_변수만(): + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + m = re.search(r"\.ui-data-table__table td\.is-can-edit \{([^}]*)\}", css) + assert m + body = m.group(1) + # 글 상자와 같은 색(accent 60% · canvas) — 줄 고름 box-shadow 와 안 겹치게 outline 안쪽 + assert ( + "outline: 1px solid color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));" + in body + ) + assert "outline-offset: -1px;" in body + assert "#" not in body + + +def _sources(*folders: str) -> list[Path]: + return [p for f in folders for p in sorted((ROOT / f).glob("*.ts"))] + + +def test_B09_표_만드는_길_한_곳_더블클릭_기본(): + calls = [ + p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") + ] + assert calls == ["B09_Estimation_UI_Shell.ts"] + shell = (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8") + assert "editOnDouble: spec.editOnDouble ?? true," in shell + assert "keyEdit: !(spec as BillTable).menu," in shell # 설계내역서 Enter · F2 = 레벨 키 몫 + + +def test_B08_고칠_칸_있는_표_모두_editOnDouble(): + seen = 0 + for p in _sources("B08_Quantity"): + text = p.read_text("utf-8") + for call in re.split(r"createDataTable\(\{", text)[1:]: + block = call.split("\n });")[0] + if "editable:" in block: + seen += 1 + assert "editOnDouble: true" in block, p.name + assert seen == 1 # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + + +def test_고르기_메뉴_이동_그대로(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));" in shell + assert "spec.choices?.(Number(cell.parentElement?.dataset.i), column.key)" in shell diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 6cc109a0f..cc6dbc72d 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -176,6 +176,12 @@ text-align: right; } +/* 고칠 수 있는 칸(PLAN 243-1 · editOnDouble) — 글일 때도 같은 입력 칸 테두리(outline 안쪽 · 줄 고름 box-shadow 와 안 겹침) */ +.ui-data-table__table td.is-can-edit { + outline: 1px solid color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas)); + outline-offset: -1px; +} + .ui-data-table__input:focus { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 8cd212f94..ff90e7b12 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -17,6 +17,8 @@ * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 + * 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 243-1) · + * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(keyEdit false = 표 밖이 맡음) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ @@ -84,6 +86,8 @@ export interface DataTableOptions { }; /** 참이면 고칠 칸 = 글 · 더블클릭 · edit() 때만 글 상자(235-1) */ editOnDouble?: boolean; + /** 칸 누른 뒤 표에서 Enter · F2 = 그 칸 입력 시작(editOnDouble · 기본 참 · 거짓 = 표 밖이 키를 맡음) */ + keyEdit?: boolean; /** 지금 글 상자인 칸(editOnDouble · 표가 정함) */ editing?: { index: number; key: string } | null; /** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */ @@ -180,7 +184,7 @@ function rowHtml( more: RowMore = {}, ): string { let tr = ""; - const { lv, spans } = more; + const { lv, spans, can } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; const span = spans?.[j] ?? 1; @@ -206,6 +210,7 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), + can?.(key) && "is-can-edit", lvc.trim(), span > 1 && "is-merged", ] @@ -238,6 +243,8 @@ function rowHtml( interface RowMore { lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; + /** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시) */ + can?: (key: string) => boolean; } /** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ @@ -353,6 +360,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin { ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), + ...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}), }, ); } @@ -478,10 +486,12 @@ export function createDataTable(opts: DataTableOptions): DataTable { return [Number(row.dataset.i), column?.key ?? ""]; }; let lastKey = ""; + let lastRow: number | null = null; // 마지막 누른 줄 — Enter · F2 입력 시작 자리 root.addEventListener("click", (event) => { const at = cellAt(event.target); if (!at) return; lastKey = at[1]; + lastRow = at[0]; if (at[1]) state.onCellClick?.(at[0], at[1]); state.onRowClick?.(at[0]); }); @@ -505,9 +515,18 @@ export function createDataTable(opts: DataTableOptions): DataTable { if (at && state.editable?.(at[0], at[1])) edit(at[0], at[1]); }); // Enter = 입력 끝(한글 조합 중 Enter 는 조합 끝) — 표에 초점 · 키가 문서까지 가지 않게(다시 입력 시작 방지) + // 표에 초점이 있을 때 Enter · F2 = 누른 칸 입력 시작(keyEdit) root.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || event.isComposing) return; - if (!(event.target instanceof HTMLInputElement)) return; + if ((event.key !== "Enter" && event.key !== "F2") || event.isComposing) return; + if (!(event.target instanceof HTMLInputElement)) { + if (opts.keyEdit === false || event.target !== root || lastRow === null) return; + if (state.editable?.(lastRow, lastKey)) { + event.preventDefault(); + edit(lastRow, lastKey); + } + return; + } + if (event.key !== "Enter") return; event.preventDefault(); event.stopPropagation(); root.focus(); From ffd94423c689a5d7721f2a73fc60cb0f5987eed2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:40:16 +0900 Subject: [PATCH 50/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20120=20=EB=8B=B5=20=C2=B7=20127?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 116ad3ce2..2289cca3c 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -153,3 +153,5 @@ 124. 입력셀 테두리 색상선정할때 블랙과 화이트테마를 잘 고려해줘. 125. B09에서 총괄설계내역서의 내용을 선택하게 되면 설계내역서의 해당항목을 하이라이트 되는데 이때 하이라이트는 해당 항목의 하위 내용까지 같이 범위로 지정해줘야해. 다음으로 그냥 행선택과 하이라이트가 중복으로 표기되는데 하나만 표기되게 해줘. 하이라이트 되었더라도 다른 행을 선택하면 하이라이트가 취소되어야하고 이전에 선택된 행이 있더라도 총괄설계내역서에서 선택하면 선택행이 취소되고 하이라이트되어야함. 126. 다음으로 B08과B09의 테이블 내용에서 수기입력 방식은 더블클릭으로 변경해줘. 모든 값들은 쉽게 변경되면 안되거든. +120-답(조사 AH 물음 넷 답 · 원문). ① 경로 그림 = 「일단 브이월드에서 길찾기가 있는지 몰랐네? 있다면 브이월드도 좋아 벌써 API가 연결되어있거든.」 ② 도로 구분 = 「모두 사용자가 선택하게 해줘. 기점별로 어차피 드롭다운 메뉴를 지정하면 되지 않을까?」 ③ 표 구분 = 「구분에서 첫번째열은 항목: 수기입력, 2번째열은 업체이름: 수기입력, 세번째 열은 목적지임. 사용자는 다이어그램으로 표현된 컨테이너를 이용하여 추가수정할수 있게 하되 대신 방향은 기준점을 기준으로 지도상의 방위를 나타냄. 항목을 추가할때는 구분정보와 지도상의 위치값과 길찾기 값을 받아와야함.」 ④ 중점 = 「사용자가 입력할수 있게 해줘. 기본값은 도로의 중간지점으로 기본값으로 하고 사용자가 지정하게 하자.」 +127. 다음으로 공사원가계산서의 테이블에서 병합기능을 사용하였는데 행의 마우스 호버에 연동된 하이라이트 기능이 형태가 복잡해지고 있어. 개선해줘. From efe0eb54171734ef81318c391c698387771df4a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:43:52 +0900 Subject: [PATCH 51/92] =?UTF-8?q?style(table):=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=EC=B9=B8=20=3D=20=EC=A4=84=20=EA=B0=95=EC=A1=B0=20=EB=B0=96=20?= =?UTF-8?q?=C2=B7=20=ED=98=B8=EB=B2=84=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EC=A4=84=EB=AC=B4=EB=8A=AC=20=EC=95=88=20?= =?UTF-8?q?=EC=B9=A0=ED=95=98=EA=B3=A0=20=EA=B7=B8=20=EC=A4=84=20=EB=82=98?= =?UTF-8?q?=EB=A8=B8=EC=A7=80=20=EC=B9=B8=EB=A7=8C=20(245-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- resources/tester/test_245_1_merged_hover.py | 43 +++++++++++++++++++++ ui_template/ui_template_data_table.css | 6 +++ 2 files changed, 49 insertions(+) create mode 100644 resources/tester/test_245_1_merged_hover.py diff --git a/resources/tester/test_245_1_merged_hover.py b/resources/tester/test_245_1_merged_hover.py new file mode 100644 index 000000000..bf19478f8 --- /dev/null +++ b/resources/tester/test_245_1_merged_hover.py @@ -0,0 +1,43 @@ +"""245-1 병합 표 호버 정리 — 병합 칸(is-merged)은 줄 강조(호버 · 이동해 온 줄 · 줄무늬) 밖. + +규칙 (가) = 병합 칸은 칠하지 않고 그 줄 나머지 칸만. 같은 세기 선택자라 병합 칸 규칙이 뒤에 와야 이김. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") +MERGED = ".ui-data-table__table tbody tr td.is-merged {" + + +def _at(sel: str) -> int: + i = CSS.find(sel) + assert i >= 0, sel + return i + + +def test_병합_칸_줄_강조_밖(): + body = CSS[_at(MERGED) :].split("}", 1)[0] + assert "background: var(--color-canvas);" in body + assert "box-shadow: none;" in body + + +def test_호버_고른줄_줄무늬_뒤에_옴(): + at = _at(MERGED) + for sel in ( + ".ui-data-table__table tbody tr:hover td {", + ".ui-data-table__table tbody tr.is-focus td {", + ".ui-data-table__table tbody tr.is-alt td {", + ): + assert _at(sel) < at, sel + + +def test_병합_칸_글_위_정렬_그대로(): + assert re.search(r"td\.is-merged \{\s*vertical-align: top;", CSS) + + +def test_B09_빨강_범위는_병합_칸에도_남음(): + page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + # 빨강(is-pick)은 .b09-estimation 이 붙어 더 셈 — 병합 칸 box-shadow: none 에 안 덮임 + assert ".b09-estimation .ui-data-table__table tbody tr.is-pick td {" in page diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 6cc109a0f..794f8ba7c 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -276,3 +276,9 @@ vertical-align: top; padding-top: calc((var(--dt-row-h) - 1.3em) / 2); } + +/* 245-1 병합 칸 = 줄 강조 밖 — 올린 줄 · 이동해 온 줄 · 줄무늬가 병합 칸을 칠하지 않음 · 그 줄 나머지 칸만 */ +.ui-data-table__table tbody tr td.is-merged { + background: var(--color-canvas); + box-shadow: none; +} From a52fe78751a99eed6d8516d2e3fa3e9097b28a89 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:53:01 +0900 Subject: [PATCH 52/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=A9=94=EC=9D=B8=20=ED=83=AD=20=C2=B7=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=ED=95=98=EC=9C=84=20=ED=83=AD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EC=82=AD=EC=A0=9C=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=82=AC=EC=A7=80=20=C2=B7=20=EA=B8=B0=EC=A4=80=EC=A0=90=20?= =?UTF-8?q?=C2=B7=20=ED=91=9C=20=ED=95=A9=20=EC=A7=9D=20=C2=B7=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20(244-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메인 탭 「거리이정표」 · 하위 탭 [거리이정표] 표 + 경로 탭(이름 = 업체 이름) · [+ 추가] · [삭제] - 경로 탭 = 구간 km(더블클릭) · 도로 구분 ▾ · 표 줄(항목 · 목적지 · 비고) · 경로 그림 하나 - 좌측 패널 카드 = 공사지(중점 측점 · 기본 노선 ÷ 2) · 기준점(있음 · 이름 · 주소 · 방위) - 표 합 = 파이썬 · TS 짝(거울 시험) · 정본 = storage B08_Quantity/milestone · [저장] 한 줄에서 PUT Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/b_page_state.ts | 2 + B08_Quantity/B08_Quantity_Milestone_Calc.py | 73 ++++ B08_Quantity/B08_Quantity_Milestone_Calc.ts | 127 +++++++ B08_Quantity/B08_Quantity_Router_Milestone.py | 259 +++++++++++++ B08_Quantity/B08_Quantity_UI_Milestone.css | 45 +++ B08_Quantity/B08_Quantity_UI_Milestone.ts | 198 ++++++++++ .../B08_Quantity_UI_Milestone_Route.ts | 339 ++++++++++++++++++ .../B08_Quantity_UI_Milestone_State.ts | 224 ++++++++++++ B08_Quantity/B08_Quantity_UI_Options.ts | 8 +- B08_Quantity/B08_Quantity_UI_Page.ts | 5 +- main.py | 2 + resources/tester/test_122_b08_options.py | 2 +- .../tester/test_243_1_double_click_tables.py | 3 +- resources/tester/test_244_1_b08_milestone.py | 192 ++++++++++ ui_template/ui_template_locale_b4.ts | 1 + 15 files changed, 1476 insertions(+), 4 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Milestone_Calc.py create mode 100644 B08_Quantity/B08_Quantity_Milestone_Calc.ts create mode 100644 B08_Quantity/B08_Quantity_Router_Milestone.py create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone.css create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_Route.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_State.ts create mode 100644 resources/tester/test_244_1_b08_milestone.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index cc12ad8eb..0399b7ad2 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -185,6 +185,8 @@ export const STATE_REGISTRY = { "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ "b08-options": { bucket: "draft", scope: "project" }, + /** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */ + "b08-milestone": { bucket: "draft", scope: "project" }, /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py new file mode 100644 index 000000000..3d4ae22c6 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -0,0 +1,73 @@ +"""B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함. + +⚠ 짝 = `B08_Quantity_Milestone_Calc.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. + 조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈. + +표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 +시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · +포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · +끝수 = 소수 셋째 자리(반올림). +""" + +from __future__ import annotations + +import math +from typing import Any + +ROADS = ("2차포장", "1차포장", "비포장") +KEYS = {"2차포장": "s2", "1차포장": "s1", "비포장": "un"} + + +def r3(value: float) -> float: + """소수 셋째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음).""" + return math.floor(value * 1000 + 0.5) / 1000 + + +def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> None: + for leg in legs or []: + key = KEYS.get(leg.get("road")) + if key: + sums[key] += float(leg.get("km") or 0) + + +def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: + """시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000.""" + station = (data.get("site") or {}).get("mid_station_m") + if station is None: + station = mid_default_m + return float(station or 0) / 1000 + + +def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]: + """표 줄 — 경로 차례 · 경로 안 줄 차례.""" + site = data.get("site") or {} + base = data.get("base") or {} + mid = {"road": site.get("mid_road") or "비포장", "km": mid_km(data, mid_default_m)} + out: list[dict[str, Any]] = [] + for route in data.get("routes") or []: + for index, row in enumerate(route.get("rows") or []): + sums = {"s2": 0.0, "s1": 0.0, "un": 0.0} + add_legs(sums, route.get("legs")) + if base.get("enabled") and route.get("via_base", True): + add_legs(sums, base.get("legs")) + if row.get("dest") == "mid": + add_legs(sums, [mid]) + paved = sums["s2"] + sums["s1"] + out.append( + { + "route": route.get("id"), + "index": index, + "item": row.get("item") or "", + "company": route.get("name") or "", + "dest": row.get("dest") or "start", + "s2": r3(sums["s2"]), + "s1": r3(sums["s1"]), + "un": r3(sums["un"]), + "paved": r3(paved), + "unpaved": r3(sums["un"]), + "total": r3(paved + sums["un"]), + "note": row.get("note") or "", + } + ) + return out diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts new file mode 100644 index 000000000..4856fc234 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * B08_Quantity_Milestone_Calc.ts + * B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). + * + * ⚠ 짝 = `B08_Quantity_Milestone_Calc.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. + * 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈. + * + * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 + * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · + * 끝수 = 소수 셋째 자리(반올림) + * ========================================================================== */ + +export const ROADS = ["2차포장", "1차포장", "비포장"] as const; +export type Road = (typeof ROADS)[number]; +/** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ +export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; +export type Dir = (typeof DIRS)[number]; +export type Dest = "start" | "mid"; + +export interface Leg { + road: Road; + km: number | null; +} + +export interface MilestoneRow { + item: string; + dest: Dest; + note: string; +} + +export interface MilestoneRoute { + id: string; + /** 업체 이름 = 하위 탭 이름 */ + name: string; + address: string; + /** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */ + via_base: boolean; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + /** 업체 → 기준점(거침) · 시점 구간 */ + legs: Leg[]; + rows: MilestoneRow[]; +} + +export interface MilestoneData { + site: { mid_station_m: number | null; mid_road: Road }; + base: { + enabled: boolean; + name: string; + address: string; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + /** 기준점 → 시점 공통 구간 */ + legs: Leg[]; + }; + routes: MilestoneRoute[]; +} + +export interface MilestoneTableRow { + route: string; + index: number; + item: string; + company: string; + dest: Dest; + s2: number; + s1: number; + un: number; + paved: number; + unpaved: number; + total: number; + note: string; +} + +const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" }; + +/** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */ +export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000; + +function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): void { + for (const leg of legs ?? []) { + const key = KEYS[leg.road]; + if (key) sums[key] += Number(leg.km ?? 0); + } +} + +/** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */ +export function midKm(data: MilestoneData, midDefaultM: number | null): number { + const station = data.site?.mid_station_m ?? midDefaultM; + return Number(station ?? 0) / 1000; +} + +/** 표 줄 — 경로 차례 · 경로 안 줄 차례 */ +export function computeMilestone( + data: MilestoneData, + midDefaultM: number | null, +): MilestoneTableRow[] { + const base = data.base; + const mid: Leg = { road: data.site?.mid_road ?? "비포장", km: midKm(data, midDefaultM) }; + const out: MilestoneTableRow[] = []; + for (const route of data.routes ?? []) { + (route.rows ?? []).forEach((row, index) => { + const sums = { s2: 0, s1: 0, un: 0 }; + addLegs(sums, route.legs); + if (base?.enabled && route.via_base !== false) addLegs(sums, base.legs); + if (row.dest === "mid") addLegs(sums, [mid]); + const paved = sums.s2 + sums.s1; + out.push({ + route: route.id, + index, + item: row.item ?? "", + company: route.name ?? "", + dest: row.dest ?? "start", + s2: r3(sums.s2), + s1: r3(sums.s1), + un: r3(sums.un), + paved: r3(paved), + unpaved: r3(sums.un), + total: r3(paved + sums.un), + note: row.note ?? "", + }); + }); + } + return out; +} diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py new file mode 100644 index 000000000..18e2cd950 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -0,0 +1,259 @@ +"""B08 거리이정표 길(PLAN 244-1) — 공사지 · 기준점 · 경로(업체) · 표 · 경로 그림. DB 표 없음. + + GET /api/projects/{id}/quantity/milestone {data, site, table, images} + PUT /api/projects/{id}/quantity/milestone [저장] — 입력만 · 표는 서버가 셈 + POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체) + GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기 + DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 + +정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · +폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). +그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. +공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 +(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). +표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +from fastapi import APIRouter, File, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field, ValidationError, model_validator + +from B03_FileInput.B03_FileInput_Engine import save_upload_stream +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from common_util.common_util_initial_snapshot import design_route_csv_path +from common_util.common_util_json import atomic_write_json +from common_util.common_util_route_geometry import ( + expected_route_csv_path, + interpolate_vertex, + planned_route_initial_path, + planned_route_working_path, + read_planned_route_csv, +) + +router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"]) +PATH = "/{project_id}/quantity/milestone" +FOLDER = Path("B08_Quantity") / "milestone" +DATA_FILE = "milestone.json" +IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") +ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" + +Road = Literal["2차포장", "1차포장", "비포장"] +Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + + +class Leg(BaseModel): + road: Road = "2차포장" + km: float | None = Field(default=None, ge=0, le=10000) + + +class Row(BaseModel): + item: str = Field(default="", max_length=100) + dest: Literal["start", "mid"] = "start" + note: str = Field(default="", max_length=200) + + +class Place(BaseModel): + name: str = Field(default="", max_length=100) + address: str = Field(default="", max_length=200) + dir: Dir | None = None + lon: float | None = Field(default=None, ge=-180, le=180) + lat: float | None = Field(default=None, ge=-90, le=90) + legs: list[Leg] = Field(default_factory=list, max_length=20) + + +class Route(Place): + id: str = Field(pattern=ROUTE_ID) + via_base: bool = True + rows: list[Row] = Field(default_factory=list, max_length=50) + + +class Base(Place): + enabled: bool = False + + +class Site(BaseModel): + mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000) + mid_road: Road = "비포장" + + +class Milestone(BaseModel): + site: Site = Field(default_factory=Site) + base: Base = Field(default_factory=Base) + routes: list[Route] = Field(default_factory=list, max_length=100) + + @model_validator(mode="after") + def _unique_ids(self) -> Milestone: + ids = [route.id for route in self.routes] + if len(ids) != len(set(ids)): + raise ValueError("경로 id 가 겹칩니다.") + return self + + +def folder(root: str | Path) -> Path: + return Path(root) / FOLDER + + +def read_data(root: str | Path) -> dict[str, Any]: + """저장본 — 없거나 깨졌으면 빈 꼴.""" + path = folder(root) / DATA_FILE + try: + return Milestone.model_validate_json(path.read_text(encoding="utf-8")).model_dump() + except (OSError, ValidationError, json.JSONDecodeError): + return Milestone().model_dump() + + +def _route_vertices(root: Path) -> list[Any]: + for path in ( + planned_route_working_path(root), + planned_route_initial_path(root), + expected_route_csv_path(root), + design_route_csv_path(root), + ): + route = read_planned_route_csv(path) if path.is_file() else None + if route is not None and len(route.vertices) >= 2: + return route.vertices + return [] + + +def _lonlat(root: Path, x: float, y: float) -> list[float] | None: + """작업 좌표계 → 경도 · 위도(다이어그램 방위 · 244-3 지도) — 못 하면 None.""" + try: + from pyproj import Transformer + + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + + crs = project_epsg_from_prj(root) + lon, lat = Transformer.from_crs(crs, "EPSG:4326", always_xy=True).transform(x, y) + return [round(lon, 7), round(lat, 7)] + except Exception: # 좌표계 못 읽음 = 좌표 없음(방위는 사용자 8방) + return None + + +def site_info(root: str | Path, mid_station_m: float | None) -> dict[str, Any]: + """노선 연장 · 중점 기본(연장 ÷ 2) · 시점 · 중점 경위도.""" + root = Path(root) + vertices = _route_vertices(root) + if not vertices: + return {"length_m": None, "mid_default_m": None, "start_lonlat": None, "mid_lonlat": None} + length = vertices[-1].chainage_m + mid_default = r3(length / 2) + mx, my, _ = interpolate_vertex( + vertices, mid_default if mid_station_m is None else mid_station_m + ) + return { + "length_m": r3(length), + "mid_default_m": mid_default, + "start_lonlat": _lonlat(root, vertices[0].x, vertices[0].y), + "mid_lonlat": _lonlat(root, mx, my), + } + + +def route_images(root: str | Path, route_id: str) -> list[Path]: + path = folder(root) + if not path.is_dir(): + return [] + return sorted( + f for f in path.glob(f"{route_id}.*") if f.is_file() and f.suffix.lower() in IMAGE_EXT + ) + + +def images(root: str | Path) -> dict[str, str]: + """경로 id → 그림 파일 이름(폴더가 목록).""" + path = folder(root) + if not path.is_dir(): + return {} + return { + f.stem: f.name + for f in sorted(path.iterdir()) + if f.is_file() and f.suffix.lower() in IMAGE_EXT and not f.name.startswith(".") + } + + +def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: + site = site_info(root, data["site"]["mid_station_m"]) + return { + "data": data, + "site": site, + "table": compute_milestone(data, site["mid_default_m"]), + "images": images(root), + } + + +def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: + data = body.model_dump() + path = folder(root) + path.mkdir(parents=True, exist_ok=True) + atomic_write_json(path / DATA_FILE, data) + keep = {route["id"] for route in data["routes"]} + for stem, name in images(root).items(): + if stem not in keep: + (path / name).unlink(missing_ok=True) + return payload(root, data) + + +def _route_id(route: str) -> str: + try: + return Route(id=route).id + except ValidationError as error: + raise HTTPException(status_code=400, detail="경로 id 가 바르지 않습니다.") from error + + +@router.get(PATH) +async def milestone_read(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(lambda: payload(root, read_data(root))) + + +@router.put(PATH) +async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(save_data, root, body) + + +@router.post(PATH + "/image") +async def milestone_image_upload( + project_id: UUID, route: str = Query(...), file: UploadFile = File(...) +) -> dict[str, Any]: + route_id = _route_id(route) + ext = Path(file.filename or "").suffix.lower() + if ext not in IMAGE_EXT: + raise HTTPException(status_code=400, detail="그림 파일(png · jpg · gif · webp · bmp)만.") + root = await _project_root(project_id) + path = folder(root) + path.mkdir(parents=True, exist_ok=True) + try: + await save_upload_stream(file, path / f".{route_id}{ext}.new") + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + for old in route_images(root, route_id): + old.unlink(missing_ok=True) + (path / f".{route_id}{ext}.new").replace(path / f"{route_id}{ext}") + return {"name": f"{route_id}{ext}"} + + +@router.get(PATH + "/image") +async def milestone_image(project_id: UUID, route: str = Query(...)): + route_id = _route_id(route) + root = await _project_root(project_id) + found = route_images(root, route_id) + if not found: + raise HTTPException(status_code=404, detail="경로 그림이 없습니다.") + return FileResponse(found[0]) + + +@router.delete(PATH + "/image") +async def milestone_image_delete(project_id: UUID, route: str = Query(...)) -> dict[str, Any]: + route_id = _route_id(route) + root = await _project_root(project_id) + for old in route_images(root, route_id): + old.unlink(missing_ok=True) + return {"name": None} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css new file mode 100644 index 000000000..be1adfa9a --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -0,0 +1,45 @@ +/* B08 거리이정표(244) — 경로 컨테이너 · 단추 줄 · 그림. 색은 테마 변수만(밝음 · 어두움 같이). */ + +.b08-milestone { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b08-milestone__route { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16); +} + +.b08-milestone__route h4 { + margin: var(--spacing-8) 0 0; + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.b08-milestone__head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); +} + +.b08-milestone__head > .ui-field { + min-width: 160px; +} + +.b08-milestone__bar { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.b08-milestone__image { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + align-self: flex-start; + border: 1px solid var(--color-border); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts new file mode 100644 index 000000000..d215bb7df --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -0,0 +1,198 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone.ts + * B08 메인 탭 「거리이정표」(PLAN 244-1) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름) + + * [+ 추가] · [삭제]. + * + * [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) + * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 + * 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도 + * 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄) + * ========================================================================== */ + +import "./B08_Quantity_UI_Milestone.css"; +import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { + liveMilestone, + loadMilestone, + onMilestoneChange, + putMilestone, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; +import { + DEST_TEXT, + editRoute, + pickAtCell, + routeView, + trackCells, + type MilestoneView, +} from "./B08_Quantity_UI_Milestone_Route"; + +const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; +const COLUMNS: DataColumn[] = [ + { key: "item", label: "항목", width: 120 }, + { key: "company", label: "업체 이름", width: 120 }, + { key: "dest", label: "목적지", width: 72 }, + { key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM }, + { key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM }, + { key: "un", label: "비포장", group: ["도로 구분 (km)"], ...KM }, + { key: "paved", label: "포장", group: ["계 (km)"], ...KM }, + { key: "unpaved", label: "비포장", group: ["계 (km)"], ...KM }, + { key: "total", label: "합계", group: ["계 (km)"], ...KM }, + { key: "note", label: "비고", width: 160 }, +]; + +/** 새 경로(업체) — 캐시에 넣고 id 를 돌려줌 */ +export function addRoute(projectId: string, init: Partial = {}): string { + const data = liveMilestone(projectId); + const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`; + data.routes.push({ + id, + name: `업체 ${data.routes.length + 1}`, + address: "", + via_base: true, + dir: null, + legs: [{ road: "2차포장", km: null }], + rows: [{ item: "", dest: "start", note: "" }], + ...init, + }); + putMilestone(projectId, data); + return id; +} + +/** [거리이정표] 하위 탭 — 표(+ 244-2 다이어그램 자리) */ +function tableView(projectId: string): MilestoneView { + let keys: Array<{ route: string; index: number }> = []; + const table = createDataTable({ + columns: COLUMNS, + rows: [], + caption: "거리이정표", + empty: "", + editOnDouble: true, + editable: (_i, key) => key === "item" || key === "company" || key === "note", + onEdit: (i, key, text) => { + const at = keys[i]; + if (!at) return; + editRoute(projectId, at.route, (_, r) => { + if (key === "company") r.name = text; + else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text; + }); + }, + onCellClick: (i, key) => { + const at = keys[i]; + if (key !== "dest" || !at) return; + pickAtCell( + (["start", "mid"] as const).map((dest) => [ + DEST_TEXT[dest], + () => + editRoute(projectId, at.route, (_, r) => { + if (r.rows[at.index]) r.rows[at.index].dest = dest; + }), + ]), + ); + }, + }); + trackCells(table.root); + table.root.dataset.b08Milestone = "table"; + const none = el("p", { + className: "ui-general-block__empty", + text: "경로가 없습니다 — 하위 탭 줄 [+ 추가]로 업체를 넣으세요.", + }); + const refresh = (): void => { + const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); + keys = rows.map(({ route, index }) => ({ route, index })); + none.hidden = rows.length > 0; + table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); + }; + refresh(); + return { root: el("div", { className: "b08-milestone", children: [none, table.root] }), refresh }; +} + +export function renderMilestone(host: HTMLElement, projectId: string): void { + const tabs = el("div", { + className: "b08-quantity__tabs b08-structure__tabs", + attrs: { role: "tablist" }, + }); + const view = el("div"); + let current = "table"; + let body: MilestoneView | null = null; + + const drawTabs = (): void => { + const routes = liveMilestone(projectId).routes; + const tab = (id: string, label: string): HTMLButtonElement => { + const button = createButton({ + label, + variant: id === current ? "filled" : "ghost", + size: "sm", + onClick: () => id !== current && open(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === current)); + button.dataset.b08MilestoneTab = id; + return button; + }; + const add = createButton({ + label: "+ 추가", + size: "sm", + variant: "ghost", + onClick: () => open(addRoute(projectId)), + }); + add.dataset.b08MilestoneAdd = ""; + const remove = createButton({ + label: "삭제", + size: "sm", + variant: "danger", + onClick: () => void removeCurrent(), + }); + remove.disabled = current === "table"; + remove.dataset.b08MilestoneDelete = ""; + tabs.replaceChildren( + tab("table", "거리이정표"), + ...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)), + add, + remove, + ); + }; + + const removeCurrent = async (): Promise => { + const data = liveMilestone(projectId); + const route = data.routes.find((r) => r.id === current); + if (!route) return; + if ( + !(await showConfirmDialog( + `「${route.name || "업체"}」 경로를 지울까요? — [저장] 때 반영`, + "삭제", + )) + ) + return; + data.routes = data.routes.filter((r) => r.id !== route.id); + putMilestone(projectId, data); // 알림이 없는 경로 탭을 보고 [거리이정표] 로 옮김 + }; + + function open(id: string): void { + current = id; + drawTabs(); + body = id === "table" ? tableView(projectId) : routeView(projectId, id); + view.replaceChildren(body.root); + } + + host.replaceChildren( + tabs, + el("p", { className: "ui-general-block__empty", text: "거리이정표를 불러오는 중…" }), + ); + loadMilestone(projectId).then( + () => { + host.replaceChildren(tabs, view); + open("table"); + onMilestoneChange(host, () => { + // 지운 경로 탭이면 [거리이정표] 로 · 아니면 탭 이름 · 몸만 다시 셈(입력 칸은 그대로) + if (current !== "table" && !liveMilestone(projectId).routes.some((r) => r.id === current)) + return open("table"); + drawTabs(); + body?.refresh(); + }); + }, + (error: unknown) => showToast(`거리이정표를 불러오지 못했습니다 — ${String(error)}`, "error"), + ); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts new file mode 100644 index 000000000..9c3671293 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -0,0 +1,339 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Route.ts + * 거리이정표 경로 하위 탭(PLAN 244-1) — 컨테이너 하나 = 업체 한 곳 · 그림 하나. + * + * 위 = 업체 이름(= 하위 탭 이름) · 주소 · 방위(기준점 · 없으면 공사지 기준 8방) · 기준점 거침 + * 구간 표 = 업체 → 기준점(또는 시점) · 기준점 → 시점(공통 · 모든 경로가 같이 씀) · 시점 → 중점(측점 km) · + * 구간마다 km(더블클릭 손 입력) · 도로 구분 ▾(2차포장 · 1차포장 · 비포장) + * 표 줄 = 항목 · 목적지 ▾ · 비고 — [거리이정표] 표에 이 업체 줄로 나감 + * 그림 = 올리는 즉시 서버(교체 · 지우기) · 나머지 조작 = 캐시(`…_UI_Milestone_State`) · [저장] 때 서버 + * ========================================================================== */ + +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { + createMapContextMenu, + type MapContextMenu, + type MapContextMenuItem, +} from "@ui/ui_template_context_menu"; +import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { + ROADS, + midKm, + type Dest, + type Dir, + type MilestoneData, + type MilestoneRoute, +} from "./B08_Quantity_Milestone_Calc"; +import { + deleteImage, + dirOptions, + imageBlobUrl, + liveMilestone, + putMilestone, + routeImages, + siteInfo, + uploadImage, +} from "./B08_Quantity_UI_Milestone_State"; + +let menu: MapContextMenu | null = null; +let lastCell: Element | null = null; + +/** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */ +export function trackCells(root: HTMLElement): void { + root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true); +} + +export function pickAtCell(items: MapContextMenuItem[]): void { + const box = lastCell?.getBoundingClientRect(); + if (!box) return; + menu ??= createMapContextMenu("b08-milestone"); + if (!menu.element.isConnected) { + menu.element.style.position = "fixed"; + document.body.append(menu.element); + } + menu.open(box.left, box.bottom, items); +} + +/** 고른 줄 강조(공용 표 is-focus · 다시 그리지 않음 — 더블클릭 그대로) */ +function markRow(root: HTMLElement, index: number | null): void { + root.querySelectorAll("tr.is-focus").forEach((tr) => tr.classList.remove("is-focus")); + if (index !== null) root.querySelector(`tr[data-i="${index}"]`)?.classList.add("is-focus"); +} + +export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; + +/** 그 경로를 고쳐 캐시에 */ +export function editRoute( + projectId: string, + routeId: string, + fn: (data: MilestoneData, route: MilestoneRoute) => void, +): void { + const data = liveMilestone(projectId); + const route = data.routes.find((r) => r.id === routeId); + if (!route) return; + fn(data, route); + putMilestone(projectId, data); +} + +const kmOf = (text: string): number | null | undefined => { + if (!text.trim()) return null; + const value = Number(text.replace(/,/g, "")); + return Number.isFinite(value) && value >= 0 ? value : undefined; // undefined = 그대로 +}; + +type LegRef = { group: "own" | "base" | "mid"; i: number }; + +const LEG_COLUMNS: DataColumn[] = [ + { key: "span", label: "구간", width: 200 }, + { key: "road", label: "도로 구분", width: 96 }, + { key: "km", label: "km", kind: "number", decimals: 3, trim: true, width: 80 }, +]; +const ROW_COLUMNS: DataColumn[] = [ + { key: "item", label: "항목", width: 140 }, + { key: "dest", label: "목적지", width: 80 }, + { key: "note", label: "비고", width: 180 }, +]; + +export interface MilestoneView { + root: HTMLElement; + refresh: () => void; +} + +export function routeView(projectId: string, routeId: string): MilestoneView { + const edit = (fn: (data: MilestoneData, route: MilestoneRoute) => void): void => + editRoute(projectId, routeId, fn); + + // 위 — 업체 이름 · 주소 · 방위 · 기준점 거침 + const name = createInputField({ label: "업체 이름", compact: true }); + const address = createInputField({ label: "주소", compact: true }); + const dir = createSelectField({ label: "방위", options: dirOptions, compact: true }); + const via = createCheckbox("기준점 거침"); + name.input.dataset.b08Milestone = "route_name"; + name.input.addEventListener("input", () => edit((_, r) => (r.name = name.input.value))); + address.input.addEventListener("input", () => edit((_, r) => (r.address = address.input.value))); + dir.select.addEventListener("change", () => + edit((_, r) => (r.dir = (dir.select.value || null) as Dir | null)), + ); + via.input.addEventListener("change", () => edit((_, r) => (r.via_base = via.input.checked))); + const head = el("div", { + className: "b08-milestone__head", + children: [name.root, address.root, dir.root, via.root], + }); + + // 구간 표 + let legRefs: LegRef[] = []; + let pickedLeg: number | null = null; + const legRows = (d: MilestoneData, r: MilestoneRoute): DataRow[] => { + const useBase = d.base.enabled && r.via_base; + const baseName = d.base.name || "기준점"; + const who = r.name || "업체"; + const rows: Array<[LegRef, string, string, number | null]> = [ + ...r.legs.map((leg, i): [LegRef, string, string, number | null] => [ + { group: "own", i }, + `${who} → ${useBase ? baseName : "시점"}`, + leg.road, + leg.km, + ]), + ...(useBase ? d.base.legs : []).map((leg, i): [LegRef, string, string, number | null] => [ + { group: "base", i }, + `${baseName} → 시점 (공통)`, + leg.road, + leg.km, + ]), + [ + { group: "mid", i: 0 }, + "시점 → 중점 (측점)", + d.site.mid_road, + midKm(d, siteInfo()?.mid_default_m ?? null), + ], + ]; + legRefs = rows.map(([ref]) => ref); + return rows.map(([, span, road, km]) => ({ span, road: `${road} ▾`, km })); + }; + const setLeg = (ref: LegRef, fn: (leg: { road: string; km: number | null }) => void): void => + edit((d, r) => { + if (ref.group === "mid") { + const leg = { road: d.site.mid_road as string, km: null }; + fn(leg); + d.site.mid_road = leg.road as MilestoneData["site"]["mid_road"]; + } else fn((ref.group === "own" ? r.legs : d.base.legs)[ref.i]); + }); + const legs = createDataTable({ + columns: LEG_COLUMNS, + rows: [], + caption: "구간", + empty: "", + editOnDouble: true, + editable: (i, key) => key === "km" && legRefs[i]?.group !== "mid", + onEdit: (i, _key, text) => { + const km = kmOf(text); + if (km !== undefined) setLeg(legRefs[i], (leg) => (leg.km = km)); + else refresh(); + }, + onCellClick: (i, key) => { + if (key !== "road") return; + pickAtCell(ROADS.map((road) => [road, () => setLeg(legRefs[i], (leg) => (leg.road = road))])); + }, + onRowClick: (i) => markRow(legs.root, (pickedLeg = i)), + }); + trackCells(legs.root); + legs.root.dataset.b08Milestone = "legs"; + const addOwn = createButton({ + label: "+ 업체 구간", + size: "sm", + variant: "ghost", + onClick: () => edit((_, r) => r.legs.push({ road: "2차포장", km: null })), + }); + const addBase = createButton({ + label: "+ 공통 구간", + size: "sm", + variant: "ghost", + onClick: () => edit((d) => d.base.legs.push({ road: "1차포장", km: null })), + }); + const dropLeg = createButton({ + label: "고른 구간 지우기", + size: "sm", + variant: "ghost", + onClick: () => { + const ref = pickedLeg === null ? null : legRefs[pickedLeg]; + if (!ref || ref.group === "mid") return showToast("지울 구간 줄을 먼저 고르세요.", "info"); + pickedLeg = null; + edit((d, r) => (ref.group === "own" ? r.legs : d.base.legs).splice(ref.i, 1)); + }, + }); + + // 표 줄 + let pickedRow: number | null = null; + const rows = createDataTable({ + columns: ROW_COLUMNS, + rows: [], + caption: "표 줄", + empty: "", + editOnDouble: true, + editable: (_i, key) => key === "item" || key === "note", + onEdit: (i, key, text) => + edit((_, r) => { + if (r.rows[i]) r.rows[i][key as "item" | "note"] = text; + }), + onCellClick: (i, key) => { + if (key !== "dest") return; + pickAtCell( + (["start", "mid"] as Dest[]).map((dest) => [ + DEST_TEXT[dest], + () => edit((_, r) => r.rows[i] && (r.rows[i].dest = dest)), + ]), + ); + }, + onRowClick: (i) => markRow(rows.root, (pickedRow = i)), + }); + trackCells(rows.root); + rows.root.dataset.b08Milestone = "rows"; + const addRow = createButton({ + label: "+ 줄", + size: "sm", + variant: "ghost", + onClick: () => edit((_, r) => r.rows.push({ item: "", dest: "start", note: "" })), + }); + const dropRow = createButton({ + label: "고른 줄 지우기", + size: "sm", + variant: "ghost", + onClick: () => { + if (pickedRow === null) return showToast("지울 줄을 먼저 고르세요.", "info"); + const at = pickedRow; + pickedRow = null; + edit((_, r) => r.rows.splice(at, 1)); + }, + }); + + // 그림 — 경로 하나 = 그림 하나 + const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } }); + const noImage = el("p", { className: "ui-general-block__empty", text: "올린 그림 없음" }); + let shown: string | null | undefined; + const loadImage = (force = false): void => { + const name = routeImages()[routeId] ?? null; + if (!force && name === shown) return; + shown = name; + img.hidden = !name; + noImage.hidden = !!name; + if (img.src) URL.revokeObjectURL(img.src); + img.removeAttribute("src"); + if (name) + imageBlobUrl(projectId, routeId).then( + (url) => (img.src = url), + (error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"), + ); + }; + const file = el("input", { attrs: { type: "file", accept: "image/*" } }); + file.hidden = true; + file.addEventListener("change", () => { + const picked = file.files?.[0]; + file.value = ""; + if (picked) + uploadImage(projectId, routeId, picked).then( + () => loadImage(true), + (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), + ); + }); + const upload = createButton({ + label: "그림 올리기", + size: "sm", + variant: "filled", + onClick: () => file.click(), + }); + const removeImage = createButton({ + label: "그림 지우기", + size: "sm", + variant: "ghost", + onClick: () => + void deleteImage(projectId, routeId).catch((error: unknown) => + showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"), + ), + }); + + const bar = (...items: HTMLElement[]): HTMLElement => + el("div", { className: "b08-milestone__bar", children: items }); + const root = el("section", { + className: "b08-milestone__route ui-card", + attrs: { "data-b08-milestone-route": routeId }, + children: [ + head, + el("h4", { text: "구간 (km 더블클릭 · 도로 구분 ▾)" }), + legs.root, + bar(addOwn, addBase, dropLeg), + el("h4", { text: "표 줄" }), + rows.root, + bar(addRow, dropRow), + el("h4", { text: "경로 그림" }), + bar(upload, removeImage, file), + noImage, + img, + ], + }); + + const refresh = (): void => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + if (!r) return; + if (document.activeElement !== name.input) name.input.value = r.name; + if (document.activeElement !== address.input) address.input.value = r.address; + dir.select.value = r.dir ?? ""; + via.input.checked = r.via_base; + via.root.hidden = !d.base.enabled; + addBase.hidden = !(d.base.enabled && r.via_base); + legs.setRows(legRows(d, r)); + rows.setRows(r.rows.map((row) => ({ ...row, dest: `${DEST_TEXT[row.dest]} ▾` }))); + markRow(legs.root, pickedLeg); + markRow(rows.root, pickedRow); + loadImage(); + }; + refresh(); + return { root, refresh }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts new file mode 100644 index 000000000..6a53b8625 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_State.ts + * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길 + 좌측 패널 카드(공사지 · 기준점). + * + * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. + * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { DIRS, ROADS, type MilestoneData, type Road } from "./B08_Quantity_Milestone_Calc"; + +export interface SiteInfo { + length_m: number | null; + mid_default_m: number | null; + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; +} + +interface Payload { + data: MilestoneData; + site: SiteInfo; + images: Record; +} + +/** 8방 이름 */ +export const DIR_TEXT: Record = { + N: "북", + NE: "북동", + E: "동", + SE: "남동", + S: "남", + SW: "남서", + W: "서", + NW: "북서", +}; + +export const EMPTY: MilestoneData = { + site: { mid_station_m: null, mid_road: "비포장" }, + base: { enabled: false, name: "", address: "", dir: null, legs: [] }, + routes: [], +}; + +const changes = new EventTarget(); +let saved: Payload | null = null; +let savedFor: string | null = null; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +/** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */ +export async function loadMilestone(projectId: string, force = false): Promise { + if (!saved || savedFor !== projectId || force) { + saved = await request(`/projects/${projectId}/quantity/milestone`); + savedFor = projectId; + changes.dispatchEvent(new Event("change")); + } + return saved; +} + +export const siteInfo = (): SiteInfo | null => saved?.site ?? null; +export const routeImages = (): Record => saved?.images ?? {}; + +export const hasMilestoneDraft = (projectId: string): boolean => + readState("b08-milestone", projectId) !== null; + +/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */ +export function liveMilestone(projectId: string): MilestoneData { + return clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY); +} + +/** 고친 한 벌을 캐시에 · 화면에 알림 */ +export function putMilestone(projectId: string, data: MilestoneData): void { + writeState("b08-milestone", data, projectId); + changes.dispatchEvent(new Event("change")); +} + +/** host 가 붙어 있는 동안 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌 */ +export function onMilestoneChange(host: HTMLElement, fn: () => void): void { + const listener = (): void => { + if (host.isConnected) fn(); + else changes.removeEventListener("change", listener); + }; + changes.addEventListener("change", listener); +} + +/** [저장] — 캐시가 있을 때만 PUT · 서버가 표를 다시 셈 */ +export async function saveMilestone(projectId: string): Promise { + const draft = readState("b08-milestone", projectId); + if (!draft) return; + saved = await request(`/projects/${projectId}/quantity/milestone`, { + method: "PUT", + body: JSON.stringify(draft), + }); + savedFor = projectId; + clearState("b08-milestone", projectId); + changes.dispatchEvent(new Event("change")); +} + +const imageUrl = (projectId: string, routeId: string): string => + `${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(routeId)}`; + +async function call(input: string, init: RequestInit = {}): Promise { + const response = await fetch(input, { credentials: "include", ...init }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + return response; +} + +/** 경로 그림 — 올리기(교체) · 지우기는 즉시 서버 · 목록 고쳐 알림 */ +export async function uploadImage(projectId: string, routeId: string, file: File): Promise { + const form = new FormData(); + form.append("file", file); + const body = (await ( + await call(imageUrl(projectId, routeId), { method: "POST", body: form }) + ).json()) as { name: string }; + if (saved) saved.images = { ...saved.images, [routeId]: body.name }; + changes.dispatchEvent(new Event("change")); +} + +export async function deleteImage(projectId: string, routeId: string): Promise { + await call(imageUrl(projectId, routeId), { method: "DELETE" }); + if (saved) { + const rest = { ...saved.images }; + delete rest[routeId]; + saved.images = rest; + } + changes.dispatchEvent(new Event("change")); +} + +/** 그림 → 화면 주소(blob · 쿠키 포함) */ +export async function imageBlobUrl(projectId: string, routeId: string): Promise { + return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); +} + +export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); +export const dirOptions = [ + { value: "", text: "방위 —" }, + ...DIRS.map((d) => ({ value: d, text: DIR_TEXT[d] })), +]; + +const km = (m: number | null | undefined): string => + m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); + +/** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위) */ +export function milestoneCardBody(projectId: string | null): HTMLElement[] { + const mid = createInputField({ label: "중점 측점 (m)", type: "number", compact: true, min: 0 }); + const midRoad = createSelectField({ + label: "시점 → 중점 도로", + options: roadOptions, + compact: true, + }); + const siteHint = el("p", { className: "b08-options__hint" }); + const has = createCheckbox("기준점 있음"); + const name = createInputField({ label: "기준점 이름", compact: true }); + const address = createInputField({ label: "기준점 주소", compact: true }); + const dir = createSelectField({ + label: "공사지에서 본 방위", + options: dirOptions, + compact: true, + }); + mid.input.dataset.b08Milestone = "mid_station_m"; + has.input.dataset.b08Milestone = "base_enabled"; + const baseFields = el("div", { children: [name.root, address.root, dir.root] }); + + const show = (): void => { + if (!projectId) return; + const data = liveMilestone(projectId); + const site = siteInfo(); + mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; + if (document.activeElement !== mid.input) + mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); + midRoad.select.value = data.site.mid_road; + siteHint.textContent = + `시점 = 노선 첫 점(측점 0) · 노선 ${km(site?.length_m)} km · ` + + `중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`; + has.input.checked = data.base.enabled; + baseFields.hidden = !data.base.enabled; + if (document.activeElement !== name.input) name.input.value = data.base.name; + if (document.activeElement !== address.input) address.input.value = data.base.address; + dir.select.value = data.base.dir ?? ""; + }; + const edit = (fn: (data: MilestoneData) => void): void => { + if (!projectId) return; + const data = liveMilestone(projectId); + fn(data); + putMilestone(projectId, data); + }; + mid.input.addEventListener("input", () => + edit((d) => { + const value = Number(mid.input.value); + d.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; + }), + ); + midRoad.select.addEventListener("change", () => + edit((d) => (d.site.mid_road = midRoad.select.value as Road)), + ); + has.input.addEventListener("change", () => + edit((d) => { + d.base.enabled = has.input.checked; + if (d.base.enabled && !d.base.legs.length) d.base.legs = [{ road: "1차포장", km: null }]; + }), + ); + name.input.addEventListener("input", () => edit((d) => (d.base.name = name.input.value))); + address.input.addEventListener("input", () => + edit((d) => (d.base.address = address.input.value)), + ); + dir.select.addEventListener("change", () => + edit((d) => (d.base.dir = (dir.select.value || null) as MilestoneData["base"]["dir"])), + ); + + const host = el("div", { + children: [mid.root, midRoad.root, siteHint, has.root, baseFields], + }); + show(); + onMilestoneChange(host, show); // 첫 알림(읽기 끝)은 패널이 붙은 뒤 + if (projectId) void loadMilestone(projectId).catch(() => undefined); + return [host]; +} diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index a6f645812..fe5160be2 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -1,12 +1,15 @@ /* ============================================================================= * B08_Quantity_UI_Options.ts - * 좌측 패널(PLAN 122 · 160-1) — 메인 탭과 같은 컨테이너 넷(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · 접이) + * 좌측 패널(PLAN 122 · 160-1 · 244-1) — 메인 탭과 같은 컨테이너 다섯(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · + * 거리이정표 · 접이) * + [저장] 줄. * * 구조물 집계표 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` * 자재 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100 * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) * 수리집수면적표 — 빈 자리(다음 옵션) + * 거리이정표 › 공사지(중점 측점 · 시점 → 중점 도로) · 기준점 — `…_UI_Milestone_State`(캐시 `b08-milestone` · + * [저장] 한 번에 같이 PUT) * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. * ========================================================================== */ @@ -24,6 +27,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { clampPct } from "./B08_Quantity_Options_Calc"; import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc"; +import { milestoneCardBody, saveMilestone } from "./B08_Quantity_UI_Milestone_State"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -210,6 +214,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void surcharge: body.material_surcharge_pct, }; clearState("b08-options", projectId); + await saveMilestone(projectId); show(); showToast("수량 산출 옵션을 저장했습니다.", "success"); onSaved(); @@ -237,6 +242,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock), card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")), card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")), + card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId)), actions, ); attachCollapsible(panel); diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 78f27502f..385aebf8c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -2,7 +2,7 @@ * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61) * - * 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1). 위 탭 단추 = 공용 + * 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1) · 거리이정표(244-1). 위 탭 단추 = 공용 * 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). 구조물도는 집계표 행을 눌러 여는 모달(95-3). * 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`). * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. @@ -22,6 +22,7 @@ import { renderMaterialSheet } from "./B08_Quantity_UI_Material"; import { renderHydro } from "./B08_Quantity_UI_Hydro"; import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; import { createOptionsPanel } from "./B08_Quantity_UI_Options"; +import { renderMilestone } from "./B08_Quantity_UI_Milestone"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -32,6 +33,7 @@ const TABS = [ { id: "material", label: "B08_Quantity_Tab_Materials" }, { id: "earthwork", label: "B08_Quantity_Tab_Earthwork" }, { id: "hydrology", label: "B08_Quantity_Tab_Hydrology" }, + { id: "milestone", label: "B08_Quantity_Tab_Milestone" }, ] as const; type TabId = (typeof TABS)[number]["id"]; @@ -101,6 +103,7 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi } if (tab === "hydrology" && projectId) return void renderHydro(view, projectId); if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId); + if (tab === "milestone" && projectId) return renderMilestone(view, projectId); view.append(pendingNote()); } diff --git a/main.py b/main.py index 35dec32f1..05ca3a1fc 100644 --- a/main.py +++ b/main.py @@ -69,6 +69,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router +from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -656,6 +657,7 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_summary_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_options_router, dependencies=protected_with_company) +app.include_router(b08_milestone_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index fa255f5d9..ac34f1342 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -140,7 +140,7 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8") assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page # 160-1 — 메인 탭 차례 = 구조물 집계표 · 자재 · 토공 · 수리 · 카드도 같은 차례(1본 → 집계표 · 할증 → 자재) - tabs = ("summary", "material", "earthwork", "hydrology") + tabs = ("summary", "material", "earthwork", "hydrology", "milestone") # 244-1 거리이정표 ids = re.findall(r'\{ id: "(\w+)", label: "B08_Quantity_Tab_', page) assert ids == [ *tabs, diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 08ea4367a..61be0ddae 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -127,7 +127,8 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble(): if "editable:" in block: seen += 1 assert "editOnDouble: true" in block, p.name - assert seen == 1 # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 셋(표 · 구간 · 표 줄 · 244-1) + assert seen == 4 def test_고르기_메뉴_이동_그대로(): diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py new file mode 100644 index 000000000..a4e3141aa --- /dev/null +++ b/resources/tester/test_244_1_b08_milestone.py @@ -0,0 +1,192 @@ +"""244-1 B08 거리이정표 — 표 합 거울(파이썬 · TS) · 저장 왕복(임시 storage) · 그림 · +하위 탭 추가 · 삭제 · 중점 기본. + +견본 = 실무 「거리이정표」 시트(거창군청 → 병곡리 968-31 → 시점 · 김천역 → 임도중점) +장비 = 2차 23.8 + 1차 0.6 + 비포장 0.8 → 포장 24.4 · 비포장 0.8 · 합계 25.2. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _leg(road, km): + return {"road": road, "km": km} + + +SAMPLE = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "E", + "legs": [_leg("1차포장", 0.6), _leg("비포장", 0.8)]}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": "W", + "legs": [_leg("2차포장", 23.8)], + "rows": [{"item": "장비(20톤)", "dest": "start", "note": ""}, + {"item": "장비(8톤)", "dest": "start", "note": "같은 길"}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": "N", + "legs": [_leg("2차포장", 61.2), _leg("1차포장", 2.05), _leg("비포장", None)], + "rows": [{"item": "철근", "dest": "mid", "note": ""}]}, + ], +} # fmt: skip + + +def _run_ts(data, mid_default): + calc = ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Calc.ts" + body = ( + f"\nconst d = {json.dumps(data, ensure_ascii=False)};" + f"\nconsole.log(JSON.stringify(computeMilestone(d, {json.dumps(mid_default)})));" + ) + script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_견본_표_합(): + rows = compute_milestone(SAMPLE, 1500.0) + a, b, c = rows + assert (a["s2"], a["s1"], a["un"]) == (23.8, 0.6, 0.8) + assert (a["paved"], a["unpaved"], a["total"]) == (24.4, 0.8, 25.2) + assert b["item"] == "장비(8톤)" and b["total"] == 25.2 and b["company"] == "거창군청" + # 기준점 안 거침 · 목적지 중점 = 시점 → 중점(기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 + assert (c["s2"], c["s1"], c["un"], c["total"]) == (61.2, 2.05, 1.5, 64.75) + # 사용자 중점 측점 · 기준점 없음 = 공통 구간 안 셈 + no_base = {**SAMPLE, "base": {**SAMPLE["base"], "enabled": False}, + "site": {"mid_station_m": 420, "mid_road": "1차포장"}} # fmt: skip + a2, _, c2 = compute_milestone(no_base, 1500.0) + assert a2["total"] == 23.8 and (c2["s1"], c2["un"]) == (2.47, 0) + + +def test_거울_TS(): + for mid in (1500.0, None): + assert _run_ts(SAMPLE, mid) == compute_milestone(SAMPLE, mid) + odd = {**SAMPLE, "site": {"mid_station_m": 1234.5678, "mid_road": "비포장"}} + assert _run_ts(odd, 10.0) == compute_milestone(odd, 10.0) + + +def _route_csv(root: Path, points): + path = root / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + lines = ["sequence,x,y"] + [f"{i},{x},{y}" for i, (x, y) in enumerate(points)] + path.write_text("\n".join(lines), encoding="utf-8") + + +def test_중점_기본_노선_절반(tmp_path): + assert ms.site_info(tmp_path, None)["mid_default_m"] is None # 노선 전 = 빈칸 + _route_csv(tmp_path, [(208000, 367000), (209000, 367000), (209000, 367500)]) + site = ms.site_info(tmp_path, None) + assert site["length_m"] == 1500 and site["mid_default_m"] == 750 + lon, lat = site["start_lonlat"] + assert 124 < lon < 132 and 33 < lat < 39 # 작업 좌표계(폴백 5186) → 경위도 + assert site["mid_lonlat"] != site["start_lonlat"] + + +def _client(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone" + + +def test_저장_왕복_그림(tmp_path, monkeypatch): + client, base = _client(tmp_path, monkeypatch) + empty = client.get(base).json() + assert empty["data"]["routes"] == [] and empty["table"] == [] and empty["images"] == {} + _route_csv(tmp_path, [(0, 0), (3000, 0)]) + saved = client.put(base, json=SAMPLE).json() + assert saved["data"] == ms.Milestone.model_validate(SAMPLE).model_dump() + assert saved["table"] == compute_milestone(SAMPLE, 1500.0) # 서버가 다시 셈 + stored = json.loads((tmp_path / "B08_Quantity/milestone/milestone.json").read_text("utf-8")) + assert stored["routes"][0]["name"] == "거창군청" and "table" not in stored + assert client.get(base).json() == saved + # 그림 — 경로 하나 = 그림 하나(교체) · 내려받기 · 지우기 + img = base + "/image" + up = client.post(img, params={"route": "r1"}, files={"file": ("a.PNG", b"png1", "image/png")}) + assert up.json() == {"name": "r1.png"} + client.post(img, params={"route": "r1"}, files={"file": ("b.jpg", b"jpg2", "image/jpeg")}) + assert client.get(base).json()["images"] == {"r1": "r1.jpg"} + assert client.get(img, params={"route": "r1"}).content == b"jpg2" + assert ( + client.post(img, params={"route": "r1"}, files={"file": ("x.pdf", b"1")}).status_code == 400 + ) + assert client.get(img, params={"route": "../x"}).status_code == 400 + client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")}) + client.delete(img, params={"route": "r2"}) + assert client.get(img, params={"route": "r2"}).status_code == 404 + # 경로 삭제 뒤 [저장] = 그 경로 그림도 지움 + client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")}) + one = {**SAMPLE, "routes": SAMPLE["routes"][:1]} + assert client.put(base, json=one).json()["images"] == {"r1": "r1.jpg"} + # 겹친 id · 나쁜 값 = 거절 + twice = {**SAMPLE, "routes": [SAMPLE["routes"][0]] * 2} + assert client.put(base, json=twice).status_code == 422 + bad = {**SAMPLE, "site": {"mid_station_m": -1, "mid_road": "비포장"}} + assert client.put(base, json=bad).status_code == 422 + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_메인_탭_하위_탭_추가_삭제(): + page = _read("B08_Quantity/B08_Quantity_UI_Page.ts") + assert '{ id: "milestone", label: "B08_Quantity_Tab_Milestone" }' in page + assert 'if (tab === "milestone" && projectId) return renderMilestone(view, projectId);' in page + assert 'B08_Quantity_Tab_Milestone: ["거리이정표"' in _read( + "ui_template/ui_template_locale_b4.ts" + ) + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui + assert 'label: "+ 추가"' in ui and "open(addRoute(projectId))" in ui + assert 'label: "삭제"' in ui and 'remove.disabled = current === "table"' in ui + assert "data.routes.filter((r) => r.id !== route.id)" in ui + assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert route.count("editOnDouble: true") == 2 and "uploadImage(projectId, routeId" in route + # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state + assert state.count('method: "PUT"') == 1 and "setTimeout" not in state + options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") + assert ( + 'card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId))' + in options + ) + assert "await saveMilestone(projectId);" in options + registry = _read("A00_Common/b_page_state.ts") + assert '"b08-milestone": { bucket: "draft", scope: "project" }' in registry + main = _read("main.py") + assert "app.include_router(b08_milestone_router, dependencies=protected_with_company)" in main + # 짝 표기 + assert "짝 = `B08_Quantity_Milestone_Calc.ts`" in _read( + "B08_Quantity/B08_Quantity_Milestone_Calc.py" + ) + assert "짝 = `B08_Quantity_Milestone_Calc.py`" in _read( + "B08_Quantity/B08_Quantity_Milestone_Calc.ts" + ) + for name in ("UI_Milestone.ts", "UI_Milestone_Route.ts", "UI_Milestone_State.ts", + "Router_Milestone.py"): # fmt: skip + rel = f"B08_Quantity/B08_Quantity_{name}" + assert len(_read(rel).splitlines()) <= 700 diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 4dd32bf14..b8433c381 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -18,6 +18,7 @@ export const ui_locales_b4 = { "Insert suggested value — KDS standard soil 0.5 m", ], B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"], + B08_Quantity_Tab_Milestone: ["거리이정표", "Distance Milestones"], B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."], B08_Quantity_Tab_Materials: ["자재", "Materials"], B08_Quantity_Tab_MaterialTotal: ["총괄자재수량집계표", "Total Material Quantity Table"], From 924b1f61ebe6e0e76c2c77b4a7e642d556cb97e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:57:12 +0900 Subject: [PATCH 53/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=E2=80=94=20=EA=B3=B5=EC=82=AC=EC=A7=80=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=A0=90=20=C2=B7=20=EC=97=85=EC=B2=B4=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EB=B0=A9=EC=9C=84=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=20km=20=EA=B8=80=20=C2=B7=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=EB=88=84=EB=A6=84=20=C2=B7=20[+]=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=B6=94=EA=B0=80=20(244-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체 · 선마다 「도로 구분 km」 - 업체 자리 = 기준점(없으면 공사지) 기준 지도 방위 — 경위도 방위각 › 8방 › 빈 방위 차례 · 같은 방위 나란히 - 업체 상자 누름 = 그 경로 하위 탭 · 공사지 · 기준점 [+] = 작은 창(항목 · 업체 · 목적지 · 방위) → 경로 탭 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 221 ++++++++++++++++++ B08_Quantity/B08_Quantity_UI_Milestone.css | 71 ++++++ B08_Quantity/B08_Quantity_UI_Milestone.ts | 17 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 174 ++++++++++++++ .../test_244_2_b08_milestone_diagram.py | 154 ++++++++++++ 5 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Milestone_Layout.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts create mode 100644 resources/tester/test_244_2_b08_milestone_diagram.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts new file mode 100644 index 000000000..d85c07088 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -0,0 +1,221 @@ +/* ============================================================================= + * B08_Quantity_Milestone_Layout.ts + * 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림). + * + * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들 + * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 둘 다 경위도가 있으면 방위각 · + * 없으면 사용자가 고른 8방 · 고르지 않았으면 빈 방위를 차례로 · 같은 방위 여럿 = 옆으로 나란히 + * 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서) + * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 + * 화면 = 북 위(SVG y 아래로 = 남) + * ========================================================================== */ + +import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; + +export interface DiagramBox { + id: string; + kind: "site" | "start" | "mid" | "base" | "route"; + x: number; + y: number; + w: number; + h: number; + label: string; +} + +export interface DiagramLine { + from: string; + to: string; + x1: number; + y1: number; + x2: number; + y2: number; + labels: string[]; +} + +export interface DiagramLayout { + boxes: DiagramBox[]; + lines: DiagramLine[]; + view: [number, number, number, number]; +} + +type LonLat = [number, number] | null | undefined; + +/** 8방 → 방위각(북 0 · 시계 방향) */ +export const DIR_DEG: Record = { + N: 0, + NE: 45, + E: 90, + SE: 135, + S: 180, + SW: 225, + W: 270, + NW: 315, +}; +const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"]; + +const SITE = { w: 240, h: 96 }; +const NODE = { w: 72, h: 32 }; +const BOX = { w: 148, h: 44 }; +const R_BASE = 300; +const R_ROUTE = 260; +const SPREAD = 64; + +/** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */ +export function bearingDeg(from: [number, number], to: [number, number]): number { + const dx = (to[0] - from[0]) * Math.cos((((from[1] + to[1]) / 2) * Math.PI) / 180); + const dy = to[1] - from[1]; + return ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360; +} + +/** 자리 방위 — 경위도 둘 다 → 방위각 · 아니면 8방 · 없으면 null */ +export function placeDeg( + own: { dir: Dir | null; lon?: number | null; lat?: number | null }, + anchor: LonLat, +): number | null { + if (own.lon != null && own.lat != null && anchor) return bearingDeg(anchor, [own.lon, own.lat]); + return own.dir ? DIR_DEG[own.dir] : null; +} + +export const legText = (legs: Leg[]): string[] => + legs.map((leg) => `${leg.road} ${leg.km == null ? "-" : String(leg.km)}km`); + +const unit = (deg: number): [number, number] => { + const rad = (deg * Math.PI) / 180; + return [Math.sin(rad), -Math.cos(rad)]; +}; + +export function layoutMilestone( + data: MilestoneData, + site: { start_lonlat?: LonLat; mid_lonlat?: LonLat } | null, + midKm: number, +): DiagramLayout { + const boxes: DiagramBox[] = []; + const lines: DiagramLine[] = []; + const center = new Map(); + const put = (box: DiagramBox): void => { + boxes.push(box); + center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]); + }; + const line = (from: string, to: string, labels: string[]): void => { + const [x1, y1] = center.get(from)!; + const [x2, y2] = center.get(to)!; + lines.push({ from, to, x1, y1, x2, y2, labels }); + }; + + // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) + put({ + id: "site", + kind: "site", + x: -SITE.w / 2, + y: -SITE.h / 2, + w: SITE.w, + h: SITE.h, + label: "공사지", + }); + put({ + id: "start", + kind: "start", + x: -SITE.w / 2 + 16, + y: -6, + w: NODE.w, + h: NODE.h, + label: "시점", + }); + put({ + id: "mid", + kind: "mid", + x: SITE.w / 2 - 16 - NODE.w, + y: -6, + w: NODE.w, + h: NODE.h, + label: "중점", + }); + line( + "start", + "mid", + legText([{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]), + ); + + const startLL = site?.start_lonlat ?? null; + const useBase = data.base.enabled; + const baseLL: LonLat = + data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null; + let baseDeg = 270; + if (useBase) { + baseDeg = placeDeg(data.base, startLL) ?? 270; + const [ux, uy] = unit(baseDeg); + const cx = ux * R_BASE; + const cy = uy * R_BASE; + put({ + id: "base", + kind: "base", + x: cx - BOX.w / 2, + y: cy - BOX.h / 2, + w: BOX.w, + h: BOX.h, + label: data.base.name || "기준점", + }); + line("base", "start", legText(data.base.legs)); + } + + // 업체 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 + const groups = new Map< + string, + Array<{ id: string; label: string; anchor: string; deg: number; legs: Leg[] }> + >(); + const used = new Set(); + const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; + const items = data.routes.map((r) => { + const anchor = useBase && r.via_base ? "base" : "site"; + return { r, anchor, deg: placeDeg(r, anchor === "base" ? baseLL : startLL) }; + }); + for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); + let auto = 0; + /** 방위 안 고른 업체 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ + const free = (anchor: string): number => { + const turn = anchor === "base" ? baseDeg : 0; + const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1; + const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( + (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), + ); + return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; + }; + for (const { r, anchor, deg } of items) { + const d = deg ?? free(anchor); + used.add(keyOf(anchor, d)); + const key = keyOf(anchor, d); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push({ id: r.id, label: r.name || "업체", anchor, deg: d, legs: r.legs }); + } + for (const group of groups.values()) { + group.forEach((g, k) => { + const [ax, ay] = center.get(g.anchor === "base" ? "base" : "site")!; + const [ux, uy] = unit(g.deg); + const off = (k - (group.length - 1) / 2) * SPREAD; + const reach = g.anchor === "site" ? R_ROUTE + SITE.w / 4 : R_ROUTE; + const cx = ax + ux * reach - uy * off; + const cy = ay + uy * reach + ux * off; + put({ + id: g.id, + kind: "route", + x: cx - BOX.w / 2, + y: cy - BOX.h / 2, + w: BOX.w, + h: BOX.h, + label: g.label, + }); + line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs)); + }); + } + + const pad = 48; + const xs = boxes.flatMap((b) => [b.x, b.x + b.w]); + const ys = boxes.flatMap((b) => [b.y, b.y + b.h]); + const minX = Math.min(...xs) - pad; + const minY = Math.min(...ys) - pad; + return { + boxes, + lines, + view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY], + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index be1adfa9a..01057d221 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -43,3 +43,74 @@ align-self: flex-start; border: 1px solid var(--color-border); } + +/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) */ +.b08-milestone__diagram { + overflow: auto; + max-height: 48vh; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); +} + +.b08-milestone__diagram svg { + display: block; + margin: 0 auto; + font-size: 13px; +} + +.b08-milestone__line { + stroke: var(--color-text-secondary); + stroke-width: 1.5; +} + +.b08-milestone__box rect { + fill: var(--color-surface); + stroke: var(--color-text-secondary); + stroke-width: 1.2; +} + +.b08-milestone__box text, +.b08-milestone__plus text { + fill: var(--color-text); +} + +.b08-milestone__box.is-site rect { + fill: var(--color-surface-raised); + stroke-dasharray: 4 3; +} + +.b08-milestone__box.is-base rect, +.b08-milestone__box.is-start rect, +.b08-milestone__box.is-mid rect { + stroke: var(--color-accent); +} + +.b08-milestone__box.is-route { + cursor: pointer; +} + +.b08-milestone__box.is-route:hover rect, +.b08-milestone__box.is-route:focus-visible rect { + stroke: var(--color-accent); + stroke-width: 2; +} + +.b08-milestone__plus { + cursor: pointer; +} + +.b08-milestone__plus circle { + fill: var(--color-surface); + stroke: var(--color-accent); + stroke-width: 1.5; +} + +.b08-milestone__km { + fill: var(--color-text); + font-size: 12px; + paint-order: stroke; + stroke: var(--color-surface); + stroke-width: 4px; + stroke-linejoin: round; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index d215bb7df..faa790110 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -6,6 +6,7 @@ * [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 * 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도 + * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(244-2 · 상자 누름 = 경로 탭 · [+] = 항목 추가) * 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄) * ========================================================================== */ @@ -13,6 +14,7 @@ import "./B08_Quantity_UI_Milestone.css"; import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { liveMilestone, loadMilestone, @@ -61,8 +63,9 @@ export function addRoute(projectId: string, init: Partial = {}): return id; } -/** [거리이정표] 하위 탭 — 표(+ 244-2 다이어그램 자리) */ -function tableView(projectId: string): MilestoneView { +/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */ +function tableView(projectId: string, open: (id: string) => void): MilestoneView { + const diagram = createDiagram(projectId, open, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; const table = createDataTable({ columns: COLUMNS, @@ -97,16 +100,20 @@ function tableView(projectId: string): MilestoneView { table.root.dataset.b08Milestone = "table"; const none = el("p", { className: "ui-general-block__empty", - text: "경로가 없습니다 — 하위 탭 줄 [+ 추가]로 업체를 넣으세요.", + text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+] 또는 하위 탭 줄 [+ 추가]로 업체를 넣으세요.", }); const refresh = (): void => { const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); keys = rows.map(({ route, index }) => ({ route, index })); none.hidden = rows.length > 0; table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); + diagram.refresh(); }; refresh(); - return { root: el("div", { className: "b08-milestone", children: [none, table.root] }), refresh }; + return { + root: el("div", { className: "b08-milestone", children: [diagram.root, none, table.root] }), + refresh, + }; } export function renderMilestone(host: HTMLElement, projectId: string): void { @@ -173,7 +180,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { function open(id: string): void { current = id; drawTabs(); - body = id === "table" ? tableView(projectId) : routeView(projectId, id); + body = id === "table" ? tableView(projectId, open) : routeView(projectId, id); view.replaceChildren(body.root); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts new file mode 100644 index 000000000..3a54a3760 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -0,0 +1,174 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Diagram.ts + * [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`. + * + * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글 + * 업체 상자 누름 = 그 경로 하위 탭으로 · 공사지 · 기준점 상자 [+] = 항목 추가(작은 창 → 경로 하위 탭 생김 · + * 같은 업체 이름이면 그 경로에 표 줄만 더함) · 조작 = 캐시(`…_UI_Milestone_State`) + * ========================================================================== */ + +import { openModal } from "@ui/ui_template_modal"; +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { midKm, type Dest, type Dir, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout"; +import { + dirOptions, + liveMilestone, + putMilestone, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; + +const NS = "http://www.w3.org/2000/svg"; + +function svg( + tag: K, + attrs: Record, + text?: string, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + if (text !== undefined) node.textContent = text; + return node; +} + +/** [+] 작은 창 — 항목 · 업체 이름 · 목적지 · 방위 → 새 경로(같은 이름 = 그 경로에 줄) */ +export function openAddItem( + projectId: string, + viaBase: boolean, + addRoute: (init: Partial) => string, +): void { + openModal({ + title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로", + closeLabel: "닫기", + mount: (body, close, actions) => { + const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); + const company = createInputField({ label: "업체 이름", compact: true }); + const dest = createSelectField({ + label: "목적지", + compact: true, + options: [ + { value: "start", text: "시점" }, + { value: "mid", text: "중점" }, + ], + }); + const dir = createSelectField({ + label: viaBase ? "방위(기준점에서 본)" : "방위(공사지에서 본)", + compact: true, + options: dirOptions, + }); + item.input.dataset.b08MilestoneAdd = "item"; + company.input.dataset.b08MilestoneAdd = "company"; + const ok = createButton({ + label: "추가", + variant: "filled", + onClick: () => { + const name = company.input.value.trim(); + if (!name) return company.setError("업체 이름을 넣으세요."); + const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; + const data = liveMilestone(projectId); + const same = data.routes.find((r) => r.name.trim() === name); + if (same) { + same.rows.push(row); + putMilestone(projectId, data); + } else + addRoute({ + name, + via_base: viaBase, + dir: (dir.select.value || null) as Dir | null, + rows: [row], + }); + close(); + }, + }); + actions.append(ok); + body.append(item.root, company.root, dest.root, dir.root); + }, + }); +} + +/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ +export function createDiagram( + projectId: string, + openTab: (routeId: string) => void, + addRoute: (init: Partial) => string, +): { root: HTMLElement; refresh: () => void } { + const host = el("div", { className: "b08-milestone__diagram" }); + host.dataset.b08Milestone = "diagram"; + + const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => { + const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 }); + const cx = box.x + box.w - 2; + const cy = box.y + 2; + g.append( + svg("circle", { cx, cy, r: 11 }), + svg("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "central" }, "+"), + svg("title", {}, "항목 추가"), + ); + g.dataset.b08MilestonePlus = viaBase ? "base" : "site"; + const go = (e: Event): void => { + e.stopPropagation(); + openAddItem(projectId, viaBase, addRoute); + }; + g.addEventListener("click", go); + g.addEventListener("keydown", (e) => e.key === "Enter" && go(e)); + return g; + }; + + const refresh = (): void => { + const data = liveMilestone(projectId); + const site = siteInfo(); + const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null)); + const [vx, vy, vw, vh] = layout.view; + const root = svg("svg", { + viewBox: `${vx} ${vy} ${vw} ${vh}`, + width: vw, + height: vh, + role: "img", + "aria-label": "거리이정표 다이어그램", + }); + root.style.maxWidth = "100%"; + root.style.height = "auto"; + for (const ln of layout.lines) { + root.append( + svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }), + ); + } + for (const box of layout.boxes) { + const g = svg("g", { class: `b08-milestone__box is-${box.kind}` }); + g.dataset.b08MilestoneBox = box.id; + g.append( + svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), + svg( + "text", + { + x: box.x + box.w / 2, + y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2, + "text-anchor": "middle", + "dominant-baseline": "central", + }, + box.label, + ), + ); + if (box.kind === "route") { + g.setAttribute("role", "button"); + g.setAttribute("tabindex", "0"); + g.addEventListener("click", () => openTab(box.id)); + g.addEventListener("keydown", (e) => e.key === "Enter" && openTab(box.id)); + } + if (box.kind === "site") g.append(plus(box, false)); + if (box.kind === "base") g.append(plus(box, true)); + root.append(g); + } + // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 + for (const ln of layout.lines) { + const x = (ln.x1 + ln.x2) / 2; + const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2; + const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" }); + ln.labels.forEach((label, i) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label))); + root.append(text); + } + host.replaceChildren(root); + }; + refresh(); + return { root: host, refresh }; +} diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py new file mode 100644 index 000000000..aa4944a42 --- /dev/null +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -0,0 +1,154 @@ +"""244-2 B08 거리이정표 다이어그램 — 방위 자리(8방 · 경위도 방위각 · 빈 방위 차례 · +같은 방위 나란히) · 선 글(도로 구분 km) · 상자 누름 · [+] 항목 추가 길. + +자리 계산 = `B08_Quantity_Milestone_Layout.ts` 를 node 로 돌림(화면 = 북 위 · SVG y 아래 = 남). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _layout(data, site=None, mid_km=0.75): + src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8") + body = ( + f"\nconst d = {json.dumps(data, ensure_ascii=False)};" + f"\nconsole.log(JSON.stringify(layoutMilestone(d, {json.dumps(site)}, {mid_km})));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", IMPORTS.sub("", src) + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _route(rid, name, dir=None, via=True, legs=None, lon=None, lat=None): + return {"id": rid, "name": name, "address": "", "via_base": via, "dir": dir, "lon": lon, + "lat": lat, "legs": legs or [], + "rows": [{"item": "", "dest": "start", "note": ""}]} # fmt: skip + + +def _data(routes, base=None): + return { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": base or {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "routes": routes, + } + + +def _center(layout, bid): + box = next(b for b in layout["boxes"] if b["id"] == bid) + return box["x"] + box["w"] / 2, box["y"] + box["h"] / 2 + + +def test_공사지_컨테이너_시점_중점_선(): + lay = _layout(_data([])) + kinds = {b["id"]: b["kind"] for b in lay["boxes"]} + assert kinds == {"site": "site", "start": "start", "mid": "mid"} + sx, _ = _center(lay, "start") + mx, _ = _center(lay, "mid") + assert sx < mx # 시점 왼 · 중점 오른 · 한 컨테이너 안 + site = next(b for b in lay["boxes"] if b["id"] == "site") + for bid in ("start", "mid"): + b = next(x for x in lay["boxes"] if x["id"] == bid) + assert site["x"] <= b["x"] and b["x"] + b["w"] <= site["x"] + site["w"] + assert lay["lines"] == [ + {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장 0.75km"]} + ] + + +def test_8방_자리_기준점_없음_공사지_기준(): + lay = _layout( + _data([_route("e", "동쪽", "E"), _route("n", "북쪽", "N"), _route("sw", "남서", "SW")]) + ) + cx, cy = _center(lay, "site") + ex, ey = _center(lay, "e") + nx, ny = _center(lay, "n") + wx, wy = _center(lay, "sw") + assert ex > cx + 200 and abs(ey - cy) < 1 + assert ny < cy - 200 and abs(nx - cx) < 1 + assert wx < cx and wy > cy + line = next(ln for ln in lay["lines"] if ln["from"] == "e") + assert line["to"] == "start" # 기준점 없으면 시점으로 + + +def test_기준점_기준_방위_선_글(): + base = {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "W", + "legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]} # fmt: skip + lay = _layout( + _data( + [ + _route("g", "거창군청", "N", legs=[{"road": "2차포장", "km": 23.8}]), + _route("k", "김천역", "E", via=False), + ], + base, + ), # fmt: skip + ) + cx, cy = _center(lay, "site") + bx, by = _center(lay, "base") + assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽 + gx, gy = _center(lay, "g") + assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽 + kx, _ = _center(lay, "k") + assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽 + labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]} + assert labels[("g", "base")] == ["2차포장 23.8km"] + assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"] + assert labels[("k", "start")] == [] + assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31" + + +def test_경위도_있으면_방위각이_8방보다_앞섬(): + site = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None} + lay = _layout(_data([_route("e", "동쪽 좌표", "N", lon=127.9, lat=35.7)]), site) + cx, cy = _center(lay, "site") + ex, ey = _center(lay, "e") + assert ex > cx + 200 and abs(ey - cy) < 1 # 고른 방위 N 보다 좌표(동) 먼저 + + +def test_빈_방위_차례_같은_방위_나란히(): + lay = _layout( + _data( + [_route("a", "가", "E"), _route("b", "나", "E"), _route("c", "다"), _route("d", "라")] + ) + ) + (ax, ay), (bx, by) = _center(lay, "a"), _center(lay, "b") + assert abs(ax - bx) < 1 and abs(ay - by) >= 60 # 같은 방위 = 옆으로 나란히 + cx, cy = _center(lay, "c") + dx, dy = _center(lay, "d") + sx, sy = _center(lay, "site") + assert cy < sy - 200 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) + assert (dx, dy) != (cx, cy) and dx < sx # 다음 빈 방위(북서) + boxes = [b for b in lay["boxes"] if b["kind"] == "route"] + for i, p in enumerate(boxes): # 업체 상자끼리 안 겹침 + for q in boxes[i + 1 :]: + assert ( + p["x"] + p["w"] <= q["x"] or q["x"] + q["w"] <= p["x"] + or p["y"] + p["h"] <= q["y"] or q["y"] + q["h"] <= p["y"] + ) # fmt: skip + + +def test_화면_상자_누름_더하기_창(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8") + assert 'g.addEventListener("click", () => openTab(box.id));' in ui + assert 'if (box.kind === "site") g.append(plus(box, false));' in ui + assert 'if (box.kind === "base") g.append(plus(box, true));' in ui + assert "openAddItem(projectId, viaBase, addRoute)" in ui and "openModal({" in ui + assert "same.rows.push(row)" in ui # 같은 업체 이름 = 그 경로에 표 줄 + page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") + assert "createDiagram(projectId, open, (init) => addRoute(projectId, init))" in page + assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"): + assert ( + len((ROOT / f"B08_Quantity/B08_Quantity_{name}").read_text("utf-8").splitlines()) <= 700 + ) From 7f0417a0108a7150914691b2c5a44da4cc78678e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:58:15 +0900 Subject: [PATCH 54/92] =?UTF-8?q?chore(plan):=2010-10=20237~245=EC=9E=A5?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20=EC=9D=B4=EA=B4=80(244=20=EB=B9=BC?= =?UTF-8?q?=EA=B3=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-10_plan_237-245.md | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/raw/plans/2026-10-10_plan_237-245.md diff --git a/docs/raw/plans/2026-10-10_plan_237-245.md b/docs/raw/plans/2026-10-10_plan_237-245.md new file mode 100644 index 000000000..f043cd58b --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_237-245.md @@ -0,0 +1,44 @@ +# 2026-10-10 계획 237~245장 기록(244 빼고) — 엑셀 맞춤 · 저장 측점 · 총괄 레벨 2 · 내역서 글꼴 · 끌기 표시 · 입력 칸 테두리 · 빨강 범위 · 더블클릭 · 병합 호버 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 114~127. + +## 237. 엑셀 프로그램(공용 스프레드시트 · M02 카드 · 조합 편집) 셀 맞춤 기본값 = 가로 가운데 · 세로 가운데 (sub1) +정함(브레인) = 사용자 114번 · 칸에 맞춤을 따로 안 준 경우(가로 「일반」 · 세로 없음)의 기본 = 가로 가운데 · 세로 가운데 · 도구줄 맞춤 표시도 가운데가 기본 · 칸에 맞춤을 준 칸은 그대로 · xlsx 내보내기에서도 같은 모양(안 준 칸 = 가운데로 써 넣음) +- [x] 237-1 기본 맞춤 가운데 · 가운데 — 새 시험 + - 검증완료 — 안 준 칸(가로 없음 · 일반 · 세로 없음) = 가운데 · 가운데(칸 · 도구줄 · 서식 창 · 편집창 · 영역 표 · 복사 HTML) · 준 칸 그대로 · 새 시험 test_spreadsheet_align_default 14 항목 + test_m02_area_table 21 + M02 카드 · 스프레드시트 관련 48 · typecheck 통과 · xlsx 내보내기 · 가져오기는 이 프로그램에 없음(복사 HTML 이 같은 몫) · 붙여넣기는 가로 · 세로 안 읽는 칸 = 가운데로 보임 + +## 238. B05 [저장]이 구조물 측점을 지우는 구멍 막기 (sub7) +정함(브레인) = 사용자 115번 · 조사 AG 5장 ① · [저장]이 관 · A군 목록으로 종단 구조물 측점을 통째 갈아 끼우지 않음 → 서버가 심는 측점 규칙(소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 기준 · 종료) 그대로 살림 · 소단 깊이(194)도 남음 · 3D 표기 넓히기 · B06 측점선 · 꺼진 종류 가림은 보류(116) +- [x] 238-1 [저장] 뒤 소단 · 옹벽 · 흙막이 · 기슭막이 측점 남음 — 새 시험(저장 앞뒤 측점 목록 같음) + - 검증완료 — test_238_1 2건(앞뒤 구조물 측점 같음 · 소단 196 · 201 · 206 남음 · 관 150→165 반영 · 고치기 전 코드는 2 실패) + 234_1 · 194_1 · 192_1 · 213_1 · b06_missing_stations · b05_extra_stations_supplier · 74 · 176_3 · app_imports 통과(83) + +## 239. B09 총괄설계내역서 탭 맨 위 = 설계내역서 레벨 2 까지 집계 · 누르면 설계내역서로 · 빨강 강조 (sub3) +정함(브레인) = 사용자 118번 · 총괄설계내역서 탭 표 맨 위에 설계내역서의 레벨 1 · 2 줄(폴더 줄)을 그 금액(재료비 · 노무비 · 경비 · 합계)과 함께 차례대로 · 그 합 = 순공사비(지금 총괄 순공사비와 같은 값) · 줄을 누르면 설계내역서 탭으로 옮겨 그 줄을 빨강으로 강조 · 값은 설계내역서 셈(Calc_Bill) 그대로 · 아래 원가계산 줄은 그대로 +- [x] 239-1 총괄 맨 위 레벨 2 집계 · 누름 이동 · 빨강 강조 — 새 시험 + - 검증완료 — test_239_1 5건(맨 위 = 레벨 1 · 2 폴더 줄 + 레벨 1 항목 · 관급 뺌 · 금액 = 설계내역서 그 줄 · 순공사비 계 = 레벨 1 합 = 총괄 가. 순공사비계 · 누름 = 설계내역서 그 줄 is-pick 빨강) + 235_1 · 229_1 · 145_4 · 185_1 · 181_2 · 200_2 · 166_1 · 171_1 · 201_1 · 228_1 통과 · typecheck 통과 + +## 240. B09 설계내역서 탭 — 폴더가 된 줄 글자 = 같은 레벨 줄과 같은 크기 · 색 · 들여쓰기 넓힘 · 끌기 표시(121) · 데이터 점검 틈(122) (브레인 직접) +정함 = 사용자 119번 · 「너가 직접 확인하고 코드작업」 · 화면에서 레벨 바꿔 폴더가 된 줄 · 원래 폴더 줄의 글자 크기 · 색을 재고 맞춤 +- [x] 240-1 폴더 줄 꼴 한 벌(레벨 고쳐 생긴 폴더도 is-title · 크기 = 레벨) · 121 들여쓰기 한 단 14 → 22px(끌기 한 단 같음) · 끌기 미리 보기(레벨 시작 점 · 「○○ 안 · 레벨 N」 · 들어갈 폴더 줄 테두리 · 끄는 줄 흐림 · 못 놓음 빨강) · 122 데이터 점검 [업데이트] 위 틈 = 카드 왼쪽 여유 12px — 검증완료(test_240_1 + 관련 91 통과 · typecheck · 화면 수치: 새 레벨 2 폴더 색 · 크기 = 다른 레벨 2 폴더 · 틈 12.0 = 왼쪽 12.9 · 미리 보기 글 · 테두리 · 흐림 확인) + +## 241. B08 · B09 표 — 입력 칸 표시 = 칸 색 대신 칸 테두리 (sub1) +정함(브레인) = 사용자 123 · 124번 · 테두리 색 = 어두움 · 밝음 테마 둘 다 또렷하게(테마 변수만 · 두 테마 대비 잼) · 사용자가 값을 넣을 수 있는 칸(공용 표 글 상자 · 시트 is-editable · 페이지 따로 칠한 입력 칸)의 채움 색을 없애고 칸 안쪽 테두리(얇은 accent 선)로 · 칸 크기 · 줄 높이 안 바뀜(inset) · 초점 테두리(focus-ring)는 그대로 · 사용자가 고친 값 표시(is-edited 색 칸)는 뜻이 달라 그대로 +- [x] 241-1 B08 · B09 입력 칸 테두리 — 새 시험 + - 검증완료 — test_241_1 3 + 공용 표 · B08 · B09 관련 시험 통과 · typecheck · prettier · 공용 표 글 상자 채움 색 → 칸 안쪽 1px accent 테두리 +- [x] 241-2 (sub1) 124번 테마 — 입력 칸 테두리 = color-mix(accent 60% · canvas)(밝음 #8b66af 대비 4.5 · 어두움 #7864a5 대비 3.7 · 둘 다 3:1 넘음 · 초점 테두리 2px 진한 accent 와 또렷이 갈림) — 시험 기대값 + - 검증완료 — test_241_1 3 + 공용 표 관련 시험 통과 · prettier · 입력 칸 테두리 색 = 두 테마 변수 color-mix(accent 60% · canvas) + +## 242. B09 총괄 → 설계내역서 빨강 강조 = 그 줄 + 하위 전부 · 고름 표시 하나만 (sub3) +정함(브레인) = 사용자 125번 · ① 총괄설계내역서 탭 맨 위 줄을 누르면 설계내역서 탭에서 그 줄과 하위 줄 전부를 한 범위로 빨강(범위 바깥 테두리 한 벌) ② 고름 상자(235 accent 상자)와 빨강이 한 줄에 겹쳐 그려지지 않음 — 한때 하나만 ③ 설계내역서에서 다른 줄을 누르면 빨강 풀림 · 그 줄만 고름 상자 ④ 이미 고른 줄이 있어도 총괄에서 누르고 오면 고름 풀림 · 빨강만 +- [x] 242-1 범위 빨강 · 표시 하나 · 서로 풀기 — 새 시험 + - 검증완료 — test_242_1 5 + 239_1 · 235_1 · 181_2 · 240_1 · 200_2 통과(30) · typecheck · 「1.1 절토」로 오면 그 줄 + 자손 5줄 빨강(첫 줄 위 · 끝 줄 아래) · 고름 0 · 다른 줄 누름 · 방향키 · Esc = 빨강 0 + +## 243. B08 · B09 표 수기 입력 = 모두 더블클릭 (sub1) +정함(브레인) = 사용자 126번 · 「모든 값들은 쉽게 변경되면 안 됨」 · B08 · B09 의 값 고칠 수 있는 표 전부 235 editOnDouble(한 번 누름 = 고름 · 더블클릭 · Enter · F2 = 입력 · Enter · Esc · 초점 떠남 = 끝) · 늘 글 상자로 그리던 표도 같음 · 241 입력 칸 테두리는 글 상자가 아닐 때도 칸(td)에 보임(고칠 수 있는 칸 표시) · 칸 누름으로 여는 메뉴 · 고르기(출처 · 관급 · 별도 · 연결 ▾)는 그대로 +- [x] 243-1 B08 · B09 모든 입력 표 더블클릭 · 입력 칸 테두리 늘 보임 — 새 시험 + - 검증완료 — test_243_1 5건(공용 표 한 번 누름 = 글 상자 없음 · 더블클릭 · Enter · F2 = 그 칸 입력 · 고칠 칸 td `is-can-edit` 테두리 늘 · 못 고치는 칸 · 옛 표 = 없음 · B09 표 만드는 길 Shell 한 곳 · B08 고칠 칸 있는 표 editOnDouble · 메뉴 · 이동 그대로) + 235_1 · 241_1 · 121 · 183_3 · 185_1 · 145_4 · 166_1 · B08 · B09 관련 152건 통과 · typecheck · prettier + +## 245. B09 공사원가계산서 탭 표 — 병합 칸 줄 호버 강조 정리 (sub3) +정함(브레인) = 사용자 127번 · 같은 값 세로 병합(mergeSame · rowSpan) 표에서 줄에 마우스를 올리면 병합 칸 일부만 칠해져 모양이 어지러움 → 공용 표 호버 규칙 정리(병합 칸은 그 칸이 덮는 줄 중 하나에 올리면 칸 전체를 한 번에 · 또는 병합 칸은 칠하지 않음 중 깔끔한 쪽 · 화면 두 꼴 비교 뒤 하나) +- [x] 245-1 병합 표 호버 정리 — 새 시험 + - 검증완료 — 규칙 (가) 병합 칸은 호버 · 고른 줄 · 줄무늬 안 칠함(CSS 한 덩이 · 공용 표라 B08 · B09 병합 표 모두) · 빨강 범위는 그대로 · test_245_1 4 + 201_1 · 228_1 · 239_1 · 242_1 · ui_data_table 합 36 통과 · typecheck · prettier From 72e2c10824da8f8543d79c1f19870114e100273a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:11:15 +0900 Subject: [PATCH 55/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 2289cca3c..7f8f27889 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -155,3 +155,4 @@ 126. 다음으로 B08과B09의 테이블 내용에서 수기입력 방식은 더블클릭으로 변경해줘. 모든 값들은 쉽게 변경되면 안되거든. 120-답(조사 AH 물음 넷 답 · 원문). ① 경로 그림 = 「일단 브이월드에서 길찾기가 있는지 몰랐네? 있다면 브이월드도 좋아 벌써 API가 연결되어있거든.」 ② 도로 구분 = 「모두 사용자가 선택하게 해줘. 기점별로 어차피 드롭다운 메뉴를 지정하면 되지 않을까?」 ③ 표 구분 = 「구분에서 첫번째열은 항목: 수기입력, 2번째열은 업체이름: 수기입력, 세번째 열은 목적지임. 사용자는 다이어그램으로 표현된 컨테이너를 이용하여 추가수정할수 있게 하되 대신 방향은 기준점을 기준으로 지도상의 방위를 나타냄. 항목을 추가할때는 구분정보와 지도상의 위치값과 길찾기 값을 받아와야함.」 ④ 중점 = 「사용자가 입력할수 있게 해줘. 기본값은 도로의 중간지점으로 기본값으로 하고 사용자가 지정하게 하자.」 127. 다음으로 공사원가계산서의 테이블에서 병합기능을 사용하였는데 행의 마우스 호버에 연동된 하이라이트 기능이 형태가 복잡해지고 있어. 개선해줘. +128. (B09 설계내역서 표 화면 붙임 · 입력 칸 보라 테두리) 테이블에 셀테두리를 추가했는데 구분에 전혀 도움이 되지 않고 있어. 일단 테두리는 원래대로 돌려주고 다음으로 새 공종추가, 아래 줄, 위에 줄 추가들은 하나로 항목 추가로 하자. 어차피 마우스 우클릭 후 해당 위치를 기준으로 그냥 하나를 넣어주면 되는거야. 어차피 위치에 따라 결정되니 없어도 문제 없어보여. 연결에서는 서고나 (마스터 / 회사/개인)이라는 이름을 삭제해줘 쓸데없어. (M01도 새산근도 동일함.) 구조물 조합, 일위대가 사전조합, 단가산출 이렇게만 표현해줘. 줄지우기는 줄 삭제로 변경 From 2402497284ca91aaebf2f659c6efa045e0deb217 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:13:08 +0900 Subject: [PATCH 56/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20129?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 7f8f27889..b67f294ed 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -156,3 +156,4 @@ 120-답(조사 AH 물음 넷 답 · 원문). ① 경로 그림 = 「일단 브이월드에서 길찾기가 있는지 몰랐네? 있다면 브이월드도 좋아 벌써 API가 연결되어있거든.」 ② 도로 구분 = 「모두 사용자가 선택하게 해줘. 기점별로 어차피 드롭다운 메뉴를 지정하면 되지 않을까?」 ③ 표 구분 = 「구분에서 첫번째열은 항목: 수기입력, 2번째열은 업체이름: 수기입력, 세번째 열은 목적지임. 사용자는 다이어그램으로 표현된 컨테이너를 이용하여 추가수정할수 있게 하되 대신 방향은 기준점을 기준으로 지도상의 방위를 나타냄. 항목을 추가할때는 구분정보와 지도상의 위치값과 길찾기 값을 받아와야함.」 ④ 중점 = 「사용자가 입력할수 있게 해줘. 기본값은 도로의 중간지점으로 기본값으로 하고 사용자가 지정하게 하자.」 127. 다음으로 공사원가계산서의 테이블에서 병합기능을 사용하였는데 행의 마우스 호버에 연동된 하이라이트 기능이 형태가 복잡해지고 있어. 개선해줘. 128. (B09 설계내역서 표 화면 붙임 · 입력 칸 보라 테두리) 테이블에 셀테두리를 추가했는데 구분에 전혀 도움이 되지 않고 있어. 일단 테두리는 원래대로 돌려주고 다음으로 새 공종추가, 아래 줄, 위에 줄 추가들은 하나로 항목 추가로 하자. 어차피 마우스 우클릭 후 해당 위치를 기준으로 그냥 하나를 넣어주면 되는거야. 어차피 위치에 따라 결정되니 없어도 문제 없어보여. 연결에서는 서고나 (마스터 / 회사/개인)이라는 이름을 삭제해줘 쓸데없어. (M01도 새산근도 동일함.) 구조물 조합, 일위대가 사전조합, 단가산출 이렇게만 표현해줘. 줄지우기는 줄 삭제로 변경 +129. 다음으로 마우스 우클릭 같은 패널에 항목을 추가하여 보여주는데 그냥 우측에 신규 페이지로 연결 5개 항목을 보여주면 됨. From 0dce57b0aa0f065182e6311334f4e4814dcdd09b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:24:56 +0900 Subject: [PATCH 57/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20130?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index b67f294ed..423c42c2e 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -157,3 +157,4 @@ 127. 다음으로 공사원가계산서의 테이블에서 병합기능을 사용하였는데 행의 마우스 호버에 연동된 하이라이트 기능이 형태가 복잡해지고 있어. 개선해줘. 128. (B09 설계내역서 표 화면 붙임 · 입력 칸 보라 테두리) 테이블에 셀테두리를 추가했는데 구분에 전혀 도움이 되지 않고 있어. 일단 테두리는 원래대로 돌려주고 다음으로 새 공종추가, 아래 줄, 위에 줄 추가들은 하나로 항목 추가로 하자. 어차피 마우스 우클릭 후 해당 위치를 기준으로 그냥 하나를 넣어주면 되는거야. 어차피 위치에 따라 결정되니 없어도 문제 없어보여. 연결에서는 서고나 (마스터 / 회사/개인)이라는 이름을 삭제해줘 쓸데없어. (M01도 새산근도 동일함.) 구조물 조합, 일위대가 사전조합, 단가산출 이렇게만 표현해줘. 줄지우기는 줄 삭제로 변경 129. 다음으로 마우스 우클릭 같은 패널에 항목을 추가하여 보여주는데 그냥 우측에 신규 페이지로 연결 5개 항목을 보여주면 됨. +130. (거리이정표 고침 · 원문) 1. 공사지의 + 버튼으로 나오는 모달창을 이용하여 항목, 업체이름 목적지를 받음. 방위항목은 지도상에 지정한 위치에 따라 자동배치되는 항목임. 하단에는 주소검색 기능을 구현과 등록완료시 지정된 맵을 보여주는 기능 구현. 프로젝트 등록에 관련 기능있음. 재활용, 시점을 컨테이너를 누르면 모달창으로 프로젝트 등록할 당시의 주소와 브이월드 주소와 하단에 맵을 보여줌. 2. 공사지의 (끊김) 3. 다이어그램의 영역은 팬과 줌인 동작이 되어야함. 동작과 관련해서는 공용 양식 있음. 확인 4. 보조탭으로 별도로 추가버튼 필요 없으며 탭의 내용은 거리 길찾기를 한 맵의 결과가 필요함. 구간, 표줄 이런건 필요없음. 만약 길찾기의 경우 안된다면 별도의 창에서 다음맵을 연결하여 길을 찾게하고 해당 브라우저를 확인시 캡처하게 하도록 변경 5. 컨테이너를 누를경우 모달창으로 관련정보를 보여줌. 페이지 이동 아님 From 08dd166da30fc327c47ee1114647adaf704d73a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:25:45 +0900 Subject: [PATCH 58/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20130?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 423c42c2e..e8de5f341 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -158,3 +158,4 @@ 128. (B09 설계내역서 표 화면 붙임 · 입력 칸 보라 테두리) 테이블에 셀테두리를 추가했는데 구분에 전혀 도움이 되지 않고 있어. 일단 테두리는 원래대로 돌려주고 다음으로 새 공종추가, 아래 줄, 위에 줄 추가들은 하나로 항목 추가로 하자. 어차피 마우스 우클릭 후 해당 위치를 기준으로 그냥 하나를 넣어주면 되는거야. 어차피 위치에 따라 결정되니 없어도 문제 없어보여. 연결에서는 서고나 (마스터 / 회사/개인)이라는 이름을 삭제해줘 쓸데없어. (M01도 새산근도 동일함.) 구조물 조합, 일위대가 사전조합, 단가산출 이렇게만 표현해줘. 줄지우기는 줄 삭제로 변경 129. 다음으로 마우스 우클릭 같은 패널에 항목을 추가하여 보여주는데 그냥 우측에 신규 페이지로 연결 5개 항목을 보여주면 됨. 130. (거리이정표 고침 · 원문) 1. 공사지의 + 버튼으로 나오는 모달창을 이용하여 항목, 업체이름 목적지를 받음. 방위항목은 지도상에 지정한 위치에 따라 자동배치되는 항목임. 하단에는 주소검색 기능을 구현과 등록완료시 지정된 맵을 보여주는 기능 구현. 프로젝트 등록에 관련 기능있음. 재활용, 시점을 컨테이너를 누르면 모달창으로 프로젝트 등록할 당시의 주소와 브이월드 주소와 하단에 맵을 보여줌. 2. 공사지의 (끊김) 3. 다이어그램의 영역은 팬과 줌인 동작이 되어야함. 동작과 관련해서는 공용 양식 있음. 확인 4. 보조탭으로 별도로 추가버튼 필요 없으며 탭의 내용은 거리 길찾기를 한 맵의 결과가 필요함. 구간, 표줄 이런건 필요없음. 만약 길찾기의 경우 안된다면 별도의 창에서 다음맵을 연결하여 길을 찾게하고 해당 브라우저를 확인시 캡처하게 하도록 변경 5. 컨테이너를 누를경우 모달창으로 관련정보를 보여줌. 페이지 이동 아님 +130-6. 거리와 도로 구분 정보는 다이어그램에서 바꿀수도 있게 변경 From 8d50aa0582f8fe0b924b0828e73a9494156ee335 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:28:09 +0900 Subject: [PATCH 59/92] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EC=9E=85=EB=A0=A5=20=EC=B9=B8=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20=EB=90=98=EB=8F=8C=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EC=9A=B0=ED=81=B4=EB=A6=AD=20=E3=80=8C=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=E3=80=8D=20=ED=95=98=EB=82=98=20=C2=B7=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=9D=B4=EB=A6=84=20=EC=85=8B=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=A4=84=20=EC=82=AD=EC=A0=9C=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EB=A9=94=EB=89=B4=20=EC=98=86=20=ED=8C=90?= =?UTF-8?q?=20(246-1=20=C2=B7=20246-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 표 입력 칸 = 241 앞 모양(accent 5% 채움 · td is-can-edit 표시 지움 · 더블클릭 입력 그대로) - 우클릭 더함 넷 → 「항목 추가」 — 항목 = 바로 아래 같은 부모 · 폴더 = 첫 하위 · 1단 = ← 내어쓰기(직접비 · 관급자재대 앞 · 자재 줄 막음) - 연결 이름 = 구조물 조합 · 일위대가 사전조합 · 단가산출 · 들여오기 알림 글 쉬운 말 · 「줄 지우기」 → 「줄 삭제」 · 안내 글 - 공용 우클릭 메뉴 children = 「글 ▸」 한 줄 + 오른쪽 옆 판(올림 · 누름 · → 열기 · ← 닫기 · 화면 끝 = 왼쪽) - 시험 기대값 고침(241_1 · 243_1 · 166_2 · 182 · 184_3 · 204_2) · 새 시험 246_1 · 246_2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 2 +- .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 48 +++-- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 15 +- resources/tester/test_166_2_b09_bill_hand.py | 28 +-- resources/tester/test_182_b09_bill_level.py | 25 +-- resources/tester/test_184_3_b09_bill_menu.py | 91 +++++----- .../tester/test_204_2_b09_bill_library.py | 12 +- .../tester/test_241_1_input_cell_border.py | 16 +- .../tester/test_243_1_double_click_tables.py | 22 +-- .../tester/test_246_1_b09_bill_menu_simple.py | 82 +++++++++ .../tester/test_246_2_context_submenu_side.py | 168 ++++++++++++++++++ ui_template/ui_template_context_menu.css | 15 +- ui_template/ui_template_context_menu.ts | 98 +++++++--- ui_template/ui_template_data_table.css | 11 +- ui_template/ui_template_data_table.ts | 9 +- 15 files changed, 446 insertions(+), 196 deletions(-) create mode 100644 resources/tester/test_246_1_b09_bill_menu_simple.py create mode 100644 resources/tester/test_246_2_context_submenu_side.py diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 2fac2632e..fc8e13020 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -64,7 +64,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "줄을 우클릭하면 추가 · 지움 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + + "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + (direct.blank ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` : ""), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index d8ea35242..f036c2206 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -5,12 +5,14 @@ * 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시 * {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]} * 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천) - * 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct + * 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위로 내어 쓴 줄(묶음 · cost direct) * 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김) * 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level · * 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비) - * 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기 + * 바뀌는 옮김 · 자재 줄 삭제(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 삭제 * 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) + * 더함(246) = 「항목 추가」 하나 — 누른 줄이 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 · + * 맨 위 1단(새 공종) = 레벨 단추 · ← 로 내어 씀(관급자재대 앞) * 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 · * 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`) * ========================================================================== */ @@ -33,7 +35,6 @@ export type BillTable = TabTable & { }; const NEW_NAME = "새 줄"; -const NEW_SECTION = "새 공종"; /** 고칠 칸 — 자동 줄은 덮개 */ const FIELDS = ["name", "spec", "unit", "qty", "ref"]; /** 깊이 한도 — 1 = 맨 위 공종 */ @@ -242,26 +243,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex if (run) parts[part].push([label, run]); }; - // 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞) - add(0, "위에 줄 추가", (w, e) => { - if (!e.parent) return false; - const at = find(w.bill.rows, key); - w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent)); - }); - add(0, "아래에 줄 추가", (w, e) => { - if (!e.parent || key === "fee") return false; - w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent)); - }); - add(0, "아래에 하위 줄 추가", (w) => { - if (!isGroup(w, key)) return false; - w.bill.rows.push(newItem(key)); - }); - add(0, "새 공종 추가(1단)", (w, e) => { + // 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모(조달수수료 = 관급 묶음 끝 · 셈이 그 뒤에 둠) + add(0, "항목 추가", (w, e) => { const list = w.bill.rows; - const from = find(list, key) < 0 ? String(e.parent) : key; - const top = chain(list, from).at(-1)!; - const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1); - list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION }); + const at = find(list, key); + if (isGroup(w, key)) list.splice(at + 1, 0, newItem(key)); + else if (!e.parent) return false; + else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent)); }); if (linkable(row)) { const [picks, more] = linkFor(ctx, rows, i); @@ -292,10 +280,18 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex }); add(2, "내어쓰기 ←", (w, e) => { const up = e.parent ? entryOf(w, e.parent) : undefined; - if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴) - e.parent = up.parent; + if (!up || (!up.parent && !canHold(key))) return false; // 자재 줄 = 1단 안 됨 pull(w.bill.rows, key); - w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e); + if (up.parent) e.parent = up.parent; + else { + // 새 공종(1단) = 직접비 맨 위 · 손 줄은 묶음(자재 줄 돈 갈래 바뀜 = fits 가 막음) + delete e.parent; + e.cost = "direct"; + if (!row.auto) Object.assign(e, { kind: "group", qty: null, ref: null }); + } + // 맨 위로 = 관급자재대 앞 · 그 밖 = 옛 부모 바로 뒤 + const top = !up.parent && costOf(w, w.bill.rows, up.key) === "supply"; + w.bill.rows.splice(find(w.bill.rows, up.key) + (top ? 0 : 1), 0, e); }); } if (!row.auto && row.kind === "item") @@ -315,7 +311,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex }); // 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움 if (!fixed(key) && !key.startsWith("mat:")) - add(3, "줄 지우기", (w, e) => { + add(3, "줄 삭제", (w, e) => { const list = w.bill.rows; if (!e.parent && list.some((c) => c.parent === key)) return false; for (const c of list) if (c.parent === key) c.parent = e.parent; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 0e384d428..9152d1120 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -100,7 +100,7 @@ async function importLib( ): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return; - showToast("서고에서 들여오는 중…", "info"); + showToast("가져오는 중…", "info"); try { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, { method: "POST", @@ -119,11 +119,11 @@ async function importLib( const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); showToast( - got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함", + got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함", "success", ); } catch (error) { - showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error"); + showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error"); } } @@ -157,12 +157,9 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L const picks: Leaf[] = [ ["일위대가 호표", () => pickHopyo(ctx, apply, "dg")], ["단가산출 호표", () => pickHopyo(ctx, apply, "sg")], - ["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)], - ["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)], - [ - "단가산출 로직(M01 · 새 산근)", - () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)), - ], + ["구조물 조합", () => pickCombo(ctx, apply)], + ["일위대가 사전조합", () => pickPrecombo(ctx, apply)], + ["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))], ]; return [picks, more]; } diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 45a41bbca..8941dc397 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -194,7 +194,7 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path): assert qty["LB000033"] >= round(10 * 0.195, 2) # 석공 = 손 줄 수량 × 0.195(+ 낙석망) -def test_우클릭_메뉴_줄_추가_지우기_연결_TS(): +def test_우클릭_메뉴_항목_추가_줄_삭제_연결_TS(): # 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘 src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) rows = [ @@ -219,24 +219,24 @@ const run = (i, label) => {{ return user.bill; }}; const out = {{ title: labels(1), auto: labels(2), hand: labels(3) }}; -out.above = run(2, "위에 줄 추가"); +out.below = run(2, "항목 추가"); out.reset = run(2, "자동값으로 되돌리기"); -out.left = run(3, "줄 지우기"); +out.left = run(3, "줄 삭제"); console.log(JSON.stringify(out)); """ out = json.loads(_node(src + body)) - add = ["위에 줄 추가", "아래에 줄 추가"] - assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"] - assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", - "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip + # 246-1 더함 = 「항목 추가」 하나 · 지움 = 「줄 삭제」 · 2단 제목 ← = 1단으로 + assert out["title"] == ["항목 추가", "-", "내어쓰기 ←", "-", "줄 삭제"] + assert out["auto"] == ["항목 추가", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", + "-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip # 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑", - "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip - # 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) - above = out["above"] - assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"] - new, earth, hand = above["rows"][2:] - assert (new["name"], new["parent"], above["v"]) == ("새 줄", "sec:1.2", 2) + assert out["hand"] == ["항목 추가", "-", "연결", "연결 풀기", "-", "위로 ↑", + "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip + # 항목 추가(항목 줄) = 바로 아래 같은 부모 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) + below = out["below"] + assert [e["key"][:5] for e in below["rows"]] == ["sec:1", "sec:1", "earth", "hand:", "hand:"] + earth, new, hand = below["rows"][2:] + assert (new["name"], new["parent"], below["v"]) == ("새 줄", "sec:1.2", 2) assert earth == {"key": "earth:4", "parent": "sec:1.2", "qty": 100} assert (hand["key"], hand["qty"], hand["ref"]) == ("hand:b", 4, HAND[1]["ref"]) assert "qty" not in out["reset"]["rows"][2] # 자동값으로 = 수량 덮개 지움 diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 1bac0f18f..1ae488159 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -189,15 +189,15 @@ const out = { g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"), b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"), }; -out.child = order(run("hand:g2", "아래에 하위 줄 추가")); -out.below = order(run("hand:g1", "아래에 줄 추가")); -out.belowB = order(run("hand:b", "아래에 줄 추가")); +out.child = order(run("hand:g2", "항목 추가")); +out.below = order(run("hand:g1", "항목 추가")); +out.belowB = order(run("hand:b", "항목 추가")); out.indentBill = run("hand:x", "들여쓰기 →"); out.indent = order(out.indentBill); out.outdent = order(run("hand:g2", "내어쓰기 ←")); out.toItem = run("hand:g2", "항목으로 바꾸기").rows.filter((r) => ["hand:g2", "hand:b"].includes(r.key)); out.toGroup = run("hand:x", "묶음 줄로 바꾸기").rows.find((r) => r.key === "hand:x"); -out.removed = order(run("hand:g1", "줄 지우기")); +out.removed = order(run("hand:g1", "줄 삭제")); console.log(JSON.stringify(out)); """ @@ -221,15 +221,16 @@ def test_우클릭_레벨_메뉴_TS(): out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + body)) level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"} pick = lambda key: [x for x in out[key] if x in level] # noqa: E731 - assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"] + assert [x for x in out["g1"] if "추가" in x] == ["항목 추가"] # 246-1 더함 하나 assert "연결" not in out["g1"] and pick("g1") == ["내어쓰기 ←", "항목으로 바꾸기"] assert pick("a") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 없음 → 들여쓰기 없음 assert pick("g2") == ["내어쓰기 ←", "항목으로 바꾸기"] # 위 형제 a = 항목 assert pick("b") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 레벨 5 묶음까지 assert pick("x") == ["들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 g1 - assert pick("auto") == ["내어쓰기 ←"] and "줄 지우기" in out["auto"] # 자동 줄도 지움 - assert out["child"] == _put(BASE, 6, ["NEW", "hand:g2"]) # g2 마지막 자식 뒤 - assert out["below"] == _put(BASE, 6, ["NEW", "sec:1.2"]) # g1 자손 끝 뒤 · 같은 부모 + assert pick("auto") == ["내어쓰기 ←"] and "줄 삭제" in out["auto"] # 자동 줄도 지움 + # 246-1 항목 추가 — 폴더 = 그 폴더 첫 하위 · 항목 = 바로 아래 같은 부모 + assert out["child"] == _put(BASE, 4, ["NEW", "hand:g2"]) + assert out["below"] == _put(BASE, 2, ["NEW", "hand:g1"]) assert out["belowB"] == _put(BASE, 5, ["NEW", "hand:g2"]) assert out["indent"] == [*BASE[:6], ["hand:x", "hand:g1"], BASE[7]] # g1 마지막 자식 assert out["outdent"] == [*BASE[:3], ["hand:g2", "sec:1.2"], *BASE[4:]] # g1 뒤 @@ -320,10 +321,10 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): picks = [ "일위대가 호표", "단가산출 호표", - "서고 구조물조합(마스터 · 회사 · 개인)", - "서고 일위대가 사전조합(마스터 · 회사 · 개인)", - "단가산출 로직(M01 · 새 산근)", - ] # 204-2 + "구조물 조합", + "일위대가 사전조합", + "단가산출", + ] # 204-2 · 246-1 이름 셋 # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 unlink = [10, 40, [*picks, "-", "연결 풀기"]] assert out["menus"] == [unlink, unlink, [10, 40, picks]] diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index 78f99583a..e7e72e92b 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -1,4 +1,4 @@ -"""184-3 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 지움 → gone · 옮김 · 새 공종 · +"""184-3 · 246-1 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 삭제 → gone · 옮김 · 새 공종(←) · 자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선. - 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄 @@ -119,26 +119,26 @@ ORDER = [(r["key"], r["parent"]) for r in ROWS if r["key"] != "fee"] def test_메뉴_모든_줄(): out = _menu({}, labels=["sec:1", "earth:4", "earth:5", "hand:g", "hand:a", "fee", "mat:M2", "mat:M1", "sec:G"])["labels"] # fmt: skip - add = ["위에 줄 추가", "아래에 줄 추가"] - # 맨 위 제목 = 하위 줄 · 새 공종 · 아래로(자식 있는 맨 위 = 못 지움) - assert out["sec:1"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "아래로 ↓"] - # 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음) - assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", - "아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip + add = ["항목 추가"] # 246-1 더함 하나 + # 맨 위 제목 = 항목 추가(첫 하위) · 아래로(자식 있는 맨 위 = 못 지움 · 맨 위 = 내어쓰기 없음) + assert out["sec:1"] == [*add, "-", "아래로 ↓"] + # 자동 항목 = 더함 · 연결 · 옮김 · 삭제(고친 칸 없음 = 자동값으로 없음) + assert out["earth:4"] == [*add, "-", "연결", "연결 풀기", "-", + "아래로 ↓", "내어쓰기 ←", "-", "줄 삭제"] # fmt: skip # 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓", + assert out["earth:5"] == [*add, "-", "연결", "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기", - "줄 지우기"] # fmt: skip - assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑", - "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-", - "줄 지우기"] # fmt: skip - assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←", - "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip - # 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음 - assert out["fee"] == ["위에 줄 추가", "새 공종 추가(1단)"] # 조달수수료 = 관급자재대 끝 고정 - assert out["mat:M2"] == [*add, "새 공종 추가(1단)"] - assert out["mat:M1"] == [*add, "새 공종 추가(1단)"] - assert out["sec:G"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)"] + "줄 삭제"] # fmt: skip + assert out["hand:g"] == [*add, "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", + "항목으로 바꾸기", "-", "줄 삭제"] # fmt: skip + assert out["hand:a"] == [*add, "-", "연결", "연결 풀기", "-", "내어쓰기 ←", + "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip + # 조달수수료 · 관급 자재 = 연결 · 옮김 · 삭제 없음 · 관급자재대 제목 = 옮김 · 삭제 없음 · + # 자재 줄 = 1단으로 못 냄(별도 자재 mat:M1 도) + assert out["fee"] == add # 조달수수료 = 관급자재대 끝 고정 + assert out["mat:M2"] == add + assert out["mat:M1"] == add + assert out["sec:G"] == add def test_칸_고침_덮개_옛_꼴_굳힘(): @@ -166,38 +166,34 @@ V2 = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k if k != "hand:b"], "gone": []}} # fmt: skip -def test_지움_옮김_새_공종_자동값으로(): +def test_삭제_옮김_새_공종_자동값으로(): user = {"bill": {**V2["bill"], "rows": [ {**e, **({"qty": 9} if e["key"] == "earth:5" else {})} for e in V2["bill"]["rows"]]}} # fmt: skip - runs = [("earth:4", "줄 지우기"), ("hand:g", "줄 지우기"), ("earth:5", "위로 ↑"), - ("earth:4", "아래로 ↓"), ("earth:4", "새 공종 추가(1단)"), - ("mat:M2", "새 공종 추가(1단)"), ("earth:5", "자동값으로 되돌리기"), - ("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "아래에 하위 줄 추가"), - ("earth:4", "위에 줄 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"), - ("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "위에 줄 추가"), - ("fee", "새 공종 추가(1단)")] # fmt: skip + runs = [("earth:4", "줄 삭제"), ("hand:g", "줄 삭제"), ("earth:5", "위로 ↑"), + ("earth:4", "아래로 ↓"), ("sec:1.2", "내어쓰기 ←"), + ("mat:M2", "내어쓰기 ←"), ("earth:5", "자동값으로 되돌리기"), + ("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "항목 추가"), + ("earth:4", "항목 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"), + ("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip out = _menu(user, runs=runs)["runs"] keys = lambda b: [k for k, _ in _order(b)] # noqa: E731 # 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘 base = keys(out["earth:5|위로 ↑"]) assert "hand:b" in base and base.index("hand:b") == base.index("hand:a") + 1 # 지움 — 자동 줄 = gone · 새 줄 = 자식은 지운 줄 부모로 - b = out["earth:4|줄 지우기"] + b = out["earth:4|줄 삭제"] assert b["gone"] == ["earth:4"] and "earth:4" not in keys(b) - b = out["hand:g|줄 지우기"] + b = out["hand:g|줄 삭제"] assert b["gone"] == [] and ("hand:a", "sec:1.2") in _order(b) and "hand:g" not in keys(b) # 위로 · 아래로 = 같은 부모 형제와 자리 바꿈 for k in ("earth:5|위로 ↑", "earth:4|아래로 ↓"): got = keys(out[k]) assert got[got.index("earth:5") + 1] == "earth:4" - # 새 공종 = 맨 위 묶음 · cost direct · 그 맨 위 공종 뒤 / 관급자재대 앞 - b = out["earth:4|새 공종 추가(1단)"] - new = [e for e in b["rows"] if e["key"].startswith("hand:") and e.get("cost")][0] - assert (new["kind"], new["cost"], new["name"], "parent" in new) == ( - "group", "direct", "새 공종", False) # fmt: skip - assert keys(b)[keys(b).index(new["key"]) + 1] == "sec:4" - b = out["mat:M2|새 공종 추가(1단)"] - assert keys(b)[keys(b).index("sec:G") - 1].startswith("hand:") + # 246-1 새 공종 = 2단을 ← 로 맨 위(cost direct · 옛 부모 바로 뒤) · 관급 자재 = 못 냄 + b = out["sec:1.2|내어쓰기 ←"] + assert [k for k, p in _order(b) if p is None] == ["sec:1", "sec:1.2", "sec:4", "sec:G"] + assert {e["key"]: e for e in b["rows"]}["sec:1.2"]["cost"] == "direct" + assert out["mat:M2|내어쓰기 ←"] is None # 자동값으로 = 고친 칸 지움(차례 · 부모 그대로) b = out["earth:5|자동값으로 되돌리기"] assert {e["key"]: e for e in b["rows"]}["earth:5"] == {"key": "earth:5", "parent": "sec:1.2"} @@ -207,23 +203,22 @@ def test_지움_옮김_새_공종_자동값으로(): assert got[got.index(("hand:g", "sec:1.2")) + 1] == ("hand:a", "sec:1.2") got = _order(out["earth:4|내어쓰기 ←"]) # 1.1 → 1 아래(제목 뒤) assert got[got.index(("sec:1.2", "sec:1")) + 1 :].index(("earth:4", "sec:1")) >= 0 - # 하위 줄 = 마지막 자식 뒤 · 위에 줄 = 같은 부모 앞 · 새 줄 = kind 없음(항목) - got = out["sec:1.2|아래에 하위 줄 추가"]["rows"] + # 246-1 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모 · 새 줄 = kind 없음(항목) + got = out["sec:1.2|항목 추가"]["rows"] kids = [e for e in got if e.get("parent") == "sec:1.2"] - assert kids[-1]["name"] == "새 줄" and "kind" not in kids[-1] - got = out["earth:4|위에 줄 추가"]["rows"] + assert kids[0]["name"] == "새 줄" and "kind" not in kids[0] + got = out["earth:4|항목 추가"]["rows"] at = [e["key"] for e in got].index("earth:4") - assert (got[at - 1]["name"], got[at - 1]["parent"]) == ("새 줄", "sec:1.2") + assert (got[at + 1]["name"], got[at + 1]["parent"]) == ("새 줄", "sec:1.2") a = {e["key"]: e for e in out["hand:a|묶음 줄로 바꾸기"]["rows"]}["hand:a"] assert (a["kind"], a["qty"], a["ref"]) == ("group", None, None) got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]} assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2" # 자동 줄 연결 풀기 = ref 덮개 null assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None - # 조달수수료 위 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) · 새 공종 = 관급자재대 앞 - got = out["fee|위에 줄 추가"]["rows"] + # 조달수수료 항목 추가 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) + got = out["fee|항목 추가"]["rows"] assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2") - assert keys(out["fee|새 공종 추가(1단)"])[-3].startswith("hand:") # … 새 공종 · sec:G · mat:M2 def test_관급_옮김_막힘_깊이_5(): @@ -237,15 +232,15 @@ def test_관급_옮김_막힘_깊이_5(): out = _menu(user, labels=["sec:4", "hand:n"], rows=rows)["labels"] assert "들여쓰기 →" not in out["sec:4"] # 별도 자재가 관급자재대 안으로 → 막음 assert "들여쓰기 →" in out["hand:n"] # 자재 없는 묶음 = 들여쓰기 됨(위 형제 4 자재대) - # 깊이 5 — 레벨 5 묶음에 하위 줄 · 레벨 5 줄 들여쓰기 = 막음 + # 깊이 5 — 레벨 5 묶음에 항목 추가(첫 하위) · 레벨 5 줄 들여쓰기 = 막음 deep = [ _row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"), _row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"), _row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5), ] # fmt: skip out = _menu({}, labels=["hand:g3", "hand:z", "hand:g2"], rows=deep)["labels"] - assert "아래에 하위 줄 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"] - assert "아래에 하위 줄 추가" in out["hand:g2"] + assert "항목 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"] + assert "항목 추가" in out["hand:g2"] TABLE_JS = """ diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index c89d23564..4da59ffb2 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -202,9 +202,9 @@ const applied = []; const apply = (ref, from) => applied.push([ref, from && from.name]); const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply); const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) }; -picks.find((x) => x[0].startsWith("서고 일위대가"))[1](); +picks.find((x) => x[0] === "일위대가 사전조합")[1](); await new Promise((r) => setTimeout(r, 0)); -picks.find((x) => x[0].startsWith("서고 구조물"))[1](); +picks.find((x) => x[0] === "구조물 조합")[1](); await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); @@ -226,10 +226,10 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): assert out["picks"] == [ "일위대가 호표", "단가산출 호표", - "서고 구조물조합(마스터 · 회사 · 개인)", - "서고 일위대가 사전조합(마스터 · 회사 · 개인)", - "단가산출 로직(M01 · 새 산근)", - ] + "구조물 조합", + "일위대가 사전조합", + "단가산출", + ] # 246-1 이름 셋(서고 · 층 · M01 글 없음) assert out["more"] == ["연결 풀기"] pre, (url, body) = out["sent"][0], out["sent"][1] assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋 diff --git a/resources/tester/test_241_1_input_cell_border.py b/resources/tester/test_241_1_input_cell_border.py index 6ab516573..4d1b2a9d6 100644 --- a/resources/tester/test_241_1_input_cell_border.py +++ b/resources/tester/test_241_1_input_cell_border.py @@ -1,4 +1,4 @@ -"""PLAN 241-1 · 123 — B08 · B09 표 입력 칸 = 채움 색 대신 칸 안쪽 테두리.""" +"""PLAN 241-1 · 123 → 246-1 · 128 — B08 · B09 표 입력 칸 = 테두리 되돌림(241 앞 채움 색).""" import re from pathlib import Path @@ -13,17 +13,13 @@ def _rule(css: str, selector: str) -> str: return m.group(1) -def test_입력_칸_채움_색_없고_안쪽_테두리(): +def test_입력_칸_채움_색_테두리_없음(): body = _rule(CSS.read_text(encoding="utf-8"), ".ui-data-table__input") - assert "background: transparent;" in body - assert "background: color-mix" not in body # 옛 accent 5% 채움 없음 - # 241-2 두 테마 — 진한 accent 60% + 캔버스(밝음 · 어두움 모두 캔버스 대비 3:1 넘음 · 초점 2px 와 갈림) - assert ( - "box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));" - in body - ) + # 246-1 — 241 앞 모양(accent 5% 채움) · 안쪽 테두리 없음 + assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body + assert "box-shadow" not in body assert "#" not in body # 하드코딩 색 없음 — 테마 변수만 - assert "border: 0;" in body # 테두리는 inset — 칸 크기 · 줄 높이 불변 + assert "border: 0;" in body assert "height: var(--dt-row-h);" in body diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 61be0ddae..5e5e52544 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -1,7 +1,7 @@ -"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 늘 보임. +"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 246-1 에서 지움. - 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자 -- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(입력 칸 테두리) · 못 고치는 칸 · 옛 방식 표 = 없음 +- 고칠 수 있는 칸(td) 표시 `is-can-edit` 없음(246-1 · 241 앞 모양) - B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble - 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로 """ @@ -74,9 +74,9 @@ setTimeout(() => { """ -def test_공용_표_더블클릭_Enter_F2_고칠_칸_테두리(): +def test_공용_표_더블클릭_Enter_F2(): out = json.loads(_node(TABLE + HARNESS + SCENE_JS)) - assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시 + assert out["can"] == [] # 246-1 고칠 칸 td 표시 없음 assert out["start"] == [] # 처음 = 글 상자 없음 assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로 assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력 @@ -90,18 +90,10 @@ def test_공용_표_더블클릭_Enter_F2_고칠_칸_테두리(): assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음) -def test_고칠_칸_테두리_CSS_테마_변수만(): +def test_고칠_칸_테두리_CSS_없음(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") - m = re.search(r"\.ui-data-table__table td\.is-can-edit \{([^}]*)\}", css) - assert m - body = m.group(1) - # 글 상자와 같은 색(accent 60% · canvas) — 줄 고름 box-shadow 와 안 겹치게 outline 안쪽 - assert ( - "outline: 1px solid color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));" - in body - ) - assert "outline-offset: -1px;" in body - assert "#" not in body + ts = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") + assert "is-can-edit" not in css and "is-can-edit" not in ts # 246-1 되돌림 def _sources(*folders: str) -> list[Path]: diff --git a/resources/tester/test_246_1_b09_bill_menu_simple.py b/resources/tester/test_246_1_b09_bill_menu_simple.py new file mode 100644 index 000000000..7a76c6545 --- /dev/null +++ b/resources/tester/test_246_1_b09_bill_menu_simple.py @@ -0,0 +1,82 @@ +"""246-1 · 128 — B09 설계내역서 우클릭 메뉴 줄임 · 입력 칸 테두리 되돌림. + +- 입력 칸 = 241 앞 모양(글 상자 accent 5% 채움 · td 표시 없음) +- 더함 = 「항목 추가」 하나 — 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 +- 맨 위 1단(새 공종) = ← 내어쓰기 — 직접비 · 손 줄은 묶음 · 관급자재대 앞 · 자재 줄은 못 냄 +- 연결 이름 = 「구조물 조합」 · 「일위대가 사전조합」 · 「단가산출」 · 지움 = 「줄 삭제」 +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import DG, ROWS, _menu, _order, _row # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +OLD_ADDS = ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가", "새 공종 추가(1단)"] + + +def test_입력_칸_테두리_없음(): + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + body = css.split(".ui-data-table__input {")[1].split("}")[0] + assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body + assert "box-shadow" not in body and "is-can-edit" not in css + + +def test_더함_하나_줄_삭제_연결_이름_셋(): + keys = ["sec:1", "earth:4", "hand:g", "hand:a", "fee", "sec:G"] + out = _menu({}, labels=keys)["labels"] + for key in keys: + assert [x for x in out[key] if "추가" in x] == ["항목 추가"], key + assert not set(OLD_ADDS) & set(out[key]) and "줄 지우기" not in out[key] + assert "줄 삭제" in out["earth:4"] and "줄 삭제" in out["hand:a"] + link = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") + picks = link.split("const picks: Leaf[] = [")[1].split("];")[0] + for name in ["일위대가 호표", "단가산출 호표", "구조물 조합", "일위대가 사전조합", "단가산출"]: + assert f'["{name}",' in picks + for word in ["서고", "마스터", "회사", "개인", "M01", "새 산근", "로직"]: + assert word not in picks + toasts = [part.split(",")[0] for part in link.split("showToast(")[1:]] + assert toasts and not any("서고" in t for t in toasts) # 알림 글 쉬운 말 + bill = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8") + assert "항목 추가 · 줄 삭제" in bill # 표 아래 안내 글 + + +#: 관급자재대 안 손 묶음(hand:s · 자재 없음) · 손 항목(hand:t) +SUPPLY = [*ROWS[:-1], _row("hand:s", "sec:G", 2, "group", cost="supply"), + _row("hand:t", "hand:s", 3, cost="supply", qty=1, ref=DG), ROWS[-1]] # fmt: skip + + +def test_자리_규칙(): + runs = [("hand:g", "항목 추가"), ("hand:a", "항목 추가"), ("hand:b", "항목 추가"), + ("hand:a", "내어쓰기 ←"), ("hand:g", "내어쓰기 ←"), ("hand:s", "내어쓰기 ←"), + ("hand:t", "내어쓰기 ←"), ("mat:M1", "내어쓰기 ←")] # fmt: skip + out = _menu({}, runs=runs, rows=SUPPLY)["runs"] + new = lambda b: [(e.get("parent"), e.get("name", e["key"])) for e in b["rows"]] # noqa: E731 + # 폴더 = 그 폴더 첫 하위 · 항목 = 바로 아래 같은 부모 + got = new(out["hand:g|항목 추가"]) + assert got[got.index(("sec:1.2", "hand:g")) + 1] == ("hand:g", "새 줄") + got = [e["key"] for e in out["hand:a|항목 추가"]["rows"]] + row = out["hand:a|항목 추가"]["rows"][got.index("hand:a") + 1] + assert (row["name"], row["parent"]) == ("새 줄", "hand:g") + got = out["hand:b|항목 추가"]["rows"] + row = got[[e["key"] for e in got].index("hand:b") + 1] + assert (row["name"], row["parent"], "kind" in row) == ("새 줄", "sec:1.2", False) + # ← = 한 단씩 — 레벨 4 → 3(sec:1.2 아래) · 레벨 3 → 2(sec:1 아래) + assert ("hand:a", "sec:1.2") in _order(out["hand:a|내어쓰기 ←"]) + assert ("hand:g", "sec:1") in _order(out["hand:g|내어쓰기 ←"]) + # 관급 안 손 묶음 ← = 새 공종(직접비) · 관급자재대 앞 + b = out["hand:s|내어쓰기 ←"] + tops = [k for k, p in _order(b) if p is None] + assert tops == ["sec:1", "sec:4", "hand:s", "sec:G"] + s = {e["key"]: e for e in b["rows"]}["hand:s"] + assert (s["kind"], s["cost"]) == ("group", "direct") + # 손 항목 ← 관급 묶음 밖(sec:G 아래) · 자재 줄 = 1단 안 됨 + assert ("hand:t", "sec:G") in _order(out["hand:t|내어쓰기 ←"]) + assert out["mat:M1|내어쓰기 ←"] is None + # 1단 손 항목 → 묶음(수량 · 연결 지움) — 2단 손 항목을 두 번 냄 + one = _menu({"bill": out["hand:t|내어쓰기 ←"]}, runs=[("hand:t", "내어쓰기 ←")], + rows=SUPPLY)["runs"]["hand:t|내어쓰기 ←"] # fmt: skip + t = {e["key"]: e for e in one["rows"]}["hand:t"] + assert ("parent" in t, t["kind"], t["qty"], t["ref"], t["cost"]) == ( + False, "group", None, None, "direct") # fmt: skip diff --git a/resources/tester/test_246_2_context_submenu_side.py b/resources/tester/test_246_2_context_submenu_side.py new file mode 100644 index 000000000..3c09cc09a --- /dev/null +++ b/resources/tester/test_246_2_context_submenu_side.py @@ -0,0 +1,168 @@ +"""246-2 · 129 — 공용 우클릭 메뉴 하위 메뉴(children) = 「글 ▸」 한 줄 + 오른쪽 옆 새 판. + +- 올림 · 누름 · → = 옆 판(머리 줄 높이 · 메뉴 오른쪽) · 다른 줄 올림 · ← = 닫힘(← 는 머리로 초점) +- 화면 오른쪽 끝이면 메뉴 왼쪽 · 옆 판 고름 = 메뉴 닫고 동작 · 바깥 누름 · Esc = 모두 닫힘 +- 옛 아코디언(같은 판 안 펼침 · ▾) 없음 · B09 「연결」 = children 그대로(옆 판으로 감) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) + +DOM_JS = """ +const docOn = {}; +class El { + constructor(tag) { Object.assign(this, { tag, kids: [], parent: null, on: {}, style: {}, + className: "", hidden: false, textContent: "", attrs: {} }); } + get classList() { + const el = this; + const list = () => el.className.split(" ").filter(Boolean); + return { add: (c) => { if (!list().includes(c)) el.className = [...list(), c].join(" "); }, + remove: (c) => (el.className = list().filter((x) => x !== c).join(" ")), + contains: (c) => list().includes(c) }; + } + get ownerDocument() { return { defaultView: globalThis.view }; } + get firstElementChild() { return this.kids[0] ?? null; } + get lastElementChild() { return this.kids.at(-1) ?? null; } + setAttribute(k, v) { this.attrs[k] = v; } + append(...xs) { for (const x of xs) { x.parent = this; this.kids.push(x); } } + replaceChildren() { this.kids = []; } + remove() { if (this.parent) this.parent.kids = this.parent.kids.filter((x) => x !== this); } + contains(x) { for (let at = x; at; at = at.parent) if (at === this) return true; return false; } + addEventListener(t, f) { (this.on[t] ??= []).push(f); } + fire(t, ev = {}) { for (const f of this.on[t] ?? []) f({ target: this, preventDefault() {}, + stopPropagation() {}, ...ev }); } + focus() { doc.activeElement = this; } + all() { return this.kids.flatMap((k) => [k, ...k.all()]); } + match(sel) { return sel.startsWith(".") ? sel.slice(1).split(".").every((c) => this.classList.contains(c)) + : this.tag === sel; } + querySelectorAll(sel) { + let found = [this]; + for (const part of sel.split(" ")) found = found.flatMap((el) => el.all().filter((k) => k.match(part))); + return found; + } + querySelector(sel) { return this.querySelectorAll(sel)[0] ?? null; } + getBoundingClientRect() { + if (this.style.left !== undefined) { + const side = this.classList.contains("ui-ctx-menu__side"); + const left = parseFloat(this.style.left), top = parseFloat(this.style.top); + const width = side ? 150 : 160; + const height = 8 + 30 * this.kids.filter((k) => k.tag === "button").length; + return { left, top, width, height, right: left + width, bottom: top + height }; + } + const box = this.parent.getBoundingClientRect(); + const top = box.top + 4 + 30 * this.parent.kids.filter((k) => k.tag === "button").indexOf(this); + return { left: box.left + 4, top, width: box.width - 8, height: 30, right: box.right - 4, bottom: top + 30 }; + } +} +const doc = { activeElement: null, createElement: (t) => new El(t), + addEventListener: (t, f) => (docOn[t] ??= []).push(f) }; +globalThis.document = doc; +globalThis.Node = El; +globalThis.view = { innerWidth: 1000, innerHeight: 800 }; +globalThis.window = globalThis.view; +""" + +SCENE_JS = """ +const log = []; +const menu = createMapContextMenu("b09-estimation"); +const items = [["A", () => log.push("A")], + { label: "연결", children: [["x", () => log.push("x")], ["y", () => log.push("y")]] }, "-", + ["B", () => log.push("B")]]; +const el = menu.element; +const btns = () => el.kids.filter((k) => k.tag === "button"); +const side = () => el.querySelector(".ui-ctx-menu__side"); +const sideText = () => side()?.kids.map((k) => k.textContent) ?? null; +const at = () => [parseFloat(side().style.left), parseFloat(side().style.top), side().style.position]; +const out = {}; +menu.open(100, 100, items); +const head = btns()[1]; +out.open = [el.kids.map((k) => k.textContent || k.className), sideText(), head.attrs["aria-haspopup"]]; +head.fire("pointerenter"); +out.hover = [sideText(), at(), head.classList.contains("is-open"), side().className]; +out.firstRow = side().kids[0].getBoundingClientRect().top === head.getBoundingClientRect().top; +out.contains = menu.contains(side().kids[0]); +btns()[0].fire("pointerenter"); +out.leave = [sideText(), head.classList.contains("is-open")]; +head.fire("click"); +out.click = [sideText(), doc.activeElement?.textContent]; +side().fire("keydown", { key: "ArrowLeft" }); +out.left = [sideText(), doc.activeElement === head]; +head.fire("keydown", { key: "ArrowRight" }); +out.right = [sideText(), doc.activeElement?.textContent]; +side().kids[1].fire("click"); +out.pick = [el.hidden, el.kids.length, [...log]]; +// 화면 오른쪽 끝 — 메뉴는 안쪽으로 당김 · 옆 판 = 메뉴 왼쪽 +view.innerWidth = 400; +menu.open(300, 100, items); +btns()[1].fire("pointerenter"); +out.edge = [parseFloat(el.style.left), at()[0]]; +// 아래 끝 — 옆 판 올림 +view.innerWidth = 1000; view.innerHeight = 100; +menu.open(100, 10, items); +btns()[1].fire("pointerenter"); +out.bottom = at()[1]; +view.innerHeight = 800; +// Esc · 바깥 누름 = 모두 닫힘 +menu.open(100, 100, items); +btns()[1].fire("pointerenter"); +for (const f of docOn.keydown) f({ key: "Escape", stopPropagation() {}, preventDefault() {} }); +out.esc = [el.hidden, side()]; +menu.open(100, 100, items); +btns()[1].fire("pointerenter"); +const inside = side().kids[0]; +for (const f of docOn.pointerdown) f({ target: inside }); +out.insideDown = el.hidden; +for (const f of docOn.pointerdown) f({ target: new El("div") }); +out.outsideDown = el.hidden; +console.log(JSON.stringify(out)); +""" + + +def _node(source: str) -> str: + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=source, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return result.stdout + + +def _src() -> str: + return (ROOT / "ui_template" / "ui_template_context_menu.ts").read_text("utf-8") + + +def test_옆_판_열기_닫기_자리(): + out = json.loads(_node(DOM_JS + IMPORTS.sub("", _src()) + SCENE_JS)) + # 「연결 ▸」 한 줄 · 처음엔 옆 판 없음(같은 판 안 펼침 없음) + assert out["open"] == [["A", "연결 ▸", "ui-ctx-menu__sep", "B"], None, "menu"] + # 올림 = 옆 판 다섯 자리 — 메뉴 오른쪽(100 + 160) · 첫 줄 = 머리 줄 높이(134 - 4) · fixed + names = "b09-estimation__context-menu-sub ui-ctx-menu ui-ctx-menu__side" + assert out["hover"] == [["x", "y"], [260, 130, "fixed"], True, names] + assert out["firstRow"] is True and out["contains"] is True + assert out["leave"] == [None, False] # 다른 줄 올림 = 닫힘 + assert out["click"] == [["x", "y"], "x"] # 누름 = 열고 첫 줄 초점 + assert out["left"] == [None, True] # ← = 닫고 머리 줄로 + assert out["right"] == [["x", "y"], "x"] # → = 열기 + assert out["pick"] == [True, 0, ["y"]] # 옆 판 고름 = 메뉴 닫고 동작 + # 오른쪽 끝 — 메뉴 = 400 - 4 - 160 · 옆 판 = 메뉴 왼쪽(236 - 150) + assert out["edge"] == [236, 86] + # 아래 끝(높이 100) — 옆 판(68) 바닥 = 100 - 4 + assert out["bottom"] == 100 - 4 - 68 + assert out["esc"] == [True, None] + assert out["insideDown"] is False and out["outsideDown"] is True + + +def test_옛_아코디언_없음_공용_꼴(): + src = _src() + assert "ui-ctx-menu__sub" not in src and "▾" not in src and "is-sub" not in src + css = (ROOT / "ui_template" / "ui_template_context_menu.css").read_text("utf-8") + assert ".ui-ctx-menu__sub" not in css and ".ui-ctx-menu__item.is-open" in css + # B09 연결 = children 꼴 그대로(공용 메뉴가 옆 판으로) + edit = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Edit.ts").read_text("utf-8") + assert 'parts[1].push({ label: "연결", children: picks }, ...more);' in edit diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 7b11ab018..c726c6c66 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -47,22 +47,13 @@ cursor: not-allowed; } -/* 하위 메뉴(아코디언) — 머리글 굵게 · 종류는 들여쓰기 */ +/* 하위 메뉴(246-2) — 머리 줄 굵게 · 연 동안 머리 줄 칠함 · 옆 판 = 같은 메뉴 꼴(자리 = fixed · 스크립트) */ .ui-ctx-menu__item.is-group { font-weight: var(--font-weight-medium); } -.ui-ctx-menu__sub { - display: flex; - flex-direction: column; -} - -.ui-ctx-menu__sub[hidden] { - display: none; -} - -.ui-ctx-menu__item.is-sub { - padding-left: var(--spacing-24); +.ui-ctx-menu__item.is-open { + background: var(--color-surface-sunken, var(--color-mist-violet)); } /* 구분선(`"-"` 항목) */ diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 4edc8545b..514413a6c 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -13,8 +13,8 @@ import "./ui_template_context_menu.css"; -/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 - * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). +/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 「글 ▸」 한 줄 · 올리거나 누르면 + * 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2). * 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */ export type MapContextMenuItem = | [label: string, action: () => void] @@ -104,7 +104,7 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { const entry = document.createElement("button"); entry.type = "button"; entry.className = `${blockClass}__context-menu-item ui-ctx-menu__item`; - if (sub) entry.classList.add("is-sub"); + if (!sub) entry.addEventListener("pointerenter", closeSide); // 다른 줄로 옮기면 옆 판 닫힘 entry.textContent = label; entry.addEventListener("click", () => { close(); @@ -113,7 +113,72 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { return entry; } - /** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 · 하위 메뉴를 펼칠 때 */ + /** 옆 판(하위 메뉴) 닫음 — 머리 줄 표시도 풂 */ + function closeSide(): void { + element.querySelector(".ui-ctx-menu__side")?.remove(); + element + .querySelectorAll(".ui-ctx-menu__item.is-open") + .forEach((head) => head.classList.remove("is-open")); + } + + /** 옆 판 자리 — 머리 줄 오른쪽(넘치면 메뉴 왼쪽) · 아래 끝은 올림 · fixed(메뉴 overflow 에 안 잘림) · + * 변환된 그릇 안이면 실제 자리와 어긋난 만큼 바로잡음 */ + function placeSide(side: HTMLElement, head: HTMLElement): void { + const view = element.ownerDocument.defaultView ?? window; + const menu = element.getBoundingClientRect(); + let x = menu.right; + let y = head.getBoundingClientRect().top; + side.style.left = `${x}px`; + side.style.top = `${y}px`; + const box = side.getBoundingClientRect(); + const [ox, oy] = [box.left - x, box.top - y]; + y -= (side.firstElementChild?.getBoundingClientRect().top ?? box.top) - box.top; // 첫 줄 = 머리 줄 높이 + if (x + box.width > view.innerWidth - 4) x = Math.max(4, menu.left - box.width); + if (y + box.height > view.innerHeight - 4) y = Math.max(4, view.innerHeight - 4 - box.height); + side.style.left = `${x - ox}px`; + side.style.top = `${y - oy}px`; + } + + /** 하위 메뉴 머리 줄 — 올림 · 누름 · → = 옆 판 · 옆 판 ← = 닫고 머리로 */ + function buildGroup(label: string, children: Array<[string, () => void]>): HTMLButtonElement { + const head = document.createElement("button"); + head.type = "button"; + head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`; + head.setAttribute("aria-haspopup", "menu"); + head.textContent = `${label} ▸`; + const show = (focus: boolean): void => { + if (!head.classList.contains("is-open")) { + closeSide(); + const side = document.createElement("div"); + side.className = `${blockClass}__context-menu-sub ui-ctx-menu ui-ctx-menu__side`; + side.style.position = "fixed"; + side.setAttribute("role", "menu"); + children.forEach(([text, action]) => side.append(buildLeaf(text, action, true))); + side.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft") return; + event.preventDefault(); + event.stopPropagation(); + closeSide(); + head.focus(); + }); + element.append(side); + head.classList.add("is-open"); + placeSide(side, head); + } + if (focus) element.querySelector(".ui-ctx-menu__side button")?.focus(); + }; + head.addEventListener("pointerenter", () => show(false)); + head.addEventListener("click", () => show(true)); + head.addEventListener("keydown", (event) => { + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + show(true); + }); + return head; + } + + /** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 */ function fit(): void { const x = parseFloat(element.style.left); const y = parseFloat(element.style.top); @@ -144,29 +209,8 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element.append(buildLeaf(item[0], item[1])); return; } - // 하위 메뉴(아코디언) — 머리글을 누르면 종류 목록이 바로 아래로 펼쳐진다. - // 한 번에 한 군만 연다(다른 군을 열면 앞의 군은 접힘). - const head = document.createElement("button"); - head.type = "button"; - head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`; - head.textContent = `${item.label} ▸`; - const body = document.createElement("div"); - body.className = `${blockClass}__context-menu-sub ui-ctx-menu__sub`; - body.hidden = true; - item.children.forEach(([label, action]) => body.append(buildLeaf(label, action, true))); - head.addEventListener("click", () => { - const willOpen = body.hidden; - element - .querySelectorAll(`.${blockClass}__context-menu-sub`) - .forEach((other) => (other.hidden = true)); - element - .querySelectorAll(`.${blockClass}__context-menu-item.is-group`) - .forEach((other) => (other.textContent = other.textContent!.replace("▾", "▸"))); - body.hidden = !willOpen; - head.textContent = `${item.label} ${willOpen ? "▾" : "▸"}`; - fit(); // 펼친 하위가 화면 아래로 넘치면 메뉴를 올림 - }); - element.append(head, body); + // 하위 메뉴 = 머리 줄 하나 · 옆 판(한 번에 하나) + element.append(buildGroup(item.label, item.children)); }); if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep")) element.lastElementChild.remove(); diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index fb9c61cc1..f17dc400a 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -158,7 +158,7 @@ box-shadow: inset 0 -2px 0 var(--color-accent); } -/* 고칠 칸(PLAN 121 · 241-1) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 · 입력 칸 표시 = 채움 색 없이 칸 안쪽 테두리(칸 크기 불변) */ +/* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */ .ui-data-table__table td.is-edit { padding: 0; } @@ -168,20 +168,13 @@ height: var(--dt-row-h); padding: 0 var(--spacing-8); border: 0; - background: transparent; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas)); + background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); color: inherit; font: inherit; font-variant-numeric: tabular-nums; text-align: right; } -/* 고칠 수 있는 칸(PLAN 243-1 · editOnDouble) — 글일 때도 같은 입력 칸 테두리(outline 안쪽 · 줄 고름 box-shadow 와 안 겹침) */ -.ui-data-table__table td.is-can-edit { - outline: 1px solid color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas)); - outline-offset: -1px; -} - .ui-data-table__input:focus { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index edc1d0bbb..afc3bc564 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -17,8 +17,7 @@ * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 - * 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 243-1) · - * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(keyEdit false = 표 밖이 맡음) + * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ @@ -184,7 +183,7 @@ function rowHtml( more: RowMore = {}, ): string { let tr = ""; - const { lv, spans, can } = more; + const { lv, spans } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; const span = spans?.[j] ?? 1; @@ -210,7 +209,6 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), - can?.(key) && "is-can-edit", lvc.trim(), span > 1 && "is-merged", ] @@ -243,8 +241,6 @@ function rowHtml( interface RowMore { lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; - /** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시) */ - can?: (key: string) => boolean; } /** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ @@ -360,7 +356,6 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin { ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), - ...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}), }, ); } From 5f2e6e18b60abde3e0799f8b2484e800d4e75ff8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:33:53 +0900 Subject: [PATCH 60/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20[+]=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EA=B2=80=EC=83=89=20=EC=9E=AC=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=20=C2=B7=20=EB=B0=A9=EC=9C=84=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=EC=A0=90=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20?= =?UTF-8?q?=ED=8C=AC=20=EC=A4=8C=20=C2=B7=20=EA=B2=BD=EB=A1=9C=20=ED=83=AD?= =?UTF-8?q?=20=3D=20=EA=B8=B8=EC=B0=BE=EA=B8=B0=20=EA=B7=B8=EB=A6=BC=20(24?= =?UTF-8?q?4-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리 - 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간) - 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan - 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰 - 서버 site-address(좌표 → 주소 · VWorld getAddress) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B01_Dashboard/B01_Dashboard_Map.py | 27 ++ .../B01_Dashboard_UI_AddressField.ts | 9 +- B01_Dashboard/B01_Dashboard_UI_MapPreview.ts | 10 +- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 14 +- B08_Quantity/B08_Quantity_Router_Milestone.py | 25 ++ B08_Quantity/B08_Quantity_UI_Milestone.css | 11 + B08_Quantity/B08_Quantity_UI_Milestone.ts | 50 +-- .../B08_Quantity_UI_Milestone_Diagram.ts | 124 +++---- .../B08_Quantity_UI_Milestone_Modal.ts | 336 ++++++++++++++++++ .../B08_Quantity_UI_Milestone_Route.ts | 316 +++++----------- .../tester/test_243_1_double_click_tables.py | 4 +- resources/tester/test_244_1_b08_milestone.py | 9 +- .../test_244_2_b08_milestone_diagram.py | 11 +- .../tester/test_244_4_b08_milestone_modal.py | 185 ++++++++++ 14 files changed, 774 insertions(+), 357 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts create mode 100644 resources/tester/test_244_4_b08_milestone_modal.py diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py index 0c26c5f33..5897561a3 100644 --- a/B01_Dashboard/B01_Dashboard_Map.py +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -114,3 +114,30 @@ def _search_sync(query: str, size: int) -> list[dict[str, Any]]: async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]: return await asyncio.to_thread(_search_sync, query.strip(), size) + + +def _reverse_sync(lon: float, lat: float) -> str | None: + """좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번).""" + query = urllib.parse.urlencode( + { + "service": "address", + "request": "getAddress", + "version": "2.0", + "crs": "epsg:4326", + "point": f"{lon},{lat}", + "type": "both", + "format": "json", + "key": VWORLD_API_KEY, + } + ) + try: + body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8")) + except Exception: + return None + items = (body.get("response") or {}).get("result") or [] + texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)} + return texts.get("road") or texts.get("parcel") or None + + +async def reverse_geocode(lon: float, lat: float) -> str | None: + return await asyncio.to_thread(_reverse_sync, lon, lat) diff --git a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts index 7a50998e4..595a03116 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts @@ -19,6 +19,8 @@ export interface AddressFieldHandle { root: HTMLElement; /** 저장할 한 줄 주소. 비면 null. */ value: () => string | null; + /** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */ + point: () => { lat: number; lon: number } | null; } /** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */ @@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { label: "지도 확인", variant: "ghost", onClick: async function onB01_Address_Map_Click() { - await map.show(base.input.value.trim()); + picked = await map.show(base.input.value.trim()); }, }); mapBtn.hidden = true; @@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root); let manual = false; + let picked: { lat: number; lon: number } | null = null; function pick(item: AddressCandidate): void { base.input.value = candidateLine(item); @@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { zip.hidden = !item.zipcode; list.hidden = true; list.innerHTML = ""; + picked = { lat: item.lat, lon: item.lon }; map.showPoint(item.lat, item.lon); detail.input.focus(); } @@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { }); // 이미 있는 주소는 열자마자 지도로 보여 준다. - if (initial && initial.trim()) void map.show(initial.trim()); + if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p)); return { root, @@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim(); return merged || null; }, + point: () => picked, }; } diff --git a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts index ed616c670..cf8c71eec 100644 --- a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts +++ b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts @@ -32,7 +32,8 @@ function tileIndex( export function buildAddressMap(): { root: HTMLElement; - show: (address: string) => Promise; + /** 찾은 좌표를 돌려줌 — 못 찾으면 null. */ + show: (address: string) => Promise<{ lat: number; lon: number } | null>; /** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */ showPoint: (lat: number, lon: number) => void; } { @@ -110,17 +111,18 @@ export function buildAddressMap(): { note.textContent = message; } - const show = async (address: string): Promise => { + const show = async (address: string): Promise<{ lat: number; lon: number } | null> => { if (!address.trim()) { fail("주소를 먼저 입력하십시오."); - return; + return null; } const point = await geocodeAddress(address).catch(() => null); if (!point) { fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오."); - return; + return null; } draw(point.lat, point.lon); + return { lat: point.lat, lon: point.lon }; }; return { root, show, showPoint: draw }; diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index d85c07088..beeffbbc3 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -3,8 +3,9 @@ * 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림). * * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들 - * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 둘 다 경위도가 있으면 방위각 · - * 없으면 사용자가 고른 8방 · 고르지 않았으면 빈 방위를 차례로 · 같은 방위 여럿 = 옆으로 나란히 + * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면 + * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · + * 같은 방위 여럿 = 옆으로 나란히 * 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서) * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) @@ -167,7 +168,7 @@ export function layoutMilestone( const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; const items = data.routes.map((r) => { const anchor = useBase && r.via_base ? "base" : "site"; - return { r, anchor, deg: placeDeg(r, anchor === "base" ? baseLL : startLL) }; + return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) }; }); for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); let auto = 0; @@ -219,3 +220,10 @@ export function layoutMilestone( view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY], }; } + +/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */ +export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string { + const part = (p: { name: string; lon: number; lat: number }): string => + `${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`; + return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; +} diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 18e2cd950..a9fd66fb7 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -5,6 +5,8 @@ POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체) GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기 DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 + GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · + 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) 정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). @@ -26,6 +28,8 @@ from fastapi import APIRouter, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, ValidationError, model_validator +from B01_Dashboard.B01_Dashboard_Map import reverse_geocode +from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 from B08_Quantity.B08_Quantity_Router_Hydro import _project_root @@ -219,6 +223,27 @@ async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: return await asyncio.to_thread(save_data, root, body) +@router.get(PATH + "/site-address") +async def milestone_site_address(project_id: UUID) -> dict[str, Any]: + """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소.""" + root = await _project_root(project_id) + data = await asyncio.to_thread(read_data, root) + site = await asyncio.to_thread(site_info, root, data["site"]["mid_station_m"]) + project = await get_project_by_id(str(project_id)) or {} + + async def where(lonlat: list[float] | None) -> str | None: + return await reverse_geocode(*lonlat) if lonlat else None + + start, mid = await asyncio.gather(where(site["start_lonlat"]), where(site["mid_lonlat"])) + return { + "region": project.get("region") or "", + "start_lonlat": site["start_lonlat"], + "mid_lonlat": site["mid_lonlat"], + "start_address": start, + "mid_address": mid, + } + + @router.post(PATH + "/image") async def milestone_image_upload( project_id: UUID, route: str = Query(...), file: UploadFile = File(...) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 01057d221..6b8a9486d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -114,3 +114,14 @@ stroke-width: 4px; stroke-linejoin: round; } + +/* 모달 구간 · 표 줄 — 칸 · [×] 한 줄(244-4) */ +.b08-milestone__line-edit { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.b08-milestone__diagram svg.is-panning { + cursor: grabbing; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index faa790110..aecaaa8b9 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -1,18 +1,18 @@ /* ============================================================================= * B08_Quantity_UI_Milestone.ts - * B08 메인 탭 「거리이정표」(PLAN 244-1) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름) + - * [+ 추가] · [삭제]. + * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름). + * 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음). * * [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 * 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도 - * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(244-2 · 상자 누름 = 경로 탭 · [+] = 항목 추가) - * 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄) + * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) + * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; -import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { @@ -64,8 +64,8 @@ export function addRoute(projectId: string, init: Partial = {}): } /** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */ -function tableView(projectId: string, open: (id: string) => void): MilestoneView { - const diagram = createDiagram(projectId, open, (init) => addRoute(projectId, init)); +function tableView(projectId: string): MilestoneView { + const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; const table = createDataTable({ columns: COLUMNS, @@ -100,7 +100,7 @@ function tableView(projectId: string, open: (id: string) => void): MilestoneView table.root.dataset.b08Milestone = "table"; const none = el("p", { className: "ui-general-block__empty", - text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+] 또는 하위 탭 줄 [+ 추가]로 업체를 넣으세요.", + text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.", }); const refresh = (): void => { const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); @@ -139,48 +139,16 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { button.dataset.b08MilestoneTab = id; return button; }; - const add = createButton({ - label: "+ 추가", - size: "sm", - variant: "ghost", - onClick: () => open(addRoute(projectId)), - }); - add.dataset.b08MilestoneAdd = ""; - const remove = createButton({ - label: "삭제", - size: "sm", - variant: "danger", - onClick: () => void removeCurrent(), - }); - remove.disabled = current === "table"; - remove.dataset.b08MilestoneDelete = ""; tabs.replaceChildren( tab("table", "거리이정표"), ...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)), - add, - remove, ); }; - const removeCurrent = async (): Promise => { - const data = liveMilestone(projectId); - const route = data.routes.find((r) => r.id === current); - if (!route) return; - if ( - !(await showConfirmDialog( - `「${route.name || "업체"}」 경로를 지울까요? — [저장] 때 반영`, - "삭제", - )) - ) - return; - data.routes = data.routes.filter((r) => r.id !== route.id); - putMilestone(projectId, data); // 알림이 없는 경로 탭을 보고 [거리이정표] 로 옮김 - }; - function open(id: string): void { current = id; drawTabs(); - body = id === "table" ? tableView(projectId, open) : routeView(projectId, id); + body = id === "table" ? tableView(projectId) : routeView(projectId, id); view.replaceChildren(body.root); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 3a54a3760..5af9bc77a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -3,20 +3,22 @@ * [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`. * * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글 - * 업체 상자 누름 = 그 경로 하위 탭으로 · 공사지 · 기준점 상자 [+] = 항목 추가(작은 창 → 경로 하위 탭 생김 · - * 같은 업체 이름이면 그 경로에 표 줄만 더함) · 조작 = 캐시(`…_UI_Milestone_State`) + * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 · + * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) + * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 더블클릭 = 원배율) * ========================================================================== */ -import { openModal } from "@ui/ui_template_modal"; -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import { midKm, type Dest, type Dir, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { el } from "@ui/ui_template_elements"; +import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; +import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout"; import { - dirOptions, - liveMilestone, - putMilestone, - siteInfo, -} from "./B08_Quantity_UI_Milestone_State"; + openAddItem, + openBaseInfo, + openRouteInfo, + openSiteInfo, +} from "./B08_Quantity_UI_Milestone_Modal"; +import { liveMilestone, siteInfo } from "./B08_Quantity_UI_Milestone_State"; const NS = "http://www.w3.org/2000/svg"; @@ -31,69 +33,21 @@ function svg( return node; } -/** [+] 작은 창 — 항목 · 업체 이름 · 목적지 · 방위 → 새 경로(같은 이름 = 그 경로에 줄) */ -export function openAddItem( - projectId: string, - viaBase: boolean, - addRoute: (init: Partial) => string, -): void { - openModal({ - title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로", - closeLabel: "닫기", - mount: (body, close, actions) => { - const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); - const company = createInputField({ label: "업체 이름", compact: true }); - const dest = createSelectField({ - label: "목적지", - compact: true, - options: [ - { value: "start", text: "시점" }, - { value: "mid", text: "중점" }, - ], - }); - const dir = createSelectField({ - label: viaBase ? "방위(기준점에서 본)" : "방위(공사지에서 본)", - compact: true, - options: dirOptions, - }); - item.input.dataset.b08MilestoneAdd = "item"; - company.input.dataset.b08MilestoneAdd = "company"; - const ok = createButton({ - label: "추가", - variant: "filled", - onClick: () => { - const name = company.input.value.trim(); - if (!name) return company.setError("업체 이름을 넣으세요."); - const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; - const data = liveMilestone(projectId); - const same = data.routes.find((r) => r.name.trim() === name); - if (same) { - same.rows.push(row); - putMilestone(projectId, data); - } else - addRoute({ - name, - via_base: viaBase, - dir: (dir.select.value || null) as Dir | null, - rows: [row], - }); - close(); - }, - }); - actions.append(ok); - body.append(item.root, company.root, dest.root, dir.root); - }, - }); -} - /** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ export function createDiagram( projectId: string, - openTab: (routeId: string) => void, addRoute: (init: Partial) => string, ): { root: HTMLElement; refresh: () => void } { const host = el("div", { className: "b08-milestone__diagram" }); host.dataset.b08Milestone = "diagram"; + let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 + const INFO: Record void> = { + route: (id) => openRouteInfo(projectId, id), + site: () => openSiteInfo(projectId), + start: () => openSiteInfo(projectId), + mid: () => openSiteInfo(projectId), + base: () => openBaseInfo(projectId), + }; const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => { const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 }); @@ -119,8 +73,9 @@ export function createDiagram( const site = siteInfo(); const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null)); const [vx, vy, vw, vh] = layout.view; + // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { - viewBox: `${vx} ${vy} ${vw} ${vh}`, + viewBox: `0 0 ${vw} ${vh}`, width: vw, height: vh, role: "img", @@ -128,8 +83,12 @@ export function createDiagram( }); root.style.maxWidth = "100%"; root.style.height = "auto"; + const layer = svg("g", {}); + const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); + layer.append(shift); + root.append(layer); for (const ln of layout.lines) { - root.append( + shift.append( svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }), ); } @@ -149,15 +108,18 @@ export function createDiagram( box.label, ), ); - if (box.kind === "route") { - g.setAttribute("role", "button"); - g.setAttribute("tabindex", "0"); - g.addEventListener("click", () => openTab(box.id)); - g.addEventListener("keydown", (e) => e.key === "Enter" && openTab(box.id)); - } + // 상자 누름 = 모달(탭 이동 아님) + const info = (): void => INFO[box.kind](box.id); + g.setAttribute("role", "button"); + g.setAttribute("tabindex", "0"); + g.addEventListener("click", (e) => { + e.stopPropagation(); // 공사지 안 시점 · 중점 상자 = 한 번만 + info(); + }); + g.addEventListener("keydown", (e) => e.key === "Enter" && info()); if (box.kind === "site") g.append(plus(box, false)); if (box.kind === "base") g.append(plus(box, true)); - root.append(g); + shift.append(g); } // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 for (const ln of layout.lines) { @@ -165,9 +127,19 @@ export function createDiagram( const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2; const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" }); ln.labels.forEach((label, i) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label))); - root.append(text); + shift.append(text); } host.replaceChildren(root); + attachSvgZoomPan({ + svg: root, + layer, + widthPx: vw, + heightPx: vh, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, + initial: zoomState, + onChange: (state) => (zoomState = state), + wheelZoom: true, + }); }; refresh(); return { root: host, refresh }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts new file mode 100644 index 000000000..c3b39e9ea --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -0,0 +1,336 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Modal.ts + * 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침. + * + * [+] 항목 추가 = 항목 · 업체 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = + * 경로(하위 탭) 생김 · 같은 업체 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 + * 업체 상자 = 그 항목 정보(업체 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] + * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 + * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) + * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 + * ========================================================================== */ + +import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { + createButton, + createInputField, + createSelectField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { + buildAddressField, + type AddressFieldHandle, +} from "../B01_Dashboard/B01_Dashboard_UI_AddressField"; +import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; +import { + ROADS, + type Dest, + type Leg, + type MilestoneData, + type MilestoneRoute, + type MilestoneRow, + type Road, +} from "./B08_Quantity_Milestone_Calc"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { liveMilestone, putMilestone, roadOptions } from "./B08_Quantity_UI_Milestone_State"; + +const DEST_OPTIONS = [ + { value: "start", text: "시점" }, + { value: "mid", text: "중점" }, +]; + +const line = (...children: HTMLElement[]): HTMLElement => + el("div", { className: "b08-milestone__line-edit", children }); + +/** 구간 줄 — 도로 구분 ▾ · km · [×] · [+ 구간] */ +function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () => Leg[] } { + const list = el("div"); + const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement }> = []; + const add = (leg: Leg): void => { + const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); + const km = createInputField({ label: "km", type: "number", min: 0, compact: true }); + road.select.value = leg.road; + km.input.value = leg.km == null ? "" : String(leg.km); + const ref = { road: road.select, km: km.input }; + rows.push(ref); + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(ref), 1); + row.remove(); + }, + }); + const row = line(road.root, km.root, drop); + row.dataset.b08MilestoneLeg = ""; + list.append(row); + }; + initial.forEach(add); + const more = createButton({ + label: "+ 구간", + size: "sm", + variant: "ghost", + onClick: () => add({ road: fresh, km: null }), + }); + return { + root: el("div", { children: [list, more] }), + value: () => + rows.map(({ road, km }) => { + const n = Number(km.value); + return { + road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : fresh, + km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null, + }; + }), + }; +} + +/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */ +function rowsEditor(initial: MilestoneRow[]): { + root: HTMLElement; + value: () => MilestoneRow[]; +} { + const list = el("div"); + const rows: Array<{ item: HTMLInputElement; dest: HTMLSelectElement; note: string }> = []; + const add = (r: MilestoneRow): void => { + const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); + const dest = createSelectField({ label: "목적지", options: DEST_OPTIONS, compact: true }); + item.input.value = r.item; + dest.select.value = r.dest; + const ref = { item: item.input, dest: dest.select, note: r.note }; + rows.push(ref); + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(ref), 1); + row.remove(); + }, + }); + const row = line(item.root, dest.root, drop); + list.append(row); + }; + initial.forEach(add); + const more = createButton({ + label: "+ 줄", + size: "sm", + variant: "ghost", + onClick: () => add({ item: "", dest: "start", note: "" }), + }); + return { + root: el("div", { children: [list, more] }), + value: () => + rows.map((r) => ({ item: r.item.value.trim(), dest: r.dest.value as Dest, note: r.note })), + }; +} + +/** 주소 칸 값 → 주소 글 · 좌표(못 찾았고 글이 그대로면 옛 좌표 · 바뀌었으면 비움) */ +function placeOf( + field: AddressFieldHandle, + old: { address: string; lon?: number | null; lat?: number | null }, +): { address: string; lon: number | null; lat: number | null } { + const address = field.value() ?? ""; + const point = field.point(); + if (point) return { address, lon: point.lon, lat: point.lat }; + return address === old.address + ? { address, lon: old.lon ?? null, lat: old.lat ?? null } + : { address, lon: null, lat: null }; +} + +const head = (text: string): HTMLElement => el("h4", { text }); + +/** [+] — 항목 · 업체 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ +export function openAddItem( + projectId: string, + viaBase: boolean, + addRoute: (init: Partial) => string, +): void { + openModal({ + title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로", + closeLabel: "닫기", + mount: (body, close, actions) => { + const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); + const company = createInputField({ label: "업체 이름", compact: true }); + const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); + const address = buildAddressField(null); + item.input.dataset.b08MilestoneAdd = "item"; + company.input.dataset.b08MilestoneAdd = "company"; + const ok = createButton({ + label: "등록", + variant: "filled", + onClick: () => { + const name = company.input.value.trim(); + if (!name) return company.setError("업체 이름을 넣으세요."); + const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; + const place = placeOf(address, { address: "" }); + const data = liveMilestone(projectId); + const same = data.routes.find((r) => r.name.trim() === name); + if (same) { + same.rows.push(row); + if (place.lon != null) Object.assign(same, place); + putMilestone(projectId, data); + } else addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); + close(); + }, + }); + actions.append(ok); + body.append( + item.root, + company.root, + dest.root, + head("업체 위치 — 주소 검색(방위는 이 자리로 자동)"), + address.root, + ); + }, + }); +} + +/** 업체 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */ +export function openRouteInfo(projectId: string, routeId: string): void { + const data = liveMilestone(projectId); + const route = data.routes.find((r) => r.id === routeId); + if (!route) return; + openModal({ + title: `항목 정보 — ${route.name || "업체"}`, + closeLabel: "닫기", + mount: (body, close, actions) => { + const name = createInputField({ label: "업체 이름", compact: true }); + name.input.value = route.name; + name.input.dataset.b08MilestoneInfo = "name"; + const via = createCheckbox("기준점 거침"); + via.input.checked = route.via_base; + via.root.hidden = !data.base.enabled; + const rows = rowsEditor(route.rows); + const address = buildAddressField(route.address || null); + const legs = legsEditor(route.legs, "2차포장"); + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh = liveMilestone(projectId); + const r = fresh.routes.find((x) => x.id === routeId); + if (!r) return close(); + Object.assign(r, placeOf(address, r), { + name: name.input.value.trim() || r.name, + via_base: via.input.checked, + rows: rows.value(), + legs: legs.value(), + }); + putMilestone(projectId, fresh); + close(); + }, + }); + const remove = createButton({ + label: "삭제", + variant: "danger", + onClick: async () => { + if ( + !(await showConfirmDialog( + `「${route.name || "업체"}」 항목을 지울까요? — [저장] 때 반영`, + "삭제", + )) + ) + return; + const fresh = liveMilestone(projectId); + fresh.routes = fresh.routes.filter((r) => r.id !== routeId); + putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐 + close(); + }, + }); + save.dataset.b08MilestoneInfo = "save"; + remove.dataset.b08MilestoneInfo = "delete"; + actions.append(remove, save); + body.append( + name.root, + via.root, + head("표 줄 — 항목 · 목적지"), + rows.root, + head( + `구간 — ${route.name || "업체"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, + ), + legs.root, + head("주소 · 지도"), + address.root, + ); + }, + }); +} + +/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */ +export function openBaseInfo(projectId: string): void { + const base = liveMilestone(projectId).base; + openModal({ + title: `기준점 — ${base.name || "기준점"}`, + closeLabel: "닫기", + mount: (body, close, actions) => { + const name = createInputField({ label: "기준점 이름", compact: true }); + name.input.value = base.name; + const address = buildAddressField(base.address || null); + const legs = legsEditor(base.legs, "1차포장"); + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh: MilestoneData = liveMilestone(projectId); + Object.assign(fresh.base, placeOf(address, fresh.base), { + name: name.input.value.trim(), + legs: legs.value(), + }); + putMilestone(projectId, fresh); + close(); + }, + }); + actions.append(save); + body.append( + name.root, + head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"), + legs.root, + head("주소 · 지도"), + address.root, + ); + }, + }); +} + +interface SiteAddress { + region: string; + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; + start_address: string | null; + mid_address: string | null; +} + +/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ +export function openSiteInfo(projectId: string): void { + openModal({ + title: "공사지 — 시점", + closeLabel: "닫기", + mount: (body) => { + const text = (label: string, value: string): HTMLElement => + el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); + const map = buildAddressMap(); + const info = el("div", { children: [text("주소", "불러오는 중…")] }); + body.append(info, map.root); + request(`/projects/${projectId}/quantity/milestone/site-address`).then( + (s) => { + const ll = (p: [number, number] | null): string => + p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : ""; + info.replaceChildren( + text("프로젝트 등록 주소(사업지역)", s.region || "없음"), + text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)), + text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)), + ); + if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]); + else if (s.region) void map.show(s.region); + }, + (error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"), + ); + }, + }); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index 9c3671293..df5c65bbb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -1,39 +1,23 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_Route.ts - * 거리이정표 경로 하위 탭(PLAN 244-1) — 컨테이너 하나 = 업체 한 곳 · 그림 하나. + * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). * - * 위 = 업체 이름(= 하위 탭 이름) · 주소 · 방위(기준점 · 없으면 공사지 기준 8방) · 기준점 거침 - * 구간 표 = 업체 → 기준점(또는 시점) · 기준점 → 시점(공통 · 모든 경로가 같이 씀) · 시점 → 중점(측점 km) · - * 구간마다 km(더블클릭 손 입력) · 도로 구분 ▾(2차포장 · 1차포장 · 비포장) - * 표 줄 = 항목 · 목적지 ▾ · 비고 — [거리이정표] 표에 이 업체 줄로 나감 - * 그림 = 올리는 즉시 서버(교체 · 지우기) · 나머지 조작 = 캐시(`…_UI_Milestone_State`) · [저장] 때 서버 + * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 업체 좌표 · + * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 + * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 + * 그림 = 올리는 즉시 서버(교체 · 지우기) * ========================================================================== */ -import { createCheckbox } from "@ui/ui_template_checkbox"; import { createMapContextMenu, type MapContextMenu, type MapContextMenuItem, } from "@ui/ui_template_context_menu"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { - ROADS, - midKm, - type Dest, - type Dir, - type MilestoneData, - type MilestoneRoute, -} from "./B08_Quantity_Milestone_Calc"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout"; import { deleteImage, - dirOptions, imageBlobUrl, liveMilestone, putMilestone, @@ -61,12 +45,6 @@ export function pickAtCell(items: MapContextMenuItem[]): void { menu.open(box.left, box.bottom, items); } -/** 고른 줄 강조(공용 표 is-focus · 다시 그리지 않음 — 더블클릭 그대로) */ -function markRow(root: HTMLElement, index: number | null): void { - root.querySelectorAll("tr.is-focus").forEach((tr) => tr.classList.remove("is-focus")); - if (index !== null) root.querySelector(`tr[data-i="${index}"]`)?.classList.add("is-focus"); -} - export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; /** 그 경로를 고쳐 캐시에 */ @@ -82,180 +60,59 @@ export function editRoute( putMilestone(projectId, data); } -const kmOf = (text: string): number | null | undefined => { - if (!text.trim()) return null; - const value = Number(text.replace(/,/g, "")); - return Number.isFinite(value) && value >= 0 ? value : undefined; // undefined = 그대로 -}; - -type LegRef = { group: "own" | "base" | "mid"; i: number }; - -const LEG_COLUMNS: DataColumn[] = [ - { key: "span", label: "구간", width: 200 }, - { key: "road", label: "도로 구분", width: 96 }, - { key: "km", label: "km", kind: "number", decimals: 3, trim: true, width: 80 }, -]; -const ROW_COLUMNS: DataColumn[] = [ - { key: "item", label: "항목", width: 140 }, - { key: "dest", label: "목적지", width: 80 }, - { key: "note", label: "비고", width: 180 }, -]; - export interface MilestoneView { root: HTMLElement; refresh: () => void; } +/** 새 창 길찾기 지점 — 업체 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */ +export function routePoints( + d: MilestoneData, + r: MilestoneRoute, +): Array<{ name: string; lon: number; lat: number }> | null { + const site = siteInfo(); + const dest = r.rows[0]?.dest ?? "start"; + const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat; + if (r.lon == null || r.lat == null || !to) return null; + const points = [{ name: r.name || "업체", lon: r.lon, lat: r.lat }]; + if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null) + points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat }); + points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] }); + return points; +} + +/** 화면 캡처 — 사용자가 고른 창 한 장(png) · 스트림은 바로 끔 */ +async function captureWindow(): Promise { + const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); + try { + const video = el("video"); + video.muted = true; + video.srcObject = stream; + await video.play(); + await new Promise((done) => requestAnimationFrame(done)); + const canvas = el("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext("2d")?.drawImage(video, 0, 0); + const blob = await new Promise((done, fail) => + canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"), + ); + return new File([blob], "capture.png", { type: "image/png" }); + } finally { + stream.getTracks().forEach((t) => t.stop()); + } +} + export function routeView(projectId: string, routeId: string): MilestoneView { - const edit = (fn: (data: MilestoneData, route: MilestoneRoute) => void): void => - editRoute(projectId, routeId, fn); - - // 위 — 업체 이름 · 주소 · 방위 · 기준점 거침 - const name = createInputField({ label: "업체 이름", compact: true }); - const address = createInputField({ label: "주소", compact: true }); - const dir = createSelectField({ label: "방위", options: dirOptions, compact: true }); - const via = createCheckbox("기준점 거침"); - name.input.dataset.b08Milestone = "route_name"; - name.input.addEventListener("input", () => edit((_, r) => (r.name = name.input.value))); - address.input.addEventListener("input", () => edit((_, r) => (r.address = address.input.value))); - dir.select.addEventListener("change", () => - edit((_, r) => (r.dir = (dir.select.value || null) as Dir | null)), - ); - via.input.addEventListener("change", () => edit((_, r) => (r.via_base = via.input.checked))); - const head = el("div", { - className: "b08-milestone__head", - children: [name.root, address.root, dir.root, via.root], - }); - - // 구간 표 - let legRefs: LegRef[] = []; - let pickedLeg: number | null = null; - const legRows = (d: MilestoneData, r: MilestoneRoute): DataRow[] => { - const useBase = d.base.enabled && r.via_base; - const baseName = d.base.name || "기준점"; - const who = r.name || "업체"; - const rows: Array<[LegRef, string, string, number | null]> = [ - ...r.legs.map((leg, i): [LegRef, string, string, number | null] => [ - { group: "own", i }, - `${who} → ${useBase ? baseName : "시점"}`, - leg.road, - leg.km, - ]), - ...(useBase ? d.base.legs : []).map((leg, i): [LegRef, string, string, number | null] => [ - { group: "base", i }, - `${baseName} → 시점 (공통)`, - leg.road, - leg.km, - ]), - [ - { group: "mid", i: 0 }, - "시점 → 중점 (측점)", - d.site.mid_road, - midKm(d, siteInfo()?.mid_default_m ?? null), - ], - ]; - legRefs = rows.map(([ref]) => ref); - return rows.map(([, span, road, km]) => ({ span, road: `${road} ▾`, km })); - }; - const setLeg = (ref: LegRef, fn: (leg: { road: string; km: number | null }) => void): void => - edit((d, r) => { - if (ref.group === "mid") { - const leg = { road: d.site.mid_road as string, km: null }; - fn(leg); - d.site.mid_road = leg.road as MilestoneData["site"]["mid_road"]; - } else fn((ref.group === "own" ? r.legs : d.base.legs)[ref.i]); - }); - const legs = createDataTable({ - columns: LEG_COLUMNS, - rows: [], - caption: "구간", - empty: "", - editOnDouble: true, - editable: (i, key) => key === "km" && legRefs[i]?.group !== "mid", - onEdit: (i, _key, text) => { - const km = kmOf(text); - if (km !== undefined) setLeg(legRefs[i], (leg) => (leg.km = km)); - else refresh(); - }, - onCellClick: (i, key) => { - if (key !== "road") return; - pickAtCell(ROADS.map((road) => [road, () => setLeg(legRefs[i], (leg) => (leg.road = road))])); - }, - onRowClick: (i) => markRow(legs.root, (pickedLeg = i)), - }); - trackCells(legs.root); - legs.root.dataset.b08Milestone = "legs"; - const addOwn = createButton({ - label: "+ 업체 구간", - size: "sm", - variant: "ghost", - onClick: () => edit((_, r) => r.legs.push({ road: "2차포장", km: null })), - }); - const addBase = createButton({ - label: "+ 공통 구간", - size: "sm", - variant: "ghost", - onClick: () => edit((d) => d.base.legs.push({ road: "1차포장", km: null })), - }); - const dropLeg = createButton({ - label: "고른 구간 지우기", - size: "sm", - variant: "ghost", - onClick: () => { - const ref = pickedLeg === null ? null : legRefs[pickedLeg]; - if (!ref || ref.group === "mid") return showToast("지울 구간 줄을 먼저 고르세요.", "info"); - pickedLeg = null; - edit((d, r) => (ref.group === "own" ? r.legs : d.base.legs).splice(ref.i, 1)); - }, - }); - - // 표 줄 - let pickedRow: number | null = null; - const rows = createDataTable({ - columns: ROW_COLUMNS, - rows: [], - caption: "표 줄", - empty: "", - editOnDouble: true, - editable: (_i, key) => key === "item" || key === "note", - onEdit: (i, key, text) => - edit((_, r) => { - if (r.rows[i]) r.rows[i][key as "item" | "note"] = text; - }), - onCellClick: (i, key) => { - if (key !== "dest") return; - pickAtCell( - (["start", "mid"] as Dest[]).map((dest) => [ - DEST_TEXT[dest], - () => edit((_, r) => r.rows[i] && (r.rows[i].dest = dest)), - ]), - ); - }, - onRowClick: (i) => markRow(rows.root, (pickedRow = i)), - }); - trackCells(rows.root); - rows.root.dataset.b08Milestone = "rows"; - const addRow = createButton({ - label: "+ 줄", - size: "sm", - variant: "ghost", - onClick: () => edit((_, r) => r.rows.push({ item: "", dest: "start", note: "" })), - }); - const dropRow = createButton({ - label: "고른 줄 지우기", - size: "sm", - variant: "ghost", - onClick: () => { - if (pickedRow === null) return showToast("지울 줄을 먼저 고르세요.", "info"); - const at = pickedRow; - pickedRow = null; - edit((_, r) => r.rows.splice(at, 1)); - }, + const title = el("h4"); + const hint = el("p", { + className: "b08-options__hint", + text: "[다음 지도에서 길찾기] → 새 창에서 길 확인 → [화면 캡처] → 그 창을 고름 = 이 탭 그림", }); // 그림 — 경로 하나 = 그림 하나 const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } }); - const noImage = el("p", { className: "ui-general-block__empty", text: "올린 그림 없음" }); + const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" }); let shown: string | null | undefined; const loadImage = (force = false): void => { const name = routeImages()[routeId] ?? null; @@ -271,21 +128,51 @@ export function routeView(projectId: string, routeId: string): MilestoneView { (error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"), ); }; + const put = (picked: File): Promise => + uploadImage(projectId, routeId, picked).then( + () => loadImage(true), + (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), + ); const file = el("input", { attrs: { type: "file", accept: "image/*" } }); file.hidden = true; file.addEventListener("change", () => { const picked = file.files?.[0]; file.value = ""; - if (picked) - uploadImage(projectId, routeId, picked).then( - () => loadImage(true), - (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), - ); + if (picked) void put(picked); }); + + const kakao = createButton({ + label: "다음 지도에서 길찾기", + size: "sm", + variant: "filled", + onClick: () => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const points = r ? routePoints(d, r) : null; + if (!points) + return showToast( + "업체 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.", + "info", + ); + window.open(kakaoRouteUrl(points), "_blank", "noopener"); + }, + }); + kakao.dataset.b08MilestoneKakao = ""; + const capture = createButton({ + label: "화면 캡처", + size: "sm", + variant: "ghost", + onClick: () => + void captureWindow().then(put, (error: unknown) => { + if ((error as Error)?.name !== "NotAllowedError") + showToast(`화면을 캡처하지 못했습니다 — ${String(error)}`, "error"); + }), + }); + capture.dataset.b08MilestoneCapture = ""; const upload = createButton({ label: "그림 올리기", size: "sm", - variant: "filled", + variant: "ghost", onClick: () => file.click(), }); const removeImage = createButton({ @@ -298,21 +185,16 @@ export function routeView(projectId: string, routeId: string): MilestoneView { ), }); - const bar = (...items: HTMLElement[]): HTMLElement => - el("div", { className: "b08-milestone__bar", children: items }); const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, children: [ - head, - el("h4", { text: "구간 (km 더블클릭 · 도로 구분 ▾)" }), - legs.root, - bar(addOwn, addBase, dropLeg), - el("h4", { text: "표 줄" }), - rows.root, - bar(addRow, dropRow), - el("h4", { text: "경로 그림" }), - bar(upload, removeImage, file), + title, + el("div", { + className: "b08-milestone__bar", + children: [kakao, capture, upload, removeImage, file], + }), + hint, noImage, img, ], @@ -322,16 +204,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const d = liveMilestone(projectId); const r = d.routes.find((x) => x.id === routeId); if (!r) return; - if (document.activeElement !== name.input) name.input.value = r.name; - if (document.activeElement !== address.input) address.input.value = r.address; - dir.select.value = r.dir ?? ""; - via.input.checked = r.via_base; - via.root.hidden = !d.base.enabled; - addBase.hidden = !(d.base.enabled && r.via_base); - legs.setRows(legRows(d, r)); - rows.setRows(r.rows.map((row) => ({ ...row, dest: `${DEST_TEXT[row.dest]} ▾` }))); - markRow(legs.root, pickedLeg); - markRow(rows.root, pickedRow); + const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; + title.textContent = `${r.name || "업체"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; loadImage(); }; refresh(); diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 61be0ddae..a44d0be7e 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -127,8 +127,8 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble(): if "editable:" in block: seen += 1 assert "editOnDouble: true" in block, p.name - # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 셋(표 · 구간 · 표 줄 · 244-1) - assert seen == 4 + # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 표(구간 · 표 줄 표는 244-4 에서 모달로) + assert seen == 2 def test_고르기_메뉴_이동_그대로(): diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index a4e3141aa..f7bed025c 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -159,12 +159,13 @@ def test_메인_탭_하위_탭_추가_삭제(): ) ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui - assert 'label: "+ 추가"' in ui and "open(addRoute(projectId))" in ui - assert 'label: "삭제"' in ui and 'remove.disabled = current === "table"' in ui - assert "data.routes.filter((r) => r.id !== route.id)" in ui + # 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만) + assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert "fresh.routes = fresh.routes.filter((r) => r.id !== routeId);" in modal assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") - assert route.count("editOnDouble: true") == 2 and "uploadImage(projectId, routeId" in route + assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index aa4944a42..4c5663804 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -140,13 +140,16 @@ def test_빈_방위_차례_같은_방위_나란히(): def test_화면_상자_누름_더하기_창(): ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8") - assert 'g.addEventListener("click", () => openTab(box.id));' in ui + # 244-4 — 상자 누름 = 모달(탭 이동 아님) + assert "openTab" not in ui and "route: (id) => openRouteInfo(projectId, id)," in ui assert 'if (box.kind === "site") g.append(plus(box, false));' in ui assert 'if (box.kind === "base") g.append(plus(box, true));' in ui - assert "openAddItem(projectId, viaBase, addRoute)" in ui and "openModal({" in ui - assert "same.rows.push(row)" in ui # 같은 업체 이름 = 그 경로에 표 줄 + assert "openAddItem(projectId, viaBase, addRoute)" in ui + modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8") + assert "openModal({" in modal + assert "same.rows.push(row)" in modal # 같은 업체 이름 = 그 경로에 표 줄 page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") - assert "createDiagram(projectId, open, (init) => addRoute(projectId, init))" in page + assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"): assert ( diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py new file mode 100644 index 000000000..091a921ce --- /dev/null +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -0,0 +1,185 @@ +"""244-4 B08 거리이정표 고침(사용자 130번) — [+] 모달(항목 · 업체 이름 · 목적지 + 주소 검색 재활용) · +방위 = 좌표 자동 · 시점 모달(등록 주소 · VWorld 주소 · 지도) · 다이어그램 팬 · 줌 공용 · +경로 하위 탭 = 그림만(다음 지도 길찾기 새 창 · 화면 캡처) · 상자 누름 = 항목 정보 모달. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _route(rid, lon=None, lat=None, via=True): + return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "lon": lon, + "lat": lat, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]} # fmt: skip + + +def _center(lay, bid): + box = next(b for b in lay["boxes"] if b["id"] == bid) + return box["x"] + box["w"] / 2, box["y"] + box["h"] / 2 + + +def test_방위_좌표_자동(): + site = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None} + base = {"enabled": True, "name": "기준", "address": "", "dir": None, "legs": []} + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": base, + # 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽 + "routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)], + } + lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "base") + sx, sy = _center(lay, "s") + assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 + cx, cy = _center(lay, "site") + ex, ey = _center(lay, "e") + assert ex > cx + 200 and abs(ey - cy) < 1 + # 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽) + data["base"] = {**base, "lon": 127.7, "lat": 35.7} + data["routes"] = [_route("n", 127.7, 35.8)] + lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "base") + nx, ny = _center(lay, "n") + assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) + assert abs(nx - bx) < 1 and ny < by - 200 + + +def test_다음_지도_길찾기_주소(): + pts = [{"name": "거창군청", "lon": 127.909, "lat": 35.686}, + {"name": "병곡리, 968/31", "lon": 127.75, "lat": 35.78}, + {"name": "임도 시점", "lon": 127.74, "lat": 35.79}] # fmt: skip + url = _node(f"kakaoRouteUrl({json.dumps(pts, ensure_ascii=False)})") + # 공식 꼴 /link/by/car/이름,위도,경도/… · 이름의 쉼표 · 빗금은 빈칸 + assert url == ( + "https://map.kakao.com/link/by/car/%EA%B1%B0%EC%B0%BD%EA%B5%B0%EC%B2%AD,35.686,127.909/" + "%EB%B3%91%EA%B3%A1%EB%A6%AC%20%20968%2031,35.78,127.75/" + "%EC%9E%84%EB%8F%84%20%EC%8B%9C%EC%A0%90,35.79,127.74" + ) + + +def test_VWorld_좌표_주소(monkeypatch): + body = {"response": {"status": "OK", "result": [ + {"type": "parcel", "text": "경남 거창군 북상면 병곡리 968-31"}, + {"type": "road", "text": "경남 거창군 북상면 덕유월성로 1"}]}} # fmt: skip + seen = [] + + def fetch(url, timeout=5): + seen.append(url) + return json.dumps(body).encode() + + monkeypatch.setattr(vmap, "_fetch", fetch) + assert vmap._reverse_sync(127.75, 35.78) == "경남 거창군 북상면 덕유월성로 1" + assert "request=getAddress" in seen[0] and "point=127.75%2C35.78" in seen[0] + body["response"]["result"] = body["response"]["result"][:1] + assert vmap._reverse_sync(127.75, 35.78) == "경남 거창군 북상면 병곡리 968-31" + monkeypatch.setattr(vmap, "_fetch", lambda url, timeout=5: b"not json") + assert vmap._reverse_sync(127.75, 35.78) is None + + +def test_시점_모달_주소_길(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_pid): + return tmp_path + + async def project(_pid): + return {"region": "경남 거창군 북상면"} + + async def where(lon, lat): + return f"주소 {lon:.3f}" + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.setattr(ms, "get_project_by_id", project) + monkeypatch.setattr(ms, "reverse_geocode", where) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + url = f"/api/projects/{uuid4()}/quantity/milestone/site-address" + empty = client.get(url).json() + assert empty["region"] == "경남 거창군 북상면" and empty["start_address"] is None + path = tmp_path / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8") + got = client.get(url).json() + lon, lat = got["start_lonlat"] + assert 124 < lon < 132 and 33 < lat < 39 + assert got["start_address"] == f"주소 {lon:.3f}" and got["mid_address"].startswith("주소 ") + + +def test_화면_모달_팬줌_그림탭(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + # ① [+] 모달 — 항목 · 업체 이름 · 목적지 + 주소 검색(B01 재활용) · 방위 고르기 없음 · [등록] + add = modal[ + modal.index("export function openAddItem") : modal.index("export function openRouteInfo") + ] + for text in ('label: "항목"', 'label: "업체 이름"', 'label: "목적지"', 'label: "등록"', + "buildAddressField(null)", "placeOf(address,"): # fmt: skip + assert text in add + assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal + assert 'from "../B01_Dashboard/B01_Dashboard_UI_AddressField"' in modal + assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal + field = _read("B01_Dashboard/B01_Dashboard_UI_AddressField.ts") + assert "point: () => picked," in field and "searchAddress(text)" in field + # ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도 + assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal + assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal + # ⑤ 업체 상자 = 항목 정보(구간 km · 도로 구분 · 고침 · 삭제) + info = modal[ + modal.index("export function openRouteInfo") : modal.index("export function openBaseInfo") + ] + for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs, "2차포장")', + "rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip + assert text in info + diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert "route: (id) => openRouteInfo(projectId, id)," in diagram + assert "start: () => openSiteInfo(projectId)," in diagram + # ③ 팬 · 줌 = 공용 + assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram + assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram + # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 화면 캡처 → 그림 올리기 길 + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert ( + "createDataTable" not in route and "구간" not in route.split("export function routeView")[1] + ) + assert "window.open(kakaoRouteUrl(points)" in route + assert "getDisplayMedia({ video: true, audio: false })" in route + assert ( + "captureWindow().then(put," in route and "uploadImage(projectId, routeId, picked)" in route + ) + assert 'label: "그림 올리기"' in route + page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page + for rel in ("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", + "B08_Quantity/B08_Quantity_UI_Milestone_Route.ts", + "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts", + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700 From 7be4feb1cf078ff880fb772258a67a4bb752d71a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:36:56 +0900 Subject: [PATCH 61/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=EC=84=A0=20=EA=B8=80=20=EB=8D=94=EB=B8=94=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=3D=20km=20=C2=B7=20=EB=8F=84=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=20=EB=B0=94=EB=A1=9C=20=EA=B3=A0=EC=B9=A8=20(244-4=20?= =?UTF-8?q?=E2=91=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선 글 한 줄 = 그 구간(업체 · 공통 · 시점 → 중점) · 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소 - 값은 모달과 한 벌(legAt · setLeg) · 시점 → 중점 km = 중점 측점 · 표 셈 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 27 +++++++ B08_Quantity/B08_Quantity_UI_Milestone.css | 22 ++++++ .../B08_Quantity_UI_Milestone_Diagram.ts | 77 +++++++++++++++++-- .../tester/test_244_4_b08_milestone_modal.py | 42 +++++++++- 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index beeffbbc3..accdb206d 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -9,6 +9,7 @@ * 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서) * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) + * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * ========================================================================== */ import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; @@ -227,3 +228,29 @@ export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: nu `${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`; return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; } + +/** 선 글 한 줄 → 그 구간 — from = 업체 id(업체 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ +export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { + if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; + const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; + return legs?.[i] ?? null; +} + +/** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */ +export function setLeg( + data: MilestoneData, + from: string, + i: number, + patch: { road?: Leg["road"]; km?: number | null }, +): MilestoneData { + if (from === "start") { + if (patch.road) data.site.mid_road = patch.road; + if (patch.km !== undefined) + data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000; + return data; + } + const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; + const leg = legs?.[i]; + if (leg) Object.assign(leg, patch); + return data; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 6b8a9486d..2c55be329 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -125,3 +125,25 @@ .b08-milestone__diagram svg.is-panning { cursor: grabbing; } + +/* 선 글 바로 고침(244-4 ⑥) — 글 아래 작은 판 */ +.b08-milestone__km tspan { + cursor: text; +} + +.b08-milestone__leg-edit { + position: fixed; + z-index: 1000; + display: flex; + align-items: flex-end; + gap: 6px; + padding: 6px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.b08-milestone__leg-edit .ui-input { + width: 80px; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 5af9bc77a..ef4cbdbae 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -5,20 +5,26 @@ * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글 * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 · * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) - * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 더블클릭 = 원배율) + * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) + * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; -import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout"; +import { layoutMilestone, legAt, setLeg, type DiagramBox } from "./B08_Quantity_Milestone_Layout"; import { openAddItem, openBaseInfo, openRouteInfo, openSiteInfo, } from "./B08_Quantity_UI_Milestone_Modal"; -import { liveMilestone, siteInfo } from "./B08_Quantity_UI_Milestone_State"; +import { + liveMilestone, + putMilestone, + roadOptions, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; const NS = "http://www.w3.org/2000/svg"; @@ -33,6 +39,56 @@ function svg( return node; } +/** 선 글 바로 고침 — 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소 */ +function editLegAt( + projectId: string, + anchor: Element, + from: string, + i: number, + midKm: number, +): void { + const leg = legAt(liveMilestone(projectId), from, i, midKm); + if (!leg) return; + document.querySelector(".b08-milestone__leg-edit")?.remove(); + const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); + road.select.value = leg.road; + const km = el("input", { + className: "ui-input", + attrs: { type: "number", min: "0", step: "any" }, + }); + km.value = leg.km == null ? "" : String(leg.km); + km.setAttribute("aria-label", "km"); + const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] }); + box.dataset.b08MilestoneLegEdit = `${from}:${i}`; + const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로 + box.style.left = `${at.left}px`; + box.style.top = `${at.bottom + 4}px`; + let done = false; + const finish = (apply: boolean): void => { + if (done) return; + done = true; + box.remove(); + if (!apply) return; + const n = Number(km.value); + const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null; + const patch = { + road: road.select.value as typeof leg.road, + ...(km.value.trim() && value === null ? {} : { km: value }), + }; + putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch)); + }; + box.addEventListener("keydown", (e) => { + if (e.key === "Enter") finish(true); + if (e.key === "Escape") finish(false); + }); + box.addEventListener("focusout", (e) => { + if (!box.contains(e.relatedTarget as Node | null)) finish(true); + }); + document.body.append(box); + km.focus(); + km.select(); +} + /** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ export function createDiagram( projectId: string, @@ -71,7 +127,8 @@ export function createDiagram( const refresh = (): void => { const data = liveMilestone(projectId); const site = siteInfo(); - const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null)); + const mk = midKm(data, site?.mid_default_m ?? null); + const layout = layoutMilestone(data, site, mk); const [vx, vy, vw, vh] = layout.view; // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { @@ -126,7 +183,15 @@ export function createDiagram( const x = (ln.x1 + ln.x2) / 2; const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2; const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" }); - ln.labels.forEach((label, i) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label))); + ln.labels.forEach((label, i) => { + const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); + span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; + span.addEventListener("dblclick", (e) => { + e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 + editLegAt(projectId, span, ln.from, i, mk); + }); + text.append(span); + }); shift.append(text); } host.replaceChildren(root); diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 091a921ce..907c76e54 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -1,6 +1,8 @@ -"""244-4 B08 거리이정표 고침(사용자 130번) — [+] 모달(항목 · 업체 이름 · 목적지 + 주소 검색 재활용) · -방위 = 좌표 자동 · 시점 모달(등록 주소 · VWorld 주소 · 지도) · 다이어그램 팬 · 줌 공용 · -경로 하위 탭 = 그림만(다음 지도 길찾기 새 창 · 화면 캡처) · 상자 누름 = 항목 정보 모달. +"""244-4 B08 거리이정표 고침(사용자 130번) — [+] 모달(항목 · 업체 이름 · 목적지 + +주소 검색 재활용) · 방위 = 좌표 자동 · 시점 모달(등록 주소 · VWorld 주소 · 지도) · +다이어그램 팬 · 줌 공용 · +경로 하위 탭 = 그림만(다음 지도 길찾기 새 창 · 화면 캡처) · 상자 누름 = 항목 정보 모달 · +선 글 바로 고침(⑥). """ import json @@ -37,8 +39,9 @@ def _node(expr): def _route(rid, lon=None, lat=None, via=True): + row = {"item": "", "dest": "start", "note": ""} return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "lon": lon, - "lat": lat, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]} # fmt: skip + "lat": lat, "legs": [], "rows": [row]} # fmt: skip def _center(lay, bid): @@ -183,3 +186,34 @@ def test_화면_모달_팬줌_그림탭(): "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts", "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip assert len(_read(rel).splitlines()) <= 700 + + +def test_선_글_바로_고침_모달과_한_벌(): + from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone + + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "기준", "address": "", "dir": None, + "legs": [{"road": "1차포장", "km": 0.6}]}, + "routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}], + } # fmt: skip + d = json.dumps(data) + got = _node( + f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0)," + f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}})," + f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" + ) + assert got[0] == {"road": "2차포장", "km": None} + assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None + after = got[3] + assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}] + assert after["base"]["legs"] == [{"road": "1차포장", "km": None}] + assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} + # 표 셈 그대로 — 고친 값 = 모달 값과 같은 칸 + (row,) = compute_milestone(after, 1500.0) + assert (row["s2"], row["s1"], row["un"], row["total"]) == (0, 23.8, 0, 23.8) + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'span.addEventListener("dblclick"' in ui and "e.stopPropagation();" in ui + assert "editLegAt(projectId, span, ln.from, i, mk)" in ui + assert "anchor.getBoundingClientRect()" in ui # 팬 · 줌 뒤 글 화면 자리 + assert "putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch))" in ui From ecf99aefde7357314cf0491fccf98f3dcbdf6eba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:39:16 +0900 Subject: [PATCH 62/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EA=B8=B0=EC=A4=80=EC=A0=90=20=EB=B0=A9?= =?UTF-8?q?=EC=9C=84=20=EC=B9=B8=20=EC=97=86=EC=95=B0=20=E2=80=94=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EC=A2=8C=ED=91=9C=EB=A1=9C=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EA=B3=B5?= =?UTF-8?q?=EC=82=AC=EC=A7=80=20=EC=84=9C=EC=AA=BD=20(244-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 패널 「공사지에서 본 방위」 칸 · 8방 고르기 지움 · 옛 저장 값 방위는 무시 - 기준점 모달에 「주소를 고르면 실제 방위로 놓임」 안내 한 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 4 +- .../B08_Quantity_UI_Milestone_Modal.ts | 4 ++ .../B08_Quantity_UI_Milestone_State.ts | 31 +-------- .../test_244_5_b08_milestone_base_dir.py | 63 +++++++++++++++++++ 4 files changed, 72 insertions(+), 30 deletions(-) create mode 100644 resources/tester/test_244_5_b08_milestone_base_dir.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index accdb206d..77b558157 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -6,7 +6,7 @@ * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면 * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · * 같은 방위 여럿 = 옆으로 나란히 - * 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서) + * 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5) * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) @@ -144,7 +144,7 @@ export function layoutMilestone( data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null; let baseDeg = 270; if (useBase) { - baseDeg = placeDeg(data.base, startLL) ?? 270; + baseDeg = placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270; const [ux, uy] = unit(baseDeg); const cx = ux * R_BASE; const cy = uy * R_BASE; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index c3b39e9ea..c18484595 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -292,6 +292,10 @@ export function openBaseInfo(projectId: string): void { head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"), legs.root, head("주소 · 지도"), + el("p", { + className: "b08-options__hint", + text: "주소를 고르면 다이어그램에 실제 방위로 놓임 — 안 고르면 공사지 서쪽(왼쪽) 기본 자리", + }), address.root, ); }, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 6a53b8625..45198f8fd 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -12,7 +12,7 @@ import { createCheckbox } from "@ui/ui_template_checkbox"; import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { DIRS, ROADS, type MilestoneData, type Road } from "./B08_Quantity_Milestone_Calc"; +import { ROADS, type MilestoneData, type Road } from "./B08_Quantity_Milestone_Calc"; export interface SiteInfo { length_m: number | null; @@ -27,18 +27,6 @@ interface Payload { images: Record; } -/** 8방 이름 */ -export const DIR_TEXT: Record = { - N: "북", - NE: "북동", - E: "동", - SE: "남동", - S: "남", - SW: "남서", - W: "서", - NW: "북서", -}; - export const EMPTY: MilestoneData = { site: { mid_station_m: null, mid_road: "비포장" }, base: { enabled: false, name: "", address: "", dir: null, legs: [] }, @@ -139,15 +127,11 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< } export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); -export const dirOptions = [ - { value: "", text: "방위 —" }, - ...DIRS.map((d) => ({ value: d, text: DIR_TEXT[d] })), -]; const km = (m: number | null | undefined): string => m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); -/** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위) */ +/** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위 = 주소 좌표 자동) */ export function milestoneCardBody(projectId: string | null): HTMLElement[] { const mid = createInputField({ label: "중점 측점 (m)", type: "number", compact: true, min: 0 }); const midRoad = createSelectField({ @@ -159,14 +143,9 @@ export function milestoneCardBody(projectId: string | null): HTMLElement[] { const has = createCheckbox("기준점 있음"); const name = createInputField({ label: "기준점 이름", compact: true }); const address = createInputField({ label: "기준점 주소", compact: true }); - const dir = createSelectField({ - label: "공사지에서 본 방위", - options: dirOptions, - compact: true, - }); mid.input.dataset.b08Milestone = "mid_station_m"; has.input.dataset.b08Milestone = "base_enabled"; - const baseFields = el("div", { children: [name.root, address.root, dir.root] }); + const baseFields = el("div", { children: [name.root, address.root] }); const show = (): void => { if (!projectId) return; @@ -183,7 +162,6 @@ export function milestoneCardBody(projectId: string | null): HTMLElement[] { baseFields.hidden = !data.base.enabled; if (document.activeElement !== name.input) name.input.value = data.base.name; if (document.activeElement !== address.input) address.input.value = data.base.address; - dir.select.value = data.base.dir ?? ""; }; const edit = (fn: (data: MilestoneData) => void): void => { if (!projectId) return; @@ -210,9 +188,6 @@ export function milestoneCardBody(projectId: string | null): HTMLElement[] { address.input.addEventListener("input", () => edit((d) => (d.base.address = address.input.value)), ); - dir.select.addEventListener("change", () => - edit((d) => (d.base.dir = (dir.select.value || null) as MilestoneData["base"]["dir"])), - ); const host = el("div", { children: [mid.root, midRoad.root, siteHint, has.root, baseFields], diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py new file mode 100644 index 000000000..716657f71 --- /dev/null +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -0,0 +1,63 @@ +"""244-5 B08 거리이정표 기준점 방위 칸 없앰 — 기준점 자리 = 주소 좌표 → 공사지 기준 방위각 · +좌표 없으면 공사지 서쪽(견본) · 옛 저장 값의 방위(dir)는 무시. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _layout(base, site): + data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, "base": base, "routes": []} + src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) + expr = f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)" + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + lay = json.loads(result.stdout) + center = {b["id"]: (b["x"] + b["w"] / 2, b["y"] + b["h"] / 2) for b in lay["boxes"]} + return center["site"], center["base"] + + +BASE = {"enabled": True, "name": "기준", "address": "", "legs": []} +SITE = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None} + + +def test_좌표_없음_서쪽_옛_방위_무시(): + for site in (SITE, None): + (cx, cy), (bx, by) = _layout({**BASE, "dir": "E"}, site) + assert bx < cx - 200 and abs(by - cy) < 1 # 옛 「동」 무시 · 서(왼) + + +def test_좌표_있음_공사지_기준_방위각(): + (cx, cy), (bx, by) = _layout({**BASE, "dir": "W", "lon": 127.8, "lat": 35.8}, SITE) + assert abs(bx - cx) < 1 and by < cy - 200 # 북 = 위(옛 「서」 무시) + (cx, cy), (bx, by) = _layout({**BASE, "dir": None, "lon": 127.9, "lat": 35.7}, SITE) + assert bx > cx + 200 and abs(by - cy) < 1 # 동 = 오른 + # 시점 좌표 모름 = 서쪽 기본 + (cx, cy), (bx, by) = _layout({**BASE, "lon": 127.9, "lat": 35.7}, None) + assert bx < cx - 200 + + +def test_좌측_패널_방위_칸_없음_모달_안내(): + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "공사지에서 본 방위" not in state and "dirOptions" not in state + assert "d.base.dir" not in state and 'label: "기준점 주소"' in state + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] + assert "주소를 고르면 다이어그램에 실제 방위로 놓임" in base + layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + assert ( + "placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270" in layout + ) From 2c174f0b3dd07fb2068a6b63fbcc8d79214185d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:39:16 +0900 Subject: [PATCH 63/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20131=20=C2=B7=20132?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index e8de5f341..fd9dbbd5d 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -159,3 +159,5 @@ 129. 다음으로 마우스 우클릭 같은 패널에 항목을 추가하여 보여주는데 그냥 우측에 신규 페이지로 연결 5개 항목을 보여주면 됨. 130. (거리이정표 고침 · 원문) 1. 공사지의 + 버튼으로 나오는 모달창을 이용하여 항목, 업체이름 목적지를 받음. 방위항목은 지도상에 지정한 위치에 따라 자동배치되는 항목임. 하단에는 주소검색 기능을 구현과 등록완료시 지정된 맵을 보여주는 기능 구현. 프로젝트 등록에 관련 기능있음. 재활용, 시점을 컨테이너를 누르면 모달창으로 프로젝트 등록할 당시의 주소와 브이월드 주소와 하단에 맵을 보여줌. 2. 공사지의 (끊김) 3. 다이어그램의 영역은 팬과 줌인 동작이 되어야함. 동작과 관련해서는 공용 양식 있음. 확인 4. 보조탭으로 별도로 추가버튼 필요 없으며 탭의 내용은 거리 길찾기를 한 맵의 결과가 필요함. 구간, 표줄 이런건 필요없음. 만약 길찾기의 경우 안된다면 별도의 창에서 다음맵을 연결하여 길을 찾게하고 해당 브라우저를 확인시 캡처하게 하도록 변경 5. 컨테이너를 누를경우 모달창으로 관련정보를 보여줌. 페이지 이동 아님 130-6. 거리와 도로 구분 정보는 다이어그램에서 바꿀수도 있게 변경 +131. 다음으로 구조물 집계표에서 해당측점의 행이 구조물조합들이 전부 연결이 된 상태라면 녹색램프 표현, +132. 거리이정표의 추가내용 - 테이블의 폭은 상단 다이어그램과 동일한 폭을 유지 - 다이어그램의 내부 내용에는 항목도 추가 - 좌측패널의 거리이정표 패널은 필요없음. 다이어그램에서 직접 수정 - 다이어그램 팬 동작 구현안됨. From 7964fbcf31081b7a67fed0d7885a235a75156eea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:41:14 +0900 Subject: [PATCH 64/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20133?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index fd9dbbd5d..17b6bdc57 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -161,3 +161,4 @@ 130-6. 거리와 도로 구분 정보는 다이어그램에서 바꿀수도 있게 변경 131. 다음으로 구조물 집계표에서 해당측점의 행이 구조물조합들이 전부 연결이 된 상태라면 녹색램프 표현, 132. 거리이정표의 추가내용 - 테이블의 폭은 상단 다이어그램과 동일한 폭을 유지 - 다이어그램의 내부 내용에는 항목도 추가 - 좌측패널의 거리이정표 패널은 필요없음. 다이어그램에서 직접 수정 - 다이어그램 팬 동작 구현안됨. +133. 업체라는 말보다는 거래처라는 말이 맞아보임 From dba853b87e01653eddeb5ad9e190c4e9ce27f363 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:43:03 +0900 Subject: [PATCH 65/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20134?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 17b6bdc57..1ac036e31 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -162,3 +162,4 @@ 131. 다음으로 구조물 집계표에서 해당측점의 행이 구조물조합들이 전부 연결이 된 상태라면 녹색램프 표현, 132. 거리이정표의 추가내용 - 테이블의 폭은 상단 다이어그램과 동일한 폭을 유지 - 다이어그램의 내부 내용에는 항목도 추가 - 좌측패널의 거리이정표 패널은 필요없음. 다이어그램에서 직접 수정 - 다이어그램 팬 동작 구현안됨. 133. 업체라는 말보다는 거래처라는 말이 맞아보임 +134. 다음을 별도 브라우저 탭이 아닌 내부 모달창 내부에서 실행 안됨? 그래야 캡처가 쉬울것같음. (브레인 확인: map.kakao.com 길찾기 링크가 iframe 안에서 열림 · 경로 · km 보임) From 4b70627682de581eb29d0601c20c54ce668cabc9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:46:37 +0900 Subject: [PATCH 66/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=ED=91=9C=20=ED=8F=AD=20=3D=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=ED=8F=AD=20=C2=B7=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=B2=98=20=EC=83=81=EC=9E=90=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EA=B8=80=20=C2=B7=20=EC=A2=8C=EC=B8=A1=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=97=86=EC=95=B0=20=C2=B7=20=ED=8C=AC=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=C2=B7=20=EA=B1=B0=EB=9E=98=EC=B2=98=20=EA=B8=80=20?= =?UTF-8?q?(244-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 틀 너비 = 표 너비 · 다이어그램은 그 너비로 늘음 - 거래처 상자 = 항목(윗줄) + 거래처 이름 - 좌측 패널 거리이정표 카드 없앰 — 중점 측점 · 도로는 공사지 모달 · 기준점은 기준점 모달([기준점 +] · 기준점 없앰) - 팬 고침: 원배율에서 공용 팬 범위가 0 이던 것 — 범위 줌 · 빈 곳 왼쪽 끌기 더함(공용 panStart 선택 칸 · 기본 그대로) - 화면 글 「업체」 → 「거래처」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/b_svg_zoom_pan.ts | 6 +- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 33 ++++- B08_Quantity/B08_Quantity_UI_Milestone.css | 13 ++ B08_Quantity/B08_Quantity_UI_Milestone.ts | 14 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 45 +++++- .../B08_Quantity_UI_Milestone_Modal.ts | 107 +++++++++++--- .../B08_Quantity_UI_Milestone_Route.ts | 10 +- .../B08_Quantity_UI_Milestone_State.ts | 77 +--------- B08_Quantity/B08_Quantity_UI_Options.ts | 7 +- resources/tester/test_122_b08_options.py | 6 +- resources/tester/test_244_1_b08_milestone.py | 5 +- .../tester/test_244_4_b08_milestone_modal.py | 2 +- .../test_244_5_b08_milestone_base_dir.py | 2 +- .../test_244_6_b08_milestone_pan_client.py | 138 ++++++++++++++++++ 14 files changed, 328 insertions(+), 137 deletions(-) create mode 100644 resources/tester/test_244_6_b08_milestone_pan_client.py diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index d7d3ff6c4..bb293e404 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -67,6 +67,8 @@ export interface SvgZoomPanOptions { content?: ContentBounds; /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */ wheelZoom?: boolean; + /** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */ + panStart?: (event: PointerEvent) => boolean; } /** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */ @@ -146,8 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { if (event.button === 1) event.preventDefault(); }); svg.addEventListener("pointerdown", (event) => { - // 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다. - if (event.button !== 1) return; + // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. + if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; event.preventDefault(); panning = true; moved = false; diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 77b558157..9dfc0bae7 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -2,12 +2,12 @@ * B08_Quantity_Milestone_Layout.ts * 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림). * - * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들 - * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면 + * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 거래처들 + * 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면 * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · * 같은 방위 여럿 = 옆으로 나란히 * 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5) - * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 + * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * ========================================================================== */ @@ -22,6 +22,8 @@ export interface DiagramBox { w: number; h: number; label: string; + /** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */ + items?: string; } export interface DiagramLine { @@ -160,10 +162,10 @@ export function layoutMilestone( line("base", "start", legText(data.base.legs)); } - // 업체 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 + // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, - Array<{ id: string; label: string; anchor: string; deg: number; legs: Leg[] }> + Array<{ id: string; label: string; items: string; anchor: string; deg: number; legs: Leg[] }> >(); const used = new Set(); const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; @@ -173,7 +175,7 @@ export function layoutMilestone( }); for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); let auto = 0; - /** 방위 안 고른 업체 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ + /** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ const free = (anchor: string): number => { const turn = anchor === "base" ? baseDeg : 0; const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1; @@ -187,7 +189,10 @@ export function layoutMilestone( used.add(keyOf(anchor, d)); const key = keyOf(anchor, d); if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push({ id: r.id, label: r.name || "업체", anchor, deg: d, legs: r.legs }); + const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · "); + groups + .get(key)! + .push({ id: r.id, label: r.name || "거래처", items, anchor, deg: d, legs: r.legs }); } for (const group of groups.values()) { group.forEach((g, k) => { @@ -205,6 +210,7 @@ export function layoutMilestone( w: BOX.w, h: BOX.h, label: g.label, + items: g.items, }); line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs)); }); @@ -229,7 +235,7 @@ export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: nu return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; } -/** 선 글 한 줄 → 그 구간 — from = 업체 id(업체 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ +/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; @@ -254,3 +260,14 @@ export function setLeg( if (leg) Object.assign(leg, patch); return data; } + +/** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */ +export const PAN_SKIP = ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus"; +export function panStart(e: { button: number; target: EventTarget | null }): boolean { + if (e.button === 1) return true; + const target = e.target as { closest?: (selector: string) => unknown } | null; + return e.button === 0 && !target?.closest?.(PAN_SKIP); +} + +/** 팬 한계 — 원배율에서도 그림 둘레 한 폭씩 끌어 옮길 수 있게(공용 팬은 이 범위까지 엶) */ +export const panContent = (w: number, h: number) => ({ x: -w, y: -h, width: 3 * w, height: 3 * h }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 2c55be329..adef962c1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -4,6 +4,13 @@ display: flex; flex-direction: column; gap: var(--spacing-8); + /* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */ + width: fit-content; + max-width: 100%; +} + +.b08-milestone > .b08-milestone__diagram { + contain: inline-size; } .b08-milestone__route { @@ -147,3 +154,9 @@ .b08-milestone__leg-edit .ui-input { width: 80px; } + +/* 거래처 상자 윗줄 = 항목(244-6) */ +.b08-milestone__items { + fill: var(--color-text-secondary); + font-size: 12px; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index aecaaa8b9..8984b5099 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -1,11 +1,11 @@ /* ============================================================================= * B08_Quantity_UI_Milestone.ts - * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름). + * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름). * 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음). * - * [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) + * [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 - * 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도 + * 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도 * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` * ========================================================================== */ @@ -34,7 +34,7 @@ import { const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; const COLUMNS: DataColumn[] = [ { key: "item", label: "항목", width: 120 }, - { key: "company", label: "업체 이름", width: 120 }, + { key: "company", label: "거래처 이름", width: 120 }, { key: "dest", label: "목적지", width: 72 }, { key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM }, { key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM }, @@ -45,13 +45,13 @@ const COLUMNS: DataColumn[] = [ { key: "note", label: "비고", width: 160 }, ]; -/** 새 경로(업체) — 캐시에 넣고 id 를 돌려줌 */ +/** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */ export function addRoute(projectId: string, init: Partial = {}): string { const data = liveMilestone(projectId); const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`; data.routes.push({ id, - name: `업체 ${data.routes.length + 1}`, + name: `거래처 ${data.routes.length + 1}`, address: "", via_base: true, dir: null, @@ -141,7 +141,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { }; tabs.replaceChildren( tab("table", "거리이정표"), - ...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)), + ...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)), ); }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index ef4cbdbae..6e5d74a6d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -2,17 +2,25 @@ * B08_Quantity_UI_Milestone_Diagram.ts * [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`. * - * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글 - * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 · + * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 거래처들 · 선마다 「도로 구분 km」 글 + * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 거래처 = 항목 정보 · 공사지 = 시점 주소 · * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) - * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) + * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) + * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * ========================================================================== */ -import { createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; -import { layoutMilestone, legAt, setLeg, type DiagramBox } from "./B08_Quantity_Milestone_Layout"; +import { + layoutMilestone, + legAt, + panContent, + panStart, + setLeg, + type DiagramBox, +} from "./B08_Quantity_Milestone_Layout"; import { openAddItem, openBaseInfo, @@ -138,7 +146,8 @@ export function createDiagram( role: "img", "aria-label": "거리이정표 다이어그램", }); - root.style.maxWidth = "100%"; + // 틀 너비 = 아래 표 너비(244-6) · 높이는 비율대로(팬 · 줌 화면 px ↔ 그림 좌표가 어긋나지 않게) + root.style.width = "100%"; root.style.height = "auto"; const layer = svg("g", {}); const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); @@ -162,9 +171,19 @@ export function createDiagram( "text-anchor": "middle", "dominant-baseline": "central", }, - box.label, + box.kind === "route" && box.items ? "" : box.label, ), ); + if (box.kind === "route" && box.items) { + // 거래처 상자 = 항목(윗줄) + 거래처 이름(아랫줄) + const x = box.x + box.w / 2; + const two = g.lastChild as SVGTextElement; + two.setAttribute("y", String(box.y + box.h / 2 - 8)); + two.append( + svg("tspan", { x, class: "b08-milestone__items" }, box.items), + svg("tspan", { x, dy: 16 }, box.label), + ); + } // 상자 누름 = 모달(탭 이동 아님) const info = (): void => INFO[box.kind](box.id); g.setAttribute("role", "button"); @@ -194,7 +213,15 @@ export function createDiagram( }); shift.append(text); } - host.replaceChildren(root); + const addBase = createButton({ + label: "기준점 +", + size: "sm", + variant: "ghost", + onClick: () => openBaseInfo(projectId, true), + }); + addBase.dataset.b08MilestoneAddBase = ""; + addBase.hidden = data.base.enabled; + host.replaceChildren(el("div", { className: "b08-milestone__bar", children: [addBase] }), root); attachSvgZoomPan({ svg: root, layer, @@ -204,6 +231,8 @@ export function createDiagram( initial: zoomState, onChange: (state) => (zoomState = state), wheelZoom: true, + content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임) + panStart, // 가운데 버튼 · 빈 곳 왼쪽 끌기 }); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index c18484595..d3cd4d9e1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -2,9 +2,9 @@ * B08_Quantity_UI_Milestone_Modal.ts * 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침. * - * [+] 항목 추가 = 항목 · 업체 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = - * 경로(하위 탭) 생김 · 같은 업체 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 - * 업체 상자 = 그 항목 정보(업체 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] + * [+] 항목 추가 = 항목 · 거래처 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = + * 경로(하위 탭) 생김 · 같은 거래처 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 + * 거래처 상자 = 그 항목 정보(거래처 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 @@ -36,7 +36,12 @@ import { type Road, } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; -import { liveMilestone, putMilestone, roadOptions } from "./B08_Quantity_UI_Milestone_State"; +import { + liveMilestone, + putMilestone, + roadOptions, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; const DEST_OPTIONS = [ { value: "start", text: "시점" }, @@ -145,7 +150,7 @@ function placeOf( const head = (text: string): HTMLElement => el("h4", { text }); -/** [+] — 항목 · 업체 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ +/** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ export function openAddItem( projectId: string, viaBase: boolean, @@ -156,7 +161,7 @@ export function openAddItem( closeLabel: "닫기", mount: (body, close, actions) => { const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); - const company = createInputField({ label: "업체 이름", compact: true }); + const company = createInputField({ label: "거래처 이름", compact: true }); const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); const address = buildAddressField(null); item.input.dataset.b08MilestoneAdd = "item"; @@ -166,7 +171,7 @@ export function openAddItem( variant: "filled", onClick: () => { const name = company.input.value.trim(); - if (!name) return company.setError("업체 이름을 넣으세요."); + if (!name) return company.setError("거래처 이름을 넣으세요."); const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; const place = placeOf(address, { address: "" }); const data = liveMilestone(projectId); @@ -184,23 +189,23 @@ export function openAddItem( item.root, company.root, dest.root, - head("업체 위치 — 주소 검색(방위는 이 자리로 자동)"), + head("거래처 위치 — 주소 검색(방위는 이 자리로 자동)"), address.root, ); }, }); } -/** 업체 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */ +/** 거래처 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */ export function openRouteInfo(projectId: string, routeId: string): void { const data = liveMilestone(projectId); const route = data.routes.find((r) => r.id === routeId); if (!route) return; openModal({ - title: `항목 정보 — ${route.name || "업체"}`, + title: `항목 정보 — ${route.name || "거래처"}`, closeLabel: "닫기", mount: (body, close, actions) => { - const name = createInputField({ label: "업체 이름", compact: true }); + const name = createInputField({ label: "거래처 이름", compact: true }); name.input.value = route.name; name.input.dataset.b08MilestoneInfo = "name"; const via = createCheckbox("기준점 거침"); @@ -232,7 +237,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { onClick: async () => { if ( !(await showConfirmDialog( - `「${route.name || "업체"}」 항목을 지울까요? — [저장] 때 반영`, + `「${route.name || "거래처"}」 항목을 지울까요? — [저장] 때 반영`, "삭제", )) ) @@ -252,7 +257,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { head("표 줄 — 항목 · 목적지"), rows.root, head( - `구간 — ${route.name || "업체"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, + `구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, ), legs.root, head("주소 · 지도"), @@ -263,22 +268,26 @@ export function openRouteInfo(projectId: string, routeId: string): void { } /** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */ -export function openBaseInfo(projectId: string): void { +export function openBaseInfo(projectId: string, create = false): void { const base = liveMilestone(projectId).base; openModal({ - title: `기준점 — ${base.name || "기준점"}`, + title: create ? "기준점 추가" : `기준점 — ${base.name || "기준점"}`, closeLabel: "닫기", mount: (body, close, actions) => { const name = createInputField({ label: "기준점 이름", compact: true }); name.input.value = base.name; const address = buildAddressField(base.address || null); - const legs = legsEditor(base.legs, "1차포장"); + const legs = legsEditor( + base.legs.length ? base.legs : [{ road: "1차포장", km: null }], + "1차포장", + ); const save = createButton({ - label: "고침", + label: create ? "등록" : "고침", variant: "filled", onClick: () => { const fresh: MilestoneData = liveMilestone(projectId); Object.assign(fresh.base, placeOf(address, fresh.base), { + enabled: true, name: name.input.value.trim(), legs: legs.value(), }); @@ -286,7 +295,21 @@ export function openBaseInfo(projectId: string): void { close(); }, }); - actions.append(save); + // 기준점 없앰 = 꺼 둠(값은 남김 · 거래처는 시점으로 바로) + const off = createButton({ + label: "기준점 없앰", + variant: "danger", + onClick: () => { + const fresh: MilestoneData = liveMilestone(projectId); + fresh.base.enabled = false; + putMilestone(projectId, fresh); + close(); + }, + }); + off.hidden = create; + save.dataset.b08MilestoneBase = "save"; + off.dataset.b08MilestoneBase = "off"; + actions.append(off, save); body.append( name.root, head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"), @@ -313,14 +336,56 @@ interface SiteAddress { /** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ - title: "공사지 — 시점", + title: "공사지 — 시점 · 중점", closeLabel: "닫기", - mount: (body) => { + mount: (body, close, actions) => { const text = (label: string, value: string): HTMLElement => el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); + // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) + const data = liveMilestone(projectId); + const site = siteInfo(); + const km = (m: number | null | undefined): string => + m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); + const mid = createInputField({ + label: "중점 측점 (m)", + type: "number", + min: 0, + compact: true, + }); + mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; + mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); + mid.input.dataset.b08Milestone = "mid_station_m"; + const road = createSelectField({ + label: "시점 → 중점 도로", + options: roadOptions, + compact: true, + }); + road.select.value = data.site.mid_road; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh = liveMilestone(projectId); + const value = Number(mid.input.value); + fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; + fresh.site.mid_road = road.select.value as Road; + putMilestone(projectId, fresh); + close(); + }, + }); + save.dataset.b08MilestoneSite = "save"; + actions.append(save); const map = buildAddressMap(); const info = el("div", { children: [text("주소", "불러오는 중…")] }); - body.append(info, map.root); + body.append( + line(mid.root, road.root), + text( + "시점 = 노선 첫 점(측점 0)", + `노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`, + ), + info, + map.root, + ); request(`/projects/${projectId}/quantity/milestone/site-address`).then( (s) => { const ll = (p: [number, number] | null): string => diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index df5c65bbb..eb09f50b5 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -2,7 +2,7 @@ * B08_Quantity_UI_Milestone_Route.ts * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). * - * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 업체 좌표 · + * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 * 그림 = 올리는 즉시 서버(교체 · 지우기) @@ -65,7 +65,7 @@ export interface MilestoneView { refresh: () => void; } -/** 새 창 길찾기 지점 — 업체 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */ +/** 새 창 길찾기 지점 — 거래처 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */ export function routePoints( d: MilestoneData, r: MilestoneRoute, @@ -74,7 +74,7 @@ export function routePoints( const dest = r.rows[0]?.dest ?? "start"; const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat; if (r.lon == null || r.lat == null || !to) return null; - const points = [{ name: r.name || "업체", lon: r.lon, lat: r.lat }]; + const points = [{ name: r.name || "거래처", lon: r.lon, lat: r.lat }]; if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null) points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat }); points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] }); @@ -151,7 +151,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const points = r ? routePoints(d, r) : null; if (!points) return showToast( - "업체 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.", + "거래처 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.", "info", ); window.open(kakaoRouteUrl(points), "_blank", "noopener"); @@ -205,7 +205,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const r = d.routes.find((x) => x.id === routeId); if (!r) return; const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; - title.textContent = `${r.name || "업체"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; + title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; loadImage(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 45198f8fd..8ccf1c96f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_State.ts - * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길 + 좌측 패널 카드(공사지 · 기준점). + * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길(좌측 패널 카드는 244-6 에서 없앰 · + * 공사지 · 기준점은 다이어그램 모달). * * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. @@ -8,11 +9,9 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import { createCheckbox } from "@ui/ui_template_checkbox"; -import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { ROADS, type MilestoneData, type Road } from "./B08_Quantity_Milestone_Calc"; +import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; export interface SiteInfo { length_m: number | null; @@ -127,73 +126,3 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< } export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); - -const km = (m: number | null | undefined): string => - m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); - -/** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위 = 주소 좌표 자동) */ -export function milestoneCardBody(projectId: string | null): HTMLElement[] { - const mid = createInputField({ label: "중점 측점 (m)", type: "number", compact: true, min: 0 }); - const midRoad = createSelectField({ - label: "시점 → 중점 도로", - options: roadOptions, - compact: true, - }); - const siteHint = el("p", { className: "b08-options__hint" }); - const has = createCheckbox("기준점 있음"); - const name = createInputField({ label: "기준점 이름", compact: true }); - const address = createInputField({ label: "기준점 주소", compact: true }); - mid.input.dataset.b08Milestone = "mid_station_m"; - has.input.dataset.b08Milestone = "base_enabled"; - const baseFields = el("div", { children: [name.root, address.root] }); - - const show = (): void => { - if (!projectId) return; - const data = liveMilestone(projectId); - const site = siteInfo(); - mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; - if (document.activeElement !== mid.input) - mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); - midRoad.select.value = data.site.mid_road; - siteHint.textContent = - `시점 = 노선 첫 점(측점 0) · 노선 ${km(site?.length_m)} km · ` + - `중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`; - has.input.checked = data.base.enabled; - baseFields.hidden = !data.base.enabled; - if (document.activeElement !== name.input) name.input.value = data.base.name; - if (document.activeElement !== address.input) address.input.value = data.base.address; - }; - const edit = (fn: (data: MilestoneData) => void): void => { - if (!projectId) return; - const data = liveMilestone(projectId); - fn(data); - putMilestone(projectId, data); - }; - mid.input.addEventListener("input", () => - edit((d) => { - const value = Number(mid.input.value); - d.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; - }), - ); - midRoad.select.addEventListener("change", () => - edit((d) => (d.site.mid_road = midRoad.select.value as Road)), - ); - has.input.addEventListener("change", () => - edit((d) => { - d.base.enabled = has.input.checked; - if (d.base.enabled && !d.base.legs.length) d.base.legs = [{ road: "1차포장", km: null }]; - }), - ); - name.input.addEventListener("input", () => edit((d) => (d.base.name = name.input.value))); - address.input.addEventListener("input", () => - edit((d) => (d.base.address = address.input.value)), - ); - - const host = el("div", { - children: [mid.root, midRoad.root, siteHint, has.root, baseFields], - }); - show(); - onMilestoneChange(host, show); // 첫 알림(읽기 끝)은 패널이 붙은 뒤 - if (projectId) void loadMilestone(projectId).catch(() => undefined); - return [host]; -} diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index fe5160be2..62d0a3147 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -8,8 +8,8 @@ * 자재 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100 * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) * 수리집수면적표 — 빈 자리(다음 옵션) - * 거리이정표 › 공사지(중점 측점 · 시점 → 중점 도로) · 기준점 — `…_UI_Milestone_State`(캐시 `b08-milestone` · - * [저장] 한 번에 같이 PUT) + * 거리이정표 — 카드 없음(244-6 · 공사지 · 기준점은 다이어그램 모달) · 캐시 `b08-milestone` 은 + * [저장] 한 번에 같이 PUT(`…_UI_Milestone_State`) * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. * ========================================================================== */ @@ -27,7 +27,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { clampPct } from "./B08_Quantity_Options_Calc"; import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc"; -import { milestoneCardBody, saveMilestone } from "./B08_Quantity_UI_Milestone_State"; +import { saveMilestone } from "./B08_Quantity_UI_Milestone_State"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -242,7 +242,6 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock), card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")), card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")), - card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId)), actions, ); attachCollapsible(panel); diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index ac34f1342..3e87c8bfd 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -149,8 +149,10 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): "quantity", ] # 자재 탭 안 = 총괄 · 콘크리트소요 · 자재수량 assert 'select("material");' in page and 'select("summary");' in page - assert [panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in tabs] == sorted( - panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in tabs + cards = tabs[:-1] # 244-6 — 거리이정표 카드 없음(다이어그램 모달) + assert 'card("milestone"' not in panel + assert [panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in cards] == sorted( + panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in cards ) assert 'card("summary", "B08_Quantity_Tab_StructureSummary", one.root),' in panel assert 'card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock),' in panel diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index f7bed025c..c69fd4724 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -171,10 +171,7 @@ def test_메인_탭_하위_탭_추가_삭제(): assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state assert state.count('method: "PUT"') == 1 and "setTimeout" not in state options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") - assert ( - 'card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId))' - in options - ) + assert 'card("milestone"' not in options and "milestoneCardBody" not in options # 244-6 assert "await saveMilestone(projectId);" in options registry = _read("A00_Common/b_page_state.ts") assert '"b08-milestone": { bucket: "draft", scope: "project" }' in registry diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 907c76e54..9b8958ed6 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -144,7 +144,7 @@ def test_화면_모달_팬줌_그림탭(): add = modal[ modal.index("export function openAddItem") : modal.index("export function openRouteInfo") ] - for text in ('label: "항목"', 'label: "업체 이름"', 'label: "목적지"', 'label: "등록"', + for text in ('label: "항목"', 'label: "거래처 이름"', 'label: "목적지"', 'label: "등록"', "buildAddressField(null)", "placeOf(address,"): # fmt: skip assert text in add assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 716657f71..d5c4ac8cd 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -53,7 +53,7 @@ def test_좌표_있음_공사지_기준_방위각(): def test_좌측_패널_방위_칸_없음_모달_안내(): state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "공사지에서 본 방위" not in state and "dirOptions" not in state - assert "d.base.dir" not in state and 'label: "기준점 주소"' in state + assert "d.base.dir" not in state # 244-6 — 기준점 칸은 다이어그램 기준점 모달로 modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] assert "주소를 고르면 다이어그램에 실제 방위로 놓임" in base diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py new file mode 100644 index 000000000..bada838af --- /dev/null +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -0,0 +1,138 @@ +"""244-6 B08 거리이정표 추가 고침(사용자 132 · 133번) — 표 폭 = 다이어그램 폭 · +거래처 상자 항목 글 · 좌측 패널 카드 없음(공사지 · 기준점 = 다이어그램 모달 · [기준점 +]) · +팬(가운데 · 빈 곳 왼쪽 끌기 · 원배율에서도) · 화면 글 「업체」 → 「거래처」. + +팬은 공용 `A00_Common/b_svg_zoom_pan.ts` + `…_Milestone_Layout.ts`(panStart · panContent)를 +node 로 돌려 pointerdown · pointermove · pointerup 사건을 실제로 보내 +그림 묶음 transform 이 바뀌는지 잼. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +UI = ("UI_Milestone.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts", + "UI_Milestone_Route.ts", "UI_Milestone_State.ts", "Milestone_Layout.ts") # fmt: skip + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(script): + src = "".join( + IMPORTS.sub("", _read(rel)) + for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts") + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + "\n" + script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +PAN = """ +const W = 600, H = 400; +class Svg extends EventTarget { + classList = { add() {}, remove() {} }; + setPointerCapture() {} + releasePointerCapture() {} + getBoundingClientRect() { return { left: 0, top: 0, width: W, height: H }; } +} +const shape = { closest: (s: string) => (s.includes(".b08-milestone__box") ? {} : null) }; +const empty = { closest: () => null }; +function fire(svg: EventTarget, type: string, button: number, x: number, y: number, + target: unknown) { + const ev = new Event(type, { cancelable: true }); + for (const [k, v] of Object.entries({ button, clientX: x, clientY: y, pointerId: 1, target })) + Object.defineProperty(ev, k, { value: v }); + svg.dispatchEvent(ev); +} +function drag(button: number, target: unknown) { + const svg = new Svg(); + const layer = { t: "", setAttribute(_k: string, v: string) { this.t = v; } }; + attachSvgZoomPan({ svg: svg as any, layer: layer as any, widthPx: W, heightPx: H, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true, + content: panContent(W, H), panStart }); + fire(svg, "pointerdown", button, 100, 100, target); + fire(svg, "pointermove", button, 160, 130, target); + fire(svg, "pointerup", button, 160, 130, target); + return layer.t; +} +console.log(JSON.stringify({ + leftEmpty: drag(0, empty), leftShape: drag(0, shape), + middleShape: drag(1, shape), right: drag(2, empty), +})); +""" + + +def test_실제_포인터_사건으로_팬(): + got = _node(PAN) + # 원배율(1)에서도 빈 곳 왼쪽 끌기 · 가운데 버튼 끌기 = 그림 묶음이 끈 만큼 옮김 + assert got["leftEmpty"] == "translate(60 30) scale(1)" + assert got["middleShape"] == "translate(60 30) scale(1)" + assert got["leftShape"] == "" and got["right"] == "" # 상자 위 왼쪽 = 그 화면 고르기 몫 + + +def test_거래처_상자_항목_글(): + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "routes": [ + {"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""}, + {"item": " 장비 ", "dest": "mid", "note": ""}, + {"item": "골재", "dest": "mid", "note": ""}]}, + {"id": "k", "name": "", "address": "", "via_base": True, "dir": None, + "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, + ], + } # fmt: skip + lay = _node(f"console.log(JSON.stringify(layoutMilestone({json.dumps(data)}, null, 0)));") + boxes = {b["id"]: b for b in lay["boxes"]} + assert boxes["g"]["items"] == "장비 · 골재" and boxes["g"]["label"] == "거창군청" + assert boxes["k"]["items"] == "" and boxes["k"]["label"] == "거래처" + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'svg("tspan", { x, class: "b08-milestone__items" }, box.items)' in ui + assert 'svg("tspan", { x, dy: 16 }, box.label)' in ui + + +def test_표_폭_다이어그램_폭_좌측_카드_없음(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + block = css[css.index(".b08-milestone {") :] + assert "width: fit-content;" in block[: block.index("}")] # 틀 = 표 너비 + assert ".b08-milestone > .b08-milestone__diagram {\n contain: inline-size;" in css + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'root.style.width = "100%";' in ui # 다이어그램 = 틀 너비 + page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "children: [diagram.root, none, table.root]" in page + # 좌측 패널 카드 없음 · 공사지 · 기준점은 모달 + options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") + assert "milestoneCardBody" not in options and "await saveMilestone(projectId);" in options + assert "milestoneCardBody" not in _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + site = modal[modal.index("export function openSiteInfo") :] + assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site + assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site + base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] + assert "enabled: true," in base and 'label: "기준점 없앰"' in base + assert "fresh.base.enabled = false;" in base + assert 'label: "기준점 +"' in ui and "openBaseInfo(projectId, true)" in ui + assert "addBase.hidden = data.base.enabled;" in ui + assert "content: panContent(vw, vh)" in ui and "panStart," in ui + + +def test_화면_글_거래처(): + for name in UI: + text = _read(f"B08_Quantity/B08_Quantity_{name}") + assert "업체" not in text, name + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert 'label: "거래처 이름"' in modal + page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert '{ key: "company", label: "거래처 이름"' in page and "`거래처 ${" in page + for name in UI: + assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700 From f103a3ad573fa79ed411b2ddcd42fb1cabd6f6b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:49:05 +0900 Subject: [PATCH 67/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20135?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 1ac036e31..8cd994f10 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -163,3 +163,4 @@ 132. 거리이정표의 추가내용 - 테이블의 폭은 상단 다이어그램과 동일한 폭을 유지 - 다이어그램의 내부 내용에는 항목도 추가 - 좌측패널의 거리이정표 패널은 필요없음. 다이어그램에서 직접 수정 - 다이어그램 팬 동작 구현안됨. 133. 업체라는 말보다는 거래처라는 말이 맞아보임 134. 다음을 별도 브라우저 탭이 아닌 내부 모달창 내부에서 실행 안됨? 그래야 캡처가 쉬울것같음. (브레인 확인: map.kakao.com 길찾기 링크가 iframe 안에서 열림 · 경로 · km 보임) +135. 추가로 다이어그램의 영역에서 스크롤이 존재함. 줌인아웃에 방해됨. 스크롤 안되게 해줘. From 4b7402270d7e3edc540f97aea00d3fbad92263ce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:51:40 +0900 Subject: [PATCH 68/92] =?UTF-8?q?fix(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=EC=98=81=EC=97=AD=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=97=86=EC=95=B0=20=C2=B7=20=ED=9C=A0=20=3D=20=EC=A4=8C?= =?UTF-8?q?=EB=A7=8C=20(244-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 틀 overflow hidden · max-height 없앰 — 그림 = 틀 안 맞춤(높이 48vh 넘으면 비율대로 좁힘) - 틀 wheel 듣개(passive false · preventDefault) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 안 샘 - 새 시험 test_244_8 — 진짜 wheel 사건으로 줌 · 기본동작 막힘 판정 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_UI_Milestone.css | 5 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 5 +- .../test_244_6_b08_milestone_pan_client.py | 2 +- .../test_244_8_b08_milestone_no_scroll.py | 85 +++++++++++++++++++ 4 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_244_8_b08_milestone_no_scroll.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index adef962c1..ffabeca3f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -51,10 +51,9 @@ border: 1px solid var(--color-border); } -/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) */ +/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ .b08-milestone__diagram { - overflow: auto; - max-height: 48vh; + overflow: hidden; border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-surface); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 6e5d74a6d..e22e9b69c 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -104,6 +104,8 @@ export function createDiagram( ): { root: HTMLElement; refresh: () => void } { const host = el("div", { className: "b08-milestone__diagram" }); host.dataset.b08Milestone = "diagram"; + // 틀 위 휠 = 줌만(244-8) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 새지 않음 + host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 const INFO: Record void> = { route: (id) => openRouteInfo(projectId, id), @@ -147,7 +149,8 @@ export function createDiagram( "aria-label": "거리이정표 다이어그램", }); // 틀 너비 = 아래 표 너비(244-6) · 높이는 비율대로(팬 · 줌 화면 px ↔ 그림 좌표가 어긋나지 않게) - root.style.width = "100%"; + // 높이 48vh 넘으면 그만큼 좁혀 틀 안에 다 들어감(244-8 · 스크롤 없음) + root.style.width = `min(100%, calc(48vh * ${vw / vh}))`; root.style.height = "auto"; const layer = svg("g", {}); const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index bada838af..4d459a64f 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -107,7 +107,7 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): assert "width: fit-content;" in block[: block.index("}")] # 틀 = 표 너비 assert ".b08-milestone > .b08-milestone__diagram {\n contain: inline-size;" in css ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert 'root.style.width = "100%";' in ui # 다이어그램 = 틀 너비 + assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui # 다이어그램 = 틀 너비 page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert "children: [diagram.root, none, table.root]" in page # 좌측 패널 카드 없음 · 공사지 · 기준점은 모달 diff --git a/resources/tester/test_244_8_b08_milestone_no_scroll.py b/resources/tester/test_244_8_b08_milestone_no_scroll.py new file mode 100644 index 000000000..cbf8855aa --- /dev/null +++ b/resources/tester/test_244_8_b08_milestone_no_scroll.py @@ -0,0 +1,85 @@ +"""244-8 B08 거리이정표 다이어그램 영역 스크롤 없앰(사용자 135번). + +틀 = overflow hidden · 그림 = 틀 안 맞춤(높이 48vh 넘으면 좁힘) · 휠 = 줌만. +휠은 공용 `A00_Common/b_svg_zoom_pan.ts` 와 다이어그램 틀 듣개를 node 로 돌려 +진짜 wheel 사건(cancelable)을 보내 기본동작(스크롤)이 막히는지 · 배율이 바뀌는지 잼. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +DIAGRAM = "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(script): + src = IMPORTS.sub("", _read("A00_Common/b_svg_zoom_pan.ts")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + "\n" + script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_틀_스크롤_없음_그림_맞춤(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + block = css[css.index("\n.b08-milestone__diagram {") :] + block = block[: block.index("}")] + assert "overflow: hidden;" in block and "overflow: auto" not in block + assert "max-height" not in block # 높이는 그림이 맞춤 — 틀이 자르지 않음 + ui = _read(DIAGRAM) + # 너비 = 틀 · 높이 48vh 넘으면 비율대로 좁힘(height auto 라 화면 px ↔ 그림 좌표 그대로) + assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui + assert 'root.style.height = "auto";' in ui + + +def test_휠_진짜_사건_줌_스크롤_막음(): + ui = _read(DIAGRAM) + line = next(ln.strip() for ln in ui.splitlines() if 'host.addEventListener("wheel"' in ln) + got = _node( + """ +const W = 600, H = 400; +class Svg extends EventTarget { + classList = { add() {}, remove() {} }; + getBoundingClientRect() { return { left: 0, top: 0, width: W, height: H }; } +} +function wheel(target: EventTarget, deltaY: number) { + const ev = new Event("wheel", { cancelable: true, bubbles: true }); + for (const [k, v] of Object.entries({ deltaY, clientX: 300, clientY: 200 })) + Object.defineProperty(ev, k, { value: v }); + target.dispatchEvent(ev); + return ev.defaultPrevented; +} +const svg = new Svg(); +const layer = { t: "", setAttribute(_k: string, v: string) { this.t = v; } }; +attachSvgZoomPan({ svg: svg as any, layer: layer as any, widthPx: W, heightPx: H, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true }); +const svgIn = wheel(svg, 100); +const zoomIn = layer.t; +const svgOut = wheel(svg, -100); +const zoomOut = layer.t; +// 틀 듣개(그림 밖 여백 · 단추 줄) — 소스 줄 그대로 돌림 · passive 면 preventDefault 무시됨 +const host = new EventTarget(); +let opts: unknown = null; +const add = host.addEventListener.bind(host); +host.addEventListener = (t: string, f: any, o: any) => { opts = o; add(t, f, o); }; +LINE +const hostBlocked = wheel(host, 100); +console.log(JSON.stringify({ svgIn, zoomIn, svgOut, zoomOut, hostBlocked, opts })); +""".replace("LINE", line) + ) + # 그림 위 휠 = 줌(커서 자리 붙잡음) · 기본동작(스크롤) 막힘 = 스크롤 위치 안 바뀜 + assert got["svgIn"] is True and got["zoomIn"].endswith(" scale(1.15)") + assert got["zoomIn"].startswith("translate(-45 -29.99") # 커서(300, 200) 제자리 + assert got["svgOut"] is True and got["zoomOut"] != got["zoomIn"] # 밀면 축소 + # 그림 밖 틀 위 휠도 페이지 스크롤로 안 샘 + assert got["hostBlocked"] is True and got["opts"] == {"passive": False} From 59b17f3a7dff78c9b3cea89cefb387fc82850d6b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:56:32 +0900 Subject: [PATCH 69/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20136?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 8cd994f10..e138dd269 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -164,3 +164,4 @@ 133. 업체라는 말보다는 거래처라는 말이 맞아보임 134. 다음을 별도 브라우저 탭이 아닌 내부 모달창 내부에서 실행 안됨? 그래야 캡처가 쉬울것같음. (브레인 확인: map.kakao.com 길찾기 링크가 iframe 안에서 열림 · 경로 · km 보임) 135. 추가로 다이어그램의 영역에서 스크롤이 존재함. 줌인아웃에 방해됨. 스크롤 안되게 해줘. +136. (길찾기 방식 결정) 일단 카카오로 가야할것같아. 카카오 디벨로퍼 페이지에 와있는데 제휴방법을 알려줘. From e299e1cdb857faacb6e5cb6ef6f28f08dc703791 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:01:47 +0900 Subject: [PATCH 70/92] =?UTF-8?q?chore(env):=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EA=B8=B8=EC=B0=BE=EA=B8=B0=20REST=20=ED=82=A4=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=B6=94=EA=B0=80=20(KAKAO=5FREST=5FAPI=5FKEY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- .env | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env b/.env index afc188a45..761b7fac0 100644 --- a/.env +++ b/.env @@ -46,7 +46,8 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000 # Public URL for email links (프론트엔드 주소) APP_PUBLIC_BASE_URL=http://localhost:5173 - +KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08 +# kakao app ID 1603330 # Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f # 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA # 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B From 09b57398b78043865b7bbaa1fb41b92f26fcd8cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:11:36 +0900 Subject: [PATCH 71/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20km=20=EC=9E=90=EB=8F=99=20=3D=20=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20=EA=B8=B8=EC=B0=BE=EA=B8=B0=20REST=20?= =?UTF-8?q?=C2=B7=20=EC=84=9C=EB=B2=84=20=EB=8B=A8=EB=8F=85=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20km=20=EC=9A=B0=EC=84=A0=20=C2=B7?= =?UTF-8?q?=20=EB=8F=84=EB=A1=9C=20=EA=B5=AC=EB=B6=84=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20(244-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 새 길 POST …/quantity/milestone/directions — 두 좌표 → 카카오 길찾기 → km 소수 1자리 · 키 없음 · 실패 = 까닭 한 줄 · 키 서버만 · 응답 원문 · 경로 좌표 안 돌려줌 - 거래처 모달 [길찾기] · [등록] 뒤 한 번 — 거래처 → 기준점 · 기준점 → 시점(기준점 없으면 거래처 → 시점) 자동 km(auto_km 만 저장) - 사용자 km 우선 · 다르면 호버 「자동 값 ○○ km」 · 도로 구분 안 고름 = 합에 안 듦 · 표 합계 칸 「도로 구분 고르기」 - 표 합 파이썬 · TS 짝 같이 고침 · 새 시험 test_244_7 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Calc.py | 22 +- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 28 +- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 79 +++++- B08_Quantity/B08_Quantity_Router_Milestone.py | 77 +++++- B08_Quantity/B08_Quantity_UI_Milestone.css | 5 + B08_Quantity/B08_Quantity_UI_Milestone.ts | 16 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 19 +- .../B08_Quantity_UI_Milestone_Modal.ts | 98 +++++-- .../B08_Quantity_UI_Milestone_State.ts | 48 ++++ .../tester/test_244_4_b08_milestone_modal.py | 2 +- .../tester/test_244_7_b08_milestone_kakao.py | 240 ++++++++++++++++++ 11 files changed, 578 insertions(+), 56 deletions(-) create mode 100644 resources/tester/test_244_7_b08_milestone_kakao.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index 3d4ae22c6..58cc57df4 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -8,6 +8,8 @@ 시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · 포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · 끝수 = 소수 셋째 자리(반올림). +구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 +pick = 참(화면 「도로 구분 고르기」). """ from __future__ import annotations @@ -24,11 +26,22 @@ def r3(value: float) -> float: return math.floor(value * 1000 + 0.5) / 1000 -def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> None: +def leg_km(leg: dict[str, Any]) -> float | None: + """구간 km — 사용자 값 우선 · 없으면 자동 값.""" + km = leg.get("km") + return leg.get("auto_km") if km is None else km + + +def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> bool: + """도로 구분마다 더함 — 안 고른 구간이 있으면 참.""" + pick = False for leg in legs or []: key = KEYS.get(leg.get("road")) if key: - sums[key] += float(leg.get("km") or 0) + sums[key] += float(leg_km(leg) or 0) + else: + pick = True + return pick def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: @@ -48,9 +61,9 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): sums = {"s2": 0.0, "s1": 0.0, "un": 0.0} - add_legs(sums, route.get("legs")) + pick = add_legs(sums, route.get("legs")) if base.get("enabled") and route.get("via_base", True): - add_legs(sums, base.get("legs")) + pick = add_legs(sums, base.get("legs")) or pick if row.get("dest") == "mid": add_legs(sums, [mid]) paved = sums["s2"] + sums["s1"] @@ -68,6 +81,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list "unpaved": r3(sums["un"]), "total": r3(paved + sums["un"]), "note": row.get("note") or "", + "pick": pick, } ) return out diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 4856fc234..481d56391 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -9,6 +9,8 @@ * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) + * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 + * pick = 참(화면 「도로 구분 고르기」) * ========================================================================== */ export const ROADS = ["2차포장", "1차포장", "비포장"] as const; @@ -19,8 +21,12 @@ export type Dir = (typeof DIRS)[number]; export type Dest = "start" | "mid"; export interface Leg { - road: Road; + /** null = 사용자가 안 고름(자동 판정 없음 · 244-7) */ + road: Road | null; + /** 사용자 값 — 있으면 우선 */ km: number | null; + /** [길찾기] 자동 값(km 만 저장 · 경로 좌표 · 응답은 저장 안 함) */ + auto_km?: number | null; } export interface MilestoneRow { @@ -72,6 +78,8 @@ export interface MilestoneTableRow { unpaved: number; total: number; note: string; + /** 도로 구분 안 고른 구간이 있음 */ + pick: boolean; } const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" }; @@ -79,11 +87,18 @@ const KEYS: Record = { "2차포장": "s2", "1차포 /** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000; -function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): void { +/** 구간 km — 사용자 값 우선 · 없으면 자동 값 */ +export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; + +/** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */ +function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): boolean { + let pick = false; for (const leg of legs ?? []) { - const key = KEYS[leg.road]; - if (key) sums[key] += Number(leg.km ?? 0); + const key = leg.road ? KEYS[leg.road] : undefined; + if (key) sums[key] += Number(legKm(leg) ?? 0); + else pick = true; } + return pick; } /** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */ @@ -103,8 +118,8 @@ export function computeMilestone( for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { const sums = { s2: 0, s1: 0, un: 0 }; - addLegs(sums, route.legs); - if (base?.enabled && route.via_base !== false) addLegs(sums, base.legs); + let pick = addLegs(sums, route.legs); + if (base?.enabled && route.via_base !== false) pick = addLegs(sums, base.legs) || pick; if (row.dest === "mid") addLegs(sums, [mid]); const paved = sums.s2 + sums.s1; out.push({ @@ -120,6 +135,7 @@ export function computeMilestone( unpaved: r3(sums.un), total: r3(paved + sums.un), note: row.note ?? "", + pick, }); }); } diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 9dfc0bae7..f687db05a 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -10,6 +10,7 @@ * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) + * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 * ========================================================================== */ import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; @@ -34,6 +35,8 @@ export interface DiagramLine { x2: number; y2: number; labels: string[]; + /** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */ + tips: string[]; } export interface DiagramLayout { @@ -81,7 +84,67 @@ export function placeDeg( } export const legText = (legs: Leg[]): string[] => - legs.map((leg) => `${leg.road} ${leg.km == null ? "-" : String(leg.km)}km`); + legs.map((leg) => { + const km = leg.km ?? leg.auto_km ?? null; // Calc `legKm` 과 같음(형 import 만) + return `${leg.road ?? "도로 구분 고르기"} ${km == null ? "-" : String(km)}km`; + }); + +/** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */ +export const legTip = (leg: Leg): string => + leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km + ? `자동 값 ${leg.auto_km} km` + : ""; + +/** [길찾기] 자동 km → 그 구간 첫 줄(없으면 도로 구분 안 고른 줄 하나) · 사용자 km 는 그대로 */ +export function setAutoKm(legs: Leg[], km: number): Leg[] { + if (!legs.length) legs.push({ road: null, km: null, auto_km: km }); + else legs[0].auto_km = km; + return legs; +} + +export interface KmSegment { + /** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */ + key: "route" | "base"; + text: string; + from: [number, number] | null; + to: [number, number] | null; +} + +const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null => + p.lon != null && p.lat != null ? [p.lon, p.lat] : null; + +/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 + * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ +export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] { + const route = data.routes.find((r) => r.id === routeId); + if (!route) return []; + const viaBase = data.base.enabled && route.via_base; + const baseName = data.base.name || "기준점"; + const from = pointOf(route); + const first: KmSegment = { + key: "route", + text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, + from, + to: viaBase ? pointOf(data.base) : (start ?? null), + }; + if (!viaBase) return [first]; + return [ + first, + { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null }, + ]; +} + +/** 자동 km 를 그 구간에(사용자 km 는 그대로) */ +export function putAutoKm( + data: MilestoneData, + routeId: string, + key: KmSegment["key"], + km: number, +): MilestoneData { + const legs = key === "base" ? data.base.legs : data.routes.find((r) => r.id === routeId)?.legs; + if (legs) setAutoKm(legs, km); + return data; +} const unit = (deg: number): [number, number] => { const rad = (deg * Math.PI) / 180; @@ -100,10 +163,10 @@ export function layoutMilestone( boxes.push(box); center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]); }; - const line = (from: string, to: string, labels: string[]): void => { + const line = (from: string, to: string, legs: Leg[]): void => { const [x1, y1] = center.get(from)!; const [x2, y2] = center.get(to)!; - lines.push({ from, to, x1, y1, x2, y2, labels }); + lines.push({ from, to, x1, y1, x2, y2, labels: legText(legs), tips: legs.map(legTip) }); }; // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) @@ -134,11 +197,7 @@ export function layoutMilestone( h: NODE.h, label: "중점", }); - line( - "start", - "mid", - legText([{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]), - ); + line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]); const startLL = site?.start_lonlat ?? null; const useBase = data.base.enabled; @@ -159,7 +218,7 @@ export function layoutMilestone( h: BOX.h, label: data.base.name || "기준점", }); - line("base", "start", legText(data.base.legs)); + line("base", "start", data.base.legs); } // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 @@ -212,7 +271,7 @@ export function layoutMilestone( label: g.label, items: g.items, }); - line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs)); + line(g.id, g.anchor === "base" ? "base" : "start", g.legs); }); } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index a9fd66fb7..9697f2997 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -7,6 +7,8 @@ DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) + POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 + 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) 정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). @@ -20,6 +22,11 @@ from __future__ import annotations import asyncio import json +import math +import os +import urllib.error +import urllib.parse +import urllib.request from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -55,8 +62,9 @@ Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] class Leg(BaseModel): - road: Road = "2차포장" - km: float | None = Field(default=None, ge=0, le=10000) + road: Road | None = "2차포장" # None = 사용자가 안 고름(244-7 · 표에 「도로 구분 고르기」) + km: float | None = Field(default=None, ge=0, le=10000) # 사용자 값 — 있으면 우선 + auto_km: float | None = Field(default=None, ge=0, le=10000) # [길찾기] 자동 값(km 만 저장) class Row(BaseModel): @@ -282,3 +290,68 @@ async def milestone_image_delete(project_id: UUID, route: str = Query(...)) -> d for old in route_images(root, route_id): old.unlink(missing_ok=True) return {"name": None} + + +KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" +KAKAO_KEY = "KAKAO_REST_API_KEY" + + +class LonLat(BaseModel): + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) + + +class Directions(BaseModel): + origin: LonLat + destination: LonLat + + +def _kakao_open(request: urllib.request.Request) -> bytes: + with urllib.request.urlopen(request, timeout=8) as response: + return response.read() + + +def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]: + """카카오 길찾기 → {km, reason} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄. + 키 · 응답 원문 · 경로 좌표는 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만).""" + key = os.environ.get(KAKAO_KEY, "").strip() + if not key: + return { + "km": None, + "reason": f"카카오 길찾기 키가 없습니다(.env {KAKAO_KEY}) — km 를 손으로 넣으세요.", + } + query = urllib.parse.urlencode( + { + "origin": f"{origin.lon},{origin.lat}", + "destination": f"{destination.lon},{destination.lat}", + "priority": "RECOMMEND", + } + ) + request = urllib.request.Request( + f"{KAKAO_URL}?{query}", headers={"Authorization": f"KakaoAK {key}"} + ) + try: + body = json.loads(_kakao_open(request).decode("utf-8")) + except urllib.error.HTTPError as error: + reason = { + 401: "카카오가 키를 받지 않았습니다(키 · 앱 설정 확인)", + 403: "카카오가 길찾기를 막았습니다(앱 · 권한 확인)", + 429: "오늘 카카오 길찾기 요청 수를 넘었습니다", + }.get(error.code, f"카카오 길찾기 응답 오류(HTTP {error.code})") + return {"km": None, "reason": f"{reason} — km 를 손으로 넣으세요."} + except ( + Exception + ): # 끊김 · 시간 넘김 · 깨진 응답 — 원문 · 예외 글은 내지 않음(키가 섞일 수 있음) + return {"km": None, "reason": "카카오 길찾기에 닿지 못했습니다 — km 를 손으로 넣으세요."} + route = ((body or {}).get("routes") or [{}])[0] if isinstance(body, dict) else {} + distance = (route.get("summary") or {}).get("distance") + if route.get("result_code") != 0 or not isinstance(distance, (int, float)): + msg = str(route.get("result_msg") or "결과 없음")[:80] + return {"km": None, "reason": f"길찾기 실패 — {msg} · km 를 손으로 넣으세요."} + return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None} + + +@router.post(PATH + "/directions") +async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, Any]: + """[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음).""" + return await asyncio.to_thread(directions_km, body.origin, body.destination) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index ffabeca3f..3e0baafe2 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -159,3 +159,8 @@ fill: var(--color-text-secondary); font-size: 12px; } + +/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */ +.b08-milestone .is-pick-road { + color: var(--color-danger); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 8984b5099..2a9aaea35 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -41,7 +41,7 @@ const COLUMNS: DataColumn[] = [ { key: "un", label: "비포장", group: ["도로 구분 (km)"], ...KM }, { key: "paved", label: "포장", group: ["계 (km)"], ...KM }, { key: "unpaved", label: "비포장", group: ["계 (km)"], ...KM }, - { key: "total", label: "합계", group: ["계 (km)"], ...KM }, + { key: "total", label: "합계", group: ["계 (km)"], ...KM, width: 108 }, { key: "note", label: "비고", width: 160 }, ]; @@ -55,7 +55,7 @@ export function addRoute(projectId: string, init: Partial = {}): address: "", via_base: true, dir: null, - legs: [{ road: "2차포장", km: null }], + legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7) rows: [{ item: "", dest: "start", note: "" }], ...init, }); @@ -67,12 +67,14 @@ export function addRoute(projectId: string, init: Partial = {}): function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; + let pickRows: boolean[] = []; const table = createDataTable({ columns: COLUMNS, rows: [], caption: "거리이정표", empty: "", editOnDouble: true, + cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), editable: (_i, key) => key === "item" || key === "company" || key === "note", onEdit: (i, key, text) => { const at = keys[i]; @@ -105,8 +107,16 @@ function tableView(projectId: string): MilestoneView { const refresh = (): void => { const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); keys = rows.map(({ route, index }) => ({ route, index })); + pickRows = rows.map((r) => r.pick); none.hidden = rows.length > 0; - table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); + // 도로 구분 안 고른 구간 = 합계 칸에 「도로 구분 고르기」(그 km 는 합에 안 듦 · 244-7) + table.setRows( + rows.map((r) => ({ + ...r, + dest: `${DEST_TEXT[r.dest]} ▾`, + total: r.pick ? "도로 구분 고르기" : r.total, + })), + ); diagram.refresh(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index e22e9b69c..c4e7cd91b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -8,11 +8,12 @@ * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) + * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) * ========================================================================== */ import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; -import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { midKm, type MilestoneRoute, type Road } from "./B08_Quantity_Milestone_Calc"; import { layoutMilestone, legAt, @@ -28,6 +29,7 @@ import { openSiteInfo, } from "./B08_Quantity_UI_Milestone_Modal"; import { + legRoadOptions, liveMilestone, putMilestone, roadOptions, @@ -58,14 +60,22 @@ function editLegAt( const leg = legAt(liveMilestone(projectId), from, i, midKm); if (!leg) return; document.querySelector(".b08-milestone__leg-edit")?.remove(); - const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); - road.select.value = leg.road; + const road = createSelectField({ + label: "도로 구분", + options: from === "start" ? roadOptions : legRoadOptions, // 시점 → 중점 = 늘 고름 + compact: true, + }); + road.select.value = leg.road ?? ""; const km = el("input", { className: "ui-input", attrs: { type: "number", min: "0", step: "any" }, }); km.value = leg.km == null ? "" : String(leg.km); km.setAttribute("aria-label", "km"); + if (leg.auto_km != null) { + km.placeholder = String(leg.auto_km); // 빈칸 = 자동 값 + km.title = `자동 값 ${leg.auto_km} km`; + } const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] }); box.dataset.b08MilestoneLegEdit = `${from}:${i}`; const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로 @@ -80,7 +90,7 @@ function editLegAt( const n = Number(km.value); const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null; const patch = { - road: road.select.value as typeof leg.road, + road: (road.select.value || null) as Road | null, ...(km.value.trim() && value === null ? {} : { km: value }), }; putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch)); @@ -208,6 +218,7 @@ export function createDiagram( ln.labels.forEach((label, i) => { const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; + if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); span.addEventListener("dblclick", (e) => { e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 editLegAt(projectId, span, ln.from, i, mk); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index d3cd4d9e1..8eb55eba1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -8,6 +8,8 @@ * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 + * [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 · + * 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」) * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; @@ -37,6 +39,8 @@ import { } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; import { + findRouteKm, + legRoadOptions, liveMilestone, putMilestone, roadOptions, @@ -51,16 +55,21 @@ const DEST_OPTIONS = [ const line = (...children: HTMLElement[]): HTMLElement => el("div", { className: "b08-milestone__line-edit", children }); -/** 구간 줄 — 도로 구분 ▾ · km · [×] · [+ 구간] */ -function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () => Leg[] } { +/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · km(빈칸 = 자동 값) · [×] · [+ 구간] */ +function legsEditor(initial: Leg[]): { root: HTMLElement; value: () => Leg[] } { const list = el("div"); - const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement }> = []; + const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; const add = (leg: Leg): void => { - const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); + const road = createSelectField({ label: "도로 구분", options: legRoadOptions, compact: true }); const km = createInputField({ label: "km", type: "number", min: 0, compact: true }); - road.select.value = leg.road; + road.select.value = leg.road ?? ""; km.input.value = leg.km == null ? "" : String(leg.km); - const ref = { road: road.select, km: km.input }; + const auto = leg.auto_km ?? null; + if (auto != null) { + km.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 + km.input.title = `자동 값 ${auto} km`; + } + const ref = { road: road.select, km: km.input, auto }; rows.push(ref); const drop = createButton({ label: "×", @@ -80,16 +89,17 @@ function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () label: "+ 구간", size: "sm", variant: "ghost", - onClick: () => add({ road: fresh, km: null }), + onClick: () => add({ road: null, km: null }), }); return { root: el("div", { children: [list, more] }), value: () => - rows.map(({ road, km }) => { + rows.map(({ road, km, auto }) => { const n = Number(km.value); return { - road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : fresh, + road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null, + auto_km: auto, }; }), }; @@ -150,6 +160,17 @@ function placeOf( const head = (text: string): HTMLElement => el("h4", { text }); +/** [길찾기] 한 번 — 구간마다 결과 한 줄(알림) · 실패 줄이 있으면 경고 */ +async function routeKm(projectId: string, routeId: string): Promise { + const lines = await findRouteKm(projectId, routeId); + if (lines.length) + showToast( + `길찾기 — ${lines.join(" / ")}`, + lines.some((l) => l.includes(" — ")) ? "warning" : "success", + ); + return lines; +} + /** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ export function openAddItem( projectId: string, @@ -176,12 +197,14 @@ export function openAddItem( const place = placeOf(address, { address: "" }); const data = liveMilestone(projectId); const same = data.routes.find((r) => r.name.trim() === name); + let id = same?.id; if (same) { same.rows.push(row); if (place.lon != null) Object.assign(same, place); putMilestone(projectId, data); - } else addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); + } else id = addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); close(); + if (id && place.lon != null) void routeKm(projectId, id); // [등록] 뒤 한 번 }, }); actions.append(ok); @@ -213,24 +236,49 @@ export function openRouteInfo(projectId: string, routeId: string): void { via.root.hidden = !data.base.enabled; const rows = rowsEditor(route.rows); const address = buildAddressField(route.address || null); - const legs = legsEditor(route.legs, "2차포장"); + let legs = legsEditor(route.legs); + const status = el("p", { className: "b08-options__hint" }); + status.dataset.b08MilestoneInfo = "status"; + /** 모달 값 → 캐시(고침 · 길찾기 앞) */ + const apply = (): boolean => { + const fresh = liveMilestone(projectId); + const r = fresh.routes.find((x) => x.id === routeId); + if (!r) return false; + Object.assign(r, placeOf(address, r), { + name: name.input.value.trim() || r.name, + via_base: via.input.checked, + rows: rows.value(), + legs: legs.value(), + }); + putMilestone(projectId, fresh); + return true; + }; const save = createButton({ label: "고침", variant: "filled", onClick: () => { - const fresh = liveMilestone(projectId); - const r = fresh.routes.find((x) => x.id === routeId); - if (!r) return close(); - Object.assign(r, placeOf(address, r), { - name: name.input.value.trim() || r.name, - via_base: via.input.checked, - rows: rows.value(), - legs: legs.value(), - }); - putMilestone(projectId, fresh); + apply(); close(); }, }); + // [길찾기] = 지금 모달 값을 캐시에 넣고 구간마다 카카오 km(서버) → 구간 칸 다시 그림 + const find = createButton({ + label: "길찾기", + variant: "ghost", + onClick: async () => { + if (!apply()) return close(); + find.disabled = true; + status.textContent = "길찾기 중…"; + const lines = await routeKm(projectId, routeId).finally(() => (find.disabled = false)); + status.textContent = lines.join(" / "); + const r = liveMilestone(projectId).routes.find((x) => x.id === routeId); + if (!r) return; + const next = legsEditor(r.legs); + legs.root.replaceWith(next.root); + legs = next; + }, + }); + find.dataset.b08MilestoneInfo = "find"; const remove = createButton({ label: "삭제", variant: "danger", @@ -250,7 +298,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { }); save.dataset.b08MilestoneInfo = "save"; remove.dataset.b08MilestoneInfo = "delete"; - actions.append(remove, save); + actions.append(remove, find, save); body.append( name.root, via.root, @@ -260,6 +308,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { `구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, ), legs.root, + status, head("주소 · 지도"), address.root, ); @@ -277,10 +326,7 @@ export function openBaseInfo(projectId: string, create = false): void { const name = createInputField({ label: "기준점 이름", compact: true }); name.input.value = base.name; const address = buildAddressField(base.address || null); - const legs = legsEditor( - base.legs.length ? base.legs : [{ road: "1차포장", km: null }], - "1차포장", - ); + const legs = legsEditor(base.legs.length ? base.legs : [{ road: null, km: null }]); const save = createButton({ label: create ? "등록" : "고침", variant: "filled", diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 8ccf1c96f..4ce05a5ca 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -6,12 +6,15 @@ * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). + * [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 · + * 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import { kmSegments, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; export interface SiteInfo { length_m: number | null; @@ -126,3 +129,48 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< } export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); + +/** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */ +export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions]; + +/** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 · + * 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */ +export async function findRouteKm(projectId: string, routeId: string): Promise { + const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); + const out: string[] = []; + const got: Array<[KmSegment["key"], number]> = []; + for (const seg of segments) { + if (!seg.from || !seg.to) { + const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; + out.push( + `${seg.text} — ${missing} 좌표가 없습니다(주소 검색 · 노선) · km 를 손으로 넣으세요.`, + ); + continue; + } + try { + const res = await request<{ km: number | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/directions`, + { + method: "POST", + body: JSON.stringify({ + origin: { lon: seg.from[0], lat: seg.from[1] }, + destination: { lon: seg.to[0], lat: seg.to[1] }, + }), + }, + ); + if (res.km == null) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`); + else { + out.push(`${seg.text} ${res.km} km`); + got.push([seg.key, res.km]); + } + } catch (error) { + out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`); + } + } + if (got.length) { + const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 + for (const [key, km] of got) putAutoKm(fresh, routeId, key, km); + putMilestone(projectId, fresh); + } + return out; +} diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 9b8958ed6..767a771f0 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -159,7 +159,7 @@ def test_화면_모달_팬줌_그림탭(): info = modal[ modal.index("export function openRouteInfo") : modal.index("export function openBaseInfo") ] - for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs, "2차포장")', + for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs)', "rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip assert text in info diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py new file mode 100644 index 000000000..2ea0966a8 --- /dev/null +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -0,0 +1,240 @@ +"""244-7 B08 거리이정표 — 카카오 길찾기 REST 로 km 자동. + +서버 길 = 가짜 응답으로 km 셈(10463 m → 10.5 km) · 키 없음 안내 · 키 값이 응답 · 로그에 안 나옴 · +구간 채움(거래처 → 기준점 · 기준점 → 시점 · 기준점 없으면 거래처 → 시점) · 사용자 km 우선 · +도로 구분 안 고름 = pick(합에 안 듦) · 실제 카카오 한 번(AISLO_KAKAO_LIVE=1 일 때만). +""" + +import io +import json +import logging +import os +import re +import subprocess +import sys +import urllib.error +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +FAKE_KEY = "fake-kakao-key-0123456789" +GEOCHANG = {"lon": 127.9095, "lat": 35.6866} # 거창군청 +BYEONGGOK = {"lon": 127.85, "lat": 35.66} # 병곡리 +OK_BODY = { + "trans_id": "t", + "routes": [ + { + "result_code": 0, + "result_msg": "길찾기 성공", + "summary": {"distance": 10463, "duration": 902}, + "sections": [{"roads": [{"vertexes": [127.9, 35.6]}]}], + } + ], +} + + +def _node(src_rel, expr): + src = (ROOT / src_rel).read_text(encoding="utf-8") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _client(): + app = FastAPI() + app.include_router(ms.router) + return TestClient(app) + + +def _post(client, origin=GEOCHANG, destination=BYEONGGOK): + return client.post( + "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone/directions", + json={"origin": origin, "destination": destination}, + ) + + +def test_가짜_응답_km_소수_한자리(monkeypatch): + monkeypatch.setenv(ms.KAKAO_KEY, FAKE_KEY) + seen = {} + + def fake(request): + seen["url"] = request.full_url + seen["auth"] = request.get_header("Authorization") + return json.dumps(OK_BODY).encode() + + monkeypatch.setattr(ms, "_kakao_open", fake) + res = _post(_client()) + assert res.status_code == 200 and res.json() == {"km": 10.5, "reason": None} + # 문서 꼴 — origin · destination = 경도,위도 · 머리 KakaoAK + assert seen["url"].startswith(ms.KAKAO_URL + "?") + assert "origin=127.9095%2C35.6866" in seen["url"] + assert "destination=127.85%2C35.66" in seen["url"] and "priority=RECOMMEND" in seen["url"] + assert seen["auth"] == f"KakaoAK {FAKE_KEY}" + # 경로 좌표 · 응답 원문은 안 돌려줌(km 만) + assert "vertexes" not in res.text and "sections" not in res.text + # 끝수 = 소수 1자리 반올림 + for meters, km in ((10450, 10.5), (10449, 10.4), (950, 1.0), (0, 0.0)): + body = {"routes": [{"result_code": 0, "summary": {"distance": meters}}]} + monkeypatch.setattr(ms, "_kakao_open", lambda r, b=body: json.dumps(b).encode()) + assert ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK))["km"] == km + + +def test_키_없음_안내(monkeypatch): + monkeypatch.delenv(ms.KAKAO_KEY, raising=False) + called = [] + monkeypatch.setattr(ms, "_kakao_open", lambda r: called.append(r)) + body = _post(_client()).json() + assert body["km"] is None and "키가 없습니다" in body["reason"] and "손으로" in body["reason"] + assert not called # 키 없으면 카카오를 부르지 않음 + monkeypatch.setenv(ms.KAKAO_KEY, " ") + assert "키가 없습니다" in _post(_client()).json()["reason"] + + +def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog): + monkeypatch.setenv(ms.KAKAO_KEY, FAKE_KEY) + caplog.set_level(logging.DEBUG) + client = _client() + + def http(code): + def fake(request): + raise urllib.error.HTTPError( + request.full_url, code, f"bad {FAKE_KEY}", {}, io.BytesIO(FAKE_KEY.encode()) + ) + + return fake + + def boom(request): + raise OSError(f"끊김 Authorization: KakaoAK {FAKE_KEY}") + + cases = [ + (http(401), "키를 받지 않았습니다"), + (http(429), "요청 수를 넘었습니다"), + (http(500), "HTTP 500"), + (boom, "닿지 못했습니다"), + (lambda r: b"{not json " + FAKE_KEY.encode(), "닿지 못했습니다"), + (lambda r: json.dumps({"routes": [{"result_code": 104, "result_msg": + "출발지와 도착지가 5 m 이내"}]}).encode(), "5 m 이내"), + ] # fmt: skip + for fake, want in cases: + monkeypatch.setattr(ms, "_kakao_open", fake) + res = _post(client) + assert res.status_code == 200 and res.json()["km"] is None + assert want in res.json()["reason"], res.json() + assert FAKE_KEY not in res.text + assert FAKE_KEY not in caplog.text + # 잘못된 좌표 = 422(카카오 안 부름) + assert _post(client, origin={"lon": 999, "lat": 0}).status_code == 422 + assert FAKE_KEY not in ms.__doc__ and "KakaoAK" not in (ms.__doc__ or "") + + +def _data(base_enabled=True, base_ll=(127.85, 35.66)): + return { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None, + "lon": base_ll[0], "lat": base_ll[1], "legs": []}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "lon": 127.9095, "lat": 35.6866, "legs": [{"road": "2차포장", "km": 23.8}], + "rows": [{"item": "장비", "dest": "mid", "note": ""}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None, + "lon": None, "lat": None, "legs": [{"road": None, "km": None}], + "rows": [{"item": "철근", "dest": "start", "note": ""}]}, + ], + } # fmt: skip + + +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +START = [127.81, 35.75] + + +def test_구간_채움(): + d = json.dumps(_data()) + seg = _node(LAYOUT, f"kmSegments({d}, 'r1', {json.dumps(START)})") + assert [(s["key"], s["text"]) for s in seg] == [ + ("route", "거창군청 → 병곡리 968-31"), + ("base", "병곡리 968-31 → 시점"), + ] + assert seg[0]["from"] == [127.9095, 35.6866] and seg[0]["to"] == [127.85, 35.66] + assert seg[1]["from"] == [127.85, 35.66] and seg[1]["to"] == START + # 기준점 없음 = 거래처 → 시점 하나 · 기준점 안 거침 · 거래처 좌표 없음 = from null + off = json.dumps(_data(base_enabled=False)) + one = _node(LAYOUT, f"kmSegments({off}, 'r1', {json.dumps(START)})") + assert [(s["key"], s["text"], s["to"]) for s in one] == [("route", "거창군청 → 시점", START)] + two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)") + assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}] + # 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로 + put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 'route', 10.5), 'r1', 'base', 1.4)") + assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}] + assert put["base"]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] + # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 + lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)") + line = {ln["from"]: ln for ln in lay["lines"]} + assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] + assert line["base"]["labels"] == ["도로 구분 고르기 1.4km"] and line["base"]["tips"] == [""] + assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 + + +def test_사용자_값_우선_고르기_표시(): + data = _data() + data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}] + data["base"]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] + a, b = compute_milestone(data, 1500.0) + # 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장 + assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False) + # 도로 구분 안 고름 = 합에 안 듦 · pick + assert (b["total"], b["pick"]) == (0, True) + data["base"]["legs"][0]["road"] = None + a2, _ = compute_milestone(data, 1500.0) + assert (a2["s1"], a2["total"], a2["pick"]) == (0, 12.0, True) + # TS 짝 거울 + for d in (data, _data()): + ts = _node("B08_Quantity/B08_Quantity_Milestone_Calc.ts", + f"computeMilestone({json.dumps(d)}, 1500)") # fmt: skip + assert ts == compute_milestone(d, 1500.0) + # 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장 + saved = ms.Milestone.model_validate(data).model_dump() + assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5 + assert saved["base"]["legs"][0]["road"] is None + assert ms.Leg.model_validate({"km": 1}).road == "2차포장" + + +def test_화면_길찾기_단추_키_화면에_없음(): + read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731 + modal = read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + state = read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + table = read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert 'label: "길찾기"' in modal and "routeKm(projectId, id)" in modal # 단추 · [등록] 뒤 + assert "/quantity/milestone/directions" in state and "kmSegments(" in state + assert '"도로 구분 고르기"' in table and "r.pick" in table + assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름 + # 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음 + for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): + text = rel.read_text(encoding="utf-8") + assert "KAKAO_REST_API_KEY" not in text and "KakaoAK" not in text, rel + + +@pytest.mark.skipif( + os.environ.get("AISLO_KAKAO_LIVE") != "1", reason="실제 카카오 = 한 번만 손으로" +) +def test_실제_카카오_한번(): + from dotenv import load_dotenv + + load_dotenv(ROOT / ".env") + got = ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK)) + assert got["reason"] is None and isinstance(got["km"], float) and 5 < got["km"] < 20 + print(f"\n실제 카카오 거창군청 → 병곡리 = {got['km']} km") From 2a969a4f1ddfad2723e2f1de61c83163881e93bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:21:33 +0900 Subject: [PATCH 72/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20137?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index e138dd269..806618146 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -165,3 +165,4 @@ 134. 다음을 별도 브라우저 탭이 아닌 내부 모달창 내부에서 실행 안됨? 그래야 캡처가 쉬울것같음. (브레인 확인: map.kakao.com 길찾기 링크가 iframe 안에서 열림 · 경로 · km 보임) 135. 추가로 다이어그램의 영역에서 스크롤이 존재함. 줌인아웃에 방해됨. 스크롤 안되게 해줘. 136. (길찾기 방식 결정) 일단 카카오로 가야할것같아. 카카오 디벨로퍼 페이지에 와있는데 제휴방법을 알려줘. +137. (경로 그림 방식) 가로 가는게 좋겠어 — 서버가 카카오 길찾기 경로 선을 브이월드 지도 위에 그려 자동 저장. 메일에도 거리와 경로데이터라고 해야하는거 아니야? From 24f39bcb549196d0d630b010625db02f5baa9d30 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:25:53 +0900 Subject: [PATCH 73/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20138?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 806618146..ac1f7a558 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -166,3 +166,4 @@ 135. 추가로 다이어그램의 영역에서 스크롤이 존재함. 줌인아웃에 방해됨. 스크롤 안되게 해줘. 136. (길찾기 방식 결정) 일단 카카오로 가야할것같아. 카카오 디벨로퍼 페이지에 와있는데 제휴방법을 알려줘. 137. (경로 그림 방식) 가로 가는게 좋겠어 — 서버가 카카오 길찾기 경로 선을 브이월드 지도 위에 그려 자동 저장. 메일에도 거리와 경로데이터라고 해야하는거 아니야? +138. (조사 더 · 너무 보수적 아님?) 가 사안은 배제해. 데이터가 다른 맵위에 맞을리가 없어. → 나(카카오 지도 JS 위 경로 + [캡처])로 From 0816595e2eb465f78be258592ec9747c97f09ea0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:34:31 +0900 Subject: [PATCH 74/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EA=B2=BD=EB=A1=9C=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=3D=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=A7=80=EB=8F=84=20JS=20?= =?UTF-8?q?=EC=9C=84=20=EA=B8=B8=20=EC=84=A0=20=C2=B7=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=EC=BA=A1=EC=B2=98=20(244-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 길찾기 응답에 경로 좌표 [위도, 경도] 목록(vertexes = 경도 · 위도 번갈아) · 저장 안 함 · km 만 저장 그대로 - 새 길 GET …/quantity/milestone/map-key — 카카오 지도 JavaScript 키(.env · 공개용) · 없으면 null - 거래처 모달 · 경로 하위 탭에 카카오 지도 — 길 선 · 거래처 · 기준점 · 시점 표지 · 경로 전체 맞춤 - [지도 캡처] = 지금 탭 캡처 → 지도 칸만 자름(CropTarget · 없으면 자리 수치) → 그 경로 그림 · 트랙 바로 멈춤 - JS 키 없음 · SDK 못 받음 = 안내 한 줄 · 사진 올리기 · 다음 지도 새 창 길은 그대로 - 새 시험 test_244_9 · test_244_7 응답 꼴 고침(path 더함) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 37 +++ B08_Quantity/B08_Quantity_Router_Milestone.py | 27 +- B08_Quantity/B08_Quantity_UI_Milestone.css | 16 ++ .../B08_Quantity_UI_Milestone_KakaoMap.ts | 225 ++++++++++++++++ .../B08_Quantity_UI_Milestone_Modal.ts | 8 + .../B08_Quantity_UI_Milestone_Route.ts | 6 + .../B08_Quantity_UI_Milestone_State.ts | 30 ++- .../tester/test_244_7_b08_milestone_kakao.py | 5 +- .../test_244_9_b08_milestone_route_map.py | 240 ++++++++++++++++++ 9 files changed, 579 insertions(+), 15 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts create mode 100644 resources/tester/test_244_9_b08_milestone_route_map.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index f687db05a..348f331d2 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -134,6 +134,43 @@ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): ]; } +/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ +export function mapMarks( + data: MilestoneData, + routeId: string, + start: LonLat, +): Array<{ name: string; lon: number; lat: number }> { + const route = data.routes.find((r) => r.id === routeId); + if (!route) return []; + const out: Array<{ name: string; lon: number; lat: number }> = []; + const add = (name: string, p: [number, number] | null): void => { + if (p) out.push({ name, lon: p[0], lat: p[1] }); + }; + add(route.name || "거래처", pointOf(route)); + if (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base)); + add("시점", start ?? null); + return out; +} + +/** 캡처 자르기(244-9 · CropTarget 없을 때) — 요소 자리(CSS px) × (영상 폭 ÷ 창 폭 = 보통 + * devicePixelRatio) → 영상 안 자를 칸(영상 밖은 잘라 냄) */ +export function cropBox( + rect: { left: number; top: number; width: number; height: number }, + viewWidth: number, + videoWidth: number, + videoHeight: number, +): { sx: number; sy: number; sw: number; sh: number } { + const k = videoWidth / viewWidth; + const sx = Math.min(Math.max(0, Math.round(rect.left * k)), videoWidth); + const sy = Math.min(Math.max(0, Math.round(rect.top * k)), videoHeight); + return { + sx, + sy, + sw: Math.max(0, Math.min(videoWidth - sx, Math.round((rect.left + rect.width) * k) - sx)), + sh: Math.max(0, Math.min(videoHeight - sy, Math.round((rect.top + rect.height) * k) - sy)), + }; +} + /** 자동 km 를 그 구간에(사용자 km 는 그대로) */ export function putAutoKm( data: MilestoneData, diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 9697f2997..a1d1affae 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -9,6 +9,9 @@ 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) + + 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함) + GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 · + 공개용 · .env KAKAO_JS_KEY · 없으면 null) 정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). @@ -312,8 +315,9 @@ def _kakao_open(request: urllib.request.Request) -> bytes: def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]: - """카카오 길찾기 → {km, reason} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄. - 키 · 응답 원문 · 경로 좌표는 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만).""" + """카카오 길찾기 → {km, reason, path} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄 · + path = 경로 좌표 [위도, 경도] 목록(성공 때만 · 지도 길 선 · 저장 안 함). + 키 · 응답 원문은 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만).""" key = os.environ.get(KAKAO_KEY, "").strip() if not key: return { @@ -348,10 +352,27 @@ def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]: if route.get("result_code") != 0 or not isinstance(distance, (int, float)): msg = str(route.get("result_msg") or "결과 없음")[:80] return {"km": None, "reason": f"길찾기 실패 — {msg} · km 를 손으로 넣으세요."} - return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None} + path: list[list[float]] = [] + try: # vertexes = 경도 · 위도 번갈아(도로마다) + for section in route.get("sections") or []: + for road in section.get("roads") or []: + v = [float(n) for n in road.get("vertexes") or []] + path += [[lat, lon] for lon, lat in zip(v[0::2], v[1::2])] + except (TypeError, ValueError, AttributeError): + path = [] # 깨진 좌표 = 길 선만 없음(km 는 그대로) + return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None, "path": path} @router.post(PATH + "/directions") async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, Any]: """[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음).""" return await asyncio.to_thread(directions_km, body.origin, body.destination) + + +KAKAO_JS = "KAKAO_JS_KEY" + + +@router.get(PATH + "/map-key") +async def milestone_map_key(project_id: UUID) -> dict[str, Any]: + """카카오 지도 JavaScript 키(공개용 · 도메인 등록으로 막힘) — 없으면 null = 화면 안내 한 줄.""" + return {"key": os.environ.get(KAKAO_JS, "").strip() or None} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 3e0baafe2..1cac2d074 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -164,3 +164,19 @@ .b08-milestone .is-pick-road { color: var(--color-danger); } + +/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */ +.b08-milestone__map { + width: 100%; + height: 360px; + border: 1px solid var(--color-border); +} + +.b08-milestone__map-label { + padding: 2px 6px; + border: 1px solid #3c3c3c; + background: #fff; + color: #141414; + font-size: 12px; + white-space: nowrap; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts new file mode 100644 index 000000000..7bbdcb75e --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -0,0 +1,225 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_KakaoMap.ts + * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 + [캡처] → 그 경로 그림. + * + * SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key + * (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기 + * 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 · + * 시점(`mapMarks`) · 경로 전체 맞춤 + * [지도 캡처] = getDisplayMedia(preferCurrentTab) → 지도 칸만(CropTarget · 없으면 `cropBox` 자리 수치) → + * 그 경로 그림(244-1 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { cropBox, mapMarks } from "./B08_Quantity_Milestone_Layout"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { + liveMilestone, + routePaths, + siteInfo, + uploadImage, +} from "./B08_Quantity_UI_Milestone_State"; + +export const NO_MAP = + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; +const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; +const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 + +type LatLng = object; +interface KMap { + setBounds(bounds: object): void; + relayout(): void; +} +interface KOverlay { + setMap(map: KMap | null): void; +} +interface KakaoMaps { + load(done: () => void): void; + LatLng: new (lat: number, lng: number) => LatLng; + LatLngBounds: new () => { extend(p: LatLng): void }; + Map: new (box: HTMLElement, o: { center: LatLng; level: number }) => KMap; + Polyline: new (o: { + path: LatLng[]; + strokeWeight: number; + strokeColor: string; + strokeOpacity: number; + }) => KOverlay; + Marker: new (o: { position: LatLng; title: string }) => KOverlay; + CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay; +} + +let sdk: Promise | null = null; + +/** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */ +export function loadKakaoMaps(projectId: string): Promise { + sdk ??= (async () => { + const { key } = await request<{ key: string | null }>( + `/projects/${projectId}/quantity/milestone/map-key`, + ); + if (!key) throw new Error(NO_MAP); + const w = window as unknown as { kakao?: { maps: KakaoMaps } }; + if (!w.kakao?.maps) { + await new Promise((done, fail) => { + const script = document.createElement("script"); + script.src = `${SDK}?appkey=${encodeURIComponent(key)}&autoload=false`; + script.onload = () => done(); + script.onerror = () => fail(new Error(NO_MAP)); + document.head.append(script); + }); + } + const maps = w.kakao?.maps; + if (!maps) throw new Error(NO_MAP); + await new Promise((done) => maps.load(done)); + return maps; + })(); + sdk.catch(() => (sdk = null)); + return sdk; +} + +/** 지도 칸만 화면 캡처(png) — 지금 탭 먼저 · 칸 자르기 · 스트림은 바로 끔 */ +export async function captureElement(target: HTMLElement): Promise { + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: false, + preferCurrentTab: true, + } as DisplayMediaStreamOptions); + try { + const [track] = stream.getVideoTracks(); + const crop = ( + window as unknown as { CropTarget?: { fromElement(e: Element): Promise } } + ).CropTarget; + let cropped = false; + if (crop && track && "cropTo" in track) + try { + await (track as unknown as { cropTo(t: object): Promise }).cropTo( + await crop.fromElement(target), + ); + cropped = true; + } catch { + cropped = false; // 다른 창을 골랐음 등 — 자리 수치로 자름 + } + const video = el("video"); + video.muted = true; + video.srcObject = stream; + await video.play(); + await new Promise((done) => requestAnimationFrame(done)); + const box = cropped + ? { sx: 0, sy: 0, sw: video.videoWidth, sh: video.videoHeight } + : cropBox( + target.getBoundingClientRect(), + window.innerWidth, + video.videoWidth, + video.videoHeight, + ); + const canvas = el("canvas"); + canvas.width = box.sw; + canvas.height = box.sh; + canvas.getContext("2d")?.drawImage(video, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh); + const blob = await new Promise((done, fail) => + canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"), + ); + return new File([blob], "capture.png", { type: "image/png" }); + } finally { + stream.getTracks().forEach((t) => t.stop()); + } +} + +export interface RouteMapView { + root: HTMLElement; + /** 지금 값 · 마지막 [길찾기] 길 선으로 다시 그림 */ + draw: () => void; +} + +/** 경로 지도 칸 + [캡처] — 거래처 모달 · 경로 하위 탭 같은 한 벌 */ +export function routeMapView( + projectId: string, + routeId: string, + onCaptured: () => void = () => {}, +): RouteMapView { + const box = el("div", { className: "b08-milestone__map" }); + const hint = el("p", { className: "b08-options__hint" }); + hint.dataset.b08MilestoneMapHint = ""; + let map: KMap | null = null; + let drawn: KOverlay[] = []; + let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로) + + const draw = (): void => { + const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); + const lines = routePaths(routeId); + const sig = JSON.stringify([marks, lines]); + if (map && sig === shown) return; + loadKakaoMaps(projectId).then( + (maps) => + requestAnimationFrame(() => { + const points = [ + ...marks.map((m) => new maps.LatLng(m.lat, m.lon)), + ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), + ]; + if (!points.length) { + hint.textContent = "거래처 주소 · 노선 시점이 있어야 지도에 놓임"; + return; + } + box.hidden = false; + capture.hidden = false; + map ??= new maps.Map(box, { center: points[0], level: 7 }); + map.relayout(); + drawn.forEach((o) => o.setMap(null)); + drawn = [ + ...lines.map( + (line) => + new maps.Polyline({ + path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)), + strokeWeight: 6, + strokeColor: LINE_COLOR, + strokeOpacity: 0.9, + }), + ), + ...marks.flatMap((m) => { + const at = new maps.LatLng(m.lat, m.lon); + return [ + new maps.Marker({ position: at, title: m.name }), + new maps.CustomOverlay({ + position: at, + content: el("span", { className: "b08-milestone__map-label", text: m.name }), + yAnchor: 2.6, + }), + ]; + }), + ]; + drawn.forEach((o) => o.setMap(map)); + const bounds = new maps.LatLngBounds(); + points.forEach((p) => bounds.extend(p)); + map.setBounds(bounds); + shown = sig; + hint.textContent = lines.length + ? "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림" + : "[길찾기]를 누르면 길 선이 그려짐 · [지도 캡처] = 이 지도 칸 → 경로 그림"; + }), + () => { + box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로) + capture.hidden = true; + hint.textContent = NO_MAP; + }, + ); + }; + + const capture = createButton({ + label: "지도 캡처", + size: "sm", + variant: "ghost", + onClick: () => + void captureElement(box) + .then((file) => uploadImage(projectId, routeId, file)) + .then(onCaptured, (error: unknown) => { + if ((error as Error)?.name !== "NotAllowedError") + showToast(`지도를 캡처하지 못했습니다 — ${String(error)}`, "error"); + }), + }); + capture.dataset.b08MilestoneCapture = ""; + const root = el("div", { + className: "b08-milestone__map-wrap", + children: [el("div", { className: "b08-milestone__bar", children: [capture] }), hint, box], + }); + draw(); + return { root, draw }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 8eb55eba1..f61314eb1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -10,6 +10,7 @@ * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 * [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 · * 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」) + * 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · [지도 캡처] → 그 경로 그림) * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; @@ -38,6 +39,7 @@ import { type Road, } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; +import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { findRouteKm, legRoadOptions, @@ -239,6 +241,9 @@ export function openRouteInfo(projectId: string, routeId: string): void { let legs = legsEditor(route.legs); const status = el("p", { className: "b08-options__hint" }); status.dataset.b08MilestoneInfo = "status"; + const mapView = routeMapView(projectId, routeId, () => + showToast("지도 캡처 = 이 거래처 경로 그림", "success"), + ); /** 모달 값 → 캐시(고침 · 길찾기 앞) */ const apply = (): boolean => { const fresh = liveMilestone(projectId); @@ -276,6 +281,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { const next = legsEditor(r.legs); legs.root.replaceWith(next.root); legs = next; + mapView.draw(); // 길 선(그때그때 · 저장 안 함) }, }); find.dataset.b08MilestoneInfo = "find"; @@ -311,6 +317,8 @@ export function openRouteInfo(projectId: string, routeId: string): void { status, head("주소 · 지도"), address.root, + head("경로 지도 — 카카오"), + mapView.root, ); }, }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index eb09f50b5..ac799ae0a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -6,6 +6,8 @@ * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 * 그림 = 올리는 즉시 서버(교체 · 지우기) + * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지 · [지도 캡처] → 이 탭 그림 · + * JS 키 없음 = 안내 한 줄(사진 올리기 · 다음 지도 새 창 길은 그대로) * ========================================================================== */ import { @@ -25,6 +27,7 @@ import { siteInfo, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; +import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap"; let menu: MapContextMenu | null = null; let lastCell: Element | null = null; @@ -185,6 +188,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { ), }); + const mapView = routeMapView(projectId, routeId, () => loadImage(true)); const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, @@ -195,6 +199,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { children: [kakao, capture, upload, removeImage, file], }), hint, + mapView.root, noImage, img, ], @@ -207,6 +212,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; loadImage(); + mapView.draw(); }; refresh(); return { root, refresh }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 4ce05a5ca..89d6da7dd 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -8,6 +8,7 @@ * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). * [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 · * 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음) + * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -133,12 +134,18 @@ export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); /** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */ export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions]; +/** 마지막 [길찾기] 길 선 — 경로 id → 구간마다 [위도, 경도] 목록(메모리만 · 저장 안 함) */ +const paths = new Map>>(); +export const routePaths = (routeId: string): Array> => + paths.get(routeId) ?? []; + /** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 · * 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */ export async function findRouteKm(projectId: string, routeId: string): Promise { const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); const out: string[] = []; const got: Array<[KmSegment["key"], number]> = []; + const lines: Array> = []; for (const seg of segments) { if (!seg.from || !seg.to) { const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; @@ -148,25 +155,28 @@ export async function findRouteKm(projectId: string, routeId: string): Promise( - `/projects/${projectId}/quantity/milestone/directions`, - { - method: "POST", - body: JSON.stringify({ - origin: { lon: seg.from[0], lat: seg.from[1] }, - destination: { lon: seg.to[0], lat: seg.to[1] }, - }), - }, - ); + const res = await request<{ + km: number | null; + reason: string | null; + path?: Array<[number, number]>; + }>(`/projects/${projectId}/quantity/milestone/directions`, { + method: "POST", + body: JSON.stringify({ + origin: { lon: seg.from[0], lat: seg.from[1] }, + destination: { lon: seg.to[0], lat: seg.to[1] }, + }), + }); if (res.km == null) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`); else { out.push(`${seg.text} ${res.km} km`); got.push([seg.key, res.km]); + if ((res.path?.length ?? 0) >= 2) lines.push(res.path!); } } catch (error) { out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`); } } + paths.set(routeId, lines); if (got.length) { const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 for (const [key, km] of got) putAutoKm(fresh, routeId, key, km); diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 2ea0966a8..75274d1c0 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -79,13 +79,14 @@ def test_가짜_응답_km_소수_한자리(monkeypatch): monkeypatch.setattr(ms, "_kakao_open", fake) res = _post(_client()) - assert res.status_code == 200 and res.json() == {"km": 10.5, "reason": None} + assert res.status_code == 200 + assert res.json() == {"km": 10.5, "reason": None, "path": [[35.6, 127.9]]} # 244-9 # 문서 꼴 — origin · destination = 경도,위도 · 머리 KakaoAK assert seen["url"].startswith(ms.KAKAO_URL + "?") assert "origin=127.9095%2C35.6866" in seen["url"] assert "destination=127.85%2C35.66" in seen["url"] and "priority=RECOMMEND" in seen["url"] assert seen["auth"] == f"KakaoAK {FAKE_KEY}" - # 경로 좌표 · 응답 원문은 안 돌려줌(km 만) + # 응답 원문은 안 돌려줌(km · 경로 좌표 [위도, 경도] 만 · 244-9) assert "vertexes" not in res.text and "sections" not in res.text # 끝수 = 소수 1자리 반올림 for meters, km in ((10450, 10.5), (10449, 10.4), (950, 1.0), (0, 0.0)): diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py new file mode 100644 index 000000000..3afc3857a --- /dev/null +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -0,0 +1,240 @@ +"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로 + [지도 캡처](사용자 138번). + +서버 = 길찾기 응답에 경로 좌표 [위도, 경도](vertexes = 경도 · 위도 번갈아) · 저장 안 함 · JS 키 길 · +화면 = 표지(거래처 · 기준점 · 시점) · 캡처 자르기 수치 · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 · +표지 · 경로 전체 맞춤(가짜 kakao.maps 로 node 에서 돌림). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" +GEOCHANG = {"lon": 127.9095, "lat": 35.6866} +BYEONGGOK = {"lon": 127.85, "lat": 35.66} +BODY = { + "routes": [ + { + "result_code": 0, + "summary": {"distance": 10463}, + "sections": [ + {"roads": [{"vertexes": [127.9095, 35.6866, 127.89, 35.68]}]}, + {"roads": [{"vertexes": [127.87, 35.672, 127.85, 35.66]}]}, + ], + } + ] +} + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(rels, script): + src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + "\n" + script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _client(monkeypatch, body=BODY): + monkeypatch.setenv(ms.KAKAO_KEY, "fake-rest-key") + monkeypatch.setattr(ms, "_kakao_open", lambda r: json.dumps(body).encode()) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app) + + +def test_길찾기_경로_좌표_위도_경도(monkeypatch): + res = _client(monkeypatch).post( + BASE + "/directions", json={"origin": GEOCHANG, "destination": BYEONGGOK} + ) + assert res.json() == { + "km": 10.5, + "reason": None, + "path": [[35.6866, 127.9095], [35.68, 127.89], [35.672, 127.87], [35.66, 127.85]], + } + assert "vertexes" not in res.text and "sections" not in res.text # 원문 안 돌려줌 + + +def test_깨진_좌표_km_그대로_길_선만_없음(monkeypatch): + monkeypatch.delenv(ms.KAKAO_KEY, raising=False) + got = ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK)) # 키 없음 = 길 선 없음 + assert got["km"] is None and "path" not in got + broken = json.loads(json.dumps(BODY)) + broken["routes"][0]["sections"][1]["roads"][0]["vertexes"] = ["x", None] + body = ( + _client(monkeypatch, broken) + .post(BASE + "/directions", json={"origin": GEOCHANG, "destination": BYEONGGOK}) + .json() + ) + assert body["km"] == 10.5 and body["path"] == [] + + +def test_좌표_저장_안_함(tmp_path): + route = {"id": "r1", "name": "거창군청", "legs": [{"road": None, "auto_km": 10.5}]} + route["path"] = [[35.6866, 127.9095], [35.66, 127.85]] # 화면이 잘못 실어 보내도 + ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [route]})) + files = [p for p in tmp_path.rglob("*") if p.is_file()] + assert [p.name for p in files] == ["milestone.json"] + text = files[0].read_text(encoding="utf-8") + assert '"path"' not in text and "35.6866" not in text and '"auto_km": 10.5' in text + + +def test_JS_키_길(monkeypatch): + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + monkeypatch.delenv(ms.KAKAO_JS, raising=False) + assert client.get(BASE + "/map-key").json() == {"key": None} + monkeypatch.setenv(ms.KAKAO_JS, " ") + assert client.get(BASE + "/map-key").json() == {"key": None} + monkeypatch.setenv(ms.KAKAO_JS, "fake-js-key") + assert client.get(BASE + "/map-key").json() == {"key": "fake-js-key"} + + +def test_표지_셋_캡처_자르기(): + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "lon": 127.85, "lat": 35.66, "legs": []}, + "routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True}, + {"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}], + } # fmt: skip + d = json.dumps(data) + got = _node( + [LAYOUT], + f"""const D = {d}; +console.log(JSON.stringify([ + mapMarks(D, "r1", [127.83, 35.64]), + mapMarks(D, "r2", [127.83, 35.64]), + mapMarks({{ ...D, base: {{ ...D.base, enabled: false }} }}, "r1", null), + cropBox({{ left: 100, top: 50, width: 400, height: 300 }}, 1000, 2000, 1200), + cropBox({{ left: -20, top: 500, width: 1100, height: 300 }}, 1000, 1000, 600), +]));""", + ) + assert got[0] == [ + {"name": "거창군청", "lon": 127.9, "lat": 35.68}, + {"name": "병곡리", "lon": 127.85, "lat": 35.66}, + {"name": "시점", "lon": 127.83, "lat": 35.64}, + ] + assert got[1] == [{"name": "시점", "lon": 127.83, "lat": 35.64}] # 좌표 없는 거래처 = 표지 없음 + assert got[2] == [{"name": "거창군청", "lon": 127.9, "lat": 35.68}] + assert got[3] == {"sx": 200, "sy": 100, "sw": 800, "sh": 600} # × 2(devicePixelRatio) + assert got[4] == {"sx": 0, "sy": 500, "sw": 1000, "sh": 100} # 영상 밖은 잘라 냄 + + +STUBS = """ +const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "", + className: o.className, dataset: {}, children: o.children ?? [] }); +const createButton = (o: any) => ({ ...el("button", { text: o.label }), onClick: o.onClick }); +const showToast = () => {}; +const uploadImage = async () => {}; +const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); +const injected: string[] = []; +(globalThis as any).document = { createElement: () => ({}), + head: { append: (s: any) => injected.push(s.src) } }; +(globalThis as any).window = globalThis; +const liveMilestone = () => ({ site: {}, base: { enabled: true, name: "병곡리", lon: 127.85, + lat: 35.66 }, routes: [{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: true }] }); +const siteInfo = () => ({ start_lonlat: [127.83, 35.64] }); +const routePaths = () => PATHS; +""" + + +def _map(key, paths, extra=""): + script = f""" +const KEY = {json.dumps(key)}; +const PATHS = {json.dumps(paths)}; +const asked: string[] = []; +const request = async (url: string) => (asked.push(url), {{ key: KEY }}); +{extra} +const view = routeMapView("p1", "r1"); +await new Promise((r) => setTimeout(r, 30)); +const [bar, hint, box] = view.root.children; +console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden, + captureHidden: bar.children[0].hidden, label: bar.children[0].textContent, + log: (globalThis as any).LOG ?? null }})); +""" + return _node([LAYOUT], STUBS + IMPORTS.sub("", _read(KMAP)) + script) + + +def test_키_없음_안내_한_줄(): + got = _map(None, []) + assert got["asked"] == ["/projects/p1/quantity/milestone/map-key"] + assert got["injected"] == [] # SDK 안 부름 + assert got["hint"] == ( + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + ) + assert got["boxHidden"] and got["captureHidden"] and got["label"] == "지도 캡처" + + +FAKE_KAKAO = """ +const LOG: any = { overlays: [], bounds: [] }; +(globalThis as any).LOG = LOG; +class LatLng { lat: number; lng: number; + constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; } } +const on = (kind: string, o: any) => ({ setMap: (m: any) => m && LOG.overlays.push([kind, o]) }); +(globalThis as any).kakao = { maps: { + load: (f: () => void) => f(), + LatLng, + LatLngBounds: class { extend(p: any) { LOG.bounds.push([p.lat, p.lng]); } }, + Map: class { constructor() { LOG.maps = (LOG.maps ?? 0) + 1; } + relayout() {} setBounds() { LOG.fit = true; } }, + Polyline: function (o: any) { return on("line", o); }, + Marker: function (o: any) { return on("marker", o); }, + CustomOverlay: function (o: any) { return on("label", o); }, +} }; +""" + + +def test_키_있음_길_선_표지_전체_맞춤(): + paths = [[[35.68, 127.9], [35.67, 127.87], [35.66, 127.85]], [[35.66, 127.85], [35.64, 127.83]]] + got = _map("fake-js-key", paths, FAKE_KAKAO) + log = got["log"] + assert not got["boxHidden"] and not got["captureHidden"] and log["maps"] == 1 and log["fit"] + lines = [o for kind, o in log["overlays"] if kind == "line"] + assert [[[p["lat"], p["lng"]] for p in o["path"]] for o in lines] == paths + assert lines[0]["strokeColor"] == "#E60046" and lines[0]["strokeWeight"] >= 5 + titles = [o["title"] for kind, o in log["overlays"] if kind == "marker"] + assert titles == ["거창군청", "병곡리", "시점"] + assert len(log["bounds"]) == 3 + 5 # 표지 셋 + 길 선 점 다섯 = 경로 전체 맞춤 + assert "[지도 캡처]" in got["hint"] + + +def test_화면_자리_캡처_키_서버만(): + kmap = _read(KMAP) + assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap + assert "preferCurrentTab: true" in kmap and "crop.fromElement(target)" in kmap + assert "cropTo(" in kmap and "stream.getTracks().forEach((t) => t.stop())" in kmap + assert "uploadImage(projectId, routeId, file)" in kmap + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert "mapView.root" in modal and "mapView.draw();" in modal + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "routeMapView(projectId, routeId, () => loadImage(true))" in route + assert "mapView.draw();" in route + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감) + for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만 + text = rel.read_text(encoding="utf-8") + assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel + for rel in (KMAP, "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", LAYOUT, + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700 From 50cc45731cae6fdd1db7daf81c12c0cc728adb74 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:49:44 +0900 Subject: [PATCH 75/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EA=B2=BD=EB=A1=9C=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=ED=85=8C=EC=9D=BC=EC=8A=A4=EC=BC=80=EC=9D=BC=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=95=88=EB=82=B4=20(244-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보안 문맥 아닌 주소(getDisplayMedia 없음) = [지도 캡처] 끔 + 까닭 한 줄 「캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요」 - SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 필요 안내 한 줄 · 사진 올리기 - 새 시험 test_244_10 · test_244_9 에 보안 문맥 navigator 가짜 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B08_Quantity_UI_Milestone_KakaoMap.ts | 26 ++++- .../test_244_10_b08_milestone_tailscale.py | 109 ++++++++++++++++++ .../test_244_9_b08_milestone_route_map.py | 2 + 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_244_10_b08_milestone_tailscale.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 7bbdcb75e..196c5539b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -8,6 +8,8 @@ * 시점(`mapMarks`) · 경로 전체 맞춤 * [지도 캡처] = getDisplayMedia(preferCurrentTab) → 지도 칸만(CropTarget · 없으면 `cropBox` 자리 수치) → * 그 경로 그림(244-1 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤 + * (244-10) 보안 문맥 아닌 주소(테일스케일 http IP 등) = getDisplayMedia 없음 → [지도 캡처] 끔 + 까닭 한 줄 · + * SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; @@ -22,6 +24,8 @@ import { export const NO_MAP = "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; +export const NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요"; +const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 @@ -63,7 +67,15 @@ export function loadKakaoMaps(projectId: string): Promise { const script = document.createElement("script"); script.src = `${SDK}?appkey=${encodeURIComponent(key)}&autoload=false`; script.onload = () => done(); - script.onerror = () => fail(new Error(NO_MAP)); + script.onerror = () => + fail( + Object.assign( + new Error( + `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`, + ), + { name: SDK_FAIL }, + ), + ); document.head.append(script); }); } @@ -142,6 +154,8 @@ export function routeMapView( let map: KMap | null = null; let drawn: KOverlay[] = []; let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로) + const canCapture = Boolean(navigator.mediaDevices?.getDisplayMedia); // http IP 주소 = 없음 + const shot = canCapture ? "[지도 캡처] = 이 지도 칸 → 경로 그림" : NO_CAPTURE; const draw = (): void => { const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); @@ -192,13 +206,13 @@ export function routeMapView( map.setBounds(bounds); shown = sig; hint.textContent = lines.length - ? "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림" - : "[길찾기]를 누르면 길 선이 그려짐 · [지도 캡처] = 이 지도 칸 → 경로 그림"; + ? `길 선 = 카카오 길찾기 · ${shot}` + : `[길찾기]를 누르면 길 선이 그려짐 · ${shot}`; }), - () => { + (error: unknown) => { box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로) capture.hidden = true; - hint.textContent = NO_MAP; + hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; }, ); }; @@ -207,6 +221,7 @@ export function routeMapView( label: "지도 캡처", size: "sm", variant: "ghost", + disabled: !canCapture, onClick: () => void captureElement(box) .then((file) => uploadImage(projectId, routeId, file)) @@ -216,6 +231,7 @@ export function routeMapView( }), }); capture.dataset.b08MilestoneCapture = ""; + if (!canCapture) capture.title = NO_CAPTURE; const root = el("div", { className: "b08-milestone__map-wrap", children: [el("div", { className: "b08-milestone__bar", children: [capture] }), hint, box], diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py new file mode 100644 index 000000000..36226bd47 --- /dev/null +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -0,0 +1,109 @@ +"""244-10 B08 거리이정표 경로 지도 — 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님). + +① getDisplayMedia 없음 = [지도 캡처] 끔 + 까닭 한 줄(지도는 그대로) +② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 · 캡처 숨김 +(가짜 kakao.maps · document 로 node 에서 돌림) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" +ORIGIN = "http://100.72.32.27:5173" +NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +STUBS = f""" +const el = (tag: string, o: any = {{}}) => ({{ tag, hidden: false, textContent: o.text ?? "", + className: o.className, dataset: {{}}, children: o.children ?? [] }}); +const createButton = (o: any) => ({{ ...el("button", {{ text: o.label }}), disabled: !!o.disabled, + title: "", onClick: o.onClick }}); +const showToast = () => {{}}; +const uploadImage = async () => {{}}; +const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); +const request = async () => ({{ key: "fake-js-key" }}); +const injected: string[] = []; +(globalThis as any).window = globalThis; +(globalThis as any).location = {{ origin: {json.dumps(ORIGIN)} }}; +const liveMilestone = () => ({{ site: {{}}, base: {{ enabled: false }}, + routes: [{{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: false }}] }}); +const siteInfo = () => ({{ start_lonlat: [127.83, 35.64] }}); +const routePaths = () => [[[35.68, 127.9], [35.64, 127.83]]]; +""" + +FAKE_KAKAO = """ +class Fake { setMap() {} relayout() {} setBounds() {} extend() {} } +(globalThis as any).kakao = { maps: { load: (f: () => void) => f(), LatLng: Fake, + LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake } }; +""" + + +def _view(setup): + script = ( + STUBS + + setup + + IMPORTS.sub("", _read(KMAP)) + + """ +const view = routeMapView("p1", "r1"); +await new Promise((r) => setTimeout(r, 30)); +const [bar, hint, box] = view.root.children; +const cap = bar.children[0]; +console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden, + capHidden: cap.hidden, capDisabled: cap.disabled, capTitle: cap.title })); +""" + ) + src = IMPORTS.sub("", _read(LAYOUT)) + script + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +NO_MEDIA = """ +Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} }); +(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } }; +""" + + +def test_보안_문맥_아님_캡처_끔_까닭_한_줄(): + got = _view(NO_MEDIA + FAKE_KAKAO) + assert not got["boxHidden"] and not got["capHidden"] # 지도는 그대로 뜸 + assert got["capDisabled"] and got["capTitle"] == NO_CAPTURE + assert got["hint"] == f"길 선 = 카카오 길찾기 · {NO_CAPTURE}" + assert "[지도 캡처] =" not in got["hint"] + + +def test_보안_문맥_캡처_켜짐(): + media = """ +Object.defineProperty(globalThis, "navigator", { configurable: true, + value: { mediaDevices: { getDisplayMedia: async () => ({}) } } }); +(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } }; +""" + got = _view(media + FAKE_KAKAO) + assert not got["capDisabled"] and got["capTitle"] == "" + assert got["hint"] == "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림" + + +def test_SDK_401_도메인_등록_안내(): + sdk_401 = """ +(globalThis as any).document = { createElement: () => ({}), + head: { append: (s: any) => (injected.push(s.src), setTimeout(() => s.onerror(), 0)) } }; +""" + got = _view(NO_MEDIA + sdk_401) # window.kakao 없음 → script 넣음 → 401 = onerror + assert len(got["injected"]) == 1 and "sdk.js?appkey=fake-js-key" in got["injected"][0] + assert got["hint"] == ( + f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + ) + assert got["boxHidden"] and got["capHidden"] diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 3afc3857a..bedc3e1ef 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -152,6 +152,8 @@ const injected: string[] = []; (globalThis as any).document = { createElement: () => ({}), head: { append: (s: any) => injected.push(s.src) } }; (globalThis as any).window = globalThis; +Object.defineProperty(globalThis, "navigator", { configurable: true, + value: { mediaDevices: { getDisplayMedia: async () => ({}) } } }); // localhost = 보안 문맥 const liveMilestone = () => ({ site: {}, base: { enabled: true, name: "병곡리", lon: 127.85, lat: 35.66 }, routes: [{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: true }] }); const siteInfo = () => ({ start_lonlat: [127.83, 35.64] }); From 2ce1e66095ad1650012f043586ca92a3c4cb2a6f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:50:10 +0900 Subject: [PATCH 76/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20139=20=C2=B7=20140?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index ac1f7a558..0d9429e5f 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -167,3 +167,5 @@ 136. (길찾기 방식 결정) 일단 카카오로 가야할것같아. 카카오 디벨로퍼 페이지에 와있는데 제휴방법을 알려줘. 137. (경로 그림 방식) 가로 가는게 좋겠어 — 서버가 카카오 길찾기 경로 선을 브이월드 지도 위에 그려 자동 저장. 메일에도 거리와 경로데이터라고 해야하는거 아니야? 138. (조사 더 · 너무 보수적 아님?) 가 사안은 배제해. 데이터가 다른 맵위에 맞을리가 없어. → 나(카카오 지도 JS 위 경로 + [캡처])로 +139. 그럼 캡처는 백엔드에서 하고 그림만 가져오는건 어때? (브레인 시험: 서버 헤드리스 Edge + 카카오 지도 JS + 길 선 → 1200×800 PNG 2.4초 성공) +140. 테일스케일 IP는 (카카오 JavaScript 도메인에) 등록했어 참고해 From c2184625fa4f98ab06c58f21509f7895353ada7c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:54:43 +0900 Subject: [PATCH 77/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20141?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 0d9429e5f..1ce521a72 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -169,3 +169,4 @@ 138. (조사 더 · 너무 보수적 아님?) 가 사안은 배제해. 데이터가 다른 맵위에 맞을리가 없어. → 나(카카오 지도 JS 위 경로 + [캡처])로 139. 그럼 캡처는 백엔드에서 하고 그림만 가져오는건 어때? (브레인 시험: 서버 헤드리스 Edge + 카카오 지도 JS + 길 선 → 1200×800 PNG 2.4초 성공) 140. 테일스케일 IP는 (카카오 JavaScript 도메인에) 등록했어 참고해 +141. 다음으로 구조물 집계표에 구조물조합 연결이 완료된 항목은 녹색 램프를 넣어달라고 했는데 없어. (화면 붙임 · 노랑만 · 247-1 이 sub7 에 안 들어갔던 것 — 다시 보냄) From c91d5aa6c9ec9a4b5d6b93a6d3ae36ac362b0c83 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:58:50 +0900 Subject: [PATCH 78/92] =?UTF-8?q?feat(b08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A7=91=EA=B3=84=ED=91=9C=20=EB=AA=A8=EB=91=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=B8=A1=EC=A0=90=20=EC=A4=84=20=EC=B4=88=EB=A1=9D?= =?UTF-8?q?=20=EB=9E=A8=ED=94=84=20(247-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측점 줄 시설이 모두 조합에 연결 = 측점 칸 왼쪽 초록 램프(노랑과 같은 자리 · 크기) · 호버 「모두 연결됨」 - 미연결 = 노랑 그대로 · 시설 없는 줄 · 합계 줄 = 램프 없음 · 색 = 성공 토큰(밝음 · 어두움) - 새 시험 test_247_1 · 198_1 시험 정규식 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B08_Quantity/B08_Quantity_UI_Page.css | 5 + B08_Quantity/B08_Quantity_UI_Summary.ts | 12 +- .../tester/test_198_1_b08_summary_lamp.py | 4 +- .../test_247_1_b08_summary_green_lamp.py | 107 ++++++++++++++++++ ui_template/ui_template_locale_b4.ts | 1 + 5 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 resources/tester/test_247_1_b08_summary_green_lamp.py diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 8de5e6c50..e07c12821 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -66,6 +66,11 @@ vertical-align: middle; } +/* 247-1 — 모두 연결 = 같은 램프 초록 */ +[data-b08-summary="table"] td.b08-summary__lamp.is-linked::before { + background: var(--color-success); +} + /* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */ .b08-options__row { display: grid; diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index a8a13ef89..4d74c1927 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -8,6 +8,7 @@ * 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 미연결 시설은 그 측점 * 줄만 측점 칸 왼쪽 노랑 램프(값 빈칸 · 서버 `unlinked` · PLAN 154-1 · 198-1) * · 램프 호버(공용 `ui_template_hover_info`) = 그 측점 빈 조합 칸(160-2 · 화면만) + * · 시설이 모두 조합에 이은 측점 줄 = 같은 자리 초록 램프(호버 「모두 연결됨」 · 247-1) * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 @@ -101,6 +102,8 @@ async function fetchPipeSummary(projectId: string): Promise { const PIPE_TOP = "관 공"; /** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ const LAMP_CLASS = "b08-summary__lamp"; +/** 모두 연결 = 같은 램프 초록(247-1) */ +const LINKED_CLASS = `${LAMP_CLASS} is-linked`; const LAMP_W = 14; const stationKey = (station: number): number => Math.round(station * 100); /** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ @@ -159,6 +162,8 @@ function buildTable( const missing = stations.map((station) => unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), ); + // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 + const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); const table = createDataTable({ columns: [ { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, @@ -179,7 +184,8 @@ function buildTable( })), total, onRowClick: (i) => onRowClick(stations[i], labels[i]), - cellClass: (i, key) => (key === "station" && missing[i] ? LAMP_CLASS : null), + cellClass: (i, key) => + key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), @@ -191,7 +197,9 @@ function buildTable( table.root, (cell) => { const why = missing[Number(cell.closest("tr")?.dataset.i)]; - return why && `${L("B08_Quantity_Summary_LampTip")} — ${why}`; + return why + ? `${L("B08_Quantity_Summary_LampTip")} — ${why}` + : L("B08_Quantity_Summary_LampOk"); }, `td.${LAMP_CLASS}`, ); diff --git a/resources/tester/test_198_1_b08_summary_lamp.py b/resources/tester/test_198_1_b08_summary_lamp.py index 184e9396d..2088141f8 100644 --- a/resources/tester/test_198_1_b08_summary_lamp.py +++ b/resources/tester/test_198_1_b08_summary_lamp.py @@ -39,7 +39,7 @@ def _lamp_class() -> str: def test_미연결_줄_측점_칸만_램프(): - cell = re.search(r"cellClass: (\(i, key\) => .*?),\n", UI) + cell = re.search(r"cellClass: (\(i, key\) =>.*?),\n\s*frozen", UI, re.S) assert cell table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) got = _node( @@ -50,6 +50,8 @@ new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8") box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); const LAMP_CLASS = {json.dumps(_lamp_class())}; const missing = ["", "BOX", ""]; +const linked = [false, false, false]; +const LINKED_CLASS = LAMP_CLASS + " is-linked"; const cellClass = {cell.group(1)}; const html = box.exports.renderDataTableHtml({{ columns: [{{ key: "station", label: "측점" }}, {{ key: "c1", label: "조합" }}], diff --git a/resources/tester/test_247_1_b08_summary_green_lamp.py b/resources/tester/test_247_1_b08_summary_green_lamp.py new file mode 100644 index 000000000..a490adb49 --- /dev/null +++ b/resources/tester/test_247_1_b08_summary_green_lamp.py @@ -0,0 +1,107 @@ +"""247-1 B08 구조물 집계표 — 시설이 모두 조합에 이은 측점 줄 = 초록 램프. + +- 칸 class: 화면 소스의 `missing` · `linked` · `cellClass` 를 떼어 공용 표로 그림(Node) + 모두 연결 = 초록 · 하나라도 빈 칸 = 노랑 · 시설 없음 · 합계 줄 = 램프 없음 +- 호버 글: 초록 = 「모두 연결됨」 +- CSS: 초록 = 성공 토큰(밝음 · 어두움) +""" + +import json +import re + +from test_198_1_b08_summary_lamp import PRELUDE, ROOT, UI, _lamp_class, _node + +DATA = (ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts").read_text(encoding="utf-8") + + +def _fac(fid: str, at: float, *linked: bool) -> dict: + slots = [ + {"copy": f"{fid}{i}", "group": "칸", "item": None, "linked": v} + for i, v in enumerate(linked) + ] + return { + "id": fid, + "type_id": "box", + "name": "BOX", + "facility": None, + "chainage_m": at, + "slots": slots, + } + + +def test_모두_연결_초록_빈_칸_노랑_없음_없음(): + grab = lambda pat: re.search(pat, UI, re.S).group(1) # noqa: E731 + missing = grab(r"(const missing = stations\.map\(.*?\);\n)") + linked = grab(r"(const linked = stations\.map\(.*?\);\n)") + cell = grab(r"cellClass: (\(i, key\) =>.*?),\n\s*frozen") + line = re.search(r"(const LINKED_CLASS = .*?;)", UI).group(1) + facilities = [_fac("a", 0, True, True), _fac("b", 20, True, False), _fac("c", 20, True)] + unlinked = [f for f in facilities if not all(s["linked"] for s in f["slots"])] + table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) + # 두 함수만 떼어 씀(모듈 import 없이) + data = json.dumps( + "".join( + re.search(rf"export (const {name} = .*?;\n)\n", DATA, re.S).group(1) + for name in ("unlinkedAt", "unassignedText") + ) + ) + got = _node( + PRELUDE + + f""" +const load = (src) => {{ + const box = {{ exports: {{}} }}; + new Function("exports", "module", "require", js(src))( + box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); + return box.exports; +}}; +const t = load(fs.readFileSync({table}, "utf8")); +const {{ unlinkedAt, unassignedText }} = new Function( + js({data}) + "; return {{ unlinkedAt, unassignedText }};")(); +const L = (k) => k; +const LAMP_CLASS = {json.dumps(_lamp_class())}; +{line} +const stations = [0, 20, 40]; +const unlinked = {json.dumps(unlinked, ensure_ascii=False)}; +const pipe = {{ facilities: {json.dumps(facilities, ensure_ascii=False)} }}; +{missing}{linked}const cellClass = {cell}; +const html = t.renderDataTableHtml({{ + columns: [{{ key: "station", label: "측점" }}, {{ key: "c1", label: "조합" }}], + rows: stations.map((s) => ({{ station: String(s) }})), + total: {{ station: "계", c1: 1 }}, + frozen: 1, empty: "", cellClass }}); +const rows = html.split("")[1].split(""); +console.log(JSON.stringify({{ rows: rows.slice(0, 3), tail: html.split("")[1] ?? "", lamps: html.split(LAMP_CLASS).length - 1 }})); +""" + ) + lamp = _lamp_class() + assert f'class="is-frozen is-edge {lamp} is-linked"' in got["rows"][0] # 0 = 모두 연결 + assert f'class="is-frozen is-edge {lamp}"' in got["rows"][1] # 20 = 빈 칸 하나 = 노랑 + assert lamp not in got["rows"][2] # 40 = 시설 없음 + assert got["lamps"] == 2 # 합계 줄 없음 + + +def test_초록_호버_모두_연결됨(): + hover = re.search( + r"attachHoverInfo\(\n\s*table\.root,\n\s*(\(cell\) => \{.*?\n\s*\}),\n", UI, re.S + ) + got = _node( + PRELUDE + + f""" +const L = (k) => ({{ B08_Quantity_Summary_LampTip: "구조물 조합 미연결", B08_Quantity_Summary_LampOk: "모두 연결됨" }})[k]; +const missing = ["", "BOX: 칸"]; +const text = eval(js("(" + {json.dumps(hover.group(1))} + ")")); +const cell = (i) => ({{ closest: () => ({{ dataset: {{ i: String(i) }} }}) }}); +console.log(JSON.stringify([text(cell(0)), text(cell(1))])); +""" + ) + assert got == ["모두 연결됨", "구조물 조합 미연결 — BOX: 칸"] + locale = (ROOT / "ui_template" / "ui_template_locale_b4.ts").read_text(encoding="utf-8") + assert 'B08_Quantity_Summary_LampOk: ["모두 연결됨",' in locale + + +def test_초록_성공_토큰(): + css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text(encoding="utf-8") + m = re.search(r"td\.b08-summary__lamp\.is-linked::before\s*\{([^}]*)\}", css) + assert m and re.search(r"background:\s*var\(--color-success\)", m.group(1)) + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8") + assert len(re.findall(r"--color-success:\s*#", theme)) >= 2 # 밝음 · 어두움 diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index b8433c381..4c0e3ff0e 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -65,6 +65,7 @@ export const ui_locales_b4 = { B08_Quantity_Summary_Total: ["계", "Total"], B08_Quantity_Summary_Unlinked: ["미연결", "Unlinked"], B08_Quantity_Summary_LampTip: ["구조물 조합 미연결", "Structure combination unlinked"], + B08_Quantity_Summary_LampOk: ["모두 연결됨", "All linked"], B08_Quantity_Link_Title: ["조합 연결", "Link combinations"], B08_Quantity_Link_Body: ["본체", "Body"], B08_Quantity_Link_Linked: ["연결됨", "Linked"], From 8f96b16380e72ae89634890db0c26b82ad17b528 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:59:25 +0900 Subject: [PATCH 79/92] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EA=B2=BD=EB=A1=9C=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=3D=20=EC=84=9C=EB=B2=84=20=EC=9E=90=EB=8F=99=20=EC=BA=A1?= =?UTF-8?q?=EC=B2=98=20=C2=B7=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=97=86=EC=95=B0=20(244-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [등록] · [길찾기] 뒤 길 선 · 표지 → 서버 헤드리스 Edge(없으면 chrome)로 카카오 지도 1200×800 PNG → auto/<경로 id>.png - 페이지 주소 = KAKAO_CAPTURE_ORIGIN(기본 http://localhost:5173) + /__route_capture page.route 채움 - 사용자 그림 우선 · 자동 그림으로 되돌리기 · 실패 = 까닭 한 줄 · 동시 요청 잠금 · 좌표 저장 없음 - [지도 캡처] · [화면 캡처] · getDisplayMedia · 244-10 캡처 안내 없앰(지도 SDK 도메인 안내는 그대로) - 새 시험 test_244_11 · 244_4 · 244_9 · 244_10 기대값 고침 · requirements playwright Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B08_Quantity_Milestone_Capture.py | 142 +++++++++ B08_Quantity/B08_Quantity_Milestone_Layout.ts | 19 -- B08_Quantity/B08_Quantity_Router_Milestone.py | 83 ++++-- .../B08_Quantity_UI_Milestone_KakaoMap.ts | 103 +------ .../B08_Quantity_UI_Milestone_Modal.ts | 7 +- .../B08_Quantity_UI_Milestone_Route.ts | 75 ++--- .../B08_Quantity_UI_Milestone_State.ts | 71 ++++- requirements.txt | 1 + .../test_244_10_b08_milestone_tailscale.py | 36 +-- .../test_244_11_b08_milestone_auto_capture.py | 277 ++++++++++++++++++ .../tester/test_244_4_b08_milestone_modal.py | 7 +- .../test_244_9_b08_milestone_route_map.py | 30 +- 12 files changed, 615 insertions(+), 236 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Milestone_Capture.py create mode 100644 resources/tester/test_244_11_b08_milestone_auto_capture.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py new file mode 100644 index 000000000..635bf9e88 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -0,0 +1,142 @@ +"""B08 거리이정표 경로 그림 자동 캡처(PLAN 244-11 · 사용자 139번) — 서버 헤드리스 Edge. + +[등록] · [길찾기] 뒤 화면이 그 경로의 길 선(마지막 길찾기 경로 좌표) · 표지를 보냄 → +서버가 헤드리스 Edge(Playwright channel msedge · 없으면 chrome · 번들 chromium 안 씀)로 +카카오 지도 JS + 길 선 + 표지 + 경로 맞춤 → tilesloaded 뒤 1200×800 PNG → `<폴더>/<경로 id>.png` +(사용자 그림이 있으면 화면은 사용자 그림 우선). +페이지 주소 = .env KAKAO_CAPTURE_ORIGIN(없으면 http://localhost:5173) + /__route_capture — +page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨). +경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 · +키 값은 돌려주지도 적지도 않음. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +import urllib.parse +from pathlib import Path +from typing import Any + +KAKAO_JS = "KAKAO_JS_KEY" +ORIGIN_KEY = "KAKAO_CAPTURE_ORIGIN" +DEFAULT_ORIGIN = "http://localhost:5173" +PAGE = "/__route_capture" +WIDTH, HEIGHT = 1200, 800 +TIMEOUT_S = 20 +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 +TAIL = " — 그림 올리기를 쓰세요." + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) + + +class CaptureError(Exception): + """까닭 한 줄(키 · 예외 원문 없음).""" + + +def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str: + """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.""" + data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c") + sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false" + return f""" +
+ +""" + + +def shoot(html: str, origin: str) -> bytes: + """헤드리스 브라우저로 그 페이지 → 지도 칸 PNG. 실패 = CaptureError(까닭 한 줄).""" + try: + from playwright.sync_api import Error as PwError + from playwright.sync_api import TimeoutError as PwTimeout + from playwright.sync_api import sync_playwright + except ImportError as error: + raise CaptureError("서버에 Playwright 가 없습니다(pip install playwright)") from error + url = origin + PAGE + deadline = time.monotonic() + TIMEOUT_S + + def left() -> float: + return max(1.0, deadline - time.monotonic()) * 1000 + + try: + with sync_playwright() as p: + browser = None + for channel in CHANNELS: + try: + browser = p.chromium.launch(channel=channel, headless=True, timeout=left()) + break + except PwError: + continue + if browser is None: + raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") + try: + page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}) + page.route( + url, + lambda route: route.fulfill( + status=200, content_type="text/html; charset=utf-8", body=html + ), + ) + page.goto(url, timeout=left()) + page.wait_for_function("window.__done || window.__fail", timeout=left()) + if page.evaluate("window.__fail"): + raise CaptureError( + f"카카오 지도 JS 가 캡처 주소({origin})를 받지 않음(키 도메인 등록 확인)" + ) + page.wait_for_timeout(500) # 마지막 타일 · 표지 그림 + return page.locator("#m").screenshot(timeout=left()) + finally: + browser.close() + except CaptureError: + raise + except PwTimeout as error: + raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") from error + + +def make_route_image( + out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]] +) -> dict[str, Any]: + """그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림.""" + key = os.environ.get(KAKAO_JS, "").strip() + if not key: + return { + "saved": False, + "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}", + } + if not any(lines) and not marks: + return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"} + origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/") + with _lock: + try: + png = shoot(page_html(key, lines, marks), origin) + except CaptureError as error: + return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {error}{TAIL}"} + out_dir.mkdir(parents=True, exist_ok=True) + new = out_dir / f".{route_id}.png.new" + new.write_bytes(png) + new.replace(out_dir / f"{route_id}.png") + return {"saved": True, "reason": None} diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 348f331d2..bc1e83b47 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -152,25 +152,6 @@ export function mapMarks( return out; } -/** 캡처 자르기(244-9 · CropTarget 없을 때) — 요소 자리(CSS px) × (영상 폭 ÷ 창 폭 = 보통 - * devicePixelRatio) → 영상 안 자를 칸(영상 밖은 잘라 냄) */ -export function cropBox( - rect: { left: number; top: number; width: number; height: number }, - viewWidth: number, - videoWidth: number, - videoHeight: number, -): { sx: number; sy: number; sw: number; sh: number } { - const k = videoWidth / viewWidth; - const sx = Math.min(Math.max(0, Math.round(rect.left * k)), videoWidth); - const sy = Math.min(Math.max(0, Math.round(rect.top * k)), videoHeight); - return { - sx, - sy, - sw: Math.max(0, Math.min(videoWidth - sx, Math.round((rect.left + rect.width) * k) - sx)), - sh: Math.max(0, Math.min(videoHeight - sy, Math.round((rect.top + rect.height) * k) - sy)), - }; -} - /** 자동 km 를 그 구간에(사용자 km 는 그대로) */ export function putAutoKm( data: MilestoneData, diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index a1d1affae..43465fa21 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -4,7 +4,11 @@ PUT /api/projects/{id}/quantity/milestone [저장] — 입력만 · 표는 서버가 셈 POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체) GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기 - DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 + DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기(자동 그림까지) · + &user_only=true = 사용자 그림만(「자동 그림으로 되돌리기」 · 244-11) + POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 · + 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · + 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 @@ -13,8 +17,9 @@ GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 · 공개용 · .env KAKAO_JS_KEY · 없으면 null) -정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · -폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). +정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 사용자 +그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 +그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님). 그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). @@ -31,7 +36,7 @@ import urllib.error import urllib.parse import urllib.request from pathlib import Path -from typing import Any, Literal +from typing import Annotated, Any, Literal from uuid import UUID from fastapi import APIRouter, File, HTTPException, Query, UploadFile @@ -42,6 +47,7 @@ from B01_Dashboard.B01_Dashboard_Map import reverse_geocode from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 +from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_json import atomic_write_json @@ -57,6 +63,7 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"]) PATH = "/{project_id}/quantity/milestone" FOLDER = Path("B08_Quantity") / "milestone" DATA_FILE = "milestone.json" +AUTO = "auto" # 자동 그림 하위 폴더(244-11) IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" @@ -172,8 +179,8 @@ def site_info(root: str | Path, mid_station_m: float | None) -> dict[str, Any]: } -def route_images(root: str | Path, route_id: str) -> list[Path]: - path = folder(root) +def route_images(root: str | Path, route_id: str, sub: str = "") -> list[Path]: + path = folder(root) / sub if not path.is_dir(): return [] return sorted( @@ -181,13 +188,13 @@ def route_images(root: str | Path, route_id: str) -> list[Path]: ) -def images(root: str | Path) -> dict[str, str]: - """경로 id → 그림 파일 이름(폴더가 목록).""" - path = folder(root) +def images(root: str | Path, sub: str = "") -> dict[str, str]: + """경로 id → 그림 파일 이름(폴더가 목록 · sub = 하위 폴더 · 이름에 붙음).""" + path = folder(root) / sub if not path.is_dir(): return {} return { - f.stem: f.name + f.stem: f"{sub}/{f.name}" if sub else f.name for f in sorted(path.iterdir()) if f.is_file() and f.suffix.lower() in IMAGE_EXT and not f.name.startswith(".") } @@ -195,11 +202,13 @@ def images(root: str | Path) -> dict[str, str]: def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: site = site_info(root, data["site"]["mid_station_m"]) + auto = images(root, AUTO) return { "data": data, "site": site, "table": compute_milestone(data, site["mid_default_m"]), - "images": images(root), + "images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선 + "auto_images": auto, } @@ -209,9 +218,10 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: path.mkdir(parents=True, exist_ok=True) atomic_write_json(path / DATA_FILE, data) keep = {route["id"] for route in data["routes"]} - for stem, name in images(root).items(): - if stem not in keep: - (path / name).unlink(missing_ok=True) + for sub in ("", AUTO): + for stem, name in images(root, sub).items(): + if stem not in keep: + (path / name).unlink(missing_ok=True) return payload(root, data) @@ -280,19 +290,54 @@ async def milestone_image_upload( async def milestone_image(project_id: UUID, route: str = Query(...)): route_id = _route_id(route) root = await _project_root(project_id) - found = route_images(root, route_id) + found = route_images(root, route_id) + route_images(root, route_id, AUTO) if not found: raise HTTPException(status_code=404, detail="경로 그림이 없습니다.") - return FileResponse(found[0]) + return FileResponse(found[0]) # 사용자 그림 우선 @router.delete(PATH + "/image") -async def milestone_image_delete(project_id: UUID, route: str = Query(...)) -> dict[str, Any]: +async def milestone_image_delete( + project_id: UUID, route: str = Query(...), user_only: bool = False +) -> dict[str, Any]: route_id = _route_id(route) root = await _project_root(project_id) - for old in route_images(root, route_id): + auto = route_images(root, route_id, AUTO) + for old in route_images(root, route_id) + ([] if user_only else auto): old.unlink(missing_ok=True) - return {"name": None} + return {"name": f"{AUTO}/{auto[0].name}" if user_only and auto else None} + + +class Mark(BaseModel): + name: str = Field(default="", max_length=100) + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) + + +Lat = Annotated[float, Field(ge=-90, le=90)] +Lon = Annotated[float, Field(ge=-180, le=180)] + + +class AutoImage(BaseModel): + """길 선 = 구간마다 [위도, 경도] 목록(마지막 [길찾기] 응답) · 표지 = 거래처 · 기준점 · 시점.""" + + lines: list[Annotated[list[tuple[Lat, Lon]], Field(max_length=20000)]] = Field( + default_factory=list, max_length=20 + ) + marks: list[Mark] = Field(default_factory=list, max_length=10) + + +@router.post(PATH + "/image/auto") +async def milestone_image_auto( + project_id: UUID, body: AutoImage, route: str = Query(...) +) -> dict[str, Any]: + """경로 그림 자동(244-11) — 서버 헤드리스 캡처 → auto/<경로 id>.png · 좌표는 저장 안 함.""" + route_id = _route_id(route) + root = await _project_root(project_id) + lines = [[list(p) for p in line] for line in body.lines] + marks = [m.model_dump() for m in body.marks] + got = await asyncio.to_thread(make_route_image, folder(root) / AUTO, route_id, lines, marks) + return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 196c5539b..f15eb4bae 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -1,30 +1,22 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_KakaoMap.ts - * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 + [캡처] → 그 경로 그림. + * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 · 표지(보기만). * * SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key * (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기 * 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 · * 시점(`mapMarks`) · 경로 전체 맞춤 - * [지도 캡처] = getDisplayMedia(preferCurrentTab) → 지도 칸만(CropTarget · 없으면 `cropBox` 자리 수치) → - * 그 경로 그림(244-1 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤 - * (244-10) 보안 문맥 아닌 주소(테일스케일 http IP 등) = getDisplayMedia 없음 → [지도 캡처] 끔 + 까닭 한 줄 · - * SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 + * 경로 그림 = 서버 자동 캡처(244-11 · `…_State` autoRouteImage) — 브라우저 캡처 없음(접속 주소 무관) + * (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { cropBox, mapMarks } from "./B08_Quantity_Milestone_Layout"; +import { el } from "@ui/ui_template_elements"; +import { mapMarks } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; -import { - liveMilestone, - routePaths, - siteInfo, - uploadImage, -} from "./B08_Quantity_UI_Milestone_State"; +import { liveMilestone, routePaths, siteInfo } from "./B08_Quantity_UI_Milestone_State"; export const NO_MAP = "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; -export const NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요"; const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 @@ -88,74 +80,20 @@ export function loadKakaoMaps(projectId: string): Promise { return sdk; } -/** 지도 칸만 화면 캡처(png) — 지금 탭 먼저 · 칸 자르기 · 스트림은 바로 끔 */ -export async function captureElement(target: HTMLElement): Promise { - const stream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: false, - preferCurrentTab: true, - } as DisplayMediaStreamOptions); - try { - const [track] = stream.getVideoTracks(); - const crop = ( - window as unknown as { CropTarget?: { fromElement(e: Element): Promise } } - ).CropTarget; - let cropped = false; - if (crop && track && "cropTo" in track) - try { - await (track as unknown as { cropTo(t: object): Promise }).cropTo( - await crop.fromElement(target), - ); - cropped = true; - } catch { - cropped = false; // 다른 창을 골랐음 등 — 자리 수치로 자름 - } - const video = el("video"); - video.muted = true; - video.srcObject = stream; - await video.play(); - await new Promise((done) => requestAnimationFrame(done)); - const box = cropped - ? { sx: 0, sy: 0, sw: video.videoWidth, sh: video.videoHeight } - : cropBox( - target.getBoundingClientRect(), - window.innerWidth, - video.videoWidth, - video.videoHeight, - ); - const canvas = el("canvas"); - canvas.width = box.sw; - canvas.height = box.sh; - canvas.getContext("2d")?.drawImage(video, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh); - const blob = await new Promise((done, fail) => - canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"), - ); - return new File([blob], "capture.png", { type: "image/png" }); - } finally { - stream.getTracks().forEach((t) => t.stop()); - } -} - export interface RouteMapView { root: HTMLElement; /** 지금 값 · 마지막 [길찾기] 길 선으로 다시 그림 */ draw: () => void; } -/** 경로 지도 칸 + [캡처] — 거래처 모달 · 경로 하위 탭 같은 한 벌 */ -export function routeMapView( - projectId: string, - routeId: string, - onCaptured: () => void = () => {}, -): RouteMapView { +/** 경로 지도 칸 — 거래처 모달 · 경로 하위 탭 같은 한 벌 */ +export function routeMapView(projectId: string, routeId: string): RouteMapView { const box = el("div", { className: "b08-milestone__map" }); const hint = el("p", { className: "b08-options__hint" }); hint.dataset.b08MilestoneMapHint = ""; let map: KMap | null = null; let drawn: KOverlay[] = []; let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로) - const canCapture = Boolean(navigator.mediaDevices?.getDisplayMedia); // http IP 주소 = 없음 - const shot = canCapture ? "[지도 캡처] = 이 지도 칸 → 경로 그림" : NO_CAPTURE; const draw = (): void => { const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); @@ -174,7 +112,6 @@ export function routeMapView( return; } box.hidden = false; - capture.hidden = false; map ??= new maps.Map(box, { center: points[0], level: 7 }); map.relayout(); drawn.forEach((o) => o.setMap(null)); @@ -206,35 +143,19 @@ export function routeMapView( map.setBounds(bounds); shown = sig; hint.textContent = lines.length - ? `길 선 = 카카오 길찾기 · ${shot}` - : `[길찾기]를 누르면 길 선이 그려짐 · ${shot}`; + ? "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" + : "[길찾기]를 누르면 길 선 · 경로 그림이 생김"; }), (error: unknown) => { - box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로) - capture.hidden = true; + box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄(사진 올리기는 그대로) hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; }, ); }; - const capture = createButton({ - label: "지도 캡처", - size: "sm", - variant: "ghost", - disabled: !canCapture, - onClick: () => - void captureElement(box) - .then((file) => uploadImage(projectId, routeId, file)) - .then(onCaptured, (error: unknown) => { - if ((error as Error)?.name !== "NotAllowedError") - showToast(`지도를 캡처하지 못했습니다 — ${String(error)}`, "error"); - }), - }); - capture.dataset.b08MilestoneCapture = ""; - if (!canCapture) capture.title = NO_CAPTURE; const root = el("div", { className: "b08-milestone__map-wrap", - children: [el("div", { className: "b08-milestone__bar", children: [capture] }), hint, box], + children: [hint, box], }); draw(); return { root, draw }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index f61314eb1..411f9c173 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -10,7 +10,8 @@ * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 * [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 · * 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」) - * 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · [지도 캡처] → 그 경로 그림) + * 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · 보기만) · 경로 그림 = [등록] · [길찾기] 뒤 + * 서버 자동 캡처(244-11) * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; @@ -241,9 +242,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { let legs = legsEditor(route.legs); const status = el("p", { className: "b08-options__hint" }); status.dataset.b08MilestoneInfo = "status"; - const mapView = routeMapView(projectId, routeId, () => - showToast("지도 캡처 = 이 거래처 경로 그림", "success"), - ); + const mapView = routeMapView(projectId, routeId); /** 모달 값 → 캐시(고침 · 길찾기 앞) */ const apply = (): boolean => { const fresh = liveMilestone(projectId); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index ac799ae0a..d61fee88b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -2,12 +2,12 @@ * B08_Quantity_UI_Milestone_Route.ts * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). * - * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · - * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 - * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 - * 그림 = 올리는 즉시 서버(교체 · 지우기) - * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지 · [지도 캡처] → 이 탭 그림 · - * JS 키 없음 = 안내 한 줄(사진 올리기 · 다음 지도 새 창 길은 그대로) + * 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자 + * 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버 + * 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기 + * [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) · + * 도착 = 목적지 좌표) — 보기만 + * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄 * ========================================================================== */ import { @@ -19,11 +19,13 @@ import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout"; import { + autoImageNote, + canRevertImage, deleteImage, imageBlobUrl, liveMilestone, putMilestone, - routeImages, + routeImageKey, siteInfo, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; @@ -84,41 +86,19 @@ export function routePoints( return points; } -/** 화면 캡처 — 사용자가 고른 창 한 장(png) · 스트림은 바로 끔 */ -async function captureWindow(): Promise { - const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); - try { - const video = el("video"); - video.muted = true; - video.srcObject = stream; - await video.play(); - await new Promise((done) => requestAnimationFrame(done)); - const canvas = el("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - canvas.getContext("2d")?.drawImage(video, 0, 0); - const blob = await new Promise((done, fail) => - canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"), - ); - return new File([blob], "capture.png", { type: "image/png" }); - } finally { - stream.getTracks().forEach((t) => t.stop()); - } -} - export function routeView(projectId: string, routeId: string): MilestoneView { const title = el("h4"); - const hint = el("p", { - className: "b08-options__hint", - text: "[다음 지도에서 길찾기] → 새 창에서 길 확인 → [화면 캡처] → 그 창을 고름 = 이 탭 그림", - }); + const HINT = + "경로 그림 = [길찾기] 때 서버가 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선"; + const hint = el("p", { className: "b08-options__hint", text: HINT }); + hint.dataset.b08MilestoneRouteHint = ""; // 그림 — 경로 하나 = 그림 하나 const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } }); const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" }); let shown: string | null | undefined; const loadImage = (force = false): void => { - const name = routeImages()[routeId] ?? null; + const name = routeImageKey(routeId); if (!force && name === shown) return; shown = name; img.hidden = !name; @@ -161,23 +141,22 @@ export function routeView(projectId: string, routeId: string): MilestoneView { }, }); kakao.dataset.b08MilestoneKakao = ""; - const capture = createButton({ - label: "화면 캡처", - size: "sm", - variant: "ghost", - onClick: () => - void captureWindow().then(put, (error: unknown) => { - if ((error as Error)?.name !== "NotAllowedError") - showToast(`화면을 캡처하지 못했습니다 — ${String(error)}`, "error"); - }), - }); - capture.dataset.b08MilestoneCapture = ""; const upload = createButton({ label: "그림 올리기", size: "sm", variant: "ghost", onClick: () => file.click(), }); + const revert = createButton({ + label: "자동 그림으로 되돌리기", + size: "sm", + variant: "ghost", + onClick: () => + void deleteImage(projectId, routeId, true).catch((error: unknown) => + showToast(`자동 그림으로 되돌리지 못했습니다 — ${String(error)}`, "error"), + ), + }); + revert.dataset.b08MilestoneRevert = ""; const removeImage = createButton({ label: "그림 지우기", size: "sm", @@ -188,7 +167,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { ), }); - const mapView = routeMapView(projectId, routeId, () => loadImage(true)); + const mapView = routeMapView(projectId, routeId); const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, @@ -196,7 +175,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { title, el("div", { className: "b08-milestone__bar", - children: [kakao, capture, upload, removeImage, file], + children: [kakao, upload, revert, removeImage, file], }), hint, mapView.root, @@ -212,6 +191,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; loadImage(); + revert.hidden = !canRevertImage(routeId); + hint.textContent = autoImageNote(routeId) ?? HINT; mapView.draw(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 89d6da7dd..a92539d1b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -9,13 +9,16 @@ * [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 · * 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음) * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) + * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 + * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; -import { kmSegments, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; +import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; export interface SiteInfo { length_m: number | null; @@ -28,6 +31,7 @@ interface Payload { data: MilestoneData; site: SiteInfo; images: Record; + auto_images: Record; } export const EMPTY: MilestoneData = { @@ -55,6 +59,24 @@ export async function loadMilestone(projectId: string, force = false): Promise

saved?.site ?? null; export const routeImages = (): Record => saved?.images ?? {}; +/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */ +export const canRevertImage = (routeId: string): boolean => { + const name = saved?.images[routeId]; + const auto = saved?.auto_images?.[routeId]; + return !!name && !!auto && name !== auto; +}; + +/** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */ +const imageRev = new Map(); +export const routeImageKey = (routeId: string): string | null => { + const name = saved?.images[routeId]; + return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null; +}; + +/** 경로 그림 자동 상태 한 줄(만드는 중 · 실패 까닭) — 없으면 null */ +const autoNotes = new Map(); +export const autoImageNote = (routeId: string): string | null => autoNotes.get(routeId) ?? null; + export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; @@ -114,16 +136,58 @@ export async function uploadImage(projectId: string, routeId: string, file: File changes.dispatchEvent(new Event("change")); } -export async function deleteImage(projectId: string, routeId: string): Promise { - await call(imageUrl(projectId, routeId), { method: "DELETE" }); +/** 그림 지우기(자동 그림까지) · userOnly = 사용자 그림만 = 자동 그림으로 되돌리기 */ +export async function deleteImage( + projectId: string, + routeId: string, + userOnly = false, +): Promise { + const url = imageUrl(projectId, routeId) + (userOnly ? "&user_only=true" : ""); + const body = (await (await call(url, { method: "DELETE" })).json()) as { name: string | null }; if (saved) { const rest = { ...saved.images }; + const auto = { ...saved.auto_images }; delete rest[routeId]; + if (body.name) rest[routeId] = body.name; + else delete auto[routeId]; saved.images = rest; + saved.auto_images = auto; } changes.dispatchEvent(new Event("change")); } +/** 경로 그림 자동(244-11) — 길 선 · 표지만 보냄 → 서버 헤드리스 캡처 → 자동 그림(사용자 그림 우선) */ +export async function autoRouteImage( + projectId: string, + routeId: string, + lines: Array>, +): Promise { + const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); + autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)"); + changes.dispatchEvent(new Event("change")); + let reason: string | null; + try { + const res = await request<{ name: string | null; reason: string | null }>( + `/projects/${projectId}/quantity/milestone/image/auto?route=${encodeURIComponent(routeId)}`, + { method: "POST", body: JSON.stringify({ lines, marks }) }, + ); + reason = res.reason; + if (res.name && saved) { + const user = saved.images[routeId] && saved.images[routeId] !== saved.auto_images?.[routeId]; + saved.auto_images = { ...saved.auto_images, [routeId]: res.name }; + if (!user) saved.images = { ...saved.images, [routeId]: res.name }; + imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1); + } + } catch (error) { + reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`; + } + if (reason) { + autoNotes.set(routeId, reason); + showToast(reason, "warning"); + } else autoNotes.delete(routeId); + changes.dispatchEvent(new Event("change")); +} + /** 그림 → 화면 주소(blob · 쿠키 포함) */ export async function imageBlobUrl(projectId: string, routeId: string): Promise { return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); @@ -177,6 +241,7 @@ export async function findRouteKm(projectId: string, routeId: string): Promise=6.1,<7 pydantic==2.5.0 pydantic_core==2.14.1 diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index 36226bd47..b1d8968e3 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -1,7 +1,7 @@ """244-10 B08 거리이정표 경로 지도 — 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님). -① getDisplayMedia 없음 = [지도 캡처] 끔 + 까닭 한 줄(지도는 그대로) -② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 · 캡처 숨김 +① getDisplayMedia 없음 = 지도 그대로 · 캡처 안내 없음([지도 캡처]는 244-11 에서 없앰 · 그림 = 서버 자동) +② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 숨김 (가짜 kakao.maps · document 로 node 에서 돌림) """ @@ -15,7 +15,6 @@ IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" ORIGIN = "http://100.72.32.27:5173" -NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요" def _read(rel): @@ -25,10 +24,6 @@ def _read(rel): STUBS = f""" const el = (tag: string, o: any = {{}}) => ({{ tag, hidden: false, textContent: o.text ?? "", className: o.className, dataset: {{}}, children: o.children ?? [] }}); -const createButton = (o: any) => ({{ ...el("button", {{ text: o.label }}), disabled: !!o.disabled, - title: "", onClick: o.onClick }}); -const showToast = () => {{}}; -const uploadImage = async () => {{}}; const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); const request = async () => ({{ key: "fake-js-key" }}); const injected: string[] = []; @@ -55,10 +50,9 @@ def _view(setup): + """ const view = routeMapView("p1", "r1"); await new Promise((r) => setTimeout(r, 30)); -const [bar, hint, box] = view.root.children; -const cap = bar.children[0]; +const [hint, box] = view.root.children; console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden, - capHidden: cap.hidden, capDisabled: cap.disabled, capTitle: cap.title })); + children: view.root.children.length })); """ ) src = IMPORTS.sub("", _read(LAYOUT)) + script @@ -77,23 +71,11 @@ Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} } """ -def test_보안_문맥_아님_캡처_끔_까닭_한_줄(): +def test_보안_문맥_아님_지도_그대로_캡처_안내_없음(): got = _view(NO_MEDIA + FAKE_KAKAO) - assert not got["boxHidden"] and not got["capHidden"] # 지도는 그대로 뜸 - assert got["capDisabled"] and got["capTitle"] == NO_CAPTURE - assert got["hint"] == f"길 선 = 카카오 길찾기 · {NO_CAPTURE}" - assert "[지도 캡처] =" not in got["hint"] - - -def test_보안_문맥_캡처_켜짐(): - media = """ -Object.defineProperty(globalThis, "navigator", { configurable: true, - value: { mediaDevices: { getDisplayMedia: async () => ({}) } } }); -(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } }; -""" - got = _view(media + FAKE_KAKAO) - assert not got["capDisabled"] and got["capTitle"] == "" - assert got["hint"] == "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림" + assert not got["boxHidden"] and got["children"] == 2 # 지도는 그대로 · 캡처 단추 없음 + assert got["hint"] == "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" + assert "캡처는 localhost" not in got["hint"] def test_SDK_401_도메인_등록_안내(): @@ -106,4 +88,4 @@ def test_SDK_401_도메인_등록_안내(): assert got["hint"] == ( f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" ) - assert got["boxHidden"] and got["capHidden"] + assert got["boxHidden"] diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py new file mode 100644 index 000000000..055f0c979 --- /dev/null +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -0,0 +1,277 @@ +"""244-11 B08 거리이정표 경로 그림 = 서버 자동 캡처(사용자 139번). + +가짜 브라우저(playwright.sync_api 자리에 가짜 모듈)로: +① [길찾기] 길 선 · 표지 → 서버 캡처 → auto/<경로 id>.png · 페이지 주소 = KAKAO_CAPTURE_ORIGIN + + /__route_capture(page.route 로 채움) · 좌표 저장 0 · 키 값 응답 0 +② 사용자 그림 우선 · 「자동 그림으로 되돌리기」(user_only) · 그림 지우기 = 둘 다 · + 경로 지우고 [저장] = 자동 그림도 +③ 실패 까닭 한 줄 — 키 없음 · Playwright 없음 · 브라우저 못 띄움 · 20초 넘김 · 도메인 불일치 +④ 동시 요청 = 하나씩(잠금) · 화면 = 브라우저 캡처 길 없음 · [길찾기] 뒤 자동 · 되돌리기 단추 +""" + +import json +import sys +import threading +import time +import types +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\nfake-map" +KEY = "fake-js-key-123" +LINES = [[[35.6866, 127.9095], [35.68, 127.89]], [[35.672, 127.87], [35.66, 127.85]]] +MARKS = [ + {"name": "거창군청", "lon": 127.9095, "lat": 35.6866}, + {"name": "시점", "lon": 127.85, "lat": 35.66}, +] + + +class Box: + """가짜 브라우저 기록 · 동작 모드.""" + + def __init__(self, mode="ok", delay=0.0): + self.mode, self.delay = mode, delay + self.channels, self.routed, self.goto, self.html = [], [], [], [] + self.active = self.peak = 0 + self.lock = threading.Lock() + + +def fake_playwright(box): + class Error(Exception): + pass + + class TimeoutError(Error): # noqa: A001 — playwright 이름 그대로 + pass + + class Page: + def route(self, url, handler): + box.routed.append(url) + self.handler = handler + + def goto(self, url, timeout): + box.goto.append(url) + fulfil = types.SimpleNamespace(fulfill=lambda **kw: box.html.append(kw["body"])) + self.handler(fulfil) + + def wait_for_function(self, expr, timeout): + assert timeout <= cap.TIMEOUT_S * 1000 + if box.mode == "timeout": + raise TimeoutError("Timeout 20000ms exceeded") + + def evaluate(self, expr): + return 1 if box.mode == "sdk_fail" else None + + def wait_for_timeout(self, ms): + pass + + def locator(self, selector): + assert selector == "#m" + return self + + def screenshot(self, timeout): + with box.lock: + box.active += 1 + box.peak = max(box.peak, box.active) + time.sleep(box.delay) + with box.lock: + box.active -= 1 + return PNG + + class Browser: + def new_page(self, viewport): + assert viewport == {"width": 1200, "height": 800} + return Page() + + def close(self): + pass + + class Chromium: + def launch(self, channel, headless, timeout): + box.channels.append(channel) + assert headless + if box.mode == "no_browser" or (box.mode == "no_edge" and channel == "msedge"): + raise Error(f"Chromium distribution '{channel}' is not found") + return Browser() + + class Ctx: + def __enter__(self): + return types.SimpleNamespace(chromium=Chromium()) + + def __exit__(self, *a): + return False + + return types.SimpleNamespace(Error=Error, TimeoutError=TimeoutError, sync_playwright=Ctx) + + +def _setup(tmp_path, monkeypatch, box, origin=None): + monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_playwright(box)) + monkeypatch.setenv(cap.KAKAO_JS, KEY) + if origin is None: + monkeypatch.delenv(cap.ORIGIN_KEY, raising=False) + else: + monkeypatch.setenv(cap.ORIGIN_KEY, origin) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone" + + +def _auto(client, base, route="r1"): + return client.post( + base + "/image/auto", params={"route": route}, json={"lines": LINES, "marks": MARKS} + ) + + +def test_자동_그림_저장_좌표_0(tmp_path, monkeypatch): + box = Box() + client, base = _setup(tmp_path, monkeypatch, box) + res = _auto(client, base) + assert res.json() == {"name": "auto/r1.png", "reason": None} + assert KEY not in res.text + auto = tmp_path / "B08_Quantity/milestone/auto/r1.png" + assert auto.read_bytes() == PNG + # 페이지 = 기본 주소 + /__route_capture 를 page.route 로 채움 · Edge 먼저 + assert box.routed == box.goto == ["http://localhost:5173/__route_capture"] + assert box.channels == ["msedge"] + html = box.html[0] + assert f"appkey={KEY}&autoload=false" in html and 'onerror="window.__fail=1"' in html + assert json.dumps(LINES) in html and "거창군청" in html and "tilesloaded" in html + assert "#E60046" in html and "1200px" in html and "800px" in html + # 좌표 저장 0 — 폴더엔 그림만 + files = sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file()) + assert files == ["B08_Quantity/milestone/auto/r1.png"] + got = client.get(base).json() + assert got["images"] == {"r1": "auto/r1.png"} and got["auto_images"] == {"r1": "auto/r1.png"} + assert client.get(base + "/image", params={"route": "r1"}).content == PNG + # 나쁜 좌표 · id = 거절 + bad = {"lines": [[[95, 127.9]]], "marks": []} + assert client.post(base + "/image/auto", params={"route": "r1"}, json=bad).status_code == 422 + assert _auto(client, base, "../x").status_code == 400 + + +def test_주소_바꿈_Edge_없으면_Chrome(tmp_path, monkeypatch): + box = Box("no_edge") + client, base = _setup(tmp_path, monkeypatch, box, origin=" http://100.72.32.27:5173/ ") + assert _auto(client, base).json()["name"] == "auto/r1.png" + assert box.channels == ["msedge", "chrome"] + assert box.goto == ["http://100.72.32.27:5173/__route_capture"] + + +def test_사용자_그림_우선_되돌리기(tmp_path, monkeypatch): + client, base = _setup(tmp_path, monkeypatch, Box()) + img = base + "/image" + client.post(img, params={"route": "r1"}, files={"file": ("u.jpg", b"user", "image/jpeg")}) + assert _auto(client, base).json()["name"] == "auto/r1.png" # 자동 그림은 늘 만듦 + got = client.get(base).json() + assert got["images"] == {"r1": "r1.jpg"} and got["auto_images"] == {"r1": "auto/r1.png"} + assert client.get(img, params={"route": "r1"}).content == b"user" # 사용자 그림 우선 + # 자동 그림으로 되돌리기 = 사용자 그림만 지움 + back = client.delete(img, params={"route": "r1", "user_only": "true"}).json() + assert back == {"name": "auto/r1.png"} + assert client.get(img, params={"route": "r1"}).content == PNG + # 그림 지우기 = 둘 다 + assert client.delete(img, params={"route": "r1"}).json() == {"name": None} + assert client.get(img, params={"route": "r1"}).status_code == 404 + assert client.get(base).json()["auto_images"] == {} + # 경로 지우고 [저장] = 자동 그림도 지움 · 남은 경로 것은 그대로 + _auto(client, base, "r1") + _auto(client, base, "r2") + client.post(img, params={"route": "r2"}, files={"file": ("u.png", b"u2", "image/png")}) + keep = {"routes": [{"id": "r2", "name": "김천역"}]} + saved = client.put(base, json=keep).json() + assert saved["images"] == {"r2": "r2.png"} and saved["auto_images"] == {"r2": "auto/r2.png"} + assert not (tmp_path / "B08_Quantity/milestone/auto/r1.png").exists() + + +def test_실패_까닭_한_줄(tmp_path, monkeypatch): + tail = " — 그림 올리기를 쓰세요." + cases = { + "no_browser": "경로 그림 자동 캡처 실패 — 서버에서 브라우저(Edge · Chrome)를 못 띄움", + "timeout": "경로 그림 자동 캡처 실패 — 지도 그림이 20초 안에 안 끝남", + "sdk_fail": "경로 그림 자동 캡처 실패 — 카카오 지도 JS 가 캡처 주소(http://localhost:5173)" + "를 받지 않음(키 도메인 등록 확인)", + } + for mode, reason in cases.items(): + box = Box(mode) + client, base = _setup(tmp_path, monkeypatch, box) + res = _auto(client, base) + assert res.json() == {"name": None, "reason": reason + tail}, mode + assert KEY not in res.text + assert not (tmp_path / "B08_Quantity/milestone/auto").exists() # 실패 = 그림 없음 + # Playwright 없음 + monkeypatch.setitem(sys.modules, "playwright.sync_api", None) + assert _auto(client, base).json()["reason"] == ( + "경로 그림 자동 캡처 실패 — 서버에 Playwright 가 없습니다(pip install playwright)" + tail + ) + # 키 없음 = 브라우저 안 띄움 + box = Box() + client, base = _setup(tmp_path, monkeypatch, box) + monkeypatch.setenv(cap.KAKAO_JS, " ") + assert _auto(client, base).json() == { + "name": None, + "reason": "카카오 지도 JavaScript 키가 없습니다(.env KAKAO_JS_KEY)" + tail, + } + assert box.channels == [] + # 길 선 · 표지 없음 + monkeypatch.setenv(cap.KAKAO_JS, KEY) + empty = client.post(base + "/image/auto", params={"route": "r1"}, json={}).json() + assert empty["reason"] == "길 선 · 표지가 없어 지도 그림을 못 만듦" + tail + + +def test_동시_요청_하나씩(tmp_path, monkeypatch): + box = Box(delay=0.15) + _setup(tmp_path, monkeypatch, box) + out = tmp_path / "auto" + got = [] + + def one(i): + got.append(cap.make_route_image(out, f"r{i}", LINES, MARKS)) + + jobs = [threading.Thread(target=one, args=(i,)) for i in range(3)] + for job in jobs: + job.start() + for job in jobs: + job.join() + assert [g["saved"] for g in got] == [True] * 3 and box.peak == 1 + assert sorted(p.name for p in out.iterdir()) == ["r0.png", "r1.png", "r2.png"] + + +def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): + def read(name): + return (ROOT / "B08_Quantity" / name).read_text(encoding="utf-8") + + ui = ("UI_Milestone_KakaoMap", "UI_Milestone_Route", "UI_Milestone_Modal", "Milestone_Layout") + for name in (f"B08_Quantity_{n}.ts" for n in ui): + text = read(name) + assert "getDisplayMedia" not in text and "지도 캡처" not in text, name + assert "캡처는 localhost" not in text, name + state = read("B08_Quantity_UI_Milestone_State.ts") + assert "if (lines.length) void autoRouteImage(projectId, routeId, lines);" in state + assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state + assert "user_only=true" in state and "showToast(reason," in state + route = read("B08_Quantity_UI_Milestone_Route.ts") + assert ( + 'label: "자동 그림으로 되돌리기"' in route + and "deleteImage(projectId, routeId, true)" in route + ) + assert "revert.hidden = !canRevertImage(routeId);" in route + assert "hint.textContent = autoImageNote(routeId) ?? HINT;" in route + kmap = read("B08_Quantity_UI_Milestone_KakaoMap.ts") + assert "등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김 + big = ("Milestone_Capture.py", "Router_Milestone.py", "UI_Milestone_State.ts") + for name in (f"B08_Quantity_{n}" for n in big): + assert len(read(name).splitlines()) <= 700 diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 767a771f0..91f57ffe6 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -168,16 +168,13 @@ def test_화면_모달_팬줌_그림탭(): # ③ 팬 · 줌 = 공용 assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram - # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 화면 캡처 → 그림 올리기 길 + # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 그림 올리기 길(화면 캡처는 244-11 에서 서버 자동으로) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert ( "createDataTable" not in route and "구간" not in route.split("export function routeView")[1] ) assert "window.open(kakaoRouteUrl(points)" in route - assert "getDisplayMedia({ video: true, audio: false })" in route - assert ( - "captureWindow().then(put," in route and "uploadImage(projectId, routeId, picked)" in route - ) + assert "getDisplayMedia" not in route and "uploadImage(projectId, routeId, picked)" in route assert 'label: "그림 올리기"' in route page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index bedc3e1ef..5ee1d655f 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -1,7 +1,7 @@ -"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로 + [지도 캡처](사용자 138번). +"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로(사용자 138번 · [지도 캡처]는 244-11 에서 없앰). 서버 = 길찾기 응답에 경로 좌표 [위도, 경도](vertexes = 경도 · 위도 번갈아) · 저장 안 함 · JS 키 길 · -화면 = 표지(거래처 · 기준점 · 시점) · 캡처 자르기 수치 · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 · +화면 = 표지(거래처 · 기준점 · 시점) · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 · 표지 · 경로 전체 맞춤(가짜 kakao.maps 로 node 에서 돌림). """ @@ -111,7 +111,7 @@ def test_JS_키_길(monkeypatch): assert client.get(BASE + "/map-key").json() == {"key": "fake-js-key"} -def test_표지_셋_캡처_자르기(): +def test_표지_셋(): data = { "site": {"mid_station_m": None, "mid_road": "비포장"}, "base": {"enabled": True, "name": "병곡리", "lon": 127.85, "lat": 35.66, "legs": []}, @@ -126,8 +126,6 @@ console.log(JSON.stringify([ mapMarks(D, "r1", [127.83, 35.64]), mapMarks(D, "r2", [127.83, 35.64]), mapMarks({{ ...D, base: {{ ...D.base, enabled: false }} }}, "r1", null), - cropBox({{ left: 100, top: 50, width: 400, height: 300 }}, 1000, 2000, 1200), - cropBox({{ left: -20, top: 500, width: 1100, height: 300 }}, 1000, 1000, 600), ]));""", ) assert got[0] == [ @@ -137,23 +135,16 @@ console.log(JSON.stringify([ ] assert got[1] == [{"name": "시점", "lon": 127.83, "lat": 35.64}] # 좌표 없는 거래처 = 표지 없음 assert got[2] == [{"name": "거창군청", "lon": 127.9, "lat": 35.68}] - assert got[3] == {"sx": 200, "sy": 100, "sw": 800, "sh": 600} # × 2(devicePixelRatio) - assert got[4] == {"sx": 0, "sy": 500, "sw": 1000, "sh": 100} # 영상 밖은 잘라 냄 STUBS = """ const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "", className: o.className, dataset: {}, children: o.children ?? [] }); -const createButton = (o: any) => ({ ...el("button", { text: o.label }), onClick: o.onClick }); -const showToast = () => {}; -const uploadImage = async () => {}; const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); const injected: string[] = []; (globalThis as any).document = { createElement: () => ({}), head: { append: (s: any) => injected.push(s.src) } }; (globalThis as any).window = globalThis; -Object.defineProperty(globalThis, "navigator", { configurable: true, - value: { mediaDevices: { getDisplayMedia: async () => ({}) } } }); // localhost = 보안 문맥 const liveMilestone = () => ({ site: {}, base: { enabled: true, name: "병곡리", lon: 127.85, lat: 35.66 }, routes: [{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: true }] }); const siteInfo = () => ({ start_lonlat: [127.83, 35.64] }); @@ -170,9 +161,8 @@ const request = async (url: string) => (asked.push(url), {{ key: KEY }}); {extra} const view = routeMapView("p1", "r1"); await new Promise((r) => setTimeout(r, 30)); -const [bar, hint, box] = view.root.children; +const [hint, box] = view.root.children; console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden, - captureHidden: bar.children[0].hidden, label: bar.children[0].textContent, log: (globalThis as any).LOG ?? null }})); """ return _node([LAYOUT], STUBS + IMPORTS.sub("", _read(KMAP)) + script) @@ -185,7 +175,7 @@ def test_키_없음_안내_한_줄(): assert got["hint"] == ( "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" ) - assert got["boxHidden"] and got["captureHidden"] and got["label"] == "지도 캡처" + assert got["boxHidden"] FAKE_KAKAO = """ @@ -211,26 +201,24 @@ def test_키_있음_길_선_표지_전체_맞춤(): paths = [[[35.68, 127.9], [35.67, 127.87], [35.66, 127.85]], [[35.66, 127.85], [35.64, 127.83]]] got = _map("fake-js-key", paths, FAKE_KAKAO) log = got["log"] - assert not got["boxHidden"] and not got["captureHidden"] and log["maps"] == 1 and log["fit"] + assert not got["boxHidden"] and log["maps"] == 1 and log["fit"] lines = [o for kind, o in log["overlays"] if kind == "line"] assert [[[p["lat"], p["lng"]] for p in o["path"]] for o in lines] == paths assert lines[0]["strokeColor"] == "#E60046" and lines[0]["strokeWeight"] >= 5 titles = [o["title"] for kind, o in log["overlays"] if kind == "marker"] assert titles == ["거창군청", "병곡리", "시점"] assert len(log["bounds"]) == 3 + 5 # 표지 셋 + 길 선 점 다섯 = 경로 전체 맞춤 - assert "[지도 캡처]" in got["hint"] + assert got["hint"] == "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" def test_화면_자리_캡처_키_서버만(): kmap = _read(KMAP) assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap - assert "preferCurrentTab: true" in kmap and "crop.fromElement(target)" in kmap - assert "cropTo(" in kmap and "stream.getTracks().forEach((t) => t.stop())" in kmap - assert "uploadImage(projectId, routeId, file)" in kmap + assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동 modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert "mapView.root" in modal and "mapView.draw();" in modal route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") - assert "routeMapView(projectId, routeId, () => loadImage(true))" in route + assert "routeMapView(projectId, routeId)" in route assert "mapView.draw();" in route state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감) From 877ea70acafb2b7fedb591a487ca8493b686a75e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:59:48 +0900 Subject: [PATCH 80/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20142~144?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 1ce521a72..dd9b4b77e 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -170,3 +170,6 @@ 139. 그럼 캡처는 백엔드에서 하고 그림만 가져오는건 어때? (브레인 시험: 서버 헤드리스 Edge + 카카오 지도 JS + 길 선 → 1200×800 PNG 2.4초 성공) 140. 테일스케일 IP는 (카카오 JavaScript 도메인에) 등록했어 참고해 141. 다음으로 구조물 집계표에 구조물조합 연결이 완료된 항목은 녹색 램프를 넣어달라고 했는데 없어. (화면 붙임 · 노랑만 · 247-1 이 sub7 에 안 들어갔던 것 — 다시 보냄) +142. 다음으로 거리이정표의 다이어그램을 보여줄때 적당히 넓은 영역을 배경영역으로 지정되어야할것같아. 초기에는 당연히 공사지 컨테이너만 있을텐데 이러면 어떤 항목이 들어와도 다시 줌아웃을 해야하잖아. +143. 시점과 중점도 연결선이 존재해야하고 시점과 중점컨테이너에도 작은 글씨로 주소와 좌표값을 보여주는게 좋을것 같아. +144. 거래처 컨테이너의 경우 항목, 거래처, 주소를 보여줘. From a137c177f8f7ebf5823b50e25b48d79a493587ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:01:42 +0900 Subject: [PATCH 81/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20145=20=C2=B7=20146?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index dd9b4b77e..bb30c2138 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -173,3 +173,5 @@ 142. 다음으로 거리이정표의 다이어그램을 보여줄때 적당히 넓은 영역을 배경영역으로 지정되어야할것같아. 초기에는 당연히 공사지 컨테이너만 있을텐데 이러면 어떤 항목이 들어와도 다시 줌아웃을 해야하잖아. 143. 시점과 중점도 연결선이 존재해야하고 시점과 중점컨테이너에도 작은 글씨로 주소와 좌표값을 보여주는게 좋을것 같아. 144. 거래처 컨테이너의 경우 항목, 거래처, 주소를 보여줘. +145. 추가로 거리이정표에는 선들과 컨테이너들의 공간을 여유를 조금 두는게 좋겠어. 자꾸 침범을 하네. +146. 추가로 누락된 업무가 있는지 확인해봐. From 0b698cb1b66c5db4d3cc750c4a3da3ef1a20401a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:13:07 +0900 Subject: [PATCH 82/92] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=EB=84=89=EB=84=89=ED=95=9C=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EB=B0=94=ED=83=95=20=C2=B7=20=EC=8B=9C=EC=A0=90=20=E2=86=92=20?= =?UTF-8?q?=EC=A4=91=EC=A0=90=20=EC=84=A0=20=C2=B7=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=C2=B7=20=EC=A2=8C=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=EC=97=AC=EC=9C=A0=20=EC=9E=90=EB=A6=AC=20=ED=94=BC=ED=95=A8(24?= =?UTF-8?q?4-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 바탕 = 기준점 · 공사지 둘레 거래처 자리까지 담는 고정 범위 · 넘는 상자가 있을 때만 넓힘 - 시점 → 중점 선 = 공사지 바탕 위(가림 없음) · 시점 · 중점 상자 작은 글씨 주소 · 좌표(소수 5자리) - 중점 주소 = 지금 중점 측점(site-address ?mid_m=) · 없으면 좌표만 - 거래처 상자 = 항목 · 이름 · 주소 세 줄 · 길면 줄임표 + 호버 - 상자 사이 여유(겹치면 바깥으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · 다른 선 · 다른 글 피한 자리 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 335 ++++++++++++++---- B08_Quantity/B08_Quantity_Router_Milestone.py | 13 +- B08_Quantity/B08_Quantity_UI_Milestone.css | 6 + .../B08_Quantity_UI_Milestone_Diagram.ts | 75 ++-- .../B08_Quantity_UI_Milestone_Modal.ts | 2 +- .../B08_Quantity_UI_Milestone_State.ts | 20 ++ .../test_244_12_b08_milestone_base_view.py | 221 ++++++++++++ .../test_244_6_b08_milestone_pan_client.py | 4 +- 8 files changed, 577 insertions(+), 99 deletions(-) create mode 100644 resources/tester/test_244_12_b08_milestone_base_view.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index bc1e83b47..6c193c89b 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -11,6 +11,10 @@ * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 + * 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘 + * 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버 + * 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · + * 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표 * ========================================================================== */ import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; @@ -25,6 +29,10 @@ export interface DiagramBox { label: string; /** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */ items?: string; + /** 이름 아래 작은 글씨 줄(244-12 · 주소 · 좌표) */ + notes?: string[]; + /** 호버 = 줄이지 않은 주소 */ + tip?: string; } export interface DiagramLine { @@ -37,6 +45,11 @@ export interface DiagramLine { labels: string[]; /** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */ tips: string[]; + /** 선 글 자리(가운데 · 244-12 ④ 상자 · 다른 선 · 다른 글 피함) · 폭 · 높이 */ + lx: number; + ly: number; + lw: number; + lh: number; } export interface DiagramLayout { @@ -60,12 +73,96 @@ export const DIR_DEG: Record = { }; const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"]; -const SITE = { w: 240, h: 96 }; -const NODE = { w: 72, h: 32 }; -const BOX = { w: 148, h: 44 }; -const R_BASE = 300; -const R_ROUTE = 260; -const SPREAD = 64; +const SITE = { w: 380, h: 106 }; +const NODE = { w: 124, h: 58 }; +const BOX = { w: 148, h: 58 }; +const R_BASE = 390; +const R_ROUTE = 280; +const PAD = 48; +/** 바탕 반폭 · 반높이 — 기준점 · 공사지 둘레 거래처(같은 거리)가 어느 방위로 와도 담김 */ +const REACH = R_BASE; +export const BASE_VIEW: [number, number, number, number] = [ + -(REACH + BOX.w / 2 + PAD), + -(REACH + BOX.h / 2 + PAD), + 2 * (REACH + BOX.w / 2 + PAD), + 2 * (REACH + BOX.h / 2 + PAD), +]; + +/** 여유(244-12 ④) — 상자 사이 · 선 끝 · 상자 안 글 */ +export const GAP = 12; +const LINE_GAP = 4; +const INSET = 8; +/** 글 크기(px) — 화면 CSS 와 같음(이름 13 · 항목 · 선 글 12 · 작은 글씨 10) */ +const FONT = { label: 13, items: 12, km: 12, note: 10 }; +const KM_LINE = 16; + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** 글 폭 어림 — 한글 · 한자 = 글 크기 · 나머지 = 0.6 배 */ +export const textW = (text: string, size: number): number => + [...text].reduce( + (w, ch) => + w + (/[\u1100-\u11ff\u3130-\u318f\u3400-\u9fff\uac00-\ud7a3]/.test(ch) ? 1 : 0.6) * size, + 0, + ); + +/** 한 줄 — 폭 넘으면 줄임표 */ +export function clip(text: string, maxW: number, size: number): string { + if (textW(text, size) <= maxW) return text; + let out = ""; + for (const ch of text) { + if (textW(`${out}${ch}…`, size) > maxW) break; + out += ch; + } + return `${out}…`; +} + +/** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */ +const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [ + ...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []), + ...(lonlat ? [`${lonlat[1].toFixed(5)}, ${lonlat[0].toFixed(5)}`] : []), +]; + +/** 두 네모가 gap 안으로 붙거나 겹침 */ +export const hits = (a: Rect, b: Rect, gap = 0): boolean => + a.x < b.x + b.w + gap && b.x < a.x + a.w + gap && a.y < b.y + b.h + gap && b.y < a.y + a.h + gap; + +/** 선분이 네모에 들어가는 자리(0~1 · Liang–Barsky) · 안 지나면 null */ +function segEnter(x1: number, y1: number, x2: number, y2: number, r: Rect): number | null { + let t0 = 0; + let t1 = 1; + const dx = x2 - x1; + const dy = y2 - y1; + for (const [p, q] of [ + [-dx, x1 - r.x], + [dx, r.x + r.w - x1], + [-dy, y1 - r.y], + [dy, r.y + r.h - y1], + ]) { + if (p === 0) { + if (q < 0) return null; + continue; + } + const t = q / p; + if (p < 0) t0 = Math.max(t0, t); + else t1 = Math.min(t1, t); + if (t0 > t1) return null; + } + return t0; +} + +/** 선분이 네모를 지남 */ +export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect): boolean => + segEnter(x1, y1, x2, y2, r) !== null; + +/** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */ +const toEdge = (b: Rect, dx: number, dy: number): number => + Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity); /** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */ export function bearingDeg(from: [number, number], to: [number, number]): number { @@ -169,25 +266,105 @@ const unit = (deg: number): [number, number] => { return [Math.sin(rad), -Math.cos(rad)]; }; +/** 선 = 두 상자 테두리에서 끊김(④ · 상자 안으로 안 들어감) · end = 끝 테두리(바깥 → 시점 = 공사지 바탕 테두리 · + * 바탕 안 중점 · 시점 상자를 가로지르지 않음) · 글 자리는 `placeLabels` */ +function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): DiagramLine { + const [ax, ay] = [a.x + a.w / 2, a.y + a.h / 2]; + const [bx, by] = [b.x + b.w / 2, b.y + b.h / 2]; + const [dx, dy] = [bx - ax, by - ay]; + const len = Math.hypot(dx, dy) || 1; + const t1 = Math.min(toEdge(a, dx, dy) + LINE_GAP / len, 0.5); + const t2 = Math.max((segEnter(ax, ay, bx, by, end) ?? 1) - LINE_GAP / len, t1); + const labels = legText(legs); + return { + from: a.id, + to: b.id, + x1: ax + dx * t1, + y1: ay + dy * t1, + x2: ax + dx * t2, + y2: ay + dy * t2, + labels, + tips: legs.map(legTip), + lx: (ax + bx) / 2, + ly: (ay + by) / 2, + lw: labels.length ? Math.max(...labels.map((l) => textW(l, FONT.km))) + 8 : 0, + lh: labels.length * KM_LINE + 2, + }; +} + +export const labelRect = (ln: DiagramLine): Rect => ({ + x: ln.lx - ln.lw / 2, + y: ln.ly - ln.lh / 2, + w: ln.lw, + h: ln.lh, +}); + +/** 선 글 자리(④) — 선 위 가운데부터 앞뒤 · 위아래 차례로 보아 상자 · 다른 선 · 다른 글을 안 덮는 첫 자리 + * (시점 → 중점 글은 공사지 바탕 안이 제자리) · 다 덮이면 가장 적게 덮는 자리 */ +function placeLabels(lines: DiagramLine[], boxes: DiagramBox[]): void { + const placed: Rect[] = []; + for (const ln of lines) { + if (!ln.labels.length) continue; + const [dx, dy] = [ln.x2 - ln.x1, ln.y2 - ln.y1]; + const len = Math.hypot(dx, dy) || 1; + const [px, py] = [-dy / len, dx / len]; + const side = (Math.abs(px) * ln.lw + Math.abs(py) * ln.lh) / 2 + 4; + const score = (r: Rect): number => + boxes.filter((b) => !(b.kind === "site" && ln.from === "start") && hits(r, b, 2)).length + + lines.filter((o) => o !== ln && segHits(o.x1, o.y1, o.x2, o.y2, r)).length + + placed.filter((p) => hits(r, p, 2)).length; + let best: { r: Rect; s: number; x: number; y: number } | null = null; + for (const t of [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8]) { + for (const o of [0, side, -side]) { + const x = ln.x1 + dx * t + px * o; + const y = ln.y1 + dy * t + py * o; + const r = labelRect({ ...ln, lx: x, ly: y }); + const s = score(r); + if (!best || s < best.s) best = { r, s, x, y }; + if (s === 0) break; + } + if (best?.s === 0) break; + } + ln.lx = best!.x; + ln.ly = best!.y; + placed.push(best!.r); + } +} + export function layoutMilestone( data: MilestoneData, - site: { start_lonlat?: LonLat; mid_lonlat?: LonLat } | null, + site: { + start_lonlat?: LonLat; + mid_lonlat?: LonLat; + start_address?: string | null; + mid_address?: string | null; + } | null, midKm: number, ): DiagramLayout { const boxes: DiagramBox[] = []; - const lines: DiagramLine[] = []; - const center = new Map(); - const put = (box: DiagramBox): void => { + const pending: Array<[string, string, Leg[]]> = []; + const byId = new Map(); + const center = (id: string): [number, number] => { + const b = byId.get(id)!; + return [b.x + b.w / 2, b.y + b.h / 2]; + }; + /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ + const put = (box: DiagramBox, out?: [number, number]): void => { + const inner = box.w - 2 * INSET; + box.label = clip(box.label, inner, FONT.label); + if (box.items) box.items = clip(box.items, inner, FONT.items); + for (let i = 0; out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) { + box.x += out[0] * 4; + box.y += out[1] * 4; + } boxes.push(box); - center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]); + byId.set(box.id, box); }; const line = (from: string, to: string, legs: Leg[]): void => { - const [x1, y1] = center.get(from)!; - const [x2, y2] = center.get(to)!; - lines.push({ from, to, x1, y1, x2, y2, labels: legText(legs), tips: legs.map(legTip) }); + pending.push([from, to, legs]); }; - // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) + // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) · 사이 = 시점 → 중점 선 · 글 put({ id: "site", kind: "site", @@ -197,24 +374,22 @@ export function layoutMilestone( h: SITE.h, label: "공사지", }); - put({ - id: "start", - kind: "start", - x: -SITE.w / 2 + 16, - y: -6, - w: NODE.w, - h: NODE.h, - label: "시점", - }); - put({ - id: "mid", - kind: "mid", - x: SITE.w / 2 - 16 - NODE.w, - y: -6, - w: NODE.w, - h: NODE.h, - label: "중점", - }); + for (const [id, label, x, address, lonlat] of [ + ["start", "시점", -SITE.w / 2 + 12, site?.start_address, site?.start_lonlat], + ["mid", "중점", SITE.w / 2 - 12 - NODE.w, site?.mid_address, site?.mid_lonlat], + ] as const) { + put({ + id, + kind: id, + x, + y: -SITE.h / 2 + 34, + w: NODE.w, + h: NODE.h, + label, + notes: notesOf(NODE.w, address, lonlat), + tip: address ?? undefined, + }); + } line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]); const startLL = site?.start_lonlat ?? null; @@ -227,22 +402,33 @@ export function layoutMilestone( const [ux, uy] = unit(baseDeg); const cx = ux * R_BASE; const cy = uy * R_BASE; - put({ - id: "base", - kind: "base", - x: cx - BOX.w / 2, - y: cy - BOX.h / 2, - w: BOX.w, - h: BOX.h, - label: data.base.name || "기준점", - }); + put( + { + id: "base", + kind: "base", + x: cx - BOX.w / 2, + y: cy - BOX.h / 2, + w: BOX.w, + h: BOX.h, + label: data.base.name || "기준점", + }, + [ux, uy], + ); line("base", "start", data.base.legs); } // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, - Array<{ id: string; label: string; items: string; anchor: string; deg: number; legs: Leg[] }> + Array<{ + id: string; + label: string; + items: string; + address: string; + anchor: string; + deg: number; + legs: Leg[]; + }> >(); const used = new Set(); const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; @@ -267,41 +453,60 @@ export function layoutMilestone( const key = keyOf(anchor, d); if (!groups.has(key)) groups.set(key, []); const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · "); - groups - .get(key)! - .push({ id: r.id, label: r.name || "거래처", items, anchor, deg: d, legs: r.legs }); + groups.get(key)!.push({ + id: r.id, + label: r.name || "거래처", + items, + address: r.address, + anchor, + deg: d, + legs: r.legs, + }); } for (const group of groups.values()) { group.forEach((g, k) => { - const [ax, ay] = center.get(g.anchor === "base" ? "base" : "site")!; + const [ax, ay] = center(g.anchor === "base" ? "base" : "site"); const [ux, uy] = unit(g.deg); - const off = (k - (group.length - 1) / 2) * SPREAD; - const reach = g.anchor === "site" ? R_ROUTE + SITE.w / 4 : R_ROUTE; + // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) + const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; + const off = (k - (group.length - 1) / 2) * spread; + const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리) const cx = ax + ux * reach - uy * off; const cy = ay + uy * reach + ux * off; - put({ - id: g.id, - kind: "route", - x: cx - BOX.w / 2, - y: cy - BOX.h / 2, - w: BOX.w, - h: BOX.h, - label: g.label, - items: g.items, - }); + put( + { + id: g.id, + kind: "route", + x: cx - BOX.w / 2, + y: cy - BOX.h / 2, + w: BOX.w, + h: BOX.h, + label: g.label, + items: g.items, + notes: notesOf(BOX.w, g.address), + tip: g.address, + }, + [ux, uy], + ); line(g.id, g.anchor === "base" ? "base" : "start", g.legs); }); } - const pad = 48; - const xs = boxes.flatMap((b) => [b.x, b.x + b.w]); - const ys = boxes.flatMap((b) => [b.y, b.y + b.h]); - const minX = Math.min(...xs) - pad; - const minY = Math.min(...ys) - pad; + const lines = pending.map(([from, to, legs]) => + edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!), + ); + placeLabels(lines, boxes); + + // 바탕 = 고정 범위 · 넘는 상자가 있을 때만 그쪽으로 넓힘(새 상자가 와도 다시 줌아웃 없음) + const [bx, by, bw, bh] = BASE_VIEW; + const xs = [bx + PAD, bx + bw - PAD, ...boxes.flatMap((b) => [b.x, b.x + b.w])]; + const ys = [by + PAD, by + bh - PAD, ...boxes.flatMap((b) => [b.y, b.y + b.h])]; + const minX = Math.min(...xs) - PAD; + const minY = Math.min(...ys) - PAD; return { boxes, lines, - view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY], + view: [minX, minY, Math.max(...xs) + PAD - minX, Math.max(...ys) + PAD - minY], }; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 43465fa21..6f962871b 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -10,7 +10,8 @@ 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · - 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) + 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램 + 지금 중점 측점(244-12 · 상자 작은 글씨) POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) + 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함) @@ -245,11 +246,15 @@ async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: @router.get(PATH + "/site-address") -async def milestone_site_address(project_id: UUID) -> dict[str, Any]: - """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소.""" +async def milestone_site_address( + project_id: UUID, mid_m: Annotated[float | None, Query(ge=0)] = None +) -> dict[str, Any]: + """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소. + mid_m = 다이어그램 지금 중점 측점(캐시 · 244-12) · 없으면 저장본.""" root = await _project_root(project_id) data = await asyncio.to_thread(read_data, root) - site = await asyncio.to_thread(site_info, root, data["site"]["mid_station_m"]) + mid = data["site"]["mid_station_m"] if mid_m is None else mid_m + site = await asyncio.to_thread(site_info, root, mid) project = await get_project_by_id(str(project_id)) or {} async def where(lonlat: list[float] | None) -> str | None: diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 1cac2d074..1581b8005 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -160,6 +160,12 @@ font-size: 12px; } +/* 상자 작은 글씨 = 주소 · 좌표(244-12) */ +.b08-milestone__note { + fill: var(--color-text-secondary); + font-size: 10px; +} + /* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */ .b08-milestone .is-pick-road { color: var(--color-danger); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index c4e7cd91b..55bf4e8a9 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -9,6 +9,8 @@ * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) + * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 + * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ import { createButton, createSelectField, el } from "@ui/ui_template_elements"; @@ -33,6 +35,7 @@ import { liveMilestone, putMilestone, roadOptions, + siteAddress, siteInfo, } from "./B08_Quantity_UI_Milestone_State"; @@ -148,7 +151,18 @@ export function createDiagram( const data = liveMilestone(projectId); const site = siteInfo(); const mk = midKm(data, site?.mid_default_m ?? null); - const layout = layoutMilestone(data, site, mk); + // 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림) + const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null); + const layout = layoutMilestone( + data, + { + start_lonlat: addr?.start_lonlat ?? site?.start_lonlat, + mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat, + start_address: addr?.start_address, + mid_address: addr?.mid_address, + }, + mk, + ); const [vx, vy, vw, vh] = layout.view; // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { @@ -166,37 +180,36 @@ export function createDiagram( const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); layer.append(shift); root.append(layer); + // 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림) + const lineLayer = svg("g", {}); for (const ln of layout.lines) { - shift.append( + lineLayer.append( svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }), ); } for (const box of layout.boxes) { const g = svg("g", { class: `b08-milestone__box is-${box.kind}` }); g.dataset.b08MilestoneBox = box.id; - g.append( - svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), - svg( - "text", - { - x: box.x + box.w / 2, - y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2, - "text-anchor": "middle", - "dominant-baseline": "central", - }, - box.kind === "route" && box.items ? "" : box.label, - ), + // 글 줄 = 항목(거래처 윗줄) · 이름 · 작은 글씨(주소 · 좌표) — 상자 가운데 맞춤 + const rows: Array<[string, string]> = [ + ...(box.kind === "route" && box.items + ? [[box.items, "b08-milestone__items"] as [string, string]] + : []), + [box.label, ""], + ...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]), + ]; + const x = box.x + box.w / 2; + const text = svg("text", { + x, + y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2, + "text-anchor": "middle", + "dominant-baseline": "central", + }); + rows.forEach(([row, cls], i) => + text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)), ); - if (box.kind === "route" && box.items) { - // 거래처 상자 = 항목(윗줄) + 거래처 이름(아랫줄) - const x = box.x + box.w / 2; - const two = g.lastChild as SVGTextElement; - two.setAttribute("y", String(box.y + box.h / 2 - 8)); - two.append( - svg("tspan", { x, class: "b08-milestone__items" }, box.items), - svg("tspan", { x, dy: 16 }, box.label), - ); - } + g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text); + if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소 // 상자 누름 = 모달(탭 이동 아님) const info = (): void => INFO[box.kind](box.id); g.setAttribute("role", "button"); @@ -209,12 +222,20 @@ export function createDiagram( if (box.kind === "site") g.append(plus(box, false)); if (box.kind === "base") g.append(plus(box, true)); shift.append(g); + if (box.kind === "site") shift.append(lineLayer); } // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 for (const ln of layout.lines) { - const x = (ln.x1 + ln.x2) / 2; - const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2; - const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" }); + // 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④) + const x = ln.lx; + const y = ln.ly - ((ln.labels.length - 1) * 16) / 2; + const text = svg("text", { + class: "b08-milestone__km", + x, + y, + "text-anchor": "middle", + "dominant-baseline": "central", + }); ln.labels.forEach((label, i) => { const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 411f9c173..fc0ed923c 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -378,7 +378,7 @@ export function openBaseInfo(projectId: string, create = false): void { }); } -interface SiteAddress { +export interface SiteAddress { region: string; start_lonlat: [number, number] | null; mid_lonlat: [number, number] | null; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index a92539d1b..bdb3d9188 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -19,6 +19,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; +import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { length_m: number | null; @@ -57,6 +58,25 @@ export async function loadMilestone(projectId: string, force = false): Promise

saved?.site ?? null; + +/** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 · + * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */ +const siteAddresses = new Map(); +export function siteAddress(projectId: string, midM: number | null): SiteAddress | null { + const key = `${projectId}:${midM ?? ""}`; + if (!siteAddresses.has(key)) { + siteAddresses.set(key, null); + const query = midM == null ? "" : `?mid_m=${midM}`; + request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( + (s) => { + siteAddresses.set(key, s); + changes.dispatchEvent(new Event("change")); + }, + () => undefined, + ); + } + return siteAddresses.get(key) ?? null; +} export const routeImages = (): Record => saved?.images ?? {}; /** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */ diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py new file mode 100644 index 000000000..2c83b6a93 --- /dev/null +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -0,0 +1,221 @@ +"""244-12 B08 거리이정표 다이어그램(사용자 142~144번) — ① 넉넉한 고정 바탕(공사지만 있어도 +거래처 · 기준점 자리까지 · 넘는 상자가 생길 때만 넓힘) ② 시점 → 중점 선(공사지 바탕 위) · 시점 · +중점 상자 작은 글씨 주소 · 좌표(위도, 경도 소수 5자리) · 중점 주소 = 지금 중점 측점(?mid_m=) +③ 거래처 상자 = 항목 · 이름 · 주소(줄임표 + 호버). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +LONG = "경상남도 거창군 북상면 병곡리 968-31" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _route(rid, dir=None, via=True, address="", item=""): + return {"id": rid, "name": rid, "address": address, "via_base": via, "dir": dir, + "lon": None, "lat": None, "legs": [], + "rows": [{"item": item, "dest": "start", "note": ""}]} # fmt: skip + + +def _data(routes, base=False): + return { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": base, "name": "", "address": "", "dir": None, "legs": []}, + "routes": routes, + } + + +def _clipped(note, box_w): + # 줄임표 · 안쪽 여백(양쪽 8) 안 · 앞부분 그대로 + assert note.endswith("…") and LONG.startswith(note[:-1]) + assert _node(f'textW("{note}", 10)') <= box_w - 16 + + +def _lay(data, site=None): + return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)") + + +def test_바탕_고정_범위_넘을_때만_넓힘(): + base_view = _node("BASE_VIEW") + assert _lay(_data([]))["view"] == base_view # 공사지만 있어도 넉넉한 바탕 + vx, vy, vw, vh = base_view + assert vw >= 900 and vh >= 800 + # 공사지 둘레 8방 거래처 · 기준점(서) — 바탕 그대로(다시 줌아웃 없음) + dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + for lay in (_lay(_data([_route(d, d, via=False) for d in dirs])), _lay(_data([], base=True))): + assert lay["view"] == base_view + for b in lay["boxes"]: + assert vx <= b["x"] and b["x"] + b["w"] <= vx + vw + assert vy <= b["y"] and b["y"] + b["h"] <= vy + vh + # 기준점 너머 거래처 = 바탕 넘음 → 그쪽만 넓힘 + lay = _lay(_data([_route("far", "W")], base=True)) + x, y, w, h = lay["view"] + far = next(b for b in lay["boxes"] if b["id"] == "far") + assert x < vx and x <= far["x"] - 48 and x + w == vx + vw and (y, h) == (vy, vh) + + +def test_시점_중점_선_상자_주소_좌표(): + site = {"start_lonlat": [127.812345678, 35.7], "mid_lonlat": [127.9, 35.712341], + "start_address": LONG, "mid_address": None} # fmt: skip + lay = _lay(_data([]), site) + boxes = {b["id"]: b for b in lay["boxes"]} + note, coord = boxes["start"]["notes"] + _clipped(note, boxes["start"]["w"]) + assert coord == "35.70000, 127.81235" + assert boxes["start"]["tip"] == LONG # 호버 = 줄이지 않은 주소 + assert boxes["mid"]["notes"] == ["35.71234, 127.90000"] # 주소 없음 = 좌표만 + assert _lay(_data([]))["boxes"][1]["notes"] == [] # 좌표도 없음 = 빈 줄 + line = next(ln for ln in lay["lines"] if ln["from"] == "start") + assert line["to"] == "mid" and line["labels"] == ["비포장 1km"] + s, m = boxes["start"], boxes["mid"] + assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리 + for b in (s, m): # 상자 = 공사지 안 + site_box = boxes["site"] + assert site_box["x"] <= b["x"] and b["x"] + b["w"] <= site_box["x"] + site_box["w"] + assert site_box["y"] + 20 < b["y"] and b["y"] + b["h"] <= site_box["y"] + site_box["h"] + + +def test_거래처_상자_항목_이름_주소(): + lay = _lay(_data([_route("g", "N", via=False, address=LONG, item="장비"), + _route("k", "E", via=False, address="거창읍")])) # fmt: skip + boxes = {b["id"]: b for b in lay["boxes"]} + assert (boxes["g"]["items"], boxes["g"]["label"]) == ("장비", "g") + _clipped(boxes["g"]["notes"][0], boxes["g"]["w"]) + assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"] + assert _node('clip("가나다", 100, 10)') == "가나다" + assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6 + + +def test_화면_선_순서_글_줄_중점_주소(): + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + # 선 = 공사지 바탕 뒤 · 다른 상자 앞(시점 → 중점 선 보임) + assert 'if (box.kind === "site") shift.append(lineLayer);' in ui + assert '...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"])' in ui + assert 'if (box.tip) g.append(svg("title", {}, box.tip));' in ui + assert "siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null)" in ui + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "site-address${query}" in state and "?mid_m=${midM}" in state + assert ".b08-milestone__note {" in _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_State.ts"): + assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700 + + +def test_중점_주소_지금_측점(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_pid): + return tmp_path + + async def project(_pid): + return {"region": ""} + + async def where(lon, lat): + return f"주소 {lon:.5f}" + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.setattr(ms, "get_project_by_id", project) + monkeypatch.setattr(ms, "reverse_geocode", where) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + path = tmp_path / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8") + url = f"/api/projects/{uuid4()}/quantity/milestone/site-address" + half = client.get(url).json() # 저장본 없음 = 기본(연장 ÷ 2) + near = client.get(url, params={"mid_m": 100}).json() + assert near["start_lonlat"] == half["start_lonlat"] + assert near["start_lonlat"][0] < near["mid_lonlat"][0] < half["mid_lonlat"][0] + assert near["mid_address"] == f"주소 {near['mid_lonlat'][0]:.5f}" + assert client.get(url, params={"mid_m": -1}).status_code == 422 + + +def _hits(a, b, gap=0): + return (a["x"] < b["x"] + b["w"] + gap and b["x"] < a["x"] + a["w"] + gap + and a["y"] < b["y"] + b["h"] + gap and b["y"] < a["y"] + a["h"] + gap) # fmt: skip + + +def _seg_hits(x1, y1, x2, y2, r): + t0, t1, dx, dy = 0.0, 1.0, x2 - x1, y2 - y1 + for p, q in ((-dx, x1 - r["x"]), (dx, r["x"] + r["w"] - x1), + (-dy, y1 - r["y"]), (dy, r["y"] + r["h"] - y1)): # fmt: skip + if p == 0: + if q < 0: + return False + continue + t = q / p + t0, t1 = (max(t0, t), t1) if p < 0 else (t0, min(t1, t)) + if t0 > t1: + return False + return True + + +def test_여유_상자_선_글_겹침_0(): + """④(145번) — 몰린 꼴(기준점 거침 셋 같은 방위 · 방위 없음 둘 · 공사지 동쪽 셋 · + 서쪽 = 기준점 자리).""" + legs = [{"road": "2차포장", "km": 23.8}] + routes = [{**_route(f"n{i}", "N", address=LONG, item="장비"), "legs": legs} for i in range(3)] + routes += [{**_route(f"f{i}"), "legs": legs} for i in range(2)] + routes += [{**_route(f"e{i}", "E", via=False), "legs": legs} for i in range(3)] + routes += [{**_route("w", "W", via=False), "legs": legs}] # 기준점과 같은 방위 = 밀려남 + data = _data(routes, base=True) + data["base"]["legs"] = [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}] + lay = _lay(data) + gap = _node("GAP") + boxes = lay["boxes"] + inside = {("site", "start"), ("site", "mid")} + for i, a in enumerate(boxes): # 상자 사이 여유(공사지 안 시점 · 중점만 품음) + for b in boxes[i + 1 :]: + if (a["id"], b["id"]) not in inside: + assert not _hits(a, b, gap - 0.01), (a["id"], b["id"]) + for ln in lay["lines"]: # 선 끝 = 어느 상자 안도 아님(바깥 → 시점 = 공사지 테두리에서 끊김) + for x, y in ((ln["x1"], ln["y1"]), (ln["x2"], ln["y2"])): + for b in boxes: + if not (b["id"] == "site" and ln["from"] == "start"): + assert not (b["x"] < x < b["x"] + b["w"] and b["y"] < y < b["y"] + b["h"]), ( + ln["from"], + ln["to"], + b["id"], + x, + y, + ) + rects = {ln["from"]: _node(f"labelRect({json.dumps(ln, ensure_ascii=False)})") + for ln in lay["lines"] if ln["labels"]} # fmt: skip + for k, r in rects.items(): # 선 글 ↔ 다른 선 + for o in lay["lines"]: + if o["from"] != k: + assert not _seg_hits(o["x1"], o["y1"], o["x2"], o["y2"], r), (k, o["from"]) + keys = list(rects) + for i, k in enumerate(keys): # 선 글 ↔ 상자 · 다른 선 글 + for b in boxes: + if not (b["kind"] == "site" and k == "start"): + assert not _hits(rects[k], b), (k, b["id"]) + for j in keys[i + 1 :]: + assert not _hits(rects[k], rects[j]), (k, j) diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 4d459a64f..341e66d4b 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -97,8 +97,8 @@ def test_거래처_상자_항목_글(): assert boxes["g"]["items"] == "장비 · 골재" and boxes["g"]["label"] == "거창군청" assert boxes["k"]["items"] == "" and boxes["k"]["label"] == "거래처" ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert 'svg("tspan", { x, class: "b08-milestone__items" }, box.items)' in ui - assert 'svg("tspan", { x, dy: 16 }, box.label)' in ui + # 244-12 — 글 줄 = 항목(윗줄) · 이름 · 작은 글씨 + assert '[box.items, "b08-milestone__items"]' in ui and '[box.label, ""]' in ui def test_표_폭_다이어그램_폭_좌측_카드_없음(): From 06b676f9144260915eb647c285ff02785657f011 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:20:19 +0900 Subject: [PATCH 83/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20147?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index bb30c2138..10145cc7a 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -175,3 +175,4 @@ 144. 거래처 컨테이너의 경우 항목, 거래처, 주소를 보여줘. 145. 추가로 거리이정표에는 선들과 컨테이너들의 공간을 여유를 조금 두는게 좋겠어. 자꾸 침범을 하네. 146. 추가로 누락된 업무가 있는지 확인해봐. +147. (브레인 물음 답 · 원문) 1. 백엔드 재시작 2. 폴더만 기준이 되면 됨. 3. 반영 4. 테이블이 아닌 항목은 대상이 아님 5. 다음으로 공사지 +로 추가하는 항목에서 나오는 모달창의 크기를 조금 더 키우고 내부 내용을 정리할 생각임. 항목은 분류(기준위치, 거래처) 항목, 거래처 이름, 목적지를 2열배치 이때 분류에 따라 항목이 바뀜(다이어그램의 기준점+ 버튼은 삭제), 거래처 위치를 소재지로 변경하고 직접입력버튼은 삭제. 전부 주소찾기나 지도에서 찾아야함. 등록에 이제 다음지도로 대체 6. 메일은 내가 알아서 하겠음. 7. 데스크탑은 세션 확인하면 살아있음. 오르카 리스트 확인 8. 추가 거리이정표 항목 - 등록으로 보이는 모달창은 해당 컨테이너를 눌렀을 때 같이 보여주면 됨. - 길찾기는 연결선을 누르면 보이는 모달창에 추가 이때의 항목은 도로 구분과 거리를 항목값으로 받고 하위에 다음맵으로 경로를 보여주면 되는데 기준점이 발생되는 이유는 임도 특성상 도로가 없는 경우 한 지점을 지정하게 됨. 다시말해 기준점이 있다면 기준점과 거래처의 다음맵지도가 필요함. 이때 위치조절을 위해 위치이동은 되어야함. 시점과 기준점은 도로가 연결이 안되는 경우가 있어 수기 입력이 되어야할때도 있음. 시점과 중점은 항상 거리는 수기입력임. 이때 도로 구분의 경우 2차 포장이란 시점이 아니라 기준점이 있을때의 경우임. 다시말해 기준점을 거쳐 시점에 들어가는 경우에만 존재하는 열 항목임. 기준점이 없다면 그냥 포장이 됨. From 1cd8668d8a917c49f7ec56a12659f556c5c8a831 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:28:49 +0900 Subject: [PATCH 84/92] =?UTF-8?q?fix(b09):=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=ED=83=AD=20=EC=9D=B4=EB=8F=99=20=EB=B9=A8=EA=B0=95=20=3D=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=E2=80=94=20=EB=B9=A8=EA=B0=95=20=ED=95=98=EB=82=98?= =?UTF-8?q?=20=C2=B7=20=EC=A4=84=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EB=B0=A9=ED=96=A5=ED=82=A4=20=C2=B7=20Esc?= =?UTF-8?q?=20=3D=20=EB=B9=A8=EA=B0=95=20=ED=92=82=20=C2=B7=20=EB=88=84?= =?UTF-8?q?=EB=A5=B8=20=EC=A4=84=EB=A7=8C=20=EA=B3=A0=EB=A6=84=20(248-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- .../B09_Estimation_UI_Tabs_Groups.ts | 41 ++++++++- resources/tester/helper_b09_group_cards.py | 9 +- resources/tester/test_171_2_group_cards.py | 4 +- resources/tester/test_171_3_b09_group_tabs.py | 6 +- .../test_248_1_b09_group_jump_unpick.py | 90 +++++++++++++++++++ ui_template/ui_template_group_cards.ts | 11 ++- 6 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 resources/tester/test_248_1_b09_group_jump_unpick.py diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index bc57acacb..8618460f7 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -6,6 +6,8 @@ * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 + * 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · + * 누른 줄만 고름(is-focus) · Esc = 고름도 풂 * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 * 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 * 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1) @@ -17,6 +19,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { clampColumns, createGroupCards, + unpickCards, type GroupCard, type GroupFoot, type GroupLine, @@ -185,6 +188,21 @@ export function lineHint(hint: HTMLElement | null): HTMLElement | null { return hint; } +/** 248-1 지금 그린 묶음 판 빨강 풀기(esc = 고름도) — 방향키 · Esc 가 부름 */ +let live: { root: HTMLElement; unpick: (esc: boolean) => void } | null = null; +let keysOn = false; +const UNPICK_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape"]); + +function listenKeys(): void { + if (keysOn) return; + keysOn = true; + document.addEventListener("keydown", (event) => { + if (!live?.root.isConnected || !UNPICK_KEYS.has(event.key)) return; + if ((event.target as Element | null)?.closest?.("input, textarea, select")) return; + live.unpick(event.key === "Escape"); + }); +} + /** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ const chosen = new Map(); @@ -227,11 +245,28 @@ export function renderGroupPanel(o: { placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽 }); cards.dataset.b09Table = o.tab; + live = { root: cards, unpick: (esc) => unpickCards(cards, esc) }; + listenKeys(); return [cards, ...(lead ? [lead] : [])]; } o.setTools([toggle]); - const range = pickRange(o.spec.rows, o.focus); - const table = o.makeTable((i) => pickClass(range, i)); - if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); + let range = pickRange(o.spec.rows, o.focus); + let chose = -1; // 누른 줄(다시 그려도 유지) + const table = o.makeTable((i) => (i === chose ? "is-focus" : pickClass(range, i))); + // 줄 class 만 고침(다시 그리지 않음 — 더블클릭 입력 그대로) + const mark = (row: number): void => { + range = null; + chose = row; + for (const tr of table.root.querySelectorAll("tr.is-pick, tr.is-focus")) + tr.classList.remove("is-pick", "is-pick-top", "is-pick-end", "is-focus"); + table.root.querySelector(`tr[data-i="${row}"]`)?.classList.add("is-focus"); + }; + table.root.addEventListener("click", (event) => { + const tr = (event.target as Element).closest("tr[data-i]"); + mark(tr ? Number(tr.dataset.i) : chose); + }); + live = { root: table.root, unpick: (esc) => mark(esc ? -1 : chose) }; + listenKeys(); + if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) return [table.root, ...(lead ? [lead] : [])]; } diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 11887582b..5fb08a813 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -66,6 +66,9 @@ class E { setAttribute(k, v) { this.attrs[k] = String(v); } scrollIntoView() { this.scrolled = true; } matches(sel) { + if (sel.includes(",")) return sel.split(",").some((s) => this.matches(s.trim())); + const tag = sel.match(/^([a-z]+)([.[].*)$/); // tr.is-pick · tr[data-i="3"] + if (tag) return this.matches(tag[1]) && this.matches(tag[2]); if (sel.startsWith(".")) return this.cls.has(sel.slice(1)); const m = sel.match(/^\[data-([\w-]+)(?:="(.*)")?\]$/); const k = m ? m[1].replace(/-(\w)/g, (_, c) => c.toUpperCase()) : ""; @@ -75,11 +78,15 @@ class E { closest(sel) { for (let e = this; e; e = e.parent) if (e.matches(sel)) return e; return null; } all(sel, out = []) { for (const c of this.children) { if (c.matches(sel)) out.push(c); c.all(sel, out); } return out; } querySelector(sel) { return this.all(sel)[0] ?? null; } + querySelectorAll(sel) { return this.all(sel); } + get isConnected() { return true; } contains(x) { for (let e = x; e; e = e.parent) if (e === this) return true; return false; } get text() { return this.textContent + this.children.map((c) => c.text).join(""); } click() { const event = { target: this }; for (let e = this; e; e = e.parent) (e.on.click ?? []).forEach((f) => f(event)); } } -globalThis.document = { createElement: (t) => new E(t) }; +const docKeys = []; // 248-1 문서 keydown — key(이름, 대상) 로 부름 +globalThis.document = { createElement: (t) => new E(t), addEventListener: (t, f) => t === "keydown" && docKeys.push(f) }; +const key = (k, target = null) => docKeys.forEach((f) => f({ key: k, target })); globalThis.requestAnimationFrame = (f) => f(); """ diff --git a/resources/tester/test_171_2_group_cards.py b/resources/tester/test_171_2_group_cards.py index 3a5fe8211..a57b32cff 100644 --- a/resources/tester/test_171_2_group_cards.py +++ b/resources/tester/test_171_2_group_cards.py @@ -122,9 +122,9 @@ def test_묶음_많아도_접힘_없음_고른_박스_붉은_외곽선(): " foot: cards.every((c) => c.querySelector('.ui-group-card__foot'))," " focus: [focused.classList.contains('is-focus'), cards[4].scrolled === true] }));" ) - # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 그 줄 강조 · 박스 스크롤 + # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 박스 스크롤(248-1 줄 강조 안 붙임) assert out["first"] == [False] * 4 + [True] + [False] * 8 - assert out["focus"] == [True, True] + assert out["focus"] == [False, True] assert out["closed"] == [False] * 13 # 13 묶음도 접힘 없음 assert out["picks"] == [10] # 머리 누름 = 이동(표 줄 번호 그대로) assert out["buttons"] == ["1", "2", "3", "4"] # 모두 펼침 · 접기 단추 없음 diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 4ac6abbc9..139aab8d6 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -149,10 +149,10 @@ def test_화면_보기_바꿈_누름_이동(): "\nconst [view] = renderGroupPanel(o);" "\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);" "\nconst label = btn.textContent;" - "\nview.querySelector('[data-at=\"2\"]').click();" - "\nview.querySelector('[data-at=\"0\"]').click();" "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" "\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');" + "\nview.querySelector('[data-at=\"2\"]').click();" + "\nview.querySelector('[data-at=\"0\"]').click();" "\nbtn.click();" "\nconst [view2] = renderGroupPanel(o);" "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," @@ -162,7 +162,7 @@ def test_화면_보기_바꿈_누름_이동(): ) assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 - assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조 + assert out["lit"] is False # 248-1 이동해 온 줄 = 빨강 하나(줄 강조 안 붙임) assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) assert out["redraws"] == 1 assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] diff --git a/resources/tester/test_248_1_b09_group_jump_unpick.py b/resources/tester/test_248_1_b09_group_jump_unpick.py new file mode 100644 index 000000000..0ebb03c45 --- /dev/null +++ b/resources/tester/test_248_1_b09_group_jump_unpick.py @@ -0,0 +1,90 @@ +"""248-1 B09 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) 다른 탭 이동 빨강 = 설계내역서(242-1) 규칙. + +- 이동해 온 호표 = 빨강 하나 — 카드 = 박스 is-pick 만(줄 is-focus 안 붙음) · 표 = 구간 is-pick 만 · + 공용 focusRow(at, false) = 스크롤만 +- 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · 누른 줄만 고름(is-focus) · Esc = 고름도 풂 · 다시 그려도 그대로 +- 글 칸 안 키 · 다른 키 = 그대로 +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 +from resources.tester.test_171_3_b09_group_tabs import DG # noqa: E402 +from resources.tester.test_181_2_b09_box_pick import ROWS # noqa: E402 + +LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts" +INPUT = "{ closest: (s) => (s.includes('input') ? {} : null) }" # 글 칸 안 키 + + +def test_카드_빨강_하나_누름_키_풂(): + out = run( + [COMMON, TABS, LABOR, GROUPS], + f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: () => {}, redraw: () => {}," + " setTools: () => {}, makeTable: () => null };" + "\nconst marks = (v) => [v.all('.is-pick').length, v.all('.is-focus').map((e) => e.dataset.at ?? 'box')];" + "\nconst out = {};" + "\nlet [v] = renderGroupPanel(o); out.came = marks(v);" + "\nv.querySelector('[data-at=\"2\"]').click(); out.line = marks(v);" + "\n[v] = renderGroupPanel(o); v.querySelector('.ui-group-card').click(); out.box = marks(v);" + "\n[v] = renderGroupPanel(o); key('a'); key('ArrowDown', " + + INPUT + + "); out.kept = marks(v);" + "\nkey('ArrowDown'); out.arrow = marks(v);" + "\n[v] = renderGroupPanel(o); v.querySelector('[data-at=\"2\"]').click(); key('Escape'); out.esc = marks(v);" + "\nconsole.log(JSON.stringify(out));", + RAW, + ) + assert out["came"] == [1, []] # 박스 빨강만 · 줄 강조 없음 + assert out["line"] == [0, ["2"]] # 누른 줄만 고름 + assert out["box"] == [0, []] # 카드 빈 곳 누름 = 빨강만 풂 + assert out["kept"] == [1, []] # 다른 키 · 글 칸 안 = 그대로 + assert out["arrow"] == [0, []] + assert out["esc"] == [0, []] # Esc = 고름도 풂 + + +def test_표_빨강_하나_누름_키_풂(): + out = run( + [COMMON, TABS, GROUPS], + f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};" + "\nlet extra = null; let root = null; const scrolled = [];" + "\nconst makeTable = (e) => { extra = e; root = document.createElement('table');" + " spec.rows.forEach((_, i) => { const tr = document.createElement('tr'); tr.dataset.i = String(i);" + " tr.className = e(i) ?? ''; tr.append(document.createElement('td')); root.append(tr); });" + " return { root, focusRow: (i, mark) => scrolled.push([i, mark]) }; };" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," + " setTools: () => {}, makeTable };" + "\nchosen.set('dg', 'table');" + "\nconst marks = () => [root.children.map((tr) => tr.className || null)," + " spec.rows.map((_, i) => extra(i))];" + "\nconst out = {};" + "\nrenderGroupPanel(o); out.came = marks(); out.scrolled = [...scrolled];" + "\nroot.children[1].children[0].click(); out.click = marks();" + "\nrenderGroupPanel(o); key('Enter'); key('ArrowUp', " + INPUT + "); out.kept = marks()[0];" + "\nkey('ArrowUp'); out.arrow = marks();" + "\nrenderGroupPanel(o); root.children[5].click(); key('Escape'); out.esc = marks();" + "\nconsole.log(JSON.stringify(out));", + ) + red = [None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end", None] + none = [None] * 8 + assert out["came"] == [red, red] # 구간 빨강만 + assert out["scrolled"] == [[4, False]] # 스크롤만(공용 강조 안 붙임) + focus1 = [None, "is-focus"] + [None] * 6 + assert out["click"] == [focus1, focus1] # 누른 줄만 · 다시 그려도 + assert out["kept"] == red + assert out["arrow"] == [none, none] + assert out["esc"] == [none, none] + + +def test_배선(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert "table.focusRow(at, false)" in groups + common = (ROOT / COMMON).read_text("utf-8") + assert 'el?.classList.add("is-focus")' not in common + assert "unpickCards(grid, true);" in common diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 976100317..3a7fb9b02 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -7,7 +7,7 @@ * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) * 접기 없음 — 묶음 모두 펼친 박스 · 같은 가로 줄 카드 = 높이 같음(200-1 · 합계 줄은 아래 붙음) * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선 - * (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`) + * (`is-pick`) + 스크롤만(248-1 빨강 하나) · 누름 = 빨강 풂 · 누른 줄만 고름(`is-focus`) * ========================================================================== */ import "./ui_template_group_cards.css"; @@ -224,7 +224,9 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.addEventListener("click", (event) => { const el = (event.target as Element).closest("[data-at]"); + unpickCards(grid, true); if (!el || !grid.contains(el)) return; + el.classList.add("is-focus"); opts.onPick?.(Number(el.dataset.at)); }); @@ -232,8 +234,13 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { const el = grid.querySelector(`[data-at="${opts.focus}"]`); const box = el?.closest(".ui-group-card"); box?.classList.add("is-pick"); - el?.classList.add("is-focus"); requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" })); } return root; } + +/** 248-1 박스 빨강(`is-pick`) 풂 · lines = 고른 줄(`is-focus`)도 */ +export function unpickCards(root: ParentNode, lines = false): void { + for (const el of root.querySelectorAll(lines ? ".is-pick, .is-focus" : ".is-pick")) + el.classList.remove("is-pick", "is-focus"); +} From 2afb1af1ca045dd0d30e26b6eed52cf3b79c0197 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:36:39 +0900 Subject: [PATCH 85/92] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=93=B1=EB=A1=9D=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?2=EC=97=B4=20=C2=B7=20=EC=86=8C=EC=9E=AC=EC=A7=80=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=EC=A7=80=EB=8F=84=20=C2=B7=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=84=A0=20=EB=AA=A8=EB=8B=AC=20=EA=B8=B8=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EB=A1=9C=20=EA=B5=AC=EB=B6=84=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=EC=97=B4(244-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록 모달 한 벌(추가 · 상자 고침 · 삭제) — 분류(기준위치 · 거래처) · 항목 고르기 · 거래처 이름 · 목적지 2열 · 큰 창 - 소재지 = 주소 찾기 · 다음 지도 누름 · 표지 끌기(직접 입력 없음) · 좌표 → VWorld 주소 서버 길 - 다이어그램 [기준점 +] 없앰 · 연결선 누름 = 도로 구분 · 거리 · 다음 지도 경로 · 끝점 끌기 = 다시 길찾기 - 시점 → 중점 = 손 입력만 · 자동 그림 = 거래처 쪽 구간 - 도로 구분 = 기준점 거침 2차 · 1차 · 비포장 · 그 밖 포장 · 비포장(파이썬 · TS 짝) · 표 열 기준점 따라 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Calc.py | 12 +- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 18 +- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 48 +- B08_Quantity/B08_Quantity_Router_Milestone.py | 14 +- B08_Quantity/B08_Quantity_UI_Milestone.css | 28 +- B08_Quantity/B08_Quantity_UI_Milestone.ts | 128 +++-- .../B08_Quantity_UI_Milestone_Diagram.ts | 57 +- .../B08_Quantity_UI_Milestone_KakaoMap.ts | 118 ++++- .../B08_Quantity_UI_Milestone_LegModal.ts | 251 +++++++++ .../B08_Quantity_UI_Milestone_Modal.ts | 494 +++++++----------- .../B08_Quantity_UI_Milestone_Place.ts | 144 +++++ .../B08_Quantity_UI_Milestone_State.ts | 118 +++-- .../test_244_11_b08_milestone_auto_capture.py | 4 +- .../test_244_13_b08_milestone_place_leg.py | 305 +++++++++++ resources/tester/test_244_1_b08_milestone.py | 4 +- .../test_244_2_b08_milestone_diagram.py | 9 +- .../tester/test_244_4_b08_milestone_modal.py | 32 +- .../test_244_5_b08_milestone_base_dir.py | 6 +- .../test_244_6_b08_milestone_pan_client.py | 12 +- .../tester/test_244_7_b08_milestone_kakao.py | 6 +- .../test_244_9_b08_milestone_route_map.py | 6 +- 21 files changed, 1315 insertions(+), 499 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_Place.ts create mode 100644 resources/tester/test_244_13_b08_milestone_place_leg.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index 58cc57df4..f77d85f6a 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -10,6 +10,9 @@ 끝수 = 소수 셋째 자리(반올림). 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 pick = 참(화면 「도로 구분 고르기」). +도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 · +그 밖 = 포장 · 비포장 · +포장 = 2차 + 1차 + 포장. """ from __future__ import annotations @@ -17,8 +20,8 @@ from __future__ import annotations import math from typing import Any -ROADS = ("2차포장", "1차포장", "비포장") -KEYS = {"2차포장": "s2", "1차포장": "s1", "비포장": "un"} +ROADS = ("2차포장", "1차포장", "포장", "비포장") +KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} def r3(value: float) -> float: @@ -60,13 +63,13 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list out: list[dict[str, Any]] = [] for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): - sums = {"s2": 0.0, "s1": 0.0, "un": 0.0} + sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0} pick = add_legs(sums, route.get("legs")) if base.get("enabled") and route.get("via_base", True): pick = add_legs(sums, base.get("legs")) or pick if row.get("dest") == "mid": add_legs(sums, [mid]) - paved = sums["s2"] + sums["s1"] + paved = sums["s2"] + sums["s1"] + sums["pv"] out.append( { "route": route.get("id"), @@ -76,6 +79,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list "dest": row.get("dest") or "start", "s2": r3(sums["s2"]), "s1": r3(sums["s1"]), + "pv": r3(sums["pv"]), "un": r3(sums["un"]), "paved": r3(paved), "unpaved": r3(sums["un"]), diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 481d56391..50aa3ae78 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -11,10 +11,15 @@ * 끝수 = 소수 셋째 자리(반올림) * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 * pick = 참(화면 「도로 구분 고르기」) + * 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · + * 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장 * ========================================================================== */ -export const ROADS = ["2차포장", "1차포장", "비포장"] as const; +export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const; export type Road = (typeof ROADS)[number]; +/** 구간 도로 구분 고르기 — 기준점 거침 = 2차 · 1차 · 비포장 · 아니면 포장 · 비포장(244-13) */ +export const roadsFor = (viaBase: boolean): Road[] => + viaBase ? ["2차포장", "1차포장", "비포장"] : ["포장", "비포장"]; /** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; export type Dir = (typeof DIRS)[number]; @@ -73,6 +78,7 @@ export interface MilestoneTableRow { dest: Dest; s2: number; s1: number; + pv: number; un: number; paved: number; unpaved: number; @@ -82,7 +88,8 @@ export interface MilestoneTableRow { pick: boolean; } -const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" }; +type Key = "s2" | "s1" | "pv" | "un"; +const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 포장: "pv", 비포장: "un" }; /** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000; @@ -91,7 +98,7 @@ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 10 export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; /** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */ -function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): boolean { +function addLegs(sums: Record, legs: Leg[] | undefined): boolean { let pick = false; for (const leg of legs ?? []) { const key = leg.road ? KEYS[leg.road] : undefined; @@ -117,11 +124,11 @@ export function computeMilestone( const out: MilestoneTableRow[] = []; for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { - const sums = { s2: 0, s1: 0, un: 0 }; + const sums = { s2: 0, s1: 0, pv: 0, un: 0 }; let pick = addLegs(sums, route.legs); if (base?.enabled && route.via_base !== false) pick = addLegs(sums, base.legs) || pick; if (row.dest === "mid") addLegs(sums, [mid]); - const paved = sums.s2 + sums.s1; + const paved = sums.s2 + sums.s1 + sums.pv; out.push({ route: route.id, index, @@ -130,6 +137,7 @@ export function computeMilestone( dest: row.dest ?? "start", s2: r3(sums.s2), s1: r3(sums.s1), + pv: r3(sums.pv), un: r3(sums.un), paved: r3(paved), unpaved: r3(sums.un), diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 6c193c89b..12f3c648b 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -11,6 +11,7 @@ * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 + * 연결선 구간(244-13) = `segmentEnds` · 도로 구분 갈래 = `chainVia`(기준점 거침 = 2차 · 1차 · 비포장) * 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘 * 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버 * 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · @@ -210,25 +211,37 @@ export interface KmSegment { const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null => p.lon != null && p.lat != null ? [p.lon, p.lat] : null; +/** 구간 한 줄(244-13 연결선) — from = 거래처 id(거래처 → 기준점(거침) · 시점) · "base"(기준점 → 시점) · + * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함) */ +export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null { + const baseName = data.base.name || "기준점"; + if (from === "base") + return data.base.enabled + ? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null } + : null; + const route = data.routes.find((r) => r.id === from); + if (!route) return null; + const viaBase = data.base.enabled && route.via_base; + return { + key: "route", + text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, + from: pointOf(route), + to: viaBase ? pointOf(data.base) : (start ?? null), + }; +} + +/** 기준점 거쳐 시점 들어가는 구간(244-13 · 도로 구분 2차 · 1차 · 비포장) — 기준점 → 시점 · 거침 거래처 */ +export function chainVia(data: MilestoneData, from: string): boolean { + if (!data.base.enabled || from === "start") return false; + return from === "base" || !!data.routes.find((r) => r.id === from)?.via_base; +} + /** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] { - const route = data.routes.find((r) => r.id === routeId); - if (!route) return []; - const viaBase = data.base.enabled && route.via_base; - const baseName = data.base.name || "기준점"; - const from = pointOf(route); - const first: KmSegment = { - key: "route", - text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, - from, - to: viaBase ? pointOf(data.base) : (start ?? null), - }; - if (!viaBase) return [first]; - return [ - first, - { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null }, - ]; + const first = segmentEnds(data, routeId, start); + if (!first) return []; + return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first]; } /** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ @@ -544,7 +557,8 @@ export function setLeg( } /** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */ -export const PAN_SKIP = ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus"; +export const PAN_SKIP = + ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus, .b08-milestone__line-hit"; export function panStart(e: { button: number; target: EventTarget | null }): boolean { if (e.button === 1) return true; const target = e.target as { closest?: (selector: string) => unknown } | null; diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 6f962871b..01f281924 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -15,6 +15,8 @@ POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) + 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함) + GET /api/projects/{id}/quantity/milestone/reverse?lon=&lat= 지도에서 고른 소재지(244-13) → + VWorld 주소 한 줄(서버 단독 · 저장 안 함 · 못 찾음 = null) GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 · 공개용 · .env KAKAO_JS_KEY · 없으면 null) @@ -68,7 +70,7 @@ AUTO = "auto" # 자동 그림 하위 폴더(244-11) IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" -Road = Literal["2차포장", "1차포장", "비포장"] +Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13) Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -270,6 +272,16 @@ async def milestone_site_address( } +@router.get(PATH + "/reverse") +async def milestone_reverse( + project_id: UUID, + lon: Annotated[float, Query(ge=-180, le=180)], + lat: Annotated[float, Query(ge=-90, le=90)], +) -> dict[str, Any]: + """지도에서 고른 소재지(244-13) — 좌표 → VWorld 주소(못 찾음 = null).""" + return {"address": await reverse_geocode(lon, lat)} + + @router.post(PATH + "/image") async def milestone_image_upload( project_id: UUID, route: str = Query(...), file: UploadFile = File(...) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 1581b8005..90ff5b010 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -134,7 +134,7 @@ /* 선 글 바로 고침(244-4 ⑥) — 글 아래 작은 판 */ .b08-milestone__km tspan { - cursor: text; + cursor: pointer; } .b08-milestone__leg-edit { @@ -186,3 +186,29 @@ font-size: 12px; white-space: nowrap; } + +/* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */ +.ui-modal.b08-milestone__modal { + width: min(960px, 94vw); + --modal-max: 90vh; +} + +.b08-milestone__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; + align-items: end; +} + +.b08-milestone__place { + margin: 6px 0; + font-weight: 600; +} + +/* 연결선 누름 자리 — 투명한 굵은 선(244-13) */ +.b08-milestone__line-hit { + stroke: transparent; + stroke-width: 14; + pointer-events: stroke; + cursor: pointer; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 2a9aaea35..df187f338 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -3,7 +3,8 @@ * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름). * 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음). * - * [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) + * [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 도로 구분 · 계 · 비고) — 열(244-13) = 기준점 있음 = + * 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계 * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 * 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도 * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) @@ -32,18 +33,34 @@ import { } from "./B08_Quantity_UI_Milestone_Route"; const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; -const COLUMNS: DataColumn[] = [ - { key: "item", label: "항목", width: 120 }, - { key: "company", label: "거래처 이름", width: 120 }, - { key: "dest", label: "목적지", width: 72 }, - { key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM }, - { key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM }, - { key: "un", label: "비포장", group: ["도로 구분 (km)"], ...KM }, - { key: "paved", label: "포장", group: ["계 (km)"], ...KM }, - { key: "unpaved", label: "비포장", group: ["계 (km)"], ...KM }, - { key: "total", label: "합계", group: ["계 (km)"], ...KM, width: 108 }, - { key: "note", label: "비고", width: 160 }, -]; +const ROAD = ["도로 구분 (km)"]; +const SUM = ["계 (km)"]; + +/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · + * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ +export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { + const roads: DataColumn[] = withBase + ? [ + { key: "s2", label: "2차포장", group: ROAD, ...KM }, + { key: "s1", label: "1차포장", group: ROAD, ...KM }, + ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), + { key: "un", label: "비포장", group: ROAD, ...KM }, + { key: "paved", label: "포장", group: SUM, ...KM }, + { key: "unpaved", label: "비포장", group: SUM, ...KM }, + ] + : [ + { key: "paved", label: "포장", group: ROAD, ...KM }, + { key: "un", label: "비포장", group: ROAD, ...KM }, + ]; + return [ + { key: "item", label: "항목", width: 120 }, + { key: "company", label: "거래처 이름", width: 120 }, + { key: "dest", label: "목적지", width: 72 }, + ...roads, + { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, + { key: "note", label: "비고", width: 160 }, + ]; +} /** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */ export function addRoute(projectId: string, init: Partial = {}): string { @@ -68,44 +85,63 @@ function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; let pickRows: boolean[] = []; - const table = createDataTable({ - columns: COLUMNS, - rows: [], - caption: "거리이정표", - empty: "", - editOnDouble: true, - cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), - editable: (_i, key) => key === "item" || key === "company" || key === "note", - onEdit: (i, key, text) => { - const at = keys[i]; - if (!at) return; - editRoute(projectId, at.route, (_, r) => { - if (key === "company") r.name = text; - else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text; - }); - }, - onCellClick: (i, key) => { - const at = keys[i]; - if (key !== "dest" || !at) return; - pickAtCell( - (["start", "mid"] as const).map((dest) => [ - DEST_TEXT[dest], - () => - editRoute(projectId, at.route, (_, r) => { - if (r.rows[at.index]) r.rows[at.index].dest = dest; - }), - ]), - ); - }, - }); - trackCells(table.root); - table.root.dataset.b08Milestone = "table"; + const makeTable = (columns: DataColumn[]) => { + const t = createDataTable({ + columns, + rows: [], + caption: "거리이정표", + empty: "", + editOnDouble: true, + cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), + editable: (_i, key) => key === "item" || key === "company" || key === "note", + onEdit: (i, key, text) => { + const at = keys[i]; + if (!at) return; + editRoute(projectId, at.route, (_, r) => { + if (key === "company") r.name = text; + else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text; + }); + }, + onCellClick: (i, key) => { + const at = keys[i]; + if (key !== "dest" || !at) return; + pickAtCell( + (["start", "mid"] as const).map((dest) => [ + DEST_TEXT[dest], + () => + editRoute(projectId, at.route, (_, r) => { + if (r.rows[at.index]) r.rows[at.index].dest = dest; + }), + ]), + ); + }, + }); + trackCells(t.root); + t.root.dataset.b08Milestone = "table"; + return t; + }; + let mode = "false:false"; + let table = makeTable(milestoneColumns(false, false)); const none = el("p", { className: "ui-general-block__empty", text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.", }); const refresh = (): void => { - const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); + const data = liveMilestone(projectId); + const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); + // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 + const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`; + if (next !== mode) { + mode = next; + const fresh = makeTable( + milestoneColumns( + data.base.enabled, + rows.some((r) => r.pv > 0), + ), + ); + table.root.replaceWith(fresh.root); + table = fresh; + } keys = rows.map(({ route, index }) => ({ route, index })); pickRows = rows.map((r) => r.pick); none.hidden = rows.length > 0; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 55bf4e8a9..91cb5e2d6 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -6,17 +6,20 @@ * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 거래처 = 항목 정보 · 공사지 = 시점 주소 · * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) - * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6) + * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰) + * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달 + * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, type MilestoneRoute, type Road } from "./B08_Quantity_Milestone_Calc"; import { + chainVia, layoutMilestone, legAt, panContent, @@ -24,14 +27,9 @@ import { setLeg, type DiagramBox, } from "./B08_Quantity_Milestone_Layout"; +import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal"; +import { openPlaceModal, openSiteInfo } from "./B08_Quantity_UI_Milestone_Modal"; import { - openAddItem, - openBaseInfo, - openRouteInfo, - openSiteInfo, -} from "./B08_Quantity_UI_Milestone_Modal"; -import { - legRoadOptions, liveMilestone, putMilestone, roadOptions, @@ -60,12 +58,13 @@ function editLegAt( i: number, midKm: number, ): void { - const leg = legAt(liveMilestone(projectId), from, i, midKm); + const data = liveMilestone(projectId); + const leg = legAt(data, from, i, midKm); if (!leg) return; document.querySelector(".b08-milestone__leg-edit")?.remove(); const road = createSelectField({ label: "도로 구분", - options: from === "start" ? roadOptions : legRoadOptions, // 시점 → 중점 = 늘 고름 + options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름 compact: true, }); road.select.value = leg.road ?? ""; @@ -121,11 +120,11 @@ export function createDiagram( host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 const INFO: Record void> = { - route: (id) => openRouteInfo(projectId, id), + route: (id) => openPlaceModal(projectId, { add: false, id }), site: () => openSiteInfo(projectId), start: () => openSiteInfo(projectId), mid: () => openSiteInfo(projectId), - base: () => openBaseInfo(projectId), + base: () => openPlaceModal(projectId, { add: false, id: "base" }), }; const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => { @@ -140,7 +139,7 @@ export function createDiagram( g.dataset.b08MilestonePlus = viaBase ? "base" : "site"; const go = (e: Event): void => { e.stopPropagation(); - openAddItem(projectId, viaBase, addRoute); + openPlaceModal(projectId, { add: true, viaBase }, addRoute); }; g.addEventListener("click", go); g.addEventListener("keydown", (e) => e.key === "Enter" && go(e)); @@ -183,9 +182,15 @@ export function createDiagram( // 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림) const lineLayer = svg("g", {}); for (const ln of layout.lines) { - lineLayer.append( - svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }), - ); + const ends = { x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }; + // 누르는 자리 = 투명한 굵은 선(244-13 · 연결선 모달) + const hit = svg("line", { class: "b08-milestone__line-hit", ...ends }); + hit.dataset.b08MilestoneLine = ln.from; + hit.addEventListener("click", (e) => { + e.stopPropagation(); + openLegInfo(projectId, ln.from); + }); + lineLayer.append(svg("line", { class: "b08-milestone__line", ...ends }), hit); } for (const box of layout.boxes) { const g = svg("g", { class: `b08-milestone__box is-${box.kind}` }); @@ -240,23 +245,23 @@ export function createDiagram( const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); + // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 + let wait = 0; + span.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(wait); + wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); + }); span.addEventListener("dblclick", (e) => { e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 + clearTimeout(wait); editLegAt(projectId, span, ln.from, i, mk); }); text.append(span); }); shift.append(text); } - const addBase = createButton({ - label: "기준점 +", - size: "sm", - variant: "ghost", - onClick: () => openBaseInfo(projectId, true), - }); - addBase.dataset.b08MilestoneAddBase = ""; - addBase.hidden = data.base.enabled; - host.replaceChildren(el("div", { className: "b08-milestone__bar", children: [addBase] }), root); + host.replaceChildren(root); attachSvgZoomPan({ svg: root, layer, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index f15eb4bae..af216b5cb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -21,14 +21,22 @@ const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 -type LatLng = object; +interface LatLng { + getLat(): number; + getLng(): number; +} interface KMap { setBounds(bounds: object): void; + setCenter(p: LatLng): void; + setLevel(level: number): void; relayout(): void; } interface KOverlay { setMap(map: KMap | null): void; } +interface KMarker extends KOverlay { + getPosition(): LatLng; +} interface KakaoMaps { load(done: () => void): void; LatLng: new (lat: number, lng: number) => LatLng; @@ -40,7 +48,10 @@ interface KakaoMaps { strokeColor: string; strokeOpacity: number; }) => KOverlay; - Marker: new (o: { position: LatLng; title: string }) => KOverlay; + Marker: new (o: { position: LatLng; title: string; draggable?: boolean }) => KMarker; + event: { + addListener(target: object, type: string, fn: (e: { latLng: LatLng }) => void): void; + }; CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay; } @@ -160,3 +171,106 @@ export function routeMapView(projectId: string, routeId: string): RouteMapView { draw(); return { root, draw }; } + +/** 고르는 지도 표지(244-13) — key = 끌어 옮길 때 돌려받는 이름 */ +export interface MapPin { + key: string; + name: string; + lon: number; + lat: number; + movable: boolean; +} + +export interface EditMap { + root: HTMLElement; + /** 표지 · 길 선 다시 놓고 맞춤 */ + set: (pins: MapPin[], lines: Array>) => void; +} + +/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */ +export function editMap( + projectId: string, + opts: { + onMove: (key: string, lon: number, lat: number) => void; + onClick?: (lon: number, lat: number) => void; + center?: () => [number, number] | null; + hint: string; + }, +): EditMap { + const box = el("div", { className: "b08-milestone__map" }); + const hint = el("p", { className: "b08-options__hint", text: opts.hint }); + hint.dataset.b08MilestoneMapHint = ""; + let map: KMap | null = null; + let drawn: KOverlay[] = []; + + const set = (pins: MapPin[], lines: Array>): void => { + loadKakaoMaps(projectId).then( + (maps) => + requestAnimationFrame(() => { + const points = [ + ...pins.map((p) => new maps.LatLng(p.lat, p.lon)), + ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), + ]; + const c = opts.center?.(); + const first = points[0] ?? (c ? new maps.LatLng(c[1], c[0]) : null); + if (!first) return void (box.hidden = true); + box.hidden = false; + if (!map) { + map = new maps.Map(box, { center: first, level: 7 }); + if (opts.onClick) + maps.event.addListener(map, "click", (e) => + opts.onClick!(e.latLng.getLng(), e.latLng.getLat()), + ); + } + map.relayout(); + drawn.forEach((o) => o.setMap(null)); + drawn = [ + ...lines.map( + (line) => + new maps.Polyline({ + path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)), + strokeWeight: 6, + strokeColor: LINE_COLOR, + strokeOpacity: 0.9, + }), + ), + ...pins.flatMap((p) => { + const at = new maps.LatLng(p.lat, p.lon); + const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable }); + if (p.movable) + maps.event.addListener(marker, "dragend", () => { + const to = marker.getPosition(); + opts.onMove(p.key, to.getLng(), to.getLat()); + }); + return [ + marker, + new maps.CustomOverlay({ + position: at, + content: el("span", { className: "b08-milestone__map-label", text: p.name }), + yAnchor: 2.6, + }), + ]; + }), + ]; + drawn.forEach((o) => o.setMap(map)); + if (points.length > 1) { + const bounds = new maps.LatLngBounds(); + points.forEach((p) => bounds.extend(p)); + map.setBounds(bounds); + } else { + map.setCenter(first); + map.setLevel(points.length ? 4 : 9); + } + }), + (error: unknown) => { + box.hidden = true; + hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; + }, + ); + }; + + return { + root: el("div", { className: "b08-milestone__map-wrap", children: [hint, box] }), + set, + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts new file mode 100644 index 000000000..f8f7cd3e6 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -0,0 +1,251 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_LegModal.ts + * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. + * + * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) + * 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 · + * 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`) + * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) + * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) + * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) + * 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`) + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { ROADS, type Leg, type Road } from "./B08_Quantity_Milestone_Calc"; +import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; +import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; +import { + findSegmentKm, + liveMilestone, + putMilestone, + roadOptions, + segmentPath, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; + +const km = (text: string): number | null => { + const n = Number(text); + return text.trim() && Number.isFinite(n) && n >= 0 ? n : null; +}; + +/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */ +export function legsEditor( + initial: Leg[], + via: boolean, +): { root: HTMLElement; value: () => Leg[] } { + const list = el("div"); + const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; + const add = (leg: Leg): void => { + const road = createSelectField({ + label: "도로 구분", + options: roadOptions(via, true, leg.road), + compact: true, + }); + const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); + road.select.value = leg.road ?? ""; + dist.input.value = leg.km == null ? "" : String(leg.km); + const auto = leg.auto_km ?? null; + if (auto != null) { + dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 + dist.input.title = `자동 값 ${auto} km`; + } + const ref = { road: road.select, km: dist.input, auto }; + rows.push(ref); + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(ref), 1); + row.remove(); + }, + }); + const row = el("div", { + className: "b08-milestone__line-edit", + children: [road.root, dist.root, drop], + }); + row.dataset.b08MilestoneLeg = ""; + list.append(row); + }; + (initial.length ? initial : [{ road: null, km: null }]).forEach(add); + const more = createButton({ + label: "+ 구간", + size: "sm", + variant: "ghost", + onClick: () => add({ road: null, km: null }), + }); + return { + root: el("div", { children: [list, more] }), + value: () => + rows.map(({ road, km: dist, auto }) => ({ + road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, + km: km(dist.value), + auto_km: auto, + })), + }; +} + +const hint = (text: string): HTMLElement => el("p", { className: "b08-options__hint", text }); + +/** 시점 → 중점 — 도로 구분 · 거리 늘 손 입력(빈칸 = 노선 ÷ 2) */ +function openMidLeg(projectId: string): void { + const data = liveMilestone(projectId); + const site = siteInfo(); + openModal({ + title: "구간 — 시점 → 중점", + closeLabel: "닫기", + dialogClass: "b08-milestone__modal", + mount: (body, close, actions) => { + const road = createSelectField({ + label: "도로 구분", + options: roadOptions(false, false, data.site.mid_road), + compact: true, + }); + road.select.value = data.site.mid_road; + const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); + dist.input.value = + data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); + dist.input.placeholder = site?.mid_default_m == null ? "" : String(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2 + dist.input.dataset.b08MilestoneLegKm = "start"; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh = liveMilestone(projectId); + putMilestone( + projectId, + setLeg(fresh, "start", 0, { + road: road.select.value as Road, + km: km(dist.input.value), + }), + ); + close(); + }, + }); + save.dataset.b08MilestoneLegSave = ""; + actions.append(save); + body.append( + el("div", { className: "b08-milestone__grid", children: [road.root, dist.root] }), + hint("시점 → 중점 = 늘 손 입력(길찾기 없음) · 빈칸 = 노선 ÷ 2"), + ); + }, + }); +} + +/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */ +export function openLegInfo(projectId: string, from: string): void { + if (from === "start") return openMidLeg(projectId); + const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; + const seg = segmentEnds(liveMilestone(projectId), from, start()); + if (!seg) return; + openModal({ + title: `구간 — ${seg.text}`, + closeLabel: "닫기", + dialogClass: "b08-milestone__modal", + mount: (body, close, actions) => { + const legsOf = (): Leg[] => { + const d = liveMilestone(projectId); + return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? []; + }; + const via = chainVia(liveMilestone(projectId), from); + let legs = legsEditor(legsOf(), via); + const status = hint(""); + status.dataset.b08MilestoneLegStatus = ""; + /** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */ + const apply = (): void => { + const fresh = liveMilestone(projectId); + const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from); + if (!target) return; + target.legs = legs.value(); + putMilestone(projectId, fresh); + }; + const redrawLegs = (): void => { + const next = legsEditor(legsOf(), via); + legs.root.replaceWith(next.root); + legs = next; + }; + const pins = (): MapPin[] => { + const d = liveMilestone(projectId); + const route = d.routes.find((r) => r.id === from); + const out: MapPin[] = []; + const add = (key: string, name: string, lon?: number | null, lat?: number | null): void => { + if (lon != null && lat != null) + out.push({ key, name, lon, lat, movable: key !== "start" }); + }; + if (route) add(route.id, route.name || "거래처", route.lon, route.lat); + if (from === "base" || via) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + else add("start", "시점", start()?.[0], start()?.[1]); + if (from === "base") add("start", "시점", start()?.[0], start()?.[1]); + return out; + }; + const drawMap = (): void => { + const path = segmentPath(from); + map.set(pins(), path.length >= 2 ? [path] : []); + }; + const run = async (image = true): Promise => { + find.disabled = true; + status.textContent = "길찾기 중…"; + const typed = JSON.stringify(legs.value()); // 기다리는 동안 손댄 칸은 그대로 + const text = await findSegmentKm(projectId, from, image).finally( + () => (find.disabled = false), + ); + status.textContent = text; + if (image) toastKm(text); + if (JSON.stringify(legs.value()) === typed) redrawLegs(); + drawMap(); + }; + // 끝점 끌어 옮김 = 그 소재지 고침(주소 다시) + 다시 길찾기 + const map = editMap(projectId, { + hint: "경로 = 카카오 길찾기 · 거래처 · 기준점 표지를 끌어 옮기면 다시 길찾기", + onMove: (key, lon, lat) => { + apply(); + void reverseAddress(projectId, lon, lat).then((address) => { + const fresh = liveMilestone(projectId); + const target = key === "base" ? fresh.base : fresh.routes.find((r) => r.id === key); + if (!target) return; + Object.assign(target, { lon, lat, address }); + putMilestone(projectId, fresh); + void run(); + }); + }, + }); + const find = createButton({ + label: "길찾기", + variant: "ghost", + onClick: () => { + apply(); + void run(); + }, + }); + find.dataset.b08MilestoneLegFind = ""; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + apply(); + close(); + }, + }); + save.dataset.b08MilestoneLegSave = ""; + actions.append(find, save); + body.append( + legs.root, + hint( + from === "base" + ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" + : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", + ), + status, + el("h4", { text: "경로 — 다음(카카오) 지도" }), + map.root, + ); + drawMap(); + // 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음) + if (!segmentPath(from).length && seg.from && seg.to) void run(false); + }, + }); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index fc0ed923c..74bc8a704 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -1,17 +1,15 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_Modal.ts - * 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침. + * 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침. * - * [+] 항목 추가 = 항목 · 거래처 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = - * 경로(하위 탭) 생김 · 같은 거래처 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 - * 거래처 상자 = 그 항목 정보(거래처 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] + * 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 · + * 2열(분류(기준위치 · 거래처) · 항목(분류 따라 고르기 목록) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · + * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제] + * 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정 + * 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리 + * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 - * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) - * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 - * [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 · - * 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」) - * 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · 보기만) · 경로 그림 = [등록] · [길찾기] 뒤 - * 서버 자동 캡처(244-11) + * 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; @@ -25,25 +23,12 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { - buildAddressField, - type AddressFieldHandle, -} from "../B01_Dashboard/B01_Dashboard_UI_AddressField"; import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; -import { - ROADS, - type Dest, - type Leg, - type MilestoneData, - type MilestoneRoute, - type MilestoneRow, - type Road, -} from "./B08_Quantity_Milestone_Calc"; +import type { Dest, MilestoneRoute, MilestoneRow, Road } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; -import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { - findRouteKm, - legRoadOptions, + findSegmentKm, liveMilestone, putMilestone, roadOptions, @@ -55,324 +40,211 @@ const DEST_OPTIONS = [ { value: "mid", text: "중점" }, ]; +/** 분류 → 항목 고르기 목록(거래처 = 견본 「거리이정표」 시트 다섯) */ +export const ITEMS: Record<"route" | "base", string[]> = { + route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"], + base: ["기준점"], +}; + const line = (...children: HTMLElement[]): HTMLElement => el("div", { className: "b08-milestone__line-edit", children }); -/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · km(빈칸 = 자동 값) · [×] · [+ 구간] */ -function legsEditor(initial: Leg[]): { root: HTMLElement; value: () => Leg[] } { - const list = el("div"); - const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; - const add = (leg: Leg): void => { - const road = createSelectField({ label: "도로 구분", options: legRoadOptions, compact: true }); - const km = createInputField({ label: "km", type: "number", min: 0, compact: true }); - road.select.value = leg.road ?? ""; - km.input.value = leg.km == null ? "" : String(leg.km); - const auto = leg.auto_km ?? null; - if (auto != null) { - km.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 - km.input.title = `자동 값 ${auto} km`; - } - const ref = { road: road.select, km: km.input, auto }; - rows.push(ref); - const drop = createButton({ - label: "×", - size: "sm", - variant: "ghost", - onClick: () => { - rows.splice(rows.indexOf(ref), 1); - row.remove(); - }, - }); - const row = line(road.root, km.root, drop); - row.dataset.b08MilestoneLeg = ""; - list.append(row); - }; - initial.forEach(add); - const more = createButton({ - label: "+ 구간", - size: "sm", - variant: "ghost", - onClick: () => add({ road: null, km: null }), - }); - return { - root: el("div", { children: [list, more] }), - value: () => - rows.map(({ road, km, auto }) => { - const n = Number(km.value); - return { - road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, - km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null, - auto_km: auto, - }; - }), - }; -} - -/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */ -function rowsEditor(initial: MilestoneRow[]): { - root: HTMLElement; - value: () => MilestoneRow[]; -} { - const list = el("div"); - const rows: Array<{ item: HTMLInputElement; dest: HTMLSelectElement; note: string }> = []; - const add = (r: MilestoneRow): void => { - const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); - const dest = createSelectField({ label: "목적지", options: DEST_OPTIONS, compact: true }); - item.input.value = r.item; - dest.select.value = r.dest; - const ref = { item: item.input, dest: dest.select, note: r.note }; - rows.push(ref); - const drop = createButton({ - label: "×", - size: "sm", - variant: "ghost", - onClick: () => { - rows.splice(rows.indexOf(ref), 1); - row.remove(); - }, - }); - const row = line(item.root, dest.root, drop); - list.append(row); - }; - initial.forEach(add); - const more = createButton({ - label: "+ 줄", - size: "sm", - variant: "ghost", - onClick: () => add({ item: "", dest: "start", note: "" }), - }); - return { - root: el("div", { children: [list, more] }), - value: () => - rows.map((r) => ({ item: r.item.value.trim(), dest: r.dest.value as Dest, note: r.note })), - }; -} - -/** 주소 칸 값 → 주소 글 · 좌표(못 찾았고 글이 그대로면 옛 좌표 · 바뀌었으면 비움) */ -function placeOf( - field: AddressFieldHandle, - old: { address: string; lon?: number | null; lat?: number | null }, -): { address: string; lon: number | null; lat: number | null } { - const address = field.value() ?? ""; - const point = field.point(); - if (point) return { address, lon: point.lon, lat: point.lat }; - return address === old.address - ? { address, lon: old.lon ?? null, lat: old.lat ?? null } - : { address, lon: null, lat: null }; -} - const head = (text: string): HTMLElement => el("h4", { text }); -/** [길찾기] 한 번 — 구간마다 결과 한 줄(알림) · 실패 줄이 있으면 경고 */ -async function routeKm(projectId: string, routeId: string): Promise { - const lines = await findRouteKm(projectId, routeId); - if (lines.length) - showToast( - `길찾기 — ${lines.join(" / ")}`, - lines.some((l) => l.includes(" — ")) ? "warning" : "success", +/** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */ +export function toastKm(text: string): void { + if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success"); +} + +/** 항목 ▾ · 목적지 ▾ 한 줄 — 목록 밖 옛 항목 글도 보임 */ +function rowField(r: MilestoneRow, kind: "route" | "base") { + const item = createSelectField({ label: "항목", compact: true, options: [] }); + const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); + const setKind = (k: "route" | "base"): void => { + const list = [...ITEMS[k]]; + if (r.item && !list.includes(r.item)) list.unshift(r.item); + const keep = list.includes(item.select.value) ? item.select.value : r.item || list[0]; + item.setOptions( + list.map((t) => ({ value: t, text: t })), + keep, ); - return lines; + dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정 + }; + setKind(kind); + dest.select.value = r.dest; + item.select.dataset.b08MilestoneAdd = "item"; + return { item, dest, note: r.note, setKind }; } -/** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ -export function openAddItem( +export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string }; + +/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ +export function openPlaceModal( projectId: string, - viaBase: boolean, - addRoute: (init: Partial) => string, + target: PlaceTarget, + addRoute?: (init: Partial) => string, ): void { - openModal({ - title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로", - closeLabel: "닫기", - mount: (body, close, actions) => { - const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); - const company = createInputField({ label: "거래처 이름", compact: true }); - const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); - const address = buildAddressField(null); - item.input.dataset.b08MilestoneAdd = "item"; - company.input.dataset.b08MilestoneAdd = "company"; - const ok = createButton({ - label: "등록", - variant: "filled", - onClick: () => { - const name = company.input.value.trim(); - if (!name) return company.setError("거래처 이름을 넣으세요."); - const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; - const place = placeOf(address, { address: "" }); - const data = liveMilestone(projectId); - const same = data.routes.find((r) => r.name.trim() === name); - let id = same?.id; - if (same) { - same.rows.push(row); - if (place.lon != null) Object.assign(same, place); - putMilestone(projectId, data); - } else id = addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); - close(); - if (id && place.lon != null) void routeKm(projectId, id); // [등록] 뒤 한 번 - }, - }); - actions.append(ok); - body.append( - item.root, - company.root, - dest.root, - head("거래처 위치 — 주소 검색(방위는 이 자리로 자동)"), - address.root, - ); - }, - }); -} - -/** 거래처 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */ -export function openRouteInfo(projectId: string, routeId: string): void { const data = liveMilestone(projectId); - const route = data.routes.find((r) => r.id === routeId); - if (!route) return; + const editBase = !target.add && target.id === "base"; + const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined; + if (!target.add && !editBase && !route) return; + const title = target.add + ? `항목 추가 — ${target.viaBase ? "기준점 거침" : "공사지로 바로"}` + : editBase + ? `기준점 — ${data.base.name || "기준점"}` + : `항목 정보 — ${route!.name || "거래처"}`; openModal({ - title: `항목 정보 — ${route.name || "거래처"}`, + title, closeLabel: "닫기", + dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { + let kind: "route" | "base" = editBase ? "base" : "route"; + const sort = createSelectField({ + label: "분류", + compact: true, + options: [ + { value: "base", text: "기준위치" }, + { value: "route", text: "거래처" }, + ], + }); + sort.select.value = kind; + sort.select.disabled = !target.add; + // 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침) + const baseOption = sort.select.querySelector('option[value="base"]'); + if (target.add && data.base.enabled && baseOption) { + baseOption.disabled = true; + baseOption.textContent = "기준위치(있음 — 기준점 상자에서 고침)"; + } + sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); - name.input.value = route.name; - name.input.dataset.b08MilestoneInfo = "name"; - const via = createCheckbox("기준점 거침"); - via.input.checked = route.via_base; - via.root.hidden = !data.base.enabled; - const rows = rowsEditor(route.rows); - const address = buildAddressField(route.address || null); - let legs = legsEditor(route.legs); - const status = el("p", { className: "b08-options__hint" }); - status.dataset.b08MilestoneInfo = "status"; - const mapView = routeMapView(projectId, routeId); - /** 모달 값 → 캐시(고침 · 길찾기 앞) */ - const apply = (): boolean => { - const fresh = liveMilestone(projectId); - const r = fresh.routes.find((x) => x.id === routeId); - if (!r) return false; - Object.assign(r, placeOf(address, r), { - name: name.input.value.trim() || r.name, - via_base: via.input.checked, - rows: rows.value(), - legs: legs.value(), + name.input.value = editBase ? data.base.name : (route?.name ?? ""); + name.input.dataset.b08MilestoneAdd = "company"; + const nameLabel = name.root.querySelector(".ui-field__label"); + const initRows: MilestoneRow[] = route?.rows.length + ? route.rows + : [{ item: "", dest: "start", note: "" }]; + const rows = initRows.map((r) => rowField(r, kind)); + const [first] = rows; + const more = el("div"); + const addRow = (field: ReturnType): void => { + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(field), 1); + row.remove(); + }, }); - putMilestone(projectId, fresh); - return true; + const row = line(field.item.root, field.dest.root, drop); + more.append(row); }; - const save = createButton({ - label: "고침", + rows.slice(1).forEach(addRow); + const plusRow = createButton({ + label: "+ 줄", + size: "sm", + variant: "ghost", + onClick: () => { + const field = rowField({ item: "", dest: "start", note: "" }, kind); + rows.push(field); + addRow(field); + }, + }); + const via = createCheckbox("기준점 거침"); + via.input.checked = route?.via_base ?? true; + via.root.hidden = !route || !data.base.enabled; + const old = editBase ? data.base : route; + const place = placePicker( + projectId, + { address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null }, + () => name.input.value.trim() || (kind === "base" ? "기준점" : "거래처"), + ); + const setKind = (k: "route" | "base"): void => { + kind = k; + if (nameLabel) nameLabel.textContent = k === "base" ? "기준점 이름" : "거래처 이름"; + rows.forEach((r) => r.setKind(k)); + more.hidden = plusRow.hidden = k === "base"; + }; + sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base")); + setKind(kind); + const rowValues = (): MilestoneRow[] => + rows.map((r) => ({ + item: r.item.select.value, + dest: r.dest.select.value as Dest, + note: r.note, + })); + const moved = (p: { lon: number | null; lat: number | null }): boolean => + p.lon != null && (p.lon !== old?.lon || p.lat !== old?.lat); + + const ok = createButton({ + label: target.add ? "등록" : "고침", variant: "filled", onClick: () => { - apply(); + const text = name.input.value.trim(); + const where = place.value(); + const fresh = liveMilestone(projectId); + let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번 + if (kind === "base") { + Object.assign(fresh.base, where, { enabled: true, name: text, dir: null }); + if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }]; + putMilestone(projectId, fresh); + if (moved(where)) segment = "base"; + } else if (route) { + const r = fresh.routes.find((x) => x.id === route.id); + if (!r) return close(); + Object.assign(r, where, { + name: text || r.name, + via_base: via.input.checked, + rows: rowValues(), + }); + putMilestone(projectId, fresh); + if (moved(where) || r.via_base !== route.via_base) segment = r.id; + } else { + if (!text) return name.setError("거래처 이름을 넣으세요."); + const same = fresh.routes.find((r) => r.name.trim() === text); + if (same) { + same.rows.push(...rowValues()); + if (where.lon != null) Object.assign(same, where); + putMilestone(projectId, fresh); + } + const viaBase = target.add && target.viaBase; + const id = + same?.id ?? + addRoute?.({ name: text, via_base: viaBase, dir: null, rows: rowValues(), ...where }); + if (id && where.lon != null) segment = id; + } close(); + if (segment) void findSegmentKm(projectId, segment).then(toastKm); }, }); - // [길찾기] = 지금 모달 값을 캐시에 넣고 구간마다 카카오 km(서버) → 구간 칸 다시 그림 - const find = createButton({ - label: "길찾기", - variant: "ghost", - onClick: async () => { - if (!apply()) return close(); - find.disabled = true; - status.textContent = "길찾기 중…"; - const lines = await routeKm(projectId, routeId).finally(() => (find.disabled = false)); - status.textContent = lines.join(" / "); - const r = liveMilestone(projectId).routes.find((x) => x.id === routeId); - if (!r) return; - const next = legsEditor(r.legs); - legs.root.replaceWith(next.root); - legs = next; - mapView.draw(); // 길 선(그때그때 · 저장 안 함) - }, - }); - find.dataset.b08MilestoneInfo = "find"; + ok.dataset.b08MilestoneInfo = "save"; const remove = createButton({ label: "삭제", variant: "danger", onClick: async () => { - if ( - !(await showConfirmDialog( - `「${route.name || "거래처"}」 항목을 지울까요? — [저장] 때 반영`, - "삭제", - )) - ) + const label = editBase ? data.base.name || "기준점" : route?.name || "거래처"; + if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제"))) return; const fresh = liveMilestone(projectId); - fresh.routes = fresh.routes.filter((r) => r.id !== routeId); - putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐 + if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] }; + else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 + putMilestone(projectId, fresh); close(); }, }); - save.dataset.b08MilestoneInfo = "save"; remove.dataset.b08MilestoneInfo = "delete"; - actions.append(remove, find, save); + remove.hidden = target.add; + actions.append(remove, ok); body.append( - name.root, - via.root, - head("표 줄 — 항목 · 목적지"), - rows.root, - head( - `구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, - ), - legs.root, - status, - head("주소 · 지도"), - address.root, - head("경로 지도 — 카카오"), - mapView.root, - ); - }, - }); -} - -/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */ -export function openBaseInfo(projectId: string, create = false): void { - const base = liveMilestone(projectId).base; - openModal({ - title: create ? "기준점 추가" : `기준점 — ${base.name || "기준점"}`, - closeLabel: "닫기", - mount: (body, close, actions) => { - const name = createInputField({ label: "기준점 이름", compact: true }); - name.input.value = base.name; - const address = buildAddressField(base.address || null); - const legs = legsEditor(base.legs.length ? base.legs : [{ road: null, km: null }]); - const save = createButton({ - label: create ? "등록" : "고침", - variant: "filled", - onClick: () => { - const fresh: MilestoneData = liveMilestone(projectId); - Object.assign(fresh.base, placeOf(address, fresh.base), { - enabled: true, - name: name.input.value.trim(), - legs: legs.value(), - }); - putMilestone(projectId, fresh); - close(); - }, - }); - // 기준점 없앰 = 꺼 둠(값은 남김 · 거래처는 시점으로 바로) - const off = createButton({ - label: "기준점 없앰", - variant: "danger", - onClick: () => { - const fresh: MilestoneData = liveMilestone(projectId); - fresh.base.enabled = false; - putMilestone(projectId, fresh); - close(); - }, - }); - off.hidden = create; - save.dataset.b08MilestoneBase = "save"; - off.dataset.b08MilestoneBase = "off"; - actions.append(off, save); - body.append( - name.root, - head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"), - legs.root, - head("주소 · 지도"), - el("p", { - className: "b08-options__hint", - text: "주소를 고르면 다이어그램에 실제 방위로 놓임 — 안 고르면 공사지 서쪽(왼쪽) 기본 자리", + el("div", { + className: "b08-milestone__grid", + children: [sort.root, first.item.root, name.root, first.dest.root], }), - address.root, + more, + plusRow, + via.root, + head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), + place.root, ); }, }); @@ -410,7 +282,7 @@ export function openSiteInfo(projectId: string): void { mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", - options: roadOptions, + options: roadOptions(false, false, data.site.mid_road), compact: true, }); road.select.value = data.site.mid_road; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts new file mode 100644 index 000000000..aa65196e9 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Place.ts + * 거리이정표 등록 모달 「소재지」 칸(PLAN 244-13 · 사용자 147번 5) — 주소 찾기 · 지도에서 고르기만(직접 입력 없음). + * + * 주소 찾기 = B01 회사 주소와 같은 서버 후보 검색(VWorld 대리 · `searchAddress`) → 후보 고름 = 소재지 · 표지 + * 지도 = 다음(카카오) 지도 JS(`…_KakaoMap` editMap) — 지도 누름 = 그 자리 소재지 · 표지 끌어 옮김 = 다시 고름 · + * 고른 좌표 → 서버 GET …/milestone/reverse(VWorld 주소 한 줄 · 못 찾음 = 위도, 경도 글) + * 값 = 주소 글 · 경도 · 위도(캐시 · [저장] 때 서버) + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { siteInfo } from "./B08_Quantity_UI_Milestone_State"; + +export interface Place { + address: string; + lon: number | null; + lat: number | null; +} + +export interface PlaceHandle { + root: HTMLElement; + value: () => Place; +} + +/** 지번 후보 = 지번 줄이 시 · 도까지 갖춤(B01 주소 칸과 같은 고름) */ +const lineOf = (c: AddressCandidate): string => + (c.category === "parcel" ? c.parcel : c.road) || c.road || c.parcel; + +/** 좌표 → 주소 한 줄(서버 VWorld) · 못 찾음 = 「위도, 경도」 글 */ +export async function reverseAddress(projectId: string, lon: number, lat: number): Promise { + const got = await request<{ address: string | null }>( + `/projects/${projectId}/quantity/milestone/reverse?lon=${lon}&lat=${lat}`, + ).catch(() => null); + return got?.address || `${lat.toFixed(5)}, ${lon.toFixed(5)}`; +} + +/** 소재지 칸 — name() = 지도 표지 이름(거래처 · 기준점 이름) */ +export function placePicker(projectId: string, initial: Place, name: () => string): PlaceHandle { + let place: Place = { ...initial }; + let seq = 0; // 늦게 온 주소가 새 자리를 덮지 않게 + const query = createInputField({ + label: "주소 찾기", + compact: true, + placeholder: "도로명 · 지번 · 건물명 일부 (예 병곡리 968-31)", + }); + const find = createButton({ label: "주소 찾기", variant: "ghost", onClick: () => void search() }); + find.dataset.b08MilestonePlace = "find"; + const list = el("div", { className: "b01-dashboard__address-list" }); + list.hidden = true; + const where = el("p", { className: "b08-milestone__place" }); + where.dataset.b08MilestonePlace = "value"; + const map = editMap(projectId, { + hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김", + onClick: (lon, lat) => void moveTo(lon, lat), + onMove: (_key, lon, lat) => void moveTo(lon, lat), + center: () => siteInfo()?.start_lonlat ?? null, + }); + + const draw = (): void => { + where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`; + map.set( + place.lon != null && place.lat != null + ? [ + { + key: "place", + name: name() || "소재지", + lon: place.lon, + lat: place.lat, + movable: true, + }, + ] + : [], + [], + ); + }; + + async function moveTo(lon: number, lat: number): Promise { + const mine = ++seq; + place = { ...place, lon, lat }; + draw(); + const address = await reverseAddress(projectId, lon, lat); + if (mine !== seq) return; + place.address = address; + draw(); + } + + async function search(): Promise { + const text = query.input.value.trim(); + if (text.length < 2) return showToast("주소를 두 글자 이상 넣으세요.", "error"); + find.disabled = true; + try { + const items = await searchAddress(text); + list.hidden = false; + list.replaceChildren( + ...(items.length + ? items.map((item) => { + const option = el("button", { + className: "b01-dashboard__address-item", + attrs: { type: "button" }, + children: [ + el("strong", { text: lineOf(item) }), + el("small", { text: [item.building, item.zipcode].filter(Boolean).join(" · ") }), + ], + }); + option.addEventListener("click", () => { + seq++; + place = { address: lineOf(item), lon: item.lon, lat: item.lat }; + list.hidden = true; + draw(); + }); + return option; + }) + : [el("p", { text: "찾은 주소가 없습니다 — 다른 낱말로 찾거나 지도에서 고르세요." })]), + ); + } catch { + showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하세요.", "error"); + } finally { + find.disabled = false; + } + } + // 검색 칸 Enter = 찾기만(모달 안 닫힘) + query.input.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + void search(); + }); + + draw(); + return { + root: el("div", { + className: "b08-milestone__place-field", + children: [ + el("div", { className: "b08-milestone__line-edit", children: [query.root, find] }), + list, + where, + map.root, + ], + }), + value: () => ({ ...place }), + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index bdb3d9188..301afa94e 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -11,14 +11,16 @@ * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 + * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · + * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; -import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; +import { roadsFor, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { @@ -182,7 +184,11 @@ export async function autoRouteImage( routeId: string, lines: Array>, ): Promise { - const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); + // 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘) + const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice( + 0, + 2, + ); autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)"); changes.dispatchEvent(new Event("change")); let reason: string | null; @@ -213,59 +219,67 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); } -export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); +/** 도로 구분 ▾ — via = 기준점 거쳐 시점 들어가는 구간(2차 · 1차 · 비포장) · 아니면 포장 · 비포장(244-13) · + * blank = 「도로 구분 고르기」 빈 값(자동 판정 없음 · 244-7) · 목록 밖 옛 값도 보임 */ +export function roadOptions( + via: boolean, + blank: boolean, + current?: string | null, +): Array<{ value: string; text: string }> { + const roads: string[] = roadsFor(via); + if (current && !roads.includes(current)) roads.push(current); + return [ + ...(blank ? [{ value: "", text: "도로 구분 고르기" }] : []), + ...roads.map((r) => ({ value: r, text: r })), + ]; +} -/** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */ -export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions]; - -/** 마지막 [길찾기] 길 선 — 경로 id → 구간마다 [위도, 경도] 목록(메모리만 · 저장 안 함) */ -const paths = new Map>>(); +/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ +const paths = new Map>(); +export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; +/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ export const routePaths = (routeId: string): Array> => - paths.get(routeId) ?? []; + paths.has(routeId) ? [paths.get(routeId)!] : []; -/** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 · - * 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */ -export async function findRouteKm(projectId: string, routeId: string): Promise { - const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); - const out: string[] = []; - const got: Array<[KmSegment["key"], number]> = []; - const lines: Array> = []; - for (const seg of segments) { - if (!seg.from || !seg.to) { - const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; - out.push( - `${seg.text} — ${missing} 좌표가 없습니다(주소 검색 · 노선) · km 를 손으로 넣으세요.`, - ); - continue; - } - try { - const res = await request<{ - km: number | null; - reason: string | null; - path?: Array<[number, number]>; - }>(`/projects/${projectId}/quantity/milestone/directions`, { - method: "POST", - body: JSON.stringify({ - origin: { lon: seg.from[0], lat: seg.from[1] }, - destination: { lon: seg.to[0], lat: seg.to[1] }, - }), - }); - if (res.km == null) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`); - else { - out.push(`${seg.text} ${res.km} km`); - got.push([seg.key, res.km]); - if ((res.path?.length ?? 0) >= 2) lines.push(res.path!); - } - } catch (error) { - out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`); - } +/** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) · + * 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */ +export async function findSegmentKm( + projectId: string, + from: string, + image = true, +): Promise { + const data = liveMilestone(projectId); + const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); + if (!seg) return ""; + if (!seg.from || !seg.to) { + const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; + return `${seg.text} — ${missing} 좌표가 없습니다(소재지 · 노선) · km 를 손으로 넣으세요.`; } - paths.set(routeId, lines); - if (lines.length) void autoRouteImage(projectId, routeId, lines); // 뒤에서 · km 알림 안 기다림 - if (got.length) { + try { + const res = await request<{ + km: number | null; + reason: string | null; + path?: Array<[number, number]>; + }>(`/projects/${projectId}/quantity/milestone/directions`, { + method: "POST", + body: JSON.stringify({ + origin: { lon: seg.from[0], lat: seg.from[1] }, + destination: { lon: seg.to[0], lat: seg.to[1] }, + }), + }); + if (res.km == null) return `${seg.text} — ${res.reason ?? "길찾기 실패"}`; + if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!); + else paths.delete(from); const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 - for (const [key, km] of got) putAutoKm(fresh, routeId, key, km); - putMilestone(projectId, fresh); + const legs = from === "base" ? fresh.base.legs : fresh.routes.find((r) => r.id === from)?.legs; + if (legs?.[0]?.auto_km !== res.km) { + putAutoKm(fresh, from, seg.key, res.km); + putMilestone(projectId, fresh); + } else changes.dispatchEvent(new Event("change")); + if (image && seg.key === "route" && paths.has(from)) + void autoRouteImage(projectId, from, routePaths(from)); // 뒤에서 · km 알림 안 기다림 + return `${seg.text} ${res.km} km`; + } catch (error) { + return `${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`; } - return out; } diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py index 055f0c979..33e0396fa 100644 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -260,7 +260,9 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): assert "getDisplayMedia" not in text and "지도 캡처" not in text, name assert "캡처는 localhost" not in text, name state = read("B08_Quantity_UI_Milestone_State.ts") - assert "if (lines.length) void autoRouteImage(projectId, routeId, lines);" in state + # 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림 + assert 'if (image && seg.key === "route" && paths.has(from))' in state + assert "void autoRouteImage(projectId, from, routePaths(from));" in state assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state assert "user_only=true" in state and "showToast(reason," in state route = read("B08_Quantity_UI_Milestone_Route.ts") diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py new file mode 100644 index 000000000..e409a1606 --- /dev/null +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -0,0 +1,305 @@ +"""244-13 B08 거리이정표 등록 모달 · 연결선 길찾기 · 도로 구분 열(사용자 147번 5 · 8). + +도로 구분 = 기준점 거쳐 시점 들어가는 구간 2차포장 · 1차포장 · 비포장 · 그 밖 포장 · 비포장 +(파이썬 · TS 거울) · 연결선 구간 끝 · 표 열(2차포장 = 기준점 있을 때만) · 소재지 역지오코딩 길 · +고르는 지도(누름 · 끌기) · 연결선 한 구간 [길찾기](거래처 쪽만 자동 그림) — node 로 실제 TS 를 돌림. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import ValidationError + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" +CALC = B08 + "Milestone_Calc.ts" +LAYOUT = B08 + "Milestone_Layout.ts" +KMAP = B08 + "UI_Milestone_KakaoMap.ts" +STATE = B08 + "UI_Milestone_State.ts" +BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(rels, script, pre=""): + src = pre + "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + result = subprocess.run( # 원본이 길어 명령 줄 대신 표준 입력 + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=src + "\n" + script, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _route(rid, legs, via=True, rows=None, **kw): + return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "legs": legs, + "rows": rows or [{"item": "철근", "dest": "start", "note": ""}], **kw} # fmt: skip + + +NO_BASE = { + "site": {"mid_station_m": 800, "mid_road": "비포장"}, + "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "routes": [ + _route("g", [{"road": "포장", "km": 10.4}, {"road": "비포장", "km": 0.5}], rows=[ + {"item": "철근", "dest": "start", "note": ""}, + {"item": "혼합골재", "dest": "mid", "note": ""}]), + ], +} # fmt: skip +WITH_BASE = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "", "dir": None, "lon": 127.85, + "lat": 35.66, "legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]}, + "routes": [ + _route("g", [{"road": "2차포장", "km": 23.8}], lon=127.9, lat=35.68), + _route("d", [{"road": "포장", "km": 5}], via=False, lon=127.8, lat=35.6), + ], +} # fmt: skip + + +def test_포장_도로_구분_거울(): + a = compute_milestone(NO_BASE, 1500.0) + assert [(r["pv"], r["un"], r["paved"], r["total"]) for r in a] == [ + (10.4, 0.5, 10.4, 10.9), + (10.4, 1.3, 10.4, 11.7), # 목적지 중점 = 시점 → 중점 0.8 km 비포장 더함 + ] + b = compute_milestone(WITH_BASE, 1500.0) + assert (b[0]["s2"], b[0]["s1"], b[0]["pv"], b[0]["un"]) == (23.8, 0.6, 0, 0.8) + assert (b[0]["paved"], b[0]["total"]) == (24.4, 25.2) # 견본 수식 = 포장 2차 + 1차 + assert (b[1]["s2"], b[1]["pv"], b[1]["paved"], b[1]["total"]) == (0, 5, 5, 5) # 거침 안 함 + ts = _node([CALC], f"console.log(JSON.stringify([computeMilestone({json.dumps(NO_BASE)}, " + f"1500), computeMilestone({json.dumps(WITH_BASE)}, 1500)]));") # fmt: skip + assert ts == [a, b] + assert ms.Leg.model_validate({"road": "포장", "km": 1}).road == "포장" + with pytest.raises(ValidationError): + ms.Leg.model_validate({"road": "3차포장"}) + + +def test_도로_구분_갈래_연결선_구간_끝(): + d = json.dumps(WITH_BASE) + off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}) + got = _node( + [CALC, LAYOUT], + f"""const D = {d}; const O = {off}; const S = [127.83, 35.64]; +console.log(JSON.stringify([roadsFor(true), roadsFor(false), + ["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)), + segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S), + segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S), + kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""", + ) + assert got[0] == ["2차포장", "1차포장", "비포장"] and got[1] == ["포장", "비포장"] + assert got[2] == [True, False, True, False] and got[3] == [False, False] + assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], + "to": [127.85, 35.66]} # fmt: skip + assert got[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64] + assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], + "to": [127.83, 35.64]} # fmt: skip + assert got[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음 + assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 + assert got[10] == ["route", "base"] and got[11] == ["route"] + + +def test_표_열_기준점_따라(): + got = _node( + [B08 + "UI_Milestone.ts"], + "console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>" + " milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));", + ) + assert got[0] == ["|항목|item", "|거래처 이름|company", "|목적지|dest", + "도로 구분 (km)|포장|paved", "도로 구분 (km)|비포장|un", + "계 (km)|합계|total", "|비고|note"] # fmt: skip + roads = [c for c in got[1] if c.startswith("도로 구분")] + assert roads == ["도로 구분 (km)|2차포장|s2", "도로 구분 (km)|1차포장|s1", + "도로 구분 (km)|비포장|un"] # fmt: skip + assert "계 (km)|포장|paved" in got[1] and "계 (km)|비포장|unpaved" in got[1] + assert "도로 구분 (km)|포장|pv" in got[2] # 기준점 있어도 거침 안 한 「포장」 값 = 포장 열 + assert not any("2차포장" in c for c in got[0]) # 2차포장 열 = 기준점 있을 때만 + + +def test_소재지_역지오코딩(monkeypatch): + asked = [] + + async def fake(lon, lat): + asked.append((lon, lat)) + return "경남 거창군 북상면 병곡리 968-31" + + monkeypatch.setattr(ms, "reverse_geocode", fake) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + res = client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 35.66}) + assert res.json() == {"address": "경남 거창군 북상면 병곡리 968-31"} + assert asked == [(127.85, 35.66)] + assert client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 91}).status_code == 422 + + +MAP_STUBS = """ +const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "", + className: o.className, dataset: {}, children: o.children ?? [] }); +const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); +(globalThis as any).window = globalThis; +(globalThis as any).document = { createElement: () => ({}), head: { append: () => undefined } }; +const request = async () => ({ key: "fake-js-key" }); +const LOG: any = { markers: [], handlers: [], lines: 0 }; +class LatLng { lat: number; lng: number; + constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; } + getLat() { return this.lat; } getLng() { return this.lng; } } +const overlay = () => ({ setMap: () => undefined }); +(globalThis as any).kakao = { maps: { + load: (f: () => void) => f(), LatLng, + LatLngBounds: class { extend() {} }, + Map: class { relayout() {} setBounds() { LOG.fit = true; } + setCenter(p: any) { LOG.center = [p.lng, p.lat]; } setLevel(l: number) { LOG.level = l; } }, + Polyline: function () { LOG.lines++; return overlay(); }, + Marker: function (o: any) { const m = { ...overlay(), o, at: o.position, + getPosition: () => m.at }; LOG.markers.push(m); return m; }, + CustomOverlay: function () { return overlay(); }, + event: { addListener: (t: any, type: string, fn: any) => LOG.handlers.push([t, type, fn]) }, +} }; +""" + + +def test_고르는_지도_누름_끌기(): + got = _node( + [KMAP], + """ +const moved: any[] = []; const clicked: any[] = []; +const m = editMap("p1", { hint: "h", onMove: (k, lon, lat) => moved.push([k, lon, lat]), + onClick: (lon, lat) => clicked.push([lon, lat]), center: () => [127.83, 35.64] }); +m.set([], []); +await new Promise((r) => setTimeout(r, 20)); +const empty = { center: LOG.center, level: LOG.level, hidden: m.root.children[1].hidden }; +m.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }, + { key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }], + [[[35.68, 127.9], [35.64, 127.83]]]); +await new Promise((r) => setTimeout(r, 20)); +const click = LOG.handlers.find(([, type]) => type === "click"); +click[2]({ latLng: new LatLng(35.7, 127.95) }); +const drags = LOG.handlers.filter(([, type]) => type === "dragend"); +const [r1] = LOG.markers.slice(-2); +r1.at = new LatLng(35.69, 127.91); +drags[0][2](); +console.log(JSON.stringify({ empty, moved, clicked, drags: drags.length, fit: !!LOG.fit, + lines: LOG.lines, draggable: LOG.markers.slice(-2).map((x: any) => !!x.o.draggable), + clicks: LOG.handlers.filter(([, t]) => t === "click").length })); +""", + MAP_STUBS, + ) + assert got["empty"] == { + "center": [127.83, 35.64], + "level": 9, + "hidden": False, + } # 점 없음 = 시점 + assert got["draggable"] == [True, False] and got["drags"] == 1 # 시점 = 고정 + assert got["moved"] == [["r1", 127.91, 35.69]] and got["clicked"] == [[127.95, 35.7]] + assert got["fit"] and got["lines"] == 1 and got["clicks"] == 1 # 지도 누름 사건 한 번만 + + +STATE_STUBS = """ +const API_BASE_URL = ""; +const showToast = () => undefined; +const store = new Map(); +const readState = (k: string, p: string) => store.get(k + p) ?? null; +const writeState = (k: string, v: any, p: string) => (store.set(k + p, v), WRITES.n++); +const clearState = (k: string, p: string) => store.delete(k + p); +const WRITES = { n: 0 }; +const CALLS: any[] = []; +const request = async (url: string, init: any = {}) => { + CALLS.push([url, init.body ? JSON.parse(init.body) : null]); + if (url.endsWith("/milestone")) return { data: DATA, site: { start_lonlat: [127.83, 35.64], + mid_default_m: 1500 }, images: {}, auto_images: {} }; + if (url.includes("/directions")) return { km: 10.5, reason: null, + path: [[35.68, 127.9], [35.66, 127.85]] }; + return { name: "auto/g.png", reason: null }; +}; +""" + + +def test_연결선_한_구간_길찾기_거래처_쪽만_그림(): + script = f"""const DATA = {json.dumps(WITH_BASE)}; +await loadMilestone("p1"); +const a = await findSegmentKm("p1", "g"); +await new Promise((r) => setTimeout(r, 10)); +const afterRoute = CALLS.length; +const b = await findSegmentKm("p1", "base"); +const writes = WRITES.n; +await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀 +const live = liveMilestone("p1"); +console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n, + route: live.routes[0].legs, base: live.base.legs, path: segmentPath("g"), + routeLines: routePaths("g"), baseLines: routePaths("base"), + start: await findSegmentKm("p1", "start") }}));""" + got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS) + assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km" + (url, body), (img, payload), (url2, body2) = got["calls"][:3] + assert url.endswith("/directions") and body == { + "origin": {"lon": 127.9, "lat": 35.68}, + "destination": {"lon": 127.85, "lat": 35.66}, + } + assert img.endswith("/image/auto?route=g") # 거래처 쪽 구간 = 자동 그림 + assert payload["lines"] == [[[35.68, 127.9], [35.66, 127.85]]] + assert [m["name"] for m in payload["marks"]] == ["g", "병곡리"] # 두 끝만(시점 없음) + assert body2["origin"] == {"lon": 127.85, "lat": 35.66} + assert body2["destination"] == {"lon": 127.83, "lat": 35.64} + assert not any( + "/image/auto" in c[0] for c in got["calls"][got["afterRoute"] :] + ) # 기준점 = 그림 없음 + assert got["route"][0]["km"] == 23.8 and got["route"][0]["auto_km"] == 10.5 # 사용자 값 그대로 + assert got["base"][0]["auto_km"] == 10.5 and got["again"] == got["writes"] + assert got["routeLines"] == [got["path"]] and got["baseLines"] == [ + got["path"] + ] # 구간마다 길 선 + assert got["start"] == "" # 시점 → 중점 = 길찾기 없음 + + +def test_화면_등록_모달_연결선_모달(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + place = _read(B08 + "UI_Milestone_Place.ts") + leg = _read(B08 + "UI_Milestone_LegModal.ts") + diagram = _read(B08 + "UI_Milestone_Diagram.ts") + css = _read(B08 + "UI_Milestone.css") + # ① 큰 창 · 2열 · 분류 · 항목 목록 · 소재지 · 직접 입력 없음 · 다음 지도 + assert 'dialogClass: "b08-milestone__modal"' in modal + assert ".ui-modal.b08-milestone__modal {\n width: min(960px, 94vw);" in css + assert "grid-template-columns: 1fr 1fr;" in css + assert "children: [sort.root, first.item.root, name.root, first.dest.root]" in modal + assert ( + '{ value: "base", text: "기준위치" }' in modal + and '{ value: "route", text: "거래처" }' in modal + ) + assert 'route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"]' in modal + assert "소재지" in modal and "거래처 위치" not in modal + for text in (modal, place): + assert 'label: "직접 입력"' not in text and "buildAddressField" not in text + assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place + assert "/quantity/milestone/reverse?lon=" in place + # ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음 + assert 'base: () => openPlaceModal(projectId, { add: false, id: "base" }),' in diagram + assert 'label: "기준점 +"' not in diagram and "addBase" not in diagram + # ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만 + assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram + assert "clearTimeout(wait);" in diagram # 선 글 더블클릭 = 바로 고침 그대로 + mid = leg[leg.index("function openMidLeg") : leg.index("export function openLegInfo")] + assert "길찾기" not in mid.replace("길찾기 없음", "") and "editMap" not in mid + assert 'if (from === "start") return openMidLeg(projectId);' in leg + assert "void reverseAddress(projectId, lon, lat)" in leg and "void run();" in leg + assert "if (!segmentPath(from).length && seg.from && seg.to) void run(false);" in leg + for rel in (B08 + "UI_Milestone_Modal.ts", B08 + "UI_Milestone_Place.ts", + B08 + "UI_Milestone_LegModal.ts", KMAP, STATE, LAYOUT, CALC, + B08 + "UI_Milestone.ts", B08 + "UI_Milestone_Diagram.ts", + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700, rel diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index c69fd4724..ef1e7b574 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -162,8 +162,8 @@ def test_메인_탭_하위_탭_추가_삭제(): # 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만) assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") - assert "fresh.routes = fresh.routes.filter((r) => r.id !== routeId);" in modal - assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui + assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal + assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index 4c5663804..a8dc4adb0 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -141,13 +141,16 @@ def test_빈_방위_차례_같은_방위_나란히(): def test_화면_상자_누름_더하기_창(): ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8") # 244-4 — 상자 누름 = 모달(탭 이동 아님) - assert "openTab" not in ui and "route: (id) => openRouteInfo(projectId, id)," in ui + assert ( + "openTab" not in ui + and "route: (id) => openPlaceModal(projectId, { add: false, id })," in ui + ) assert 'if (box.kind === "site") g.append(plus(box, false));' in ui assert 'if (box.kind === "base") g.append(plus(box, true));' in ui - assert "openAddItem(projectId, viaBase, addRoute)" in ui + assert "openPlaceModal(projectId, { add: true, viaBase }, addRoute)" in ui modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8") assert "openModal({" in modal - assert "same.rows.push(row)" in modal # 같은 업체 이름 = 그 경로에 표 줄 + assert "same.rows.push(...rowValues())" in modal # 같은 업체 이름 = 그 경로에 표 줄 page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 91f57ffe6..8d533d060 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -140,30 +140,30 @@ def test_시점_모달_주소_길(tmp_path, monkeypatch): def test_화면_모달_팬줌_그림탭(): modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") - # ① [+] 모달 — 항목 · 업체 이름 · 목적지 + 주소 검색(B01 재활용) · 방위 고르기 없음 · [등록] + # ① [+] 모달 — 항목 · 거래처 이름 · 목적지 + 소재지(244-13 · 주소 찾기 = B01 후보 검색 재활용 · + # 지도 = 다음 지도) · 방위 고르기 없음 · [등록] add = modal[ - modal.index("export function openAddItem") : modal.index("export function openRouteInfo") + modal.index("export function openPlaceModal") : modal.index("export interface SiteAddress") ] - for text in ('label: "항목"', 'label: "거래처 이름"', 'label: "목적지"', 'label: "등록"', - "buildAddressField(null)", "placeOf(address,"): # fmt: skip + for text in ('label: "항목"', 'label: "목적지"'): # 항목 · 목적지 줄 = rowField + assert text in modal + for text in ('label: "거래처 이름"', '"등록"', "placePicker(", "place.value()"): assert text in add assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal - assert 'from "../B01_Dashboard/B01_Dashboard_UI_AddressField"' in modal - assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal - field = _read("B01_Dashboard/B01_Dashboard_UI_AddressField.ts") - assert "point: () => picked," in field and "searchAddress(text)" in field + place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts") + assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place + assert "searchAddress(text)" in place + assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도 # ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도 assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal - # ⑤ 업체 상자 = 항목 정보(구간 km · 도로 구분 · 고침 · 삭제) - info = modal[ - modal.index("export function openRouteInfo") : modal.index("export function openBaseInfo") - ] - for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs)', - "rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip - assert text in info + # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 + for text in ('"고침"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): + assert text in add + leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert "legsEditor(legsOf(), via)" in leg diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert "route: (id) => openRouteInfo(projectId, id)," in diagram + assert "route: (id) => openPlaceModal(projectId, { add: false, id })," in diagram assert "start: () => openSiteInfo(projectId)," in diagram # ③ 팬 · 줌 = 공용 assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index d5c4ac8cd..0b1b161e1 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -55,8 +55,10 @@ def test_좌측_패널_방위_칸_없음_모달_안내(): assert "공사지에서 본 방위" not in state and "dirOptions" not in state assert "d.base.dir" not in state # 244-6 — 기준점 칸은 다이어그램 기준점 모달로 modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") - base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] - assert "주소를 고르면 다이어그램에 실제 방위로 놓임" in base + base = modal[ + modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") + ] + assert "방위는 이 자리로 자동" in base # 244-13 — 기준점 = 등록 모달 분류 기준위치 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") assert ( "placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270" in layout diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 341e66d4b..9b5248ef8 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -118,11 +118,13 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): site = modal[modal.index("export function openSiteInfo") :] assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site - base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] - assert "enabled: true," in base and 'label: "기준점 없앰"' in base - assert "fresh.base.enabled = false;" in base - assert 'label: "기준점 +"' in ui and "openBaseInfo(projectId, true)" in ui - assert "addBase.hidden = data.base.enabled;" in ui + # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음 + base = modal[ + modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") + ] + assert "enabled: true," in base and 'label: "삭제"' in base + assert "fresh.base = { enabled: false," in base + assert 'label: "기준점 +"' not in ui and "openBaseInfo" not in ui and "addBase" not in ui assert "content: panContent(vw, vh)" in ui and "panStart," in ui diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 75274d1c0..0392f8230 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -219,8 +219,10 @@ def test_화면_길찾기_단추_키_화면에_없음(): modal = read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") state = read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") table = read("B08_Quantity/B08_Quantity_UI_Milestone.ts") - assert 'label: "길찾기"' in modal and "routeKm(projectId, id)" in modal # 단추 · [등록] 뒤 - assert "/quantity/milestone/directions" in state and "kmSegments(" in state + # 244-13 — [길찾기] = 연결선 모달 단추 · [등록] 뒤 그 구간 한 번 + leg = read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert 'label: "길찾기"' in leg and "findSegmentKm(projectId, segment)" in modal + assert "/quantity/milestone/directions" in state and "segmentEnds(" in state assert '"도로 구분 고르기"' in table and "r.pick" in table assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름 # 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음 diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 5ee1d655f..1d25f62a8 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -215,13 +215,13 @@ def test_화면_자리_캡처_키_서버만(): kmap = _read(KMAP) assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동 - modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") - assert "mapView.root" in modal and "mapView.draw();" in modal + leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도 + assert "map.root," in leg and "map.set(pins()" in leg route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert "routeMapView(projectId, routeId)" in route assert "mapView.draw();" in route state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") - assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감) + assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감) for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만 text = rel.read_text(encoding="utf-8") assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel From 6ff34a92f29a812c5dfc85fac93157d559b95d7a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:43:55 +0900 Subject: [PATCH 86/92] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=93=B1=EB=A1=9D=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=3D=20=EC=86=90=20=EC=9E=85=EB=A0=A5=20+?= =?UTF-8?q?=20=EB=B6=84=EB=A5=98=EB=B3=84=20=EC=A0=9C=EC=95=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D(244-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항목 칸 = 손 입력 + datalist 제안(거래처 = 견본 다섯 · 기준위치 = 기준점) · 목록 밖 글도 받음 - 분류 바꿈 = 제안만 바뀜 · 쓴 글 그대로 · 줄마다 제안 목록 따로 - 시험 test_244_14 (실제 rowField 를 node 로) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B08_Quantity_UI_Milestone_Modal.ts | 25 +++--- .../test_244_14_b08_milestone_item_input.py | 83 +++++++++++++++++++ 2 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_244_14_b08_milestone_item_input.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 74bc8a704..f0454ab76 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -3,7 +3,7 @@ * 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침. * * 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 · - * 2열(분류(기준위치 · 거래처) · 항목(분류 따라 고르기 목록) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · + * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제] * 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정 * 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리 @@ -56,23 +56,24 @@ export function toastKm(text: string): void { if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success"); } -/** 항목 ▾ · 목적지 ▾ 한 줄 — 목록 밖 옛 항목 글도 보임 */ +let listSeq = 0; + +/** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */ function rowField(r: MilestoneRow, kind: "route" | "base") { - const item = createSelectField({ label: "항목", compact: true, options: [] }); + const item = createInputField({ label: "항목", compact: true }); + const list = el("datalist", { attrs: { id: `b08-milestone-items-${++listSeq}` } }); + item.input.setAttribute("list", list.id); + item.input.value = r.item; + item.root.append(list); const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); const setKind = (k: "route" | "base"): void => { - const list = [...ITEMS[k]]; - if (r.item && !list.includes(r.item)) list.unshift(r.item); - const keep = list.includes(item.select.value) ? item.select.value : r.item || list[0]; - item.setOptions( - list.map((t) => ({ value: t, text: t })), - keep, - ); + list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } }))); + item.input.placeholder = `예 ${ITEMS[k][0]}`; dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정 }; setKind(kind); dest.select.value = r.dest; - item.select.dataset.b08MilestoneAdd = "item"; + item.input.dataset.b08MilestoneAdd = "item"; return { item, dest, note: r.note, setKind }; } @@ -169,7 +170,7 @@ export function openPlaceModal( setKind(kind); const rowValues = (): MilestoneRow[] => rows.map((r) => ({ - item: r.item.select.value, + item: r.item.input.value.trim(), dest: r.dest.select.value as Dest, note: r.note, })); diff --git a/resources/tester/test_244_14_b08_milestone_item_input.py b/resources/tester/test_244_14_b08_milestone_item_input.py new file mode 100644 index 000000000..f13a81e1a --- /dev/null +++ b/resources/tester/test_244_14_b08_milestone_item_input.py @@ -0,0 +1,83 @@ +"""244-14 B08 거리이정표 [+] 모달 「항목」 = 손 입력 + 제안 목록(사용자 120-답 ③ 「항목: 수기 입력」). + +거래처 제안 = 견본 다섯 · 기준위치 제안 = 기준점 · 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜(쓴 글 그대로) +— node 로 실제 rowField 를 돌림(가짜 DOM). +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MODAL = ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts" +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +STUBS = """ +type N = any; +const el = (tag: string, o: any = {}): N => { + const n: N = { tag, children: [] as N[], attrs: { ...(o.attrs ?? {}) }, dataset: {}, style: {}, + value: "", placeholder: "", + get id() { return n.attrs.id; }, + setAttribute(k: string, v: string) { n.attrs[k] = v; }, + append(...c: N[]) { n.children.push(...c); }, + replaceChildren(...c: N[]) { n.children = c; } }; + return n; +}; +const createInputField = (o: any) => { + const input = el("input"); + return { root: el("div", { children: [] }), input, label: o.label, setError: () => undefined }; +}; +const createSelectField = (o: any) => { + const select = el("select"); + return { root: el("div"), select, setOptions: () => undefined }; +}; +""" + +SCRIPT = """ +const sug = (f: any) => f.item.root.children.find((c: N) => c.tag === "datalist") + .children.map((c: N) => c.attrs.value); +const a = rowField({ item: "", dest: "start", note: "" }, "route"); +const empty = { value: a.item.input.value, list: sug(a), ph: a.item.input.placeholder, + linked: a.item.input.attrs.list === a.item.root.children[0].id }; +a.item.input.value = " 레미콘(25-21-150) "; // 목록 밖 손 글 +a.setKind("base"); +const base = { value: a.item.input.value, list: sug(a), ph: a.item.input.placeholder }; +a.setKind("route"); +const old = rowField({ item: "옛 항목", dest: "mid", note: "" }, "route"); +const b = rowField({ item: "", dest: "start", note: "" }, "route"); +console.log(JSON.stringify({ empty, base, back: a.item.input.value, list: sug(a), + old: [old.item.input.value, old.dest.select.value, sug(old)], + ids: new Set([a, old, b].map((f) => f.item.input.attrs.list)).size, + tag: a.item.input.dataset.b08MilestoneAdd })); +""" + + +def test_항목_손_입력_제안_분류_바꿔도_글_유지(): + src = STUBS + IMPORTS.sub("", MODAL.read_text(encoding="utf-8")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=src + "\n" + SCRIPT, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + got = json.loads(result.stdout) + five = ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"] + # 빈 줄 = 빈 손 입력 칸 + 거래처 제안 다섯(datalist 이어짐) + assert got["empty"] == {"value": "", "list": five, "ph": "예 장비(20톤)", "linked": True} + # 분류 → 기준위치 = 제안만 기준점 · 쓴 글 그대로 + assert got["base"] == { + "value": " 레미콘(25-21-150) ", + "list": ["기준점"], + "ph": "예 기준점", + } + assert got["back"] == " 레미콘(25-21-150) " and got["list"] == five + # 옛 항목 글 = 그대로 칸에 · 목적지 그대로 + assert got["old"] == ["옛 항목", "mid", five] + assert got["ids"] == 3 and got["tag"] == "item" # 줄마다 제안 목록 따로 + + +def test_저장_값_손_글_그대로(): + modal = MODAL.read_text(encoding="utf-8") + assert "item: r.item.input.value.trim()," in modal # 목록 밖 글도 받음(앞뒤 빈칸만 뺌) + assert 'createSelectField({ label: "항목"' not in modal + assert len(modal.splitlines()) <= 700 From 9ac7a3fd129872f9e0c063b9146ca1eabe92a8ae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:44:51 +0900 Subject: [PATCH 87/92] =?UTF-8?q?chore(plan):=2010-10=20244=20=C2=B7=20246?= =?UTF-8?q?~248=EC=9E=A5=20=EA=B8=B0=EB=A1=9D=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-10_plan_244-248.md | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/raw/plans/2026-10-10_plan_244-248.md diff --git a/docs/raw/plans/2026-10-10_plan_244-248.md b/docs/raw/plans/2026-10-10_plan_244-248.md new file mode 100644 index 000000000..959dd8622 --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_244-248.md @@ -0,0 +1,57 @@ +# 2026-10-10 계획 244 · 246~248장 기록 — 거리이정표 · 설계내역서 메뉴 · 초록 램프 · 다른 탭 빨강 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 120 · 128~147 · 조사 AH. + +## 244. B08 수량산출 · 메인 탭 「거리이정표」 (sub8) +정함 = 사용자 120번 + 120-답 · 조사 AH · 브이월드는 길찾기 API 없음(공개 목록에 없음) → 길찾기 = 카카오모빌리티 길찾기 REST(키 = 사용자 발급 · 오기 전엔 km 손 입력) · 그림 = 서버가 VWorld 바탕 + 길 선(이미 쓰는 VWorld 길) + 사진 올리기 +- 짜임: 메인 탭 「거리이정표」 → 하위 탭 [거리이정표](다이어그램 + 표) · [경로 하나씩](컨테이너 하나 = 그림 하나 + 구간 km) · [+ 추가] · [삭제] +- 공사지 = 시점(노선 첫 점) + 중점(사용자 입력 측점 · 기본 = 노선 길이 ÷ 2) 한 컨테이너 · 기준점 = 있거나 없음(예 병곡리 968-31) +- 다이어그램 = 컨테이너(상자) 로 추가 · 고침 · 상자 자리 방향 = 기준점(없으면 공사지) 기준 지도 방위 · 선마다 구간 km · 도로 구분 +- 도로 구분 = 구간(기점)마다 드롭다운(2차포장 · 1차포장 · 비포장) 사용자 고름 · 자동 판정 없음 +- 표 = 항목(수기) · 업체 이름(수기) · 목적지(시점 · 중점 등 고름) · 도로 구분 km(2차포장 · 1차포장 · 비포장) · 계(포장 · 비포장 · 합계) · 비고 — 견본 수식(포장 = 2차 + 1차) +- 항목 추가 = 구분 정보 + 지도 위치(주소 검색 → 좌표) + 길찾기 값(km · 길 선)을 받음 +- 정본 = 프로젝트 storage B08_Quantity/milestone/(json + 경로 그림) · 표 합 = 파이썬 · TS 짝 · 주소 · 길찾기 · 그림 = 서버 단독 +- [x] 244-1 데이터 · 라우터(GET · PUT) · 메인 탭 · 하위 탭(추가 · 삭제) · 공사지(시점 · 중점) · 표(구간 드롭다운 · 합 짝) · km 손 입력 · 사진 올리기 — 새 시험 + - 검증완료 — test_244_1 6건(견본 거창군청 2차 23.8 + 1차 0.6 + 비포장 0.8 = 포장 24.4 · 합계 25.2 · 중점 목적지 = 노선 ÷ 2 비포장 더함 · TS 거울 같음 · 중점 기본 1500 m 노선 → 750 · 저장 왕복 · 그림 교체 · 지우기 · 경로 삭제 뒤 [저장] = 그 그림 지움 · 겹친 id 422) + B08 관련 · 243_1 · 122 통과(203) · typecheck · prettier · ruff +- [x] 244-2 다이어그램(컨테이너 · 방위 자리 · 선 · km · 도로 구분 · 상자로 추가 · 고침) — 새 시험 + - 검증완료 — test_244_2 6건(공사지 컨테이너 안 시점 왼 · 중점 오른 · 선 「비포장 0.75km」 · 8방 자리 E = 오른 · N = 위 · 기준점 W = 공사지 서쪽 · 거창군청 N = 기준점 북쪽 · 선 글 「2차포장 23.8km」 · 「1차포장 0.6km」 「비포장 0.8km」 · 경위도 동쪽이면 고른 N 보다 동 · 같은 방위 둘 = 옆으로 64 · 안 고름 = 빈 방위 북 → 북서 · 상자 안 겹침) + B08 관련 · 243_1 · 244_1 통과(209) · typecheck · prettier · ruff +- [x] (244-4 로 바뀜 · 130번) 244-3 지도 위치(주소 → 좌표 · VWorld 지오코더) · 경로 그림(VWorld 바탕 + 길 선) · 길찾기(카카오 REST · 키 있을 때만 · 없으면 안내 한 줄) — 새 시험 + +## 246. B09 설계내역서 — 입력 칸 테두리 되돌림 · 우클릭 「항목 추가」 하나 · 연결 이름 줄임 · 「줄 삭제」 (sub3) +정함(브레인) = 사용자 128번 · ① 241 · 243 입력 칸 테두리(글 상자 테두리 · td 늘 테두리)를 241 앞 모양으로 되돌림(더블클릭 입력은 그대로) ② 우클릭 「위에 줄 추가」 · 「아래에 줄 추가」 · 「아래에 하위 줄 추가」 · 「새 공종 추가(1단)」 → 「항목 추가」 하나(누른 줄이 항목 = 바로 아래 같은 레벨 · 폴더 = 그 폴더 첫 하위 · 1단은 레벨 단추 · 방향키로) ③ 연결 메뉴 이름 = 「구조물 조합」 · 「일위대가 사전조합」 · 「단가산출」(서고 · 마스터 · 회사 · 개인 · M01 · 새 산근 글 지움 · 호표 둘은 그대로) ④ 「줄 지우기」 → 「줄 삭제」 +- [x] 246-1 네 가지 — 시험 기대값 · 새 시험 + - 검증완료 — test_246_1 3건(입력 칸 accent 5% 채움 · box-shadow · is-can-edit 없음 · 줄마다 더함 = 「항목 추가」 하나 · 「줄 삭제」 · 연결 이름 다섯(서고 · 층 · M01 · 새 산근 글 없음) · 알림 글 서고 없음 · 안내 글 · 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모 · ← 한 단씩 · 관급 안 손 묶음 ← = 새 공종 직접비 · 관급자재대 앞 · 자재 줄 1단 막음 · 1단 손 항목 = 묶음) + 241_1 · 243_1 · 166_2 · 182 · 184_3 · 204_2 기대값 고침 · 204_1 · 235_1 · 200_2 · 240_1 · 236_1 · 202_1 · 201_1 · 121 · 242_1 · 245_1 통과 · typecheck · prettier · ruff +- [x] 246-2 (129번) 우클릭 「연결 ▸」 = 같은 패널 안에 펼침 대신 오른쪽 옆 새 판(하위 메뉴)으로 연결 다섯(일위대가 호표 · 단가산출 호표 · 구조물 조합 · 일위대가 사전조합 · 단가산출) · 공용 우클릭 메뉴 children 꼴 · 화면 끝이면 왼쪽으로 + - 검증완료 — test_246_2 2건(「연결 ▸」 한 줄 · 올림 = 옆 판(메뉴 오른쪽 260 · 첫 줄 = 머리 줄 높이 · fixed) · 다른 줄 올림 · ← = 닫힘(머리로 초점) · 누름 · → = 열고 첫 줄 초점 · 고름 = 닫고 동작 · 오른쪽 끝 = 메뉴 왼쪽 · 아래 끝 = 올림 · Esc · 바깥 누름 = 닫힘 · 안 누름 = 그대로 · 옛 아코디언 없음) + 129(B05 구조물 메뉴) 통과 · 영향 = 공용 메뉴 children 쓰는 곳 모두 옆 판(B09 연결 · B05 종단 · 배수 구조물군 · 스프레드시트 칸 메뉴 붙여넣기 · 삽입 · 삭제 · 고정 · 그룹 · 시트 탭 숨김 해제) +- [x] 244-4 (130번 고침) ① 공사지 [+] = 모달(항목 · 업체 이름 · 목적지 + 아래 주소 검색 · 등록되면 그 자리 지도) · 방위 = 지도 위치로 자동 자리(고르기 없앰) · 주소 검색 · 지도 = B02 프로젝트 등록 · B01 회사 주소(geocode · 후보 검색 · 지도 미리 보기) 재활용 ② 시점 상자 누름 = 모달(프로젝트 등록 때 주소 · 브이월드 주소 · 아래 지도) ③ 다이어그램 영역 팬 · 줌 = 공용 A00_Common/b_svg_zoom_pan.ts ④ 경로 하위 탭 = 길찾기 지도 결과만(구간 표 · 표 줄 · 하위 탭 [추가] 단추 없앰 · 탭은 항목 추가 · 삭제로만 생기고 없어짐) · 길찾기 API 없으면 [다음 지도에서 길찾기] = 새 창 map.kakao.com 길찾기 주소(출발 · 도착 좌표) → 사용자가 길 확인 → [화면 캡처](getDisplayMedia 로 그 창 한 장) → 그 탭 그림 ⑤ 상자 누름 = 그 항목 정보 모달(탭 이동 아님 · km · 도로 구분 · 고침 · 삭제는 모달 안) ⑥ (130-6) 다이어그램 선 위 km · 도로 구분 글을 다이어그램에서 바로 고침(더블클릭 = km 글 상자 · 도로 구분 ▾ · 모달과 한 벌) — 새 시험 + - 검증완료 — test_244_4 6건(업체 좌표 = 방위 자동 · 기준점 좌표 없음 = 시점 기준 남 · 있음 = 기준점 기준 북 · 다음 지도 /link/by/car/이름,위도,경도 꼴 · 이름 쉼표 · 빗금 = 빈칸 · VWorld getAddress 도로명 먼저 · 지번 · 깨진 응답 None · site-address = 사업지역 · 시점 · 중점 주소 · [+] 모달 칸 · 방위 칸 없음 · B01 주소 칸 재활용 · 공용 팬 줌 · 경로 탭 표 없음 · 새 창 · getDisplayMedia → 그림 올리기 · 선 글 고침 = 업체 23.8 · 공통 비움 · 중점 1.2345 km = 측점 1234.5 m · 표 1차 23.8) + 244_1 · 244_2 · 243_1(B08 표 4 → 2) 기대값 고침 · B08 관련 통과(215) · typecheck · prettier · ruff +- [x] 244-5 기준점 좌측 패널 「공사지에서 본 방위」 칸 없앰(130번 = 방위는 지도 위치로 자동) · 기준점 자리 = 기준점 주소 좌표 · 좌표 없으면 견본처럼 공사지 서쪽 기본 · 새 시험 + - 검증완료 — test_244_5 3건(좌표 없음 = 서쪽 · 옛 dir 「동」 무시 · 시점 좌표 모름 = 서쪽 · 기준점 북 좌표 = 위 · 동 좌표 = 오른 · 옛 「서」 무시 · 좌측 패널 방위 칸 없음 · 기준점 모달 안내 한 줄) + 244_* 20건 · 122 14건 통과 · typecheck · prettier · ruff +- [x] 244-6 (132번) 표 폭 = 위 다이어그램 폭 · 다이어그램 상자 안에 항목 글도 · 좌측 패널 거리이정표 카드 없앰(중점 측점 · 기준점 = 다이어그램 상자 모달에서 고침) · 팬 안 됨 고침(가운데 단추 끌기 실제 동작 + 빈 곳 왼쪽 끌기도 옮김) · (133번) 화면 글 「업체」 → 「거래처」(다이어그램 · 모달 · 표 머리 · 탭 이름 · 안내 · 알림) — 새 시험(실제 포인터 사건으로 팬 판정) + - 검증완료 — test_244_6 4건(pointerdown · move(+60 · +30) · up 사건 = 원배율에서 빈 곳 왼쪽 · 상자 위 가운데 끌기 모두 transform translate(60 30) · 상자 위 왼쪽 · 오른쪽 = 그대로 · 팬 안 되던 까닭 = 원배율에서 공용 팬 범위가 0 · 거래처 상자 항목 「장비 · 골재」(겹침 · 빈칸 뺌) + 이름 · 이름 없음 = 「거래처」 · 틀 fit-content + 다이어그램 contain inline-size · svg 너비 100% · 좌측 카드 없음 · 공사지 모달 중점 측점 · 도로 · 기준점 모달 등록 · 없앰 · [기준점 +] · 화면 파일 6개 「업체」 0) + 122 · 244_1 · 244_4 · 244_5 기대값 고침 · B08 관련 · 243_1 · 164(공용 팬 줌) 통과(222 · 13) · typecheck · prettier · ruff + +## 247. B08 구조물 집계표 — 측점 줄 구조물조합 모두 연결 = 초록 램프 (sub7) +정함(브레인) = 사용자 131번 · 지금 노랑 램프(미연결 · 154-1 · 198-1)만 있음 → 그 측점 줄의 시설이 모두 조합 연결된 줄 = 초록 램프(같은 자리 · 같은 크기 · 호버 글 「모두 연결됨」) · 미연결 = 노랑 그대로 · 시설 없는 줄 = 램프 없음 · 색 = 테마 변수(밝음 · 어두움) +- [x] 247-1 초록 램프 — 새 시험 + - 검증완료 — test_247_1 3건(공용 표 실제 그림: 모두 연결 측점 칸 = 램프 + is-linked · 빈 칸 하나 = 노랑 · 시설 없음 · 합계 줄 = 없음 · 호버 「모두 연결됨」 · 성공 토큰 밝음 · 어두움) + 198_1 · 160_2 · 61_1 · 143_1 · 154_2 · 156_1 · 145_3 · 120 · 98 통과(52) · typecheck · prettier · ruff +- [x] 244-7 (136번) 카카오 길찾기 REST 로 km 자동 — 키 .env KAKAO_REST_API_KEY(브레인 확인: 거창군청 → 병곡리 10,463 m · 길찾기 성공) · 서버만 부름(키 브라우저로 안 감) · 거래처 등록 · [길찾기] 때 구간마다(거래처 → 기준점 · 기준점 → 시점 · 기준점 없으면 거래처 → 시점) km 자동 · 시점 → 중점 = 노선 측점 거리 · 도로 구분 = 사용자 고름 그대로(자동 판정 없음) · 경로 좌표 · 응답은 저장 안 함(km 만) · 키 없음 · 실패 = 안내 한 줄 + 손 입력 · 경로 그림(카카오 지도 JS)은 244-9 로(자바스크립트 키 · 도메인 등록 뒤) + - 검증완료 — test_244_7 6건(가짜 응답 10463 m = 10.5 km · 10450 → 10.5 · 10449 → 10.4 · origin · destination = 경도,위도 · KakaoAK 머리 · 경로 좌표 응답에 없음 · 키 없음 = 안내 · 카카오 안 부름 · 401 · 429 · 500 · 끊김 · 깨진 응답 · result_code 104 = 까닭 한 줄 · 키 값 응답 · 로그 0 · 구간 = 거래처 → 기준점 · 기준점 → 시점 · 기준점 없음 = 거래처 → 시점 · 사용자 23.8 우선 + 호버 「자동 값 10.5 km」 · 안 고름 = 「도로 구분 고르기 1.4km」 · 합에 안 듦 · TS 거울 같음) + 실제 카카오 한 번 = 10.5 km + 244_* · 122 · 243_1 통과(41 · 5) · typecheck · prettier · ruff +- [x] 244-8 (135번) 다이어그램 영역 스크롤 없앰 — 틀 overflow 숨김 · 휠 = 줌만(페이지 · 틀 스크롤로 안 샘 · passive false preventDefault) · 다이어그램 크기 = 틀 안 맞춤 · 표 영역 스크롤은 그대로 — 새 시험 + - 검증완료 — test_244_8(진짜 wheel 사건: 그림 위 = scale 1.15 · defaultPrevented · 틀 위 = passive false · defaultPrevented · 틀 overflow hidden) + 244_* 26 통과 · typecheck · prettier +- [x] 244-9 (138번 · 가 배제 → 나) 경로 그림 = 카카오 지도 JS 위 경로 — 거래처 모달 · 경로 하위 탭에 카카오 지도(JS SDK · 키 = .env KAKAO_JS_KEY · 서버 설정 길로 줌 · 도메인 = localhost:5173~5181 등록) · 길 선 = 서버 길찾기 응답 경로 좌표(그때그때 · 저장 안 함) · 표지 · [캡처] = getDisplayMedia(preferCurrentTab) + 지도 칸만 잘라(CropTarget · 안 되면 자리 수치) → 그 탭 그림 · JS 키 없음 = 안내 한 줄 · VWorld 바탕 그림 없앰 — 새 시험 + - 검증완료 — test_244_9 8건(가짜 vertexes 2구간 → path [위도, 경도] 4점 · 원문 없음 · 깨진 좌표 = km 10.5 그대로 · path [] · 화면이 path 실어 보내도 milestone.json 에 좌표 0 · map-key 없음 · 빈칸 = null · 표지 = 거래처 · 기준점 · 시점 · 좌표 없는 것 뺌 · 캡처 자르기 ×2 = 200 · 100 · 800 · 600 · 영상 밖 자름 · 키 없음 = SDK 안 부름 + 안내 한 줄 + [지도 캡처] 숨김 · 가짜 kakao.maps = 길 선 2 · 표지 3 · 맞춤 점 8) + 실제 카카오 거창군청 → 병곡리 = 10.5 km · 경로 좌표 159점(JS 키 없음 = 지도 실제 확인은 브레인) + 244_* 40 통과 · typecheck · prettier · ruff +- [x] 244-10 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님) — [지도 캡처]는 getDisplayMedia 없으면 끄고 까닭 한 줄(「캡처는 localhost 또는 https 주소에서만 — 사진 올리기」) · 지도 SDK 도메인 불일치(401)면 「카카오 JavaScript 키 도메인에 지금 주소 등록 필요(예 http://100.72.32.27:5173)」 안내 한 줄 — 새 시험 + - 검증완료 — test_244_10 3건(navigator.mediaDevices 없음 = [지도 캡처] disabled · title · 안내 「길 선 = 카카오 길찾기 · 캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요」 · 지도 그대로 · 있음 = 켜짐 · SDK script onerror = 「카카오 JavaScript 키 도메인에 지금 주소(http://100.72.32.27:5173) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요」 · 지도 · 캡처 숨김) + 244_* 43 통과 · typecheck · prettier · ruff +- [x] 244-11 (139번) 경로 그림 = 서버 자동 캡처 — 서버가 헤드리스 Edge(Playwright channel msedge · 없으면 chrome)로 등록 도메인 주소(기본 http://localhost:5173/__route_capture · 가로채 채움 · .env KAKAO_CAPTURE_ORIGIN 로 바꿈)에 카카오 지도 JS + 길 선 + 표지 + 경로 맞춤 → tilesloaded 뒤 1200×800 PNG → 그 거래처 경로 하위 탭 그림 · [등록] · [길찾기] 때 자동 · 사용자 그림 우선 · 브라우저 [지도 캡처] 없앰(접속 주소 무관 · 테일스케일도 됨) · 모달 · 탭 안 카카오 지도 보기는 그대로 · 실패 = 까닭 한 줄 · 사진 올리기 · 브레인 견본 = sub8 tmp/route_capture_proto.py(2.4초 · 159점 · 10.5 km) — 새 시험 + - 검증완료 — test_244_11 6건(가짜 브라우저: 길 선 · 표지 → auto/r1.png · 주소 = 기본 · KAKAO_CAPTURE_ORIGIN(100.72.32.27:5173) + /__route_capture page.route · Edge 없음 = chrome · 폴더 파일 = 그림 하나(좌표 0) · 키 값 응답 0 · 사용자 그림 우선 · user_only 되돌리기 = 자동 그림 · 지우기 = 둘 다 · 경로 지우고 [저장] = 자동 그림도 · 까닭 한 줄 = 키 없음 · Playwright 없음 · 브라우저 못 띄움 · 20초 넘김 · 도메인 불일치 · 동시 3건 = 한 번에 하나 · 화면 getDisplayMedia · 지도 캡처 0) + 실제 한 번(엔드포인트: 거창군청 → 시점 10.5 km · 159점 → 1200×800 PNG 2.2초 · 파일 생김) + 244_* 48 통과 · typecheck · prettier · ruff +- [x] 244-12 (142~144번) 다이어그램 ① 처음 보기 = 넉넉한 바탕 영역(공사지만 있어도 거래처 · 기준점 들어올 자리까지 담는 고정 바탕 범위 · 새 상자 들어와도 다시 줌아웃 필요 없음 · 바탕 넘는 상자가 생기면 그때만 넓힘) ② 시점 ↔ 중점 연결선(도로 구분 · km 글 = 시점 → 중점 구간) · 시점 · 중점 상자 안 작은 글씨 주소 · 좌표(위도 · 경도 소수 5자리) ③ 거래처 상자 = 항목 · 거래처 이름 · 주소(작은 글씨 · 길면 줄임표 + 호버) ④ (145번) 상자 · 선 · 선 글 서로 여유(상자 사이 최소 간격 · 선 글이 상자 · 다른 선을 덮지 않게 자리 피함 · 선은 상자 테두리에서 끊김) — 새 시험 + - 검증완료 — test_244_12 6건(바탕 = 고정 1024×934 · 공사지만 · 공사지 둘레 8방 · 기준점만 = 바탕 그대로 · 기준점 너머 거래처만 그쪽 넓힘 · 시점 → 중점 선 = 공사지 바탕 위 · 시점 · 중점 작은 글씨 주소(줄임표 · 호버 원문) · 좌표 35.70000, 127.81235 · 주소 없음 = 좌표만 · ?mid_m= 중점 주소 = 지금 측점 · 음수 422 · 거래처 = 항목 · 이름 · 주소 · ④ 몰린 꼴 13 상자 = 상자 사이 12 이상 · 선 끝 상자 밖(바깥 → 시점 = 공사지 테두리) · 선 글 ↔ 상자 · 다른 선 · 다른 글 겹침 0) + 244_* 54 통과 · typecheck · prettier · ruff +- [x] 244-13 (147번 5 · 8) ① 공사지 [+] 모달 = 더 크게 · 2열(분류(기준위치 · 거래처) · 항목(분류 따라 바뀜) · 거래처 이름 · 목적지) · 다이어그램 [기준점 +] 없앰(분류 = 기준위치로 넣음) · 「거래처 위치」 → 「소재지」 · 직접 입력 단추 없앰(주소 찾기 · 지도 고르기만) · 등록 모달 지도 = 다음(카카오) 지도 ② 상자 누름 = 같은 등록 모달(고침) ③ 길찾기 = 연결선 누름 모달 — 값 = 도로 구분 · 거리 · 아래 다음 지도 경로(기준점 있으면 거래처 ↔ 기준점 경로 · 지도에서 위치 끌어 옮김) · 시점 ↔ 기준점 = 도로 없을 수 있어 거리 손 입력 가능 · 시점 ↔ 중점 = 늘 손 입력 ④ 도로 구분 = 기준점 거쳐 시점 들어가는 경로만 「2차포장 · 1차포장 · 비포장」 · 기준점 없으면 「포장 · 비포장」(표 열도 그에 따름) — 새 시험 + - 검증완료 — test_244_13 7건(포장 거울: 기준점 없음 포장 10.4 + 비포장 0.5 = 10.9 · 중점 줄 11.7 · 기준점 거침 2차 23.8 + 1차 0.6 + 비포장 0.8 = 25.2 · 거침 안 함 포장 5 · 파이썬 = TS · 3차포장 거절 · 도로 구분 갈래 = 거침 3종 · 아니면 포장 · 비포장 · 구간 끝 g → 병곡리 · d → 시점 · 병곡리 → 시점 · 시점 → 중점 = 없음 · 표 열 = 기준점 없음 포장 · 비포장 · 합계 · 있음 2차 · 1차 · 비포장 + 계 · 소재지 역지오코딩 · 위도 91 = 422 · 지도 누름 = 소재지 · 끌기 = 거래처만(시점 고정) · 점 없음 = 시점 가운데 · 연결선 길찾기 한 구간 · 거래처 쪽만 자동 그림(표지 둘) · 같은 km = 캐시 안 씀 · 사용자 23.8 그대로) + 244_* 61 · 122 9 통과(옛 화면 글 기대값 고침 8개) · typecheck · prettier · ruff + +## 248. B09 다른 탭 이동 빨강 = 설계내역서 규칙과 같게 (sub3) +정함 = 사용자 147번 3 「반영」(242-1 보고의 목록) · 일위대가표 · 중기사용료 · 단가산출근거(Groups pickRange 호표 구간 빨강) · 묶음 카드 보기 is-pick — 공용 focusRow 강조와 겹치지 않음(빨강 하나만) · 다른 줄 · 카드 누름 · 방향키 · Esc = 빨강 풀림 · 산근 시트 칸 td.is-pick 은 뜻 달라 그대로 +- [x] 248-1 — 묶음 카드 보기 = 이동해 온 박스 빨강만(줄 강조 안 붙음) · 카드 · 줄 누름 = 빨강 풂 · 누른 줄만 고름 / 표 보기 = 호표 구간 빨강만(focusRow 스크롤만) · 줄 누름 = 빨강 풂 · 누른 줄만 고름(다시 그려도) / 방향키 = 빨강 풂 · Esc = 고름도 풂 · 글 칸 안 키 그대로 — 새 시험 + - 검증완료 — test_248_1 3건 + 242_1 · 239_1 · 181_2 · 245_1 · 171_2 · 171_3(도착 줄 강조 기대 고침) · 묶음 카드 시험 통과 · typecheck · prettier · ruff +- [x] 244-14 [+] 모달 「항목」 = 손 입력 + 고르기 제안(120-답 ③ 「항목: 수기 입력」 · 거래처 제안 = 견본 다섯 장비(20톤) · 장비(8톤) · 혼합골재 · 전석운반 · 철근 · 기준위치 제안 = 기준점 · 목록 밖 글도 받음) — 시험 고침 + - 검증완료 — test_244_14 2건(빈 칸 + 제안 다섯 · 기준위치 = 제안 기준점 · 목록 밖 글 유지 · 줄마다 제안 목록 따로) + 244_* 63건 통과 · typecheck · prettier · ruff From 3d45038658b8f9d344257b9d317f6ea59acb8a27 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:45:20 +0900 Subject: [PATCH 88/92] =?UTF-8?q?chore(env):=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20JavaScript=20=ED=82=A4=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=B6=94=EA=B0=80=20(KAKAO=5FJS=5FKEY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- .env | 1 + 1 file changed, 1 insertion(+) diff --git a/.env b/.env index 761b7fac0..eae2c17ad 100644 --- a/.env +++ b/.env @@ -47,6 +47,7 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000 # Public URL for email links (프론트엔드 주소) APP_PUBLIC_BASE_URL=http://localhost:5173 KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08 +KAKAO_JS_KEY=98ac6edef14ffbca300d90e540644b68 # kakao app ID 1603330 # Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f # 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA From 9ac0e395af4cb0332cb8f3e9e384ad02d1c58116 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:48:15 +0900 Subject: [PATCH 89/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20148?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 10145cc7a..2bc21ddc8 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -176,3 +176,4 @@ 145. 추가로 거리이정표에는 선들과 컨테이너들의 공간을 여유를 조금 두는게 좋겠어. 자꾸 침범을 하네. 146. 추가로 누락된 업무가 있는지 확인해봐. 147. (브레인 물음 답 · 원문) 1. 백엔드 재시작 2. 폴더만 기준이 되면 됨. 3. 반영 4. 테이블이 아닌 항목은 대상이 아님 5. 다음으로 공사지 +로 추가하는 항목에서 나오는 모달창의 크기를 조금 더 키우고 내부 내용을 정리할 생각임. 항목은 분류(기준위치, 거래처) 항목, 거래처 이름, 목적지를 2열배치 이때 분류에 따라 항목이 바뀜(다이어그램의 기준점+ 버튼은 삭제), 거래처 위치를 소재지로 변경하고 직접입력버튼은 삭제. 전부 주소찾기나 지도에서 찾아야함. 등록에 이제 다음지도로 대체 6. 메일은 내가 알아서 하겠음. 7. 데스크탑은 세션 확인하면 살아있음. 오르카 리스트 확인 8. 추가 거리이정표 항목 - 등록으로 보이는 모달창은 해당 컨테이너를 눌렀을 때 같이 보여주면 됨. - 길찾기는 연결선을 누르면 보이는 모달창에 추가 이때의 항목은 도로 구분과 거리를 항목값으로 받고 하위에 다음맵으로 경로를 보여주면 되는데 기준점이 발생되는 이유는 임도 특성상 도로가 없는 경우 한 지점을 지정하게 됨. 다시말해 기준점이 있다면 기준점과 거래처의 다음맵지도가 필요함. 이때 위치조절을 위해 위치이동은 되어야함. 시점과 기준점은 도로가 연결이 안되는 경우가 있어 수기 입력이 되어야할때도 있음. 시점과 중점은 항상 거리는 수기입력임. 이때 도로 구분의 경우 2차 포장이란 시점이 아니라 기준점이 있을때의 경우임. 다시말해 기준점을 거쳐 시점에 들어가는 경우에만 존재하는 열 항목임. 기준점이 없다면 그냥 포장이 됨. +148. 길찾기 그림이 없는 이유는 뭐야? (브레인 판정: 서버 안에서 헤드리스 브라우저를 못 띄움 — uvicorn 이 쓰는 Windows Selector 이벤트 루프는 하위 프로세스를 못 만들어 Playwright NotImplementedError · 시험은 서버 밖이라 통과) From b5cbe2c5898dc1403fc5af06a86f25cc9ad77f52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:51:19 +0900 Subject: [PATCH 90/92] =?UTF-8?q?fix(B08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EC=9E=90=EB=8F=99=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=3D=20=EC=9E=90=EC=8B=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=EC=8D=AC=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4(249-1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버(uvicorn · Windows Selector 루프) 스레드 안 Playwright 드라이버 기동 실패 해소 · 입력 JSON stdin · 결과 JSON 한 줄 · 25초 넘김 · 잠금 그대로 · Selector 정책 시험 추가 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B08_Quantity_Milestone_Capture.py | 62 ++++++++++-- .../test_244_11_b08_milestone_auto_capture.py | 3 + .../test_249_1_b08_milestone_capture_child.py | 97 +++++++++++++++++++ 3 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_249_1_b08_milestone_capture_child.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py index 635bf9e88..ca6a9cff1 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Capture.py +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -8,12 +8,17 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨). 경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 · 키 값은 돌려주지도 적지도 않음. +캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 · +키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가 +드라이버 하위 프로세스를 못 만듦(249-1). """ from __future__ import annotations import json import os +import subprocess +import sys import threading import time import urllib.parse @@ -26,6 +31,9 @@ DEFAULT_ORIGIN = "http://localhost:5173" PAGE = "/__route_capture" WIDTH, HEIGHT = 1200, 800 TIMEOUT_S = 20 +CHILD_TIMEOUT_S = 25 # 자식 프로세스 전체(파이썬 · 드라이버 기동 포함) +CHILD = __file__ # 자식으로 돌릴 스크립트 +NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 TAIL = " — 그림 올리기를 쓰세요." @@ -90,7 +98,7 @@ def shoot(html: str, origin: str) -> bytes: except PwError: continue if browser is None: - raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") + raise CaptureError(NO_BROWSER) try: page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}) page.route( @@ -114,7 +122,41 @@ def shoot(html: str, origin: str) -> bytes: except PwTimeout as error: raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 - raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") from error + raise CaptureError(NO_BROWSER) from error + + +def capture(job: dict[str, Any]) -> dict[str, Any]: + """자식 프로세스 몸통 — 그림 찍어 `/.png` 저장 → {saved, reason}.""" + key = os.environ.get(KAKAO_JS, "").strip() + try: + png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"]) + except CaptureError as error: + return {"saved": False, "reason": str(error)} + out_dir = Path(job["out"]) + out_dir.mkdir(parents=True, exist_ok=True) + new = out_dir / f".{job['route_id']}.png.new" + new.write_bytes(png) + new.replace(out_dir / f"{job['route_id']}.png") + return {"saved": True, "reason": None} + + +def spawn(job: dict[str, Any]) -> dict[str, Any]: + """자식 파이썬 프로세스로 capture — 서버 이벤트 루프 정책과 무관(기본 Proactor).""" + try: + done = subprocess.run( + [sys.executable, CHILD], + input=json.dumps(job).encode(), + capture_output=True, + timeout=CHILD_TIMEOUT_S, + ) + except subprocess.TimeoutExpired: + return {"saved": False, "reason": f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남"} + except OSError: + return {"saved": False, "reason": NO_BROWSER} + try: + return json.loads(done.stdout.decode().strip().splitlines()[-1]) + except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음 + return {"saved": False, "reason": NO_BROWSER} def make_route_image( @@ -130,13 +172,13 @@ def make_route_image( if not any(lines) and not marks: return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"} origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/") + job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks} with _lock: - try: - png = shoot(page_html(key, lines, marks), origin) - except CaptureError as error: - return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {error}{TAIL}"} - out_dir.mkdir(parents=True, exist_ok=True) - new = out_dir / f".{route_id}.png.new" - new.write_bytes(png) - new.replace(out_dir / f"{route_id}.png") + got = spawn({**job, "origin": origin}) + if not got.get("saved"): + return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"} return {"saved": True, "reason": None} + + +if __name__ == "__main__": + print(json.dumps(capture(json.loads(sys.stdin.buffer.read())))) diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py index 33e0396fa..25a591302 100644 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -115,6 +115,9 @@ def fake_playwright(box): def _setup(tmp_path, monkeypatch, box, origin=None): monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_playwright(box)) + monkeypatch.setattr( + cap, "spawn", cap.capture + ) # 가짜 브라우저 = 같은 프로세스(249-1 자식은 별도 시험) monkeypatch.setenv(cap.KAKAO_JS, KEY) if origin is None: monkeypatch.delenv(cap.ORIGIN_KEY, raising=False) diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py new file mode 100644 index 000000000..d567527cc --- /dev/null +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -0,0 +1,97 @@ +"""249-1 B08 거리이정표 자동 그림 = 자식 프로세스 캡처(사용자 148번). + +서버(uvicorn · Windows) = WindowsSelectorEventLoopPolicy → 스레드 안 sync_playwright 가 +드라이버 하위 프로세스를 못 만듦. 같은 정책 · 스레드에서: +① 옛 길(같은 프로세스 shoot) = 「브라우저 못 띄움」 재현 +② make_route_image(자식 파이썬 프로세스) = 실제 Edge 로 PNG 생김 · 키 값 응답 0 +③ 자식 시간 넘김 · 자식 죽음 = 까닭 한 줄 +카카오 SDK 대신 단색 페이지(이 파일을 자식 스크립트로 · page_html 만 바꿈) — 네트워크 없음. +""" + +import asyncio +import json +import sys +import threading +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402 + +HTML = ( + '

' +) +KEY = "fake-js-key-249" +LINES = [[[35.6866, 127.9095], [35.66, 127.85]]] +MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}] + +if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로 + cap.page_html = lambda key, lines, marks: HTML + print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read())))) + sys.exit(0) + +import pytest # noqa: E402 + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows Selector 루프 재현") + + +def in_server_thread(fn): + """uvicorn 처럼 Selector 정책을 건 채 스레드(asyncio.to_thread 자리)에서 fn().""" + got = {} + + def run(): + try: + got["value"] = fn() + except BaseException as error: # noqa: BLE001 — 시험에서 그대로 봄 + got["error"] = error + + before = asyncio.get_event_loop_policy() + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + try: + job = threading.Thread(target=run) + job.start() + job.join(60) + finally: + asyncio.set_event_loop_policy(before) + return got + + +def test_옛_길_같은_프로세스는_못_띄움(): + pytest.importorskip("playwright.sync_api") + got = in_server_thread(lambda: cap.shoot(HTML, cap.DEFAULT_ORIGIN)) + assert isinstance(got.get("error"), cap.CaptureError) + assert str(got["error"]) == cap.NO_BROWSER + + +def test_자식_프로세스로_PNG_생김(tmp_path, monkeypatch): + pytest.importorskip("playwright.sync_api") + monkeypatch.setattr(cap, "CHILD", __file__) + monkeypatch.setenv(cap.KAKAO_JS, KEY) + monkeypatch.delenv(cap.ORIGIN_KEY, raising=False) + out = tmp_path / "auto" + got = in_server_thread(lambda: cap.make_route_image(out, "r1", LINES, MARKS)) + assert got.get("value") == {"saved": True, "reason": None}, got + png = (out / "r1.png").read_bytes() + assert png[:8] == b"\x89PNG\r\n\x1a\n" and len(png) > 1000 + assert sorted(p.name for p in out.iterdir()) == ["r1.png"] # .new 남지 않음 + + +def test_자식_시간_넘김_죽음_까닭(tmp_path, monkeypatch): + monkeypatch.setenv(cap.KAKAO_JS, KEY) + tail = " — 그림 올리기를 쓰세요." + head = "경로 그림 자동 캡처 실패 — " + hang = tmp_path / "hang.py" + hang.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") + monkeypatch.setattr(cap, "CHILD", str(hang)) + monkeypatch.setattr(cap, "CHILD_TIMEOUT_S", 1) + got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS) + assert got == {"saved": False, "reason": head + "지도 그림이 20초 안에 안 끝남" + tail} + dead = tmp_path / "dead.py" + dead.write_text(f"raise SystemExit('boom {KEY}')\n", encoding="utf-8") + monkeypatch.setattr(cap, "CHILD", str(dead)) + got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS) + assert got == {"saved": False, "reason": head + cap.NO_BROWSER + tail} + assert not (tmp_path / "auto").exists() From 91aa771878ef216b0f380a2062cd3313e9382f0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:52:44 +0900 Subject: [PATCH 91/92] =?UTF-8?q?chore(plan):=2010-10=20249=EC=9E=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/plans/2026-10-10_plan_249.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/raw/plans/2026-10-10_plan_249.md diff --git a/docs/raw/plans/2026-10-10_plan_249.md b/docs/raw/plans/2026-10-10_plan_249.md new file mode 100644 index 000000000..9862c4c83 --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_249.md @@ -0,0 +1,8 @@ +# 2026-10-10 계획 249장 기록 — 거리이정표 자동 그림 서버 안 캡처 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 148. + +## 249. B08 거리이정표 자동 그림이 서버 안에서 안 됨 (sub8) +정함(브레인 판정) = 사용자 148번 · 서버(uvicorn reload · Windows Selector 이벤트 루프) 안에서 Playwright 가 드라이버 하위 프로세스를 못 만듦(NotImplementedError) → 「브라우저 못 띄움」 · 시험은 서버 밖이라 통과 → 캡처를 서버 프로세스와 떼어 자식 파이썬 프로세스로(sys.executable · 입력 = JSON stdin · 출력 = PNG 경로 · 20초 넘김 · 키는 환경으로) · 서버 안 실제 경로 시험(Selector 정책 걸고 돌림) +- [x] 249-1 자식 프로세스 캡처 · Selector 정책 아래 시험 — 실제 서버 API 로 그림 생김 + - 검증완료 — test_249_1(Selector 정책 · 스레드: 옛 같은 프로세스 = 「브라우저 못 띄움」 재현 · 자식 프로세스 = 실제 Edge PNG 생김 · 시간 넘김 · 자식 죽음 까닭) 3 · 244_* 포함 66 통과 · typecheck · ruff · 실제 API 재확인은 브레인 From 84bb7a1b72d85d053c9c2736fa9d889574d6bfa1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:53:57 +0900 Subject: [PATCH 92/92] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20149?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 2bc21ddc8..a2ae14ba1 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -177,3 +177,4 @@ 146. 추가로 누락된 업무가 있는지 확인해봐. 147. (브레인 물음 답 · 원문) 1. 백엔드 재시작 2. 폴더만 기준이 되면 됨. 3. 반영 4. 테이블이 아닌 항목은 대상이 아님 5. 다음으로 공사지 +로 추가하는 항목에서 나오는 모달창의 크기를 조금 더 키우고 내부 내용을 정리할 생각임. 항목은 분류(기준위치, 거래처) 항목, 거래처 이름, 목적지를 2열배치 이때 분류에 따라 항목이 바뀜(다이어그램의 기준점+ 버튼은 삭제), 거래처 위치를 소재지로 변경하고 직접입력버튼은 삭제. 전부 주소찾기나 지도에서 찾아야함. 등록에 이제 다음지도로 대체 6. 메일은 내가 알아서 하겠음. 7. 데스크탑은 세션 확인하면 살아있음. 오르카 리스트 확인 8. 추가 거리이정표 항목 - 등록으로 보이는 모달창은 해당 컨테이너를 눌렀을 때 같이 보여주면 됨. - 길찾기는 연결선을 누르면 보이는 모달창에 추가 이때의 항목은 도로 구분과 거리를 항목값으로 받고 하위에 다음맵으로 경로를 보여주면 되는데 기준점이 발생되는 이유는 임도 특성상 도로가 없는 경우 한 지점을 지정하게 됨. 다시말해 기준점이 있다면 기준점과 거래처의 다음맵지도가 필요함. 이때 위치조절을 위해 위치이동은 되어야함. 시점과 기준점은 도로가 연결이 안되는 경우가 있어 수기 입력이 되어야할때도 있음. 시점과 중점은 항상 거리는 수기입력임. 이때 도로 구분의 경우 2차 포장이란 시점이 아니라 기준점이 있을때의 경우임. 다시말해 기준점을 거쳐 시점에 들어가는 경우에만 존재하는 열 항목임. 기준점이 없다면 그냥 포장이 됨. 148. 길찾기 그림이 없는 이유는 뭐야? (브레인 판정: 서버 안에서 헤드리스 브라우저를 못 띄움 — uvicorn 이 쓰는 Windows Selector 이벤트 루프는 하위 프로세스를 못 만들어 Playwright NotImplementedError · 시험은 서버 밖이라 통과) +149. 고침이라는 명칭을 수정으로 변경. 컨테이너 항목에서 + 줄이 있는 이유가 뭐야. 거래처 등록은 거래처에 관련 정보를 선에는 경로관련을 묶어서 표현하려고 하는거잖아. 그리고 기준위치에는 항목이 필요없어. 거쳐가야하는 지점이다보니 의미가 없어. 이번 부터는 너가 직접작업해. 거리이정표의 경우에는 폭을 브라우저에 맞춰서 폭 전체를 사용하게 하고 하단의 테이블도 동일하게 폭에 맞춰 열의 사이즈를 조절해줘. 그리고 현재 다이어그램이 줌아웃이 너무 멀어서 글자가 잘안보여 적당히 줌인해줘.