711 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5.5 ca5485b208 fix(M02 실무자료): 339 덧고침 2 — 본 칸 호표 목록 제안 수 · 셈 중 표시 셈 끝까지 다시 읽음
- 본 칸 호표 표: 뒤 숫자 판정 셈 중이면 3 초마다 다시 읽어 「제안 n / 줄 m」 · 호표 줄 갱신(찾기 글 그대로)
- 머리 줄 「숫자 판정 셈 중 n/m」 표시 · 셈 끝 · 호표 하나로 감 · 못 읽음이면 멈춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 01:13:13 +09:00
eomsangdon 480a93ce1c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 01:10:30 +09:00
eomsangdonandClaude Opus 5.5 7f33ffe2bd fix(M02 실무자료): 339 덧고침 — 확인 창 표 꼴 · 만들기 뒤 사전조합 목록 갱신 · 엑셀 보기 창 제안 수 다시 읽음
- 확인 창 줄 표: 한글 낱말 단위 줄바꿈 · 품명 · 규격 최소 폭 · 옵션 · 품 비교 항목마다 한 줄 · 단추 칸 세로 쌓음(좁게)
- 별칭 장부 표: 품명 · 규격 같은 손질
- 만들기(확인 창 · 엑셀 보기 창 일괄) 뒤 document 알림 → 좌측 「일위대가 사전조합」 목록 다시 읽음
- 엑셀 보기 창: 뒤 셈 중이면 3 초마다 목록 「제안 n/m」 다시 읽음 · 펼침 표 숫자 판정 칸 = 맞음 n · 다른 점 n
- 품 비교 판정 함수 · 서버 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 01:09:47 +09:00
eomsangdonandClaude Opus 5.5 d9cc66b5e9 fix(CAD): 339 덧고침 — CAD 시작 상태를 「명령:」 쉼(선택 도구)으로 · 선 명령으로 시작하던 것 고침 · 시작 도구 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 01:03:02 +09:00
eomsangdonandClaude Opus 5.5 933c4c5d71 refactor(CAD): 337-6 가 끝 몫 — 옛 OFFSET · LENGTHEN · 옛 꼴 통과 지움 · ALL · L · P 실제 고름 시험 · 안내 글
- transform-acad: 고르기 물음 ALL · L · P = 「n개를 찾음, 총 m개」 · 다시 「객체 선택:」 · 빈 Enter = 고르기 끝(설계 1-4 · 2-15)
- transform-tools: 안 쓰는 옛 OFFSET · LENGTHEN 머신 지움(새 offset-tool · lengthen-tool 이 대신)
- prompt-input · prompt · sequence-tool: 옛 꼴 통과(meta.options 글 배열 · TEXT_INPUT 대문자 통과 · chainFromLastPoint) 지움
- prompt-contract: 옛 꼴 안 쓰는지 지키는 시험 더함
- 안내 글(설계 4 장 끝): 오른쪽 단추 · 휠 두 번 · CAD 키 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 00:34:02 +09:00
eomsangdon 746c70fcce Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-16 00:26:36 +09:00
eomsangdon 15a270939e Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-10-16 00:26:22 +09:00
eomsangdon 0063e1866f Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-16 00:26:21 +09:00
eomsangdon 2a9418f50b Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-16 00:26:20 +09:00
eomsangdonandClaude Opus 5.5 4ff989c125 feat(일위대가실무): 338-4 이음매 — 확인 화면 ↔ 실물 맞춤 응답 · guess 길 목록 · 끝까지 시험 · 이음매 기준 새 뜻
- 길 — guess 몸 대상 list | dict · to = 만들 층
- 줄 맞춤 — 섞인 줄 1위 대상에 몫 · 배(고임돌 GF000211 × 0.1) · 장비 품 비교 열쇠 「+」(화면과 같음) · guess 가 사용자 배 그대로
- 화면 — 품 비교 장비 표시 열쇠를 갈래 묶음 이름으로
- 시험 — client 고정물 맞춤셈 캐시 = 임시 폴더 · 뒤 셈 안 돌림(tmp/m02_hopyo 안 남음 · 결과 흔들림 없음) · 끝까지(북부청 올림 → 셈 → 보기 → 화면 몸 → 만들기 → 장부 → 다시 올림 별칭 · 몸 없음 400 · hopyo-memory 404 · 영월 STmate) · 실물 대역 화면 시험 · 이음매 268 줄 맞음 122 · 다른 점 86 · 후보 60 · 후보 1순위 근거 맞음 123 → 9
- 응답 대역 JSON 실물로 다시 뽑음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 00:25:51 +09:00
eomsangdonandClaude Opus 5.5 1d5cdff0a5 feat(CAD): 337-5 라 옮기기 무리 2 단계 — COPY · MIRROR · STRETCH · ARRAY · PEDIT 고르기
- COPY: 기준점 [변위(D)/모드(O)/다중(M)] · 두 번째 점 [배열(A)] → [배열(A)/종료(E)/명령 취소(U)] <종료> 되풀이 · 첫 빈 Enter = 변위 · COPYMODE 기억 · A 항목 수 · F 맞춤
- MIRROR: 대칭선 두 점 → 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오> · Esc = 안 만듦
- STRETCH: MOVE 틀 + 걸침 창 안 꼭짓점만(선 · 폴리선 토막 · 사각형) · 창 안 통째 · 하나씩 고른 것 = 이동 · STRETCH.DISP
- ARRAY: 유형 [직사각형(R)/원형(PO)] <직사각형>(ARRAYTYPE) → 편집 물음 되풀이(COU · S · COL · R · X / I · A · F · ROT · X) · 미리보기 4 × 3 · 6 개 360° · 처음 간격 범위 1.5 배 · Esc = 안 만듦(N17 없어짐)
- PEDIT: 공용 고르기 상태(객체 선택 · 빈 Enter · Esc = 끝 · 묶은 뒤 끝)
- 시험 transform-acad 29 + ALL · L · P 1(선택 도구 처리 뒤 돎)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 00:25:20 +09:00
eomsangdon 26b6d3ae0d Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_4 2026-10-16 00:17:55 +09:00
eomsangdonandClaude Opus 5.5 d35bdfa69d feat(CAD): 337-5 라 옮기기 무리 1 단계 — MOVE · ROTATE · SCALE 오토캐드 물음 차례
- MOVE: 기준점 [변위(D)] <변위> · 두 번째 점 <첫 번째 점을 변위로 사용> · 변위 기억(MOVE.DISP) · 한 번 뒤 끝 · Esc 제자리 끝
- ROTATE · SCALE: 공용 틀(pivot-tool) — 누름 한 번 = 값 · 복사(C) · 참조(R) → 새 값 [점(P)] · 각 · 비율 기억 · 0 이하 축척 거절
- 공용 고르기 상태(transform.helpers) — PICKFIRST · 빈 고르기 Enter = 끝 · OPTION.* 넘김 · 원본은 확정까지 도면에 그대로(N1 · N2)
- 시험 transform-acad(15) · scale-tool 단위 시험 새 상태 이름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 00:17:16 +09:00
eomsangdon 7ab4c3263f Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-16 00:16:51 +09:00
eomsangdonandClaude Opus 5.5 3cebb37ed0 feat(CAD): 337-3a 나-1 그리기 8 — 오토캐드 물음 차례 · 옵션 · 기본값 · 빈 Enter · Esc · 끝난 뒤
- LINE: 「첫 번째 점 지정」 빈 Enter = 마지막 끝점에서 이음 · U = 마지막 토막(되돌림 한 칸) · 둘째 토막부터 C · Enter · Esc = 끝(토막 남음)
- PLINE(새 pline-tool): 호(A) 접선 호 · 길이(L) · 닫기(C · 호 모드 CL) · 명령 취소(U) · 선(L) · Esc · Enter = 그린 데까지 남김
- RECTANG: 치수(D) 길이 · 폭 기억(RECT.L · RECT.W) → 누른 쪽 사분면 · 없는 옵션 안내
- CIRCLE: 3P · 2P · 지름(D) <2r> · 반지름 기억(CIRCLERAD · 처음 <> 없음) · 한 번 뒤 끝
- POLYGON(새 polygon-tool): 변 수 기억 <4> · 3~1024 정수 · 모서리(E) · 내접/외접 기억 · 숫자 = 아래 변 수평
- ARC(새 arc-tool): 세 점 · 중심(C) · 각도(A) · 끝(E) · 반지름(R)
- ELLIPSE(새 ellipse-tool): 축 두 끝 · 중심(C) · 회전(R) · 다른 축 숫자 = 중심에서 반 길이(N10)
- SPLINE: 다음 점 입력 · U · 넷째 점부터 C · Esc 버림 · DONUT 중심 되풀이(loopFrom)
- 시험 draw-acad 22 · keyboard-chain 옛 선 · 폴리선 줄 두 곳 새 동작으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 00:16:15 +09:00
eomsangdon c5f16fd4fa Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 00:16:05 +09:00
eomsangdonandClaude Opus 5.5 703c89c617 fix(B07 CAD): 337-2 가-2 합침 어긋남 — ZOOM onText 답을 가-2 settle(sub, result) 꼴로
- prompt-input.ts routeTyped 덧물음 onText 답 = settle(그 덧물음, 답) — 옛 settle(답) 한 인자로 남아 타입 오류 · 「'Z 2X」 뒤 덧물음이 안 닫혀 다음 누름(스냅 메뉴 시험)을 먹던 것
- 가-2 base · 스냅 이름 · FROM · M2P 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 00:15:37 +09:00
eomsangdonandClaude Opus 5.5 c15be5116a feat(B07 CAD): 337-4b 다-2 늘이기 · 재기 · 치수 오토캐드 조작 — LENGTHEN · DIVIDE · MEASURE · DIST · DIM 류
- LENGTHEN 새 도구(lengthen-tool.ts) — 측정할 객체 [증분(DE)/퍼센트(P)/전체(T)] <마지막 방식> · 누름 = 현재 길이 알림 · 방식별 값 기억 · 변경할 객체 되풀이 · U = 이 명령 안 바꿈만 · 빈 Enter · Esc 끝 · 선 · 호
- DIVIDE · MEASURE — 기본값 없음 · 정수 2~32767 · 양수 · 블록(B) 없음 안내 · MEASURE 누른 끝부터
- DIST — 오토캐드 꼴 두 줄 기록(거리 · 각도 · 증분) · 다중 점(M) 없음 안내
- DIMLINEAR · DIMALIGNED — 빈 Enter = 객체 선택 · [문자(T)/수평(H)/수직(V)] · 치수 문자 = 기록 · 치수 객체 문자 바꿈 칸(<> = 잰 값)
- DIMCONTINUE · DIMBASELINE — 되풀이 · 명령 취소(U) · 빈 Enter 끝 · DIMRADIUS · DIMDIAMETER [문자(T)] · 그 밖 치수 물음 글
- 시험 measure-acad 11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 00:15:35 +09:00
eomsangdon eff5fa2505 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-16 00:15:12 +09:00
eomsangdonandClaude Opus 5.5 00a379595b feat(CAD): 337-4a 다-1 고치기 7 — OFFSET · FILLET · CHAMFER · TRIM · EXTEND · BREAK · JOIN 오토캐드 물음 차례 · 옵션 · 기본값 기억 · 빈 Enter · Esc · 끝난 뒤
- OFFSET 새 파일(offset-tool.ts) — 거리 <통과점> · 통과점(T) · 지우기(E) 예/아니오 · 객체 → 면의 점 되풀이 · 다중(M) <다음 객체> · 명령 취소(U) · 기억 OFFSETDIST · OFFSETERASE
- FILLET · CHAMFER 한 꼴 — 객체 먼저 · 반지름(R) · 거리(D) 둘(둘째 기본 = 첫째) · 자르기(T) 모드 · 다중(M) · 명령 취소(U) · Shift = 구석 · 기억 FILLETRAD · CHAMFERA/B · TRIMMODE
- TRIM · EXTEND 빠른 모드 한 꼴 — 되풀이 · Shift = 반대 일 · 집기 반경 밖 무시 · 지우기(R) · 숨은 U · 사각형 변 원래 층 · 꼴(N9) · 자른 선 꼴 유지
- BREAK — 누른 점 = 첫 끊기점 · 첫 번째 점(F) · @0,0 = 같은 점 · JOIN 물음 글
- 시험 resources/tester/cad/modify-acad.test.ts 13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 00:14:29 +09:00
eomsangdon 4a7ad0f159 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 00:12:14 +09:00
eomsangdonandClaude Opus 5.5 66279024d8 feat(B07 CAD): 337-2 마-2 ZOOM 덧물음 · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 종료(X) · ↶↷ 끊고 되돌림 · snapped 캐스트 지움
- ZOOM(Z): 「윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:」 덧물음 · E · A = 범위 · P = 앞 화면 · W · 누름 = 두 구석 윈도우 · 2X = 배율 곱 · 빈 Enter = 실시간 안내 · 중심 · 동적 · 축척 · 객체 = 없음 안내 · 'Z 투명 · ⛶ 단추 = 바로 범위
- 약속 더함: SubPrompt.onText(꼴이 정해지지 않은 글 · nX) — routeTyped 가 덧물음에 먼저 물음 · undefined = 사다리 그대로(브레인 알림)
- Shift+우클릭: 점 물음에서만 한 번 스냅 메뉴(시작점에서 · 두 점 사이의 중간 · 끝점 · 중간점 · 교차점 · 중심 · 사분점 · 수직 · 노드 · 근처점 · 없음) → 고른 이름을 친 것과 같은 길 · Esc · 바깥 누름 = 메뉴만 닫음(ic/snap-menu.ts)
- 고르기 숨은 키워드 ALL · L · P(선택 도구 첫 점 대기 · 쉼 · 명령 안 자식) — 앞 고름에 더함 · 「n개를 찾음, 총 m개」
- 그립 물음 새 꼴 「** 신축 ** 신축점 지정 또는 [종료(X)]:」 · X = 놓음 · 기준점(B) · 복사(C) · 명령 취소(U) 없음 안내
- ↶ ↷ 단추 = undoCommand · redoCommand(끊고 되돌림) · 우클릭 = 명령 중 Enter · 쉴 때 반복(가-1 길 · 시험)
- 시험: mouse-acad 마-2 더함(21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 00:11:53 +09:00
eomsangdon 990ce104d9 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 00:11:27 +09:00
eomsangdonandClaude Opus 5.5 b384fa0660 feat(CAD): 337-3b 나-2 구성선 · 글자 · 해치 — XLINE · TEXT · MTEXT · HATCH 오토캐드 물음 차례
- XLINE 「점 지정 또는 [수평(H)/수직(V)/각도(A)]:」 → 「통과점을 지정:」 되풀이 · 각도 기억(XLINE.ANGLE) · 이등분 · 간격띄우기 = 없는 옵션 안내 · 빈 Enter · Esc = 끝(그은 선 남음)
- RAY 통과점 되풀이(loopFrom)
- TEXT 시작점 → 높이 <TEXTSIZE> → 회전 각 <TEXT.ANGLE> → 줄마다 「문자 입력:」(Enter = 아래 새 줄 · 빈 줄 = 끝) · 시작점 Enter = 마지막 글 아래 이어 씀(높이 · 각 안 물음)
- MTEXT 첫 구석 → 「반대 구석 지정 또는 [높이(H)/폭(W)]:」 → 글(\P 줄바꿈) · 상자 왼쪽 위에서 · 줄 감기 없음
- HATCH 「내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:」 되풀이 · 품은 닫힌 객체 중 가장 작은 경계 · 빈 Enter = 만듦 · Esc = 안 만듦 · 손 머신(엔진은 직교가 둘째 내부 점을 끌어감)
- 시험 resources/tester/cad/text-acad.test.ts 12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 00:10:42 +09:00
eomsangdon 49b6ab871c Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-16 00:06:06 +09:00
eomsangdon e81ec1c1e2 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-16 00:05:29 +09:00
eomsangdonandClaude Opus 5.5 5762bee9f1 test(일위대가실무): 338-1 뒤 셈 시험 — 이 시험만의 일감 줄 · 일꾼(앞 시험 올리기가 쌓은 북부청 통째 셈을 안 기다림 · 메인 합침에서 60 초 넘던 것)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 00:05:23 +09:00
eomsangdonandClaude Opus 5.5 8661501dae feat(CAD): 337-1 가-2 점 물음 보조 · ↑↓ 기록 · Tab 후보 — FROM · M2P · @ · # · * · 스냅 이름 늘림 · H11
- 새 inputController/input-history.ts — 쉴 때 = 친 명령 기록 · 명령 중 = 그 물음 최근 값 20 · 알림 줄 안 섞음(H11) · 후보 돌림
- 새 inputController/point-modifiers.ts — FROM(기준점 → <간격띄우기>) · M2P/MTP · @ = 마지막 점 · #·* 절대 좌표 · 스냅 이름
- 스냅 이름 — 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · QUA · NOD 더함 · TAN · EXT · INS · APP · PAR · GCE 는 「이 CAD 에 없음」 안내(N14)
- 사다리 — 기준점 없는 첫 점 @dx,dy = 마지막 점 기준 · 옵션 영문 전체 딱 맞음은 옵션 먼저(CENTER) · 줄임은 스냅(CEN) · 덧물음 base
- ↑↓ = 기록 · 후보 · Shift+↑↓ = 화면 옮김 · Tab(쉼 + 친 글) = 후보 돌림 · 명령행도 같은 함수
- 시험 — prompt-frame 가-2 8 · keyboard-chain PLINE 첫 점 줄 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 00:04:30 +09:00
eomsangdon b82ea6edcf Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_7 2026-10-16 00:01:44 +09:00
eomsangdon 8ef7bf0e5c Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-16 00:01:11 +09:00
eomsangdon 7c35341002 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_4 2026-10-16 00:01:08 +09:00
eomsangdonandClaude Opus 5.5 e6cdcb2952 feat(일위대가실무): 338-1 맞춤 핵심 서버 2 단계 — 줄 새 상태 · 체크 제안 · 대상 목록 · 품 비교 · 호표 통째 · 셈 · 만든 · guess 목록 + 골든 시험
- 새 Line — 상태 차례(% · 호표 · 노임 · 장부 뺌 · 별칭 · 맞춤셈 판정 · 이름 근거 후보) · 체크 제안(맞음 · 별칭 규칙 셋 · 확인된 하위 호표) · 대상(입력값 출처 별칭 > 블록 > 규격 · 환산 · 몫 · 배) · 품 비교 · 직접 줄 호표 통째 · guess(대상 목록 · 낱개도 받음 · 엔진 한 번)
- Match — view(to = 만들 층 · 셈) · picks · guess 를 Line 으로 · 옛 층 기억 · confirm · 들어감 없앰 · b_order 후보에 근거 칸
- Verdict — 위 다섯 딱 맞음 먼저(조합 없는 줄) · 후보 근거 = 근거 맞춤 꼴 · 지문차례(조합 전)
- Fit — 맞춤셈 캐시 이름에 셈 판(CALC_VERSION)
- 골든 시험(느림 · 1 분 54 초) — 사람 126 1순위 118 · 다섯 안 122 · 맞음 57 틀림 0 · 다른 점 틀림 1 · 옵션까지 57 · 조합 맞음 62/83 · 새 파일 51 맞음 틀림 2 · 씨앗 끔 · 북부청 조합 전 통과
- 근거 시험(나 창) 세 곳 — 옛 match_line → b_order(새 API)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 00:00:56 +09:00
eomsangdonandClaude Opus 5.5 6e346cfb5e Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 — 337 가-1 옵션 틀 · 마-1 import 겹침 풂(안 쓰는 import 셋 뺌)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 23:58:14 +09:00
eomsangdonandClaude Opus 5.5 83e246deb4 feat(CAD): 337-1 가-1 옵션 틀 — 물음 꼴 · 친 글 사다리 한 곳 · 명령 끝 → 쉼 · 순서 엔진 v2 · 키보드 흠
- 새 src/commands/prompt.ts — PromptSpec · renderPrompt(키워드 물음은 「또는」 없음) · matchOption(initget 줄임) · 세션 기억 · OPT · SELECT_OBJECTS
- 새 inputController/prompt-input.ts — routeTyped 사다리(점 · 옵션 묻는 중 명령 이름 거절 · 식 · 단위 거절 · 투명 ') · 알림 줄 · 덧물음 · cancelActiveCommand · undoCommand
- 새 tools/factories/end-command.ts — final(END) 갈고리 → 같은 틱 선택 도구 · state.ts 갈고리 · LASTPOINT
- sequence-tool v2 — ROUTE · id · prompt · when · next · options · keyword · loopFrom · escCommits · pickfirst · 확정 뒤 기본 끝 · Esc 늘 끝
- 흠 — N1 ②③ · N5 · N6 · N11 · N18(앞글자 실행 없앰) · H1(Ctrl · Alt 글자 안 쌓음 · Ctrl+C · X · Shift+C · Shift+V · 1) · 우클릭 = 쉴 때 반복 · SE 뺌 · ED 더함
- 시험 — cad-drive 도우미 · prompt-frame · prompt-contract · 옛 시험 손질(keyboard-chain · ime-sink · command-catalog · input-prefs-cad · eraser 녹화)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 23:55:58 +09:00
eomsangdon ffaf66d4d6 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-15 23:52:36 +09:00
eomsangdonandClaude Opus 5.5 908923c23e test(일위대가실무): 338-1 맞춤 응답 대역 JSON — 실물 북부청 임도 제1 · 4호표 파일 보기 · 호표 보기(상태 · 체크 제안 · 대상 · 후보 · 품 비교) · guess(설계 3-3 · 3-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 23:51:59 +09:00
eomsangdon fbfc49c2fa Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-15 23:42:08 +09:00
eomsangdonandClaude Opus 5.5 db8462c6d4 feat(B07 CAD): 337-2 마-1 오토캐드 마우스 — 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · 그립 누르고-옮기고-누르기 · 그립 숫자 · 보기 전용 문 · 직교 강제 · Shift 임시 직교 · 휠 기본 밂 · 휠 두 번 범위
- 고르기: 누름 = 더함 · Shift = 뺌 · Ctrl = 넣고 빼기 · 상자 = 앞 고름 ∪ 상자 · Shift 상자 = 뺌(H4 · H5) · 명령 안 고르기 단계도 누를 때 첫 점 · 스침 강조(ic/selecting.ts)
- 그립: 쉴 때만 집음(N7) · 누르고 떼면 대기 → 다음 누름에 놓음 · 끌어 놓기(H3) · 거리 숫자(N8) · 확정 · 읽기 전용 도면은 그립 · Delete 막음(H2)
- 직교: F8 = 기준점에서 가로 · 세로 강제 · Shift 누른 동안 뒤집음 · 객체 스냅 점 · 좌표 · 각도 잠금은 안 덮음 · 클릭 사건 snapped 채움(ic/ortho.ts · H8) · 가이드는 F3 와 뗌 · 가이드 잡는 거리 = 화면 픽셀(H9)
- 휠: 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 모든 화면 · ☰ 설정 뒤집기 그대로) · 가운데 단추 두 번 = 범위 줌 · ZOOM · ⛶ 앞 화면 기억(ic/zoom-prompt.ts)
- 시험: mouse-acad 새로 · 휠 방향 시험 손질(input-wheel · input-prefs-cad · input_prefs · app_settings · 164 · 220_1 · 221_1 · 244_8 기대값 뒤집음 · 266 pull 박음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 23:41:46 +09:00
eomsangdon 638b5422d2 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-15 23:38:34 +09:00
eomsangdonandClaude Opus 5.5 01d3b0d2f2 feat(M02): 338-3c 확인 창 · 고르기 창 · 별칭 장부 화면 — 체크한 줄만 [확인하고 사전조합 만들기] · 옛 즉시 확정 · 바로 만들기 없앰
- 새 Hopyo_Check(확인 창 · 본 칸 · 모달) — 상태 색 · 체크 제안 · 우리 로직 여럿 · 배 · 몫 · 옵션 · 품 비교 · 뺌 · 원문 보기 · 캐시 m02-hopyo-check · 셈 중 3 초 다시 읽음 · 최신 응답만
- 새 Hopyo_Choose(고르기 창 · 후보 판정 · 여럿 고름 · 몫 · 찾기 글 미리 넣음) · 새 Hopyo_Ledger(장부 화면 · 고친 것 캐시 · [저장] 때만 PUT)
- 고침 View(호표 하나 = 확인 창) · Sheet(펼침 뒤 한 번 · 하위 호표 먼저) · Pick(확인된 것 바로 참조 · 아니면 확인 창) · Api(3-3 꼴 · 만들기 몸 · 장부 길) · Side([별칭 장부]) · css · Labor_Pick(query)
- 없앰 saveHopyoMemory · [확정] · [같음 모두 확정] · [근거 맞음 모두 확정] · 바로 만들기
- 시험 test_m02_hopyo_screen.py 새로 · helper_m02_hopyo_check.cjs 새 · pick · seam 도우미 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:37:24 +09:00
eomsangdon 146831fdba Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-15 23:36:37 +09:00
eomsangdonandClaude Opus 5.5 e9e949112c feat(일위대가실무): 338-1 맞춤 핵심 서버 1 단계 — 엑셀 지문 · 우리 로직 지문 캐시 · 옵션 거꾸로 · 순위 · 판정 · 조합 · 뒤 셈 + 정답 표
- 새 파일 — Words(식 풀기 · 장비 갈래 · 조건 낱말) · Finger(블록 글 · STmate 자원 → 지문 · 기계 줄 · 직접 줄 호표) · Shares(절 몫 벡터) · Ours(마스터 판 열쇠 · 우리 지문 캐시 tmp/m02_hopyo · 엔진 셈 캐시 · 자재 값 찾기 건너뜀) · Option(옵션 공간 · 거꾸로 찾기 · 견줌) · Fit(손잡이 한 곳 · 순위 · 뒤 셈 · 맞춤셈 캐시) · Combine(조합 · 섞인 줄 몫 먼저) · Verdict(블록 셈 · 맞음 / 다른 점 / 후보)
- Match — b_order(이름 · 근거 차례 · 같음 근거 검사 · 옛 판 글 · 조항 오타) · clause(윗마디 0.9 · 나 창 Basis.clause 있으면 그것) · 이름 후보 로직은 접지 않음
- 정답 표 fixtures/m02_hopyo_golden.json(사람 136 · 새 파일 51) · 골든 도우미
- 북부청 92 줄 실측 — 1순위 87 · 다섯 안 91 · 맞음 41 틀림 0 · 다른 점 28 틀림 0 · 조합 맞음 57/66 · 우리 지문 셈 37 초 · 캐시 읽기 0.05 초

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 23:36:23 +09:00
eomsangdon b647be18e5 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-15 23:31:36 +09:00
eomsangdonandClaude Opus 5.5 75b8815dac test(M02 일위대가): 338-2c 영월 정답 자리 y_ids — D · X 고유 줄 36(Y000~Y035) 실험 C1 차례와 같음 · 골든 시험용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:30:44 +09:00
eomsangdon 6261529017 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-15 23:29:39 +09:00
eomsangdonandClaude Opus 5.5 193e03927b feat(M02 일위대가): 338-2b STmate 꼴 읽기 · 거창 흩어진 칸 이음 · 이음 넓힘 · 노임 갈래 · 문서 보안 400 · STmate 격자
- 새 M02_Hopyo_Stmate — 일위대가표 · 단가산출근거 · 목록표 → 엑셀꼴과 같은 HP(줄 코드 = M~Z 열 코드 꼴 첫 것 · 블록 글 · 자원 품 · 절 · 출처 · 직종 · 호표 근거글)
- 여섯 파일 올라감 — 봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표 · 노임 이름 어긋남 0
- Basis.read 거창 꼴 — 빈 산출근거 칸은 오른쪽 칸 이어 붙임 · 돈 머리 맨 오른쪽 묶음 · 단산 줄 21 · 출처 17 · 직종 21
- Excel 이음 넓힘(단산 · 산근 · 단가산출 / 일위 · 대가 · 일위대가 N · 참조 · 호표) · 빈 비고 줄 이름으로 이음 · 노임 갈래 · DRM 400 글(장수 · 진안)
- Grid STmate 시트 · 숨은 칸 글 뺌
- 시험 stmate · excel · basis · grid · match · build 68 통과 · 원본 엑셀 · master_data sha256 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:28:57 +09:00
eomsangdonandClaude Opus 5.5 33207a450c chore(조사): 337 내장 CAD 오토캐드 조작 설계 — 옵션 틀 약속 · 명령 31 프롬프트 사전 · 흠 23 · 마우스 · 휠 · 창 다섯 · 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 23:25:05 +09:00
eomsangdon ef76443fab Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-15 23:21:40 +09:00
eomsangdonandClaude Opus 5.5 bf5d130e32 feat(M02): 338-3b 확인한 줄만 사전조합 — Build 새 몸 · 재검사 · 별칭 장부 씀 · 장부 길 · 옛 즉시 확정 길 없앰
- M02_Hopyo_Build — 몸 줄만(없으면 400) · 대상 여럿 같은 별칭 · 배 · 몫 · 통째 · 하위 호표 확인 전 409 · 본뜬 원본 · 확인 · 같은 본뜬 덮음 · confirmed(folder, source)
- M02_Labor_Router — hopyo-build 엄격 몸(extra forbid) · hopyo-ledger GET · PUT · hopyo-memory 없앰 · picks 확인 · 올린 뒤 Fit.ensure · 시동 Ours.warm(있을 때만) · ?to= 만들 층
- M02_Hopyo_Ledger — times_of 나눔
- 시험 test_m02_hopyo_build.py 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:21:09 +09:00
eomsangdon 3f2c0583a2 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-15 23:16:12 +09:00
eomsangdonandClaude Opus 5.5 ea21006352 feat(M02): 338-3a 별칭 장부 뼈대 — 열쇠 · 층 차례 찾기 · 느슨 찾기 · 옛 꼴 바꿔 읽음 · 재검사 · 쓰기
- 새 M02_Hopyo_Ledger.py — key · hopyo_key · recall(같은 층 → 위 층 · 품명|단위 느슨 = 규격다름) · books · rows · check · write · put
- 옛 기억 줄(로직|자재|빼기 · 환산 · 확정) = 새 꼴(갈래 짝 · 짝 [{… 배}] · 빼기 = 안 씀)로 읽음 · 쓸 때 새 꼴
- 시험 test_m02_hopyo_ledger.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:15:47 +09:00
eomsangdonandClaude Opus 5.5 2286031266 feat(M02 일위대가): 338-2a 단가산출 블록 글 · 출처 · 직종 HP 저장 · 꼴 칸 · Basis.clause 윗마디 0.9 · 조항 오타 13-114
- 단산 블록마다 글(머리 뺀 몸 줄 · 200 줄 · 300 자 안) · 출처 · 직종 → HP 단산 · 꼴 「엑셀꼴」
- Basis.clause 공개 — 같음 1 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5 · 근거 맞음에 윗마디 포함
- Basis.sources 조항 오타 「13-114」 → 13-11-4
- 북부청 단산 268 · 사방 139 이음 그대로 · 시험 basis · excel · grid · match 51 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:14:23 +09:00
eomsangdonandClaude Opus 5.5 7a8f334520 chore(조사): 338 외부 일위대가 맞춤 설계 — 연결된 단가산출 블록 → 우리 로직 + 옵션 → 일위대가 구성 · 창 셋 · 시험 · 기준 수치 + 실험 자료 사본
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 23:08:43 +09:00
eomsangdonandClaude Opus 5.5 8a4598c051 chore(요구): 229 — CAD 명령 · 옵션 조작을 오토캐드와 같게 구현 · 외부 일위대가 = 연결된 단가산출로 우리 로직 맞춤 → 일위대가 구성 구현 · 끝에 브레인 브라우저 검증 + 아티팩트
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 22:39:05 +09:00
eomsangdonandClaude Opus 5.5 43f3fa923b chore(조사): 336 외부 일위대가 목적 다시 세움 — 우리 사전조합(로직 + 옵션 + 수량)으로 옮김 · 확인한 줄만 · 별칭 장부 · 옵션 거꾸로 찾기 실측 · 묶음 A~F · 기본안 다섯
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 21:28:33 +09:00
eomsangdonandClaude Opus 5.5 3656ebcdf0 chore(조사): 335 다시 조사 — 오토캐드 마우스 · 키보드 조작 146 대 내장 CAD(있음 26 · 일부 69 · 없음 51) · 흠 30 · 묶음 일곱 · 정할 것 24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 21:02:08 +09:00
eomsangdonandClaude Opus 5.5 9c0ef7f827 chore(요구): 228 — 엑셀 일위대가 = 우리 로직에 옵션을 넣고 나온 값일 확률 높음 · 코덱스 세션 다시 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 20:02:18 +09:00
eomsangdon 8e0bd24853 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-15 19:56:26 +09:00
eomsangdonandClaude Opus 5.5 eba998b585 chore(요구): 227 — CAD 입력 조사 범위 바로잡음(오토캐드 마우스 · 키보드 숫자 · 형상 입력) · 외부 일위대가 목적 다시 고민(별칭 · 사용자 확인)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 19:56:06 +09:00
eomsangdon c267196139 Merge remote-tracking branch 'origin/dev' into CODEX 2026-10-15 19:40:05 +09:00
eomsangdonandClaude Opus 5.5 546db1ab11 chore(조사): 336 외부 일위대가 맞춤 재검토 — 로직 조합 기준 · 원본 로직 안 고침 · 1순위 71 → 93% · 조합 맞음 10 → 76% · 묶음 여섯 · 정할 것 다시 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 19:22:57 +09:00
eomsangdonandClaude Opus 5.5 42e4aa70ca chore(조사): 335 CAD 입력 보강 재조사 — 코덱스 · 안티그래비티 · 브레인 바깥 조사 53 줄 · 새 흠 H13 · H14 · 묶음 ⑧~⑩ · 정할 것 15~23
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 18:38:06 +09:00
eomsangdonandClaude Opus 5.5 b7e7278630 chore(요구): 226 — CAD 입력 보강 바깥 조사 넓힘(코덱스 · 안티그래비티 · 브레인) · 외부 일위대가 로직 조합 맞춤 재조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 17:33:08 +09:00
eomsangdonandClaude Opus 5.5 5bf5e0c46e chore(조사): 336 외부 일위대가 이름 맞춤 개선 상세 검토 — 실측 세 파일 · 묶음 넷 · 정할 것 · 부록 둘
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 16:24:23 +09:00
eomsangdonandClaude Opus 5.5 04021d1f73 chore(조사): 335 CAD 입력 보강 상세 검토 — 오토캐드 71 가지 대 우리 · 흠 12 · 묶음 일곱 · 부록 둘
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 16:13:03 +09:00
eomsangdonandClaude Opus 5.5 26fcebeb46 feat(설정): 334-2 ☰ 시스템 설정 4 페이지 바로가기 — 🏠 대시보드 + 카드 · 지금 칸 강조 · M02 → M02 이동은 떠나기 물음 먼저
- 새 A00_Common/m_settings_nav: settingLinks = 대시보드 카드 역할 규칙 한 벌 · mountSettingsDrawer = ☰ 진행 단계 자리
- B01 시스템 설정 카드가 settingLinks 로만 그림(화면 그대로)
- M01 · M02 page 한 줄씩(M02 = 진입 open · company 이어 실음)
- test_b01_settings_cards 가 옮긴 함수 소스를 같이 실음(기대값 그대로) · test_225 뒷부분

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 15:53:07 +09:00
eomsangdonandClaude Opus 5.5 c583dec29b feat(대시보드): 334-1 ☰ 구역 바로가기 — 누름 = 서랍 닫힘 + 그 구역으로 굴림 · 전처리 잠금 길 기준 · 서랍이 집 없는 요소 받음
- ui_template_workflow_layout: 시스템 관리자 전용 판정 = 칸 번호 대신 전처리 길
- app_shell: setDrawerSteps 가 부모 없는 요소도 받음(닫을 때 안 옮김)
- B01 buildPage: 그린 구역 그대로 · 화면 차례 · 아이콘 구역 키 짝 · 떠난 뒤엔 안 맡김
- general_layout.css: B·M 구역 scroll-margin-top = ☰ 자리
- 새 시험 test_225_drawer_shortcuts(앞)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 15:47:14 +09:00
eomsangdonandClaude Opus 5.5 422c449eb9 chore(조사): 334 ☰ 바로가기 코드 조사 — 대시보드 구역 · 시스템 설정 4 페이지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 15:36:42 +09:00
eomsangdonandClaude Opus 5.5 31e6adf7de chore(요구): 225 대시보드 · 시스템 설정 ☰ 바로가기 · CAD 입력 보강 · 외부 일위대가 이름 맞춤 검토 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 15:12:15 +09:00
eomsangdonandClaude Opus 5.5 1a029141f8 fix(일위대가실무): 330 덧고침 2 — 화면 근거 맞음 판정을 조항 같음 · 단위 · 직종(줄 직종이 보통인부뿐 · 없으면 안 봄)으로
- Hopyo_View basisOk(후보 근거, 줄 근거) — 앞 마디 같음은 근거 맞음 아님 · 엑셀 직종 없는 줄(기계)도 조항 같음 + 단위면 맞음 · basisPick · 고르기 창 꼬리표가 줄 근거를 넘김
- 시험 screen(앞 마디 · 보통인부뿐 · 직종 없음) · seam(북부청 실제 응답 1순위 근거 맞음 123 = 서버 기준 · 앞 마디 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:57:25 +09:00
eomsangdon 46ab9d5b11 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-15 12:54:52 +09:00
eomsangdonandClaude Opus 5.5 a8d7832c8b test(일위대가실무): 329 · 330 이음매 — 실제 격자 · 맞춤 응답을 화면 도우미(Hopyo_Sheet · Hopyo_View)에 먹이는 시험
- helper_m02_hopyo_seam.cjs · test_m02_hopyo_seam — 행 → 호표(25 빈 줄) · 영역 B20:O24 · 단산93 행 · 엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상(GC000083 · GC000143)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:52:42 +09:00
eomsangdonandClaude Opus 5.5 c4a09c1ca2 fix(B05추천): 328 추천 저장본 ALGO_VERSION 1 → 2 — 큰 R 로 셈이 바뀌어 옛 저장본(R 12) 무효
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 12:52:08 +09:00
eomsangdon 67e3162a57 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-15 12:51:29 +09:00
eomsangdon 510965f51b Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-15 12:51:26 +09:00
eomsangdonandClaude Opus 5.5 20b2deeddd fix(B05추천): 328-2 큰 R — 단순화가 기울기 위반을 늘리는 급한 지형(검증_sub8)에서 2단을 최소 R 판 노드에 바로 건 판을 1단보다 먼저 · 실측 네 갈래 R 12 → 18/29/147
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 12:50:43 +09:00
eomsangdonandClaude Opus 5.5 510b36eea2 fix(일위대가실무): 330 덧고침 — 후보 다섯에 이름 1순위 자리 · 낱말 안 겹치는 근거 후보 뒤로 · 위 층 격자 읽기 시험
- Basis.merge 같은 점수는 이름 겹침 큰 쪽 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 · 장비 뺌) 안 겹치는 근거 후보는 근거 맞음(조항 · 직종 · 단위)이 아니면 겹치는 근거 후보 뒤로
- Basis.pick 다섯 안에 이름 1순위 하나 늘 남김 · Match match_line 이 씀
- 시험 basis(제3호표 구조물터파기 다섯 = 굴착(인력/토사) 1위 · 육상토사 안 · 터널굴착 밖 · pick) · match(이름 1순위 자리) · grid(고르기 줄 층 · 주인 그대로 회사 · 시스템 격자 읽기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:50:31 +09:00
eomsangdon 16c363fb9b Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-15 12:49:25 +09:00
eomsangdonandClaude Opus 5.5 34659db663 feat(CAD): 332-3 설정 배선 — 휠 확대 방향 · 내 별칭
- 휠 — A00_Common/input_prefs 방향(기본 당김 = 지금과 같음 · 밀기 저장 뒤 뒤집힘) · 트랙패드 핀치는 늘 벌리면 확대(옛 CAD 는 벌리면 축소)
- 별칭 — 명령 이름 > 내 별칭(cadAliases) > 기본 별칭 · 자동완성 후보 맨 앞 · 부를 때마다 읽어 저장 즉시 따름
- tsconfig include 에 input_prefs.ts · 시험 resources/tester/cad/input-prefs-cad.test.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:47:42 +09:00
eomsangdon 23425e5e15 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-10-15 12:45:45 +09:00
eomsangdonandClaude Opus 5.5 aaecfa3438 feat(설정): 332-2 ☰ [설정] 창 — 마우스 · CAD 단축키 · 다른 곳 단축키 탭 · [저장] 때만 계정 pref · 닫을 때 물음
- app_shell ☰ 설정 묶음에 [설정] 단추(서랍 닫고 창은 처음 누를 때 불러옴)
- 새 app_settings.ts(틀 · 탭 셋 · [저장] · askLeave) · app_settings_cad.ts(명령 표 · 내 별칭 · 검사) · app_settings.css
- 새 openwebcad/src/commands/command-catalog.json(명령 153 · 한글 이름 · 기본 별칭) + 거울 시험
- locale 문구(ui_template_locale_common)
- 시험: test_app_settings · cad/command-catalog 새로 · test_device_audit · test_touch_bridge 화면 주소 환경값(AISLO_UI_BASE · AISLO_UI_PROJECT)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 12:45:03 +09:00
eomsangdon 958f50ee6e Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-15 12:44:39 +09:00
eomsangdonandClaude Opus 5.5 309be22ce0 feat(CAD): 331-4 동적 입력 — 커서 옆 길이 · 각도 두 칸 · Tab 잠금
- 기준점 있는 점 단계에서만 Tab 가로챔(그 밖은 브라우저 초점 이동) · 숫자 넣고 Tab = 그 칸 잠금 + 다음 칸 · 빈 Tab = 칸만 바꿈
- 각도 칸 잠금 = 각도 고정 · 길이 잠근 채 Enter = 그 길이 · `100` Tab `30` Enter → (86.603, 50)
- 커서 옆 줄 `▸[길이 100 🔒] [각도 30_]` — 잠금 🔒 · 지금 칸 ▸ · 나머지는 기준점→커서 실측
- 한 점 · Esc · 새 명령이면 각도 고정 · 길이 잠금 모두 풂(releaseOnePoint)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:44:23 +09:00
eomsangdon 825ae3dcc5 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-15 12:41:58 +09:00
eomsangdon 4d64317563 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-15 12:41:55 +09:00
eomsangdonandClaude Opus 5.5 ca11ee1456 feat(CAD): 331-3 선 중 닫기(C) · 명령취소(U) · 한 번 스냅 이름 · 한글 안내문
- 선 — 이어 그리기 첫 점 · 구간 기억 · C = 둘째 선부터 첫 점으로 닫고 끝 · U = 마지막 한 구간만 취소
- 점 단계 글 — 선택지 글자(meta.options)만 도구로 · 나머지는 명령 이름 그대로 · 한글 받개는 점 단계에서 안 잡음
- 새 input-snap-override — END · MID · CEN · INT · PER · NEA · NON 다음 한 점만 · F3 꺼져도 · 맞는 점 없으면 클릭 버림 · Esc 풂 · 커서 옆 안내
- 원본 도구 안내문 한글(선 · 원 · 사각형 · 이동 · 복사 · 회전 · 배열 · 정렬 · 지우개 · 그림 · 폴리선 합치기 · 선택)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:41:16 +09:00
eomsangdonandClaude Opus 5.5 f044b8d0b4 feat(일위대가실무): 330-3 고르기 창 근거 — 후보 근거 꼬리표(조항 · 직종 · 단위) · 엑셀 근거 줄(출처 · 직종 · 단가산출 행) + [엑셀에서 보기] · [근거 맞음 모두 확정](셋 모두 맞는 후보만) · 옛 파일 단추 없음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 12:38:41 +09:00
eomsangdon 16aea41631 Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_1' into main_laptop_1 2026-10-15 12:38:16 +09:00
eomsangdonandClaude Opus 5.5 27c62ce220 feat(일위대가실무): 329-1 · 330-1 · 330-2 서버 — 엑셀꼴 격자 · 행 범위 · 단가산출 근거 맞춤
- 329-1 새 M02_Hopyo_Grid(xls formatting_info 값 · 병합 · 열 폭 · 행 높이 · 서식표 · 틀고정 / xlsx 값만) · read_book 호표 · 줄 「행」 · Store 격자 저장 · 읽기 · 지움(실무자료/격자/<키>.json) · GET hopyo-grid/{key}(옛 파일 404) · Match 요약 .get
- 330-1 새 M02_Hopyo_Basis.read — 단산N 블록 이름 · 단위 · 행 · 출처(책 · 절) · 직종(우리 인력 이름 · 옛이름) · 금액 · 단산 줄 「근거」
- 330-2 Basis.ours · candidates(조항 1.0 · 앞 마디 0.5 · 건설 공통 · 토목 0.1 · 직종 0.5 · 단위 0.15 · 까닭) · match_line 이름 후보와 로직 키로 합침 · 상태 후보 그대로
- 시험 test_m02_hopyo_grid · test_m02_hopyo_basis 새로 · test_m02_hopyo_match 제3호표 구조물터파기 1순위 → 굴착(인력/토사)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:37:18 +09:00
eomsangdonandClaude Opus 5.5 be86e3ee2c feat(노선추천): 327-2 미리 셈 · 표시 — 업로드 체인 스냅샷 뒤 기본 다섯 알약 예약 · 추천 창 상태 줄 「저장된 결과」
- B03 자동 설계 체인: 초기값 스냅샷 뒤 precompute_defaults 예약(기다리지 않음 · 실패는 비치명적)
- 추천 창: 서버 stored 를 받아 미리 보기 줄에 「(저장된 결과)」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 12:37:17 +09:00
eomsangdon 4d84b54f2d Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_5 2026-10-15 12:37:13 +09:00
eomsangdonandClaude Opus 5.5 32ddaa8064 feat(CAD): 331-2 이동 · 복사 · 회전 키보드 값
- 이동 · 복사 — 기준점 `x,y` · 둘째 점 거리 · `x,y` · `@x,y` · 각도 고정 · 미리보기도 같은 점 계산(getPointFromEvent)
- 회전 — 기준점 `x,y` · 각도 숫자 바로(반시계 +)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:36:19 +09:00
eomsangdonandClaude Opus 5.5 b1e8d6cb7f feat(설정): 332-1 휠 확대 방향 읽기 층 — input_prefs · 모든 휠 화면 한 설정(기본 당김) · 핀치는 늘 벌리면 확대
- 새 A00_Common/input_prefs.ts(INPUT_PREFS_KEY · DEFAULT_INPUT_PREFS · inputPrefs · wheelIn · wheelEventIn · 의존 없음)
- 등록표 input-prefs(pref · global) 한 줄
- touch_bridge wheelZoomsIn · b_svg_zoom_pan · B04 3D 카메라 · M02 DF 그림 · M02 노무 그림 · 돋보기 · 시트 배선
- M02 그림 · 시트 · 돋보기 기본 방향 = 당김(지금과 반대)
- 시험: test_input_prefs 새로 · 돋보기 · 266 · 244-8 헬퍼가 읽기 층을 불러옴 · 돋보기 부호 당김 기본

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 12:36:04 +09:00
eomsangdonandClaude Opus 5.5 b55bad1686 feat(B05): 326-3 계획노선 편집 창 [측점 스냅] 단추 — 켬끔 보기 모드(되돌리기 밖) · 열 때 서버 값 · 측점 한 곳(stations)으로 눈금 · 누름 · 기울기 · 횡단 미리보기 · [확인] 때 station_snap 실음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-15 12:35:57 +09:00
eomsangdonandClaude Opus 5.5 f127c0179e feat(노선추천): 327-1 추천 결과 영구 저장 — 입력 지문 키 · 같은 입력이면 저장본 · 바뀐 입력만 셈해 더함
- 새 B05_Profile_Engine_RouteInitial_Store(ALGO_VERSION · request_fingerprint · 읽기 · 쓰기 · 묶음 잠금) · 자리 B05_Profile/route_recommend/<키>.json · 덮어쓰기 없음 · 상한 없음
- 엔드포인트 몸통 compute_initial(찾기 → 셈 · 더함 · 덮개 수 꺼낼 때 다시 셈 · stored 표시) · precompute_defaults(기본 다섯 알약)
- resolve_inputs 로 기본값 풀이 한 곳 · 머리글 「파일 안 씀」 정정
- 시험 test_224_3_recommend_store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 12:34:58 +09:00
eomsangdon 2a5d9a4254 Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_6', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-15 12:34:10 +09:00
eomsangdonandClaude Opus 5.5 e36fc5091d feat(일위대가실무): 329-3 엑셀 보기 창 — 읽기 전용 시트(일위대가 · 단가산출 탭) + 호표 목록(체크 · 찾기 · 맞춤) · 목록 누름 → 영역 강조 · 칸 누름 → 그 호표 · 고른 호표 한 번에 사전조합 + 결과 표 · 여는 자리 넷(올린 직후 · 엑셀로 보기 · 엑셀에서 보기 · 로직 추가 덧줄) · 늦게 불러옴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 12:33:32 +09:00
eomsangdonandClaude Opus 5.5 8e96880880 feat(CAD): 331-1 각도 고정 <각도 → 길이 · 시퀀스 첫 점 숫자 무시
- 새 input-angle-lock — 다음 한 점만 고정 방향(동쪽 0° 반시계) · 커서 · 클릭은 고정 직선 위로 · 숫자는 그 방향 길이 · 한 점 · Esc · 새 명령이면 풂 · 커서 옆 안내 「각도 30° 고정 — 길이 입력」
- input-patterns — 숫자 음수 · .5 허용 · 각도 고정 꼴 · 점 글 해석 한 곳(parsePointText)
- Enter 해석 차례 — 자유 글 단계만 원문 먼저 → 각도 고정 → 점 · 거리 → 단계 선택지 → 명령 이름
- 선 · 시퀀스 미리보기도 고정 방향 · 시퀀스 첫 점의 거리 · 상대 좌표는 무시(도구 멈춤 · 「Error in tool actor」 없앰)
- 시험 resources/tester/cad/keyboard-chain.test.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:33:28 +09:00
eomsangdonandClaude Opus 5.5 394fb7ba60 feat(B05추천): 328-2 추천 노선 큰 R — 곡선마다 [맞춘 R, 접선 자리 ÷ tan(IA/2)] 안 탐색 경로 이탈 ≤ 4 m 인 가장 큰 R(1 m 내림) · 못박아 다시 짬(편 노드 · [확인] 재구성과 같은 선) · 허용 이탈 상수 · test_224_4_recommend_radius
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 12:32:56 +09:00
eomsangdonandClaude Opus 5.5 6ae8bb4f6a feat(B05추천): 328-1 추천 노선 R 맞춤 — 탐색 경로를 예정노선 규칙(단순화 · 묶음 · R 맞춤)으로 · 최소 R 판보다 위반 많으면 최소 R 판 · 도움말 추적 「맞춘 곡선반지름」
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 12:32:45 +09:00
eomsangdonandClaude Opus 5.5 4c6658cf1c feat(B05): 326-1·326-2 측점 스냅 서버 — 요청 모델 Schema 로 쪼갬 · 호 안 측점 하나를 곡선 중점으로 옮기는 짝 셈(snap_curve_stations ↔ snapCurveStations) · [확인] 때 곡선표 station_snap 저장 · GET 돌려줌 · 실패 되돌림에 곡선표·노드 · 측점 셈 둘 · 횡단 미리보기 반영
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-15 12:32:36 +09:00
eomsangdonandClaude Opus 5.5 d33cf220f3 feat(종단): 325-3 유토곡선 · 배수유역 [창 분할] — 몸통 attachPopout 한 벌 · 유토 판 좌상단 · 배수 머리줄 맨 앞 단추 · 창 이름 따로 · 단추가 놓인 창에서 엶 · 뗀 판 창 꽉 채움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-15 12:31:21 +09:00
eomsangdonandClaude Opus 5.5 2d239f780e feat(종단): 325-2 유토 판 붙어 있음 판정 — 높이 나눔 · 끌기는 열림 && 종단 뿌리 안만 · 뗀 유토는 노선 전체 세로 · 방향키 판정에 뗀 유토 · 배수 판
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-15 12:26:31 +09:00
eomsangdonandClaude Opus 5.5 7bae5ebb7d feat(스프레드시트): 329-2 손잡이 select(범위, 시트?) — 범위 글 → 고름(병합 늘림 · 여럿) · 그 시트 탭으로 · 보이게 굴림 · 틀린 글 false
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 12:25:45 +09:00
eomsangdonandClaude Opus 5.5 6d5e5f911e feat(종단): 325-1 배수 판 창구 — 지도 크기 관찰을 놓인 창 것으로 바꿔 끼우는 rehome · 직전 크기 이어받아 배율 유지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-15 12:22:37 +09:00
eomsangdonandClaude Opus 5.5 b5b6264016 chore(조사): 요구 224 정밀 분석 — 325 유토곡선·배수유역 창분리 · 326 측점 스냅 · 327-328 추천 저장·큰 R · 329-330 외부 일위대가 보기 창·근거 맞춤 · 331 CAD 연속 명령(오토캐드 조사) · 332 설정 모달
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 12:12:08 +09:00
eomsangdonandClaude Opus 5.5 837fe3d64a chore(요구): 224 원문 — B05·B06 유토곡선·배수유역 창분리 · 계획노선 측점 스냅 · 추천 노선 영구저장 · 큰 R 우선 · M02 외부 일위대가 엑셀꼴 모달 · 계산식·출처 매칭 · CAD 연속 명령 · 설정 모달
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 11:43:40 +09:00
eomsangdon 6461b41293 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-15 10:15:22 +09:00
eomsangdonandClaude Opus 5.5 9edaec82e3 fix(일위대가실무): 324 다듬기 — 줄 표 줄바꿈 없음 · 고르기 창 [닫기] 안 갈라짐 · 뜻 낱말 안 겹치는 후보 숨김 · 다시 씀 · 새로 덮음 알림 글
- 줄 표: 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 열 줄바꿈 없음(글 폭) · 입력값 길면 줄임표 · 칸 여백 좁힘 · 품명 · 규격 · 대상이 남는 폭
- 고르기 창 머리: [닫기] 단추 줄바꿈 없음 · 안 줄어듦
- 후보: 정리 이름 뜻 낱말(괄호 밖 두 글자 이상 덩어리 · 낱글자 붙임)이 하나도 안 겹치면 뺌(잔토처리 · 임도표지석 → 후보 없음) · 다 걸러지면 「가까운 것 없음」 + [다른 로직 · 자재 찾기]
- 알림: 있던 사전조합 = 「다시 씀 — 빠진 줄 N」 · [새로 만들기] 뒤 = 「새로 만들어 덮음」(토스트 · 판 글)
- 시험 hopyo 넷 42 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:46:24 +09:00
eomsangdon c7e9747c72 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-14 22:43:00 +09:00
eomsangdonandClaude Opus 5.5 56f606f331 feat(일위대가실무): 324-4 화면 — 좌측 「일위대가 실무자료」 판 · 본 화면(호표 표 · 줄 맞춤 · 확정 · 빠진 줄 · 값 비교) · [로직 추가] 「실무 호표」 줄
- 좌측 판(Hopyo_Side): 일위대가 사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 [엑셀 올리기] 꺼짐 · 숨은 file 입력 .xls,.xlsx · 파일 줄 → 본 화면
- 본 화면(Hopyo_View): 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · [지우기] / 호표 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산) · [고르기](후보 5 + 다른 로직 · 자재 찾기 → 미리 채움) · [확정] · [빼기] · [되살림] · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기](확인 뒤 덮음)
- [로직 추가](Hopyo_Pick · Labor_Pick 늦은 덧줄 `more` · 종류 고르기): 문서 층 · 위 층 호표 줄(맞춤 n/m) → 고르면 서버가 문서 층 사전조합을 만들고 참조 줄(별칭 = 호표 품명) · 빠진 줄 알림 · 프로젝트 층 없음
- Labor_Detail: 제자리 넷 줄(가져오기 · pick 별칭 받음 · withHopyo) · 693 줄 그대로
- 서버: GET /layers/{층}/hopyo-picks(이 층 · 위 층 · 이 층 기억)
- 색 = 공용 토큰(밝음 · 어두움) · 시험 test_m02_hopyo_screen 4 + hopyo-picks 1 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:27:57 +09:00
eomsangdonandClaude Opus 5.5 736aa405cf feat(일위대가실무): 324-3 실무 호표 → 우리 일위대가 사전조합 만들기 · 사전조합 줄 별칭 살림
- M02_Hopyo_Build: 호표 = 사전조합 하나(이름 = 품명 + 규격 · 엑셀 단위 · 구분 실무자료 · 상세구분 파일 · 본뜬 실무자료 표시) · 로직 줄 {별칭 = 엑셀 품명 40 자 · 메모 「실무 0.14㎥ × 1 · 규격」 · 입력값 · 수량 손 = 엑셀 × 환산} · 자재 줄(값 없음 = 별도계상 · 아니면 직접계산 · 같은 자재 수량 더함) · 같은 로직 둘째 = 한 줄 사전조합으로 감쌈 · 일위N = 먼저 만들어 참조 · 순환 = 빠진 줄 · 같은 층 · 파일 · 호표 = 다시 씀 · 새로 = 그 키에 덮음 · 빠진 줄 + 엑셀 금액 · 만든 값 ↔ 엑셀 단가
- M02_Labor_Ref._combo_lines: 별칭 있으면 남김(한 줄)
- 길 POST /layers/{층}/hopyo-build(이 층 쓰기 · 파일 층 읽기)
- 시험 test_m02_hopyo_build 5 통과 · 곁 시험 84 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:15:59 +09:00
eomsangdonandClaude Opus 5.5 5e33dda958 feat(일위대가실무): 324-2 맞춤 — 엑셀 구성 줄 → 돈 로직 · 우리 자재 · 규격 입력값 · 환산 · 층 기억
- M02_Hopyo_Match: 정규화(master_material.plain · plain_spec · plain_unit) · 차례 = 층 기억(같은 층 → 위 층) → 이름 같음 · 일위대가조합.json 메모 씨앗(읽기만) → 정리 이름 · 후보 5(두 글자 묶음 + 단위 + 산림품셈 · 규격 같은 자재 덤) · 후보는 확정 뒤에만 들어감
- 규격 → 입력값: 고르기 글(품명 괄호 포함 · 낱말 머리) · 기계 고르기이름 · D= H= H: B= T= L= L3= · 천 단위 쉼표 · 못 읽음 표시
- 환산: 같은 단위 1 · 단위표 · ㎡→㎥ T · m→㎡ B · m→㎥ B×T = 추정 · 두 단위 로직 · 그 밖 입력
- 길: GET /hopyo/{키}(호표마다 맞춤 n/m) · GET /hopyo/{키}/{번}(줄 맞춤 · 후보) · POST …/guess(미리 채움) · POST /hopyo-memory(확정 · 빼기 · 지움 = 층 쓰기)
- 시험 test_m02_hopyo_match 15 통과(+ 324-1 15)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:12:00 +09:00
eomsangdonandClaude Opus 5.5 63ba44a717 feat(일위대가실무): 324-1 실무자료 엑셀 읽기 · 저장 · 올리기 길 — 「일위대가」 시트 → 호표 · 줄 · 갈래 · 참조 · 검산
- M02_Hopyo_Excel: xls(xlrd · logfile 막음) · xlsx(openpyxl) · 머리 글로 시트 · 열 찾기 · 제N호표 · 구성 줄 · 갈래(단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타) · 단산N/일위N 참조 · 검산(줄 금액 floor · 합계 단가 · % 줄 · 호표 비목 · 총괄표) · 규격 원문 보관 · 시트 없음 = 「일위대가 시트 없음」
- M02_Hopyo_Store: 층 폴더 실무자료/HP-######.json · 맞춤 기억 실무자료/맞춤.json · 잠금 + 원자 쓰기 · 양식 종류 밖(목록 · 복사 · 프로젝트 씨앗에 안 섞임)
- M02_Labor_Router: GET/POST /layers/{층}/hopyo · GET/DELETE /layers/{층}/hopyo/{키} · 20 MB · 엉터리 · 시트 없음 400 · 권한 = 층 읽기 · 쓰기 규칙
- requirements: xlrd==2.0.2
- 시험 test_m02_hopyo_excel 15 통과(북부청 68 · 사방 27 · 검산 0 · 역할 표 · sha256)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:03:59 +09:00
eomsangdonandClaude Opus 5.5 c637ff14ea chore(plan): 324 일위대가 실무자료 조사 · 정함 — 엑셀 일위대가 시트 꼴(xlrd · 열 · 호표 · 갈래 · 검산 · 규격) · 우리 구조(로직 · 일위대가 · 사전조합 · 고르기 창 덧줄) · 호표 = 사전조합 하나 + 참조 줄 · 별칭 = 엑셀 이름 · 규격 → 입력값 · 환산 · 맞춤 기억 · 단계 324-1~5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 21:56:09 +09:00
eomsangdon 13646d2a8c Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-14 21:36:58 +09:00
eomsangdon ecd4a95ab5 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-14 21:34:50 +09:00
eomsangdonandClaude Opus 5.5 4baf49ba48 chore(master): 사용자 지시 — A군 맹암거 옵션 기본값 암거 높이 3 m · 암거 경사 3 %(요구 223 · M02 저장 길 · 판 21 · 이력 판_20)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 21:34:05 +09:00
eomsangdonandClaude Opus 5.5 0d1c4f2615 fix(구조물표): 323-9 ④ 덧고침 — 노선 끝 관 3D 둘(관 카드 1078 · EP 카드 1078.010813) · 3D 몸체 = 같은 시설(얹을 때 적은 시설 누가거리)을 그리는 카드 중 시설 자리에 가장 가까운 하나만(ownsBody · 관 · 세월교 · BOX · 물넘이는 fordPavementChute 같은 규칙)
- B05 3D: ownsBodies(같은 m 만) → ownsBody(시설 누가거리로 묶음 · 차례 무관)
- 시험: test_323_9_twin_cards_3d 에 EP 꼴(0.010813 m 차 · 차례 바꿈) 더함 · 고치기 전 두 배 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:25:41 +09:00
eomsangdonandClaude Opus 5.5 dd35589079 fix(구조물표): 323-9 ④ 작은 것 — 검사 글 띄어쓰기 「… · 구조물에 안 맞음」(화면 · 서버 짝) · 노선 끝(EP)처럼 같은 m 카드 둘이면 3D 관 · 세월교 · BOX 몸체를 한 번만(앞 카드 · ownsBodies)
- M02_SO_Def_Check.ts ↔ M02_SO_Check.py 글 · test_322_2_check_mirror 기대 글
- B05 3D: buildCorridorStructures 관 몸체 = 같은 m 앞 카드만 · 벽은 그대로(wallSweepAt)
- 시험: test_323_9_twin_cards_3d(세월교 · BOX 카드 둘 = 하나와 같음 · 고치기 전 두 배)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:13:36 +09:00
eomsangdonandClaude Opus 5.5 e067a22439 fix(구조물표): 323-9 ③ M02 부품 끔 막힘 안내 — 형상 부품을 꺼서 형상이 「없음」이 된 것이면 「부품 「맹암거」 끔 — 이 부품을 쓰는 종류(A 횡단배수 · 맹암거)의 횡단도를 「안 만듦」으로 바꿔야 저장」 한 줄(부품 이름 + 쓰는 종류 전부) · 막음은 그대로 · 화면 · 서버 짝
- M02_SO_Def_Check.ts ↔ M02_SO_Check.py: 부품 끄기 전 표로 끈 탓 가름 · 부품마다 한 줄
- 시험: test_322_2_check_mirror(벽 끔 = 다섯 종류 한 줄 · 맹암거 끔 = A 횡단배수 · 맹암거 · 짝 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:09:49 +09:00
eomsangdonandClaude Opus 5.5 13608c6be5 fix(구조물표): 323-9 ② 정의 판 알림 되풀이 — 정본이 옛 정의 판으로 적혔으면 구조물을 안 고쳐도 [저장]이 정본을 그대로 다시 써 지금 판 도장 · 다시 열면 알림 없음 · B05 · B06 같은 길
- B05_Profile_Api_Structures: definitionStale(열 때 판 알림이 표시 · 저장 성공이 풂) · flushPendingStructures 가 초안 없어도 옛 판이면 정본 그대로 내보냄
- B05 보기 saveStructuresIfDirty 같은 조건
- 시험: test_323_9_definition_stamp(옛 판 열기 알림 · 고침 없음 저장 = 판 도장 · 다시 열기 알림 없음 · 또 저장 안 나감)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:07:25 +09:00
eomsangdonandClaude Opus 5.5 7cb82c6112 fix(구조물표): 323-9 ① B06 [저장] 뒤 카드 = 서버 새 상세 — 저장 도중 바뀜 신호로 낡은 상세 캐시 · 빈 초안을 그리지 않음(savingDesign) · 뒤처리가 비운 캐시로 새 상세를 받아 한 번 다시 그림(보일 때만) · B05 보기 저장 · [확정] 길 그대로
- design_save: 저장 도중 표시(savingDesign) · 다 나간 뒤 · 실패 때 풂
- design_view_cross: refreshDetailForStructures 가 저장 도중이면 차례만 올리고 안 그림(최신만) · persistContext.redraw
- B06 Persist done: invalidateSectionDetail 뒤 redraw
- 시험: test_323_9_save_redraw(저장 전 옛 자리 · 저장 뒤 새 자리 · 도중 건너뜀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:04:51 +09:00
eomsangdonandClaude Opus 5.5 35f861a517 knowledge(실무문서): 2026 실무일위대가 모음 xls 넷 추가 — 사용자 자료(북부청 임도 설계내역서 공통단가 · 사방 계류+댐 · 사방사업 표준품셈(안) · 수목진료)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 20:59:33 +09:00
eomsangdonandClaude Opus 5.5 56bc88bc47 chore(요구): 223 구조물 표 흠 고침 진행 · 본보기 목록 맹암거 · 옛 종류 그대로(비활성) · 맹암거 기본값 3 m · 3 % · 전역 깃 싱크 · 싱크 뒤 M02 일위대가 실무자료(엑셀 일위대가 탭) 불러오기 · 별칭 · 규격 옵션 · 단위 변환 상세 분석 뒤 신규 계획
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 20:59:24 +09:00
eomsangdonandClaude Opus 5.5 b342e33411 chore(요구): 222 sub1 구조물 관리 표 재설계 전체 = 브레인이 브라우저로 사용자처럼 전수 검증 · 미반영 · 오류 찾기 · 하나씩
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 19:16:17 +09:00
eomsangdonandClaude Opus 5.5 81f7d98e6f feat(구조물표): 323-7 새 종류 · 정리 — M02 종류 더하기(본보기 복사 · 일곱 갈래 본보기 그대로) · 손코딩 동작 · 명세 = 본보기 키 · 특수 동작 = 동작 이름 · 정본 목록 = 표 · drawing_views 걷음 · 옛 이름 되짚기 걷음(자리로 가름) · 허용 거리 파이썬 한 곳 · 벽 툴팁 = 종류 이름
- M02 종류별 탭 [종류 더하기] = 본보기(구조물 정본 원본) 복사 — 키 자동 · 이름 「본보기 2」 · 옵션 · 표시 · 그룹 · 배치 · 규칙 · 종류 덧칸 · 반영 그대로 · 종류.{새}.본보기 · 관 지점 종류 · 복사본은 본보기 밖
- 구조물 표 = 본보기 처음값 이어받음(resolveCatalog · resolve_catalog 짝 · 줄 칸 base) · baseType/base_type · specFor(명세) · 덧단 · 길이 전후 풀기 · 옹벽 추가 단 콘크리트
- 검사(화면 = 서버 짝) — 본보기 = 원본 · 같은 군 · 자리꼴 · 정본 · 만든 뒤 고정 · 복사본은 본보기가 숨긴 칸 이어받음 · 서버 덧칸 본보기 받음
- 특수 동작 = 동작 이름 — 바로 안 놓기 · 포장 나누기 = 포장_물넘이_겹침 · 월류 높이 기다림 = 월류단면
- 정본 목록 = 표 — 파이썬 관 지점 종류 = catalog owner · B05 시설 서브폼 = 표 형상 · 안 쓰는 손 목록 걷음
- drawing_views — 스키마에서 빼고 읽을 때 버림 · M02 [저장] 때 빠짐(코드는 마스터 안 고침)
- 이관 = 지난 판 이름 대신 측점 계획 자리(planted)로 가름 · all_type_names 걷음
- 허용 거리 = common_util_structure_station.py 한 곳(SAME_STATION_M · EDGE_TOLERANCE_M · PIPE_MATCH_M) · TS 표는 station.ts 머리
- 시험: test_323_7_new_type(템플릿 · 일곱 갈래 · 그림 · 검사 짝 · 서버 읽기) · 322-0 유령 시험 = 자리 · 옛 도움 파일 동작 · 표 길 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:59:13 +09:00
eomsangdonandClaude Opus 5.5 6744524669 fix(구조물표): 323-6 고침 — 지우고 새로 바꾸는 종류 바꿈 = 새 종류가 놓인 뒤에만 옛 것을 지움(못 놓으면 옛 것 · 옛 폼 그대로 + 알림) · [추가] 무반응 = 막힌 칸 이름 알림
- 반사경 → 가드레일(길이 빈칸)이 옛 것을 먼저 지우고 새 것은 못 놓아 반사경이 사라지던 것 — 패널 addAt 이 놓였는지 돌려줌 · Events replaceAt = 먼저 놓고 확인 뒤 옛 것 지움 · 못 놓으면 옛 기록 폼(관은 관 폼) + 「반사경 그대로 — 가드레일(으)로 못 바꿈」
- commit 의 조용한 return 여섯 곳 = 막힌 칸으로 커서 + [추가] 때 「「길이」 칸이 비었거나 맞지 않아 못 놓음」(실시간 반영은 알림 없음)
- 패널 줄 수 지킴(setter 한 줄로 · 697)
- 시험: test_319 바꿈 막힘 둘(구조물 · 관) · 비동기 바꿈 · test_323_6 막힌 칸 알림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:32:46 +09:00
eomsangdonandClaude Opus 5.5 529baca3ff feat(구조물표): 323-6 바꿈 한 길 — 바뀜 신호 하나(구조물 초안도 설계 가게 · watchDrafts) · 종류 바꿈 표 규칙(swapsInPlace · E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로) · 조용한 되돌림 없앰(알림) · 그리기가 조정값 안 만듦 · replayDraftPipeOptions 걷음
- 구조물 초안 쓰기 = useDesignStore().set("structures")(칸 그대로) → 관 초안과 같은 구독 · 횡단 보기 watchDrafts(구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만 · 옵션만은 폼 길이 그 카드만 · 카드 줌 · 팬 지킴) · 옛 되부름 onStructuresChanged 걷음
- 종류 바꿈 = 표(catalog) swapsInPlace — 정본 다름 · 구조물 자리꼴 다름(E · F) = 지우고 같은 기준 측점에 새로(구간 = 기본 길이) · 같으면 종류 키만 · 막히면 옛 기록 · 옛 종류 폼 + 알림(전엔 [수정] 조기 반환 → 조용히 되돌림 · 드롭다운만 새 종류)
- syncApplied = 사용자가 조작한 세션이 있을 때만 깎음(카드 그리기가 자동 배치를 조정값으로 굳혀 [저장]으로 내보내던 것)
- replayDraftPipeOptions 걷음 — 그림 자료는 형상 얹기(초안 현황) · 다단 조정값은 조정 세션이 정본
- 시험: test_323_6_change(신호 · 거르기 · 표 규칙 · 그리기 · 걷음) · test_319 점형 ↔ 구간형 · 막힘 알림 · 되얹기 시험 = 형상 얹기로 · 옛 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:21:41 +09:00
eomsangdonandClaude Opus 5.5 cb4bab7bf2 feat(구조물표): 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(pickLights · 카드 형상 · 3D 같은 함수) · 그린 것마다 신원표(data-structure-id + data-part) · 같은 자리 거리 = PIPE_MATCH_M 하나 · 322-0 이 깬 옛 시험 도움 파일 · 줄 수 고침
- `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds)
- `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만
- 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐)
- 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704)
- 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:06:13 +09:00
eomsangdonandClaude Opus 5.5 c45814f910 feat(구조물표): 323-4 형상 얹기 한 벌(TS · 파이썬 짝) — 저장분 · 초안 · 미리 뜬 횡단 같은 길(저장 전 그림 = 저장 뒤 그림) · 빈칸 경고 한 꼴(카드 칩 「종류 칸 빈칸」) · 3D 지문 = 그림 자료 통째 · C군 벽 3D 한 번 · 소단 원천 = 표 형상 berm
- 짝 `common_util_structure_attach.ts` attachShapes ↔ `B06_Section_Engine_Attach.py` attach_shapes — 열쇠 걷고 관 세트 → 벽 → 포장 → 맹암거 → 빈칸 경고(측점 계획 자리 · 「옹벽 높이」 · 「배관 관경」 · 「맹암거 암거 높이」 · 「물넘이포장 월류 높이」)
- 서버 `_read_section_detail` · 재생성 응답 = attach_saved_shapes 하나 · 화면 `withDraftShapes`(현황 = 초안 ?? 저장분 · 초안 없어지면 저장분으로 한 번) — decorate 관 세트 · withDraftPipeKinds · 맹암거 · 포장 · 벽 블록 걷음 · 구조물 초안 없을 때 미리 뜬 횡단에 포장 · 벽 · 맹암거 빠지던 것 · 반폭 넓힘 뒤 날 상세(초안 빠짐) 고침
- 파이썬 얹기 = 목록 함수(culvert_sets_from · attach_culvert_specs · wall_specs_from · attach_wall_specs · underdrains_from · attach_underdrain_specs) + 옛 파일 함수는 감쌈
- 3D 지문 = SHAPE_KEYS(`common_util_structure_shape_keys.ts`) + 조작값 통째(손 해시 걷음 · 벽 기초 · 단 · 포장 · 맹암거 빠짐) · C군 벽 = 기준 측점과 가장 가까운 측점에서만 시작 ~ 끝 스윕(`wallSweepAt`)
- 소단 = 표 형상 berm(BERM_TYPE_ID 걷음 · 화면 isBermType · 서버 catalog_row)
- 시험: test_323_4_attach_mirror(42종 + 빈칸 · 서버 = 화면 · 옛 자리 걷힘 · 벽 한 번 · 지문) · 전수 표 ★ 둘(물넘이포장 걸친 격자 100 = 서버와 같음 · STAGE 4) · 옛 도움 파일 = 측점 → 형상 차례

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 17:26:53 +09:00
eomsangdonandClaude Opus 5.5 3d8c161efa fix(구조물표): 322-3 고침 — B06 을 바로 열어도 옛 소수 관 자리 = 정수 m 초안(B05 snapOldStations 와 같은 셈 · 요구 167) · 계획고 편집 · 고름 따라감 · 알림 한 줄 같은 글 · 투영 이월 끝 = 선 길이(관이 전부 0 m 로 가던 흠) · design_view_cross 685줄 안(322-3 넘침)
- B06 구조물 목록 load: 관 초안 없고 저장분에 소수 자리 = `snapPlan` 으로 옮긴 초안(persistPipes) → 카드 · 측점 계획이 B05 를 먼저 연 것과 같음 · 정본은 [저장]에서(B08 사본 이름도 PUT 길)
- 서버 관 읽기는 그대로(파일 = 측점) · `project_pipe_points` 끝 = 선 길이
- 시험: test_322_3_flow 견본(85.05 … 982.64) → 85 · 173 · 258 · 352 · 440 · 534 · 620 · 720 · 804 · 900 · 983 · 정수 저장분 = 새 미리 뜬 카드 0 · 소수 저장분 = 정수 자리로 · test_322_3_saved_snap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 17:02:22 +09:00
eomsangdonandClaude Opus 5.5 0da2fa0767 fix(구조물표): 322-2 다듬기(브레인 화면 확인) — 화면에 영어 키 안 보임 · 검사 글 한국어(화면 · 서버 짝) · 부품 탭 읽는 칸 = 정의 이름 · 줄바꿈 · 한눈 표 폭 줄임 · 바꿨다 되돌리면 [저장] 꺼짐
- 자리꼴 점 · 구간 · 터 · 정본 관 지점 · 구조물(키는 툴팁) · 고르기 글 = 부품 짧은 이름(괄호 설명 · 키는 툴팁) · 횡단도 없음 = 「안 만듦」 · 「벽 스윕」 → 「벽」 · 종류 키는 툴팁
- 검사 글: 「형상이 「없음」이면 횡단도도 「안 만듦」」 · 부품 짧은 이름 · 칸 이름 · 종류 이름(M02_SO_Def_Check.ts = M02_SO_Check.py)
- 부품 탭: 읽는 칸 = 그 부품을 쓰는 종류의 칸 이름(단위) · 표 줄바꿈(table-layout fixed)
- 한눈 표 형상칸 「기본 형태」 = 그림 형태 고르기(REVET_FORMS)
- [저장] 켬 = 저장할 꼴(tidy · 목록 짝 enabled)끼리 지금 판과 다를 때만(M02_SO_Def_Reflect.differs)
- 시험: 322-2 검사 글 · 되돌림 견본

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 16:29:30 +09:00
eomsangdonandClaude Opus 5.5 6c87207f76 feat(구조물표): 322-3 측점 계획 한 벌(TS · 파이썬 짝) — 카드 목록 · B05 종단 · 3D 막대 · B06 종단 띠(모든 종류) · 서버 측점이 같은 목록 · 일반 측점 자리 새 구조물 이름 · 형상(219) · 서버 낡음 = 자리 + 이름 · 옮긴 관 고름 유지 · 배수 패널 되먹임 끊음
- 측점 계획 planStations(TS) ↔ plan_stations(py): 관 · 구조물 + 구조물 표 → 측점 · 역할 · 카드 이름 · 덮는 구조물 id · 빈칸 경고(형상 부품 필수 칸) · 같은 m 하나(이름 = 관 먼저 · 앞선 것)
- 서버 resolve_extra_stations = 계획(같은 자리 한 번 · 옛 「마지막 이름」 걷음) · _stale_marks = 자리 + 이름(종류 · 관 공식 · 정의 판 이름)
- 카드 목록(Section_Drafts) = 계획 이름 · 일반 측점 자리(regular)에 새로 놓은 구조물 = 그 자리 구조물 없는 사본에 이름 칩 · 관 그림 · 벽 · 포장(219 흠)
- B05 종단 · 3D 측점 = 계획(design_structure_status.plannedIrregular) · 배수유역도로 관 자리 되먹임 끊음
- B06 종단 띠 = 계획(관만 → 모든 종류 · 관 구간도 넘김)
- 고름: followMovedPipe — 고른 관을 옮기면 고름도 새 자리(B05 폼 · 종단 끌기 · B06 옮김)
- 부품 상자 형상 「필수 칸」(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 · 기슭막이 높이)
- 시험: test_322_3_plan_mirror(거울 · 규칙 · 서버 공급자 · 낡음) · test_322_3_flow(흐름 일곱 + 고름 따라감) · 272_1 = 같은 자리 한 번 · 318 도움 파일 표 모듈 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 16:03:33 +09:00
eomsangdonandClaude Opus 5.5 3abd2bdfd4 feat(구조물표): 322-2 M02 표 화면 — 탭 셋(한눈 표 · 종류별 · 부품) · 반영 · 부품 · 알약 색 저장 · 목록 + enabled 함께 · 검사 짝(화면 = 서버 · 1-3 규칙 · 만든 뒤 고정) · 명세 폼 보임 따름 · 판 알림
- 한눈 표(M02_SO_Def_Table): 군 · 종류 ▲▼ · 이름 · 목록 · 자리꼴 · 정본(고정) · 횡단도 · 카드 이름 · 형상(+ 벽 기본 형태 · 물빼기) · 알약 색 · 3D · 검사 · 처음 값 = 흐림 「기본」 · 바꿈 = 진함 · [처음 값으로]
- 종류별: 반영 칸 + 명세 이름 나무 줄마다 보임(컨테이너 = 그 안 칸) · 못 숨김 · 미리보기 숨김 따름 · 「안 씀」 = 목록 B06 만
- 부품 탭(M02_SO_Def_Parts): 부품 · 읽는 칸 · 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 부품 = 끈 것만)
- 편집 손질(M02_SO_Def_Reflect): 처음 값이면 반영에서 지움 · [저장] 때 enabled = 목록 both 일 때만 참
- 검사 짝 M02_SO_Def_Check.ts ↔ M02_SO_Check.py(글까지 같음): 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 기본값 없는 읽는 칸 새로 숨김 금지(앞 판 숨김 그대로) · 종류 지우기 · 자리꼴 · 정본 · 군 글자 · 선택지 값 고정 · 서버 check(doc, 지금 판) · 앞 판 없으면 꼴만
- 명세 폼: 쓰는 옵션 키가 모두 숨긴 칸 · 상자는 안 그림(값 그대로) · B05 · B06 시설 · 구조물 명세 폼이 정의 「표시」를 따름
- 판 알림: GET 구조물 목록에 definition_version · structures.json 판 ≠ 지금 판이면 「구조물 정의가 판 N 으로 바뀜 — 저장하면 반영」 한 번
- 시험: test_322_2_check_mirror(거울 · 규칙 · 반영 손질 · 서버 저장 · 422) · 판 알림 · 옛 정의 화면 시험 = 선택지 값 대신 이름 표 · 못 숨김 아닌 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 15:40:38 +09:00
eomsangdonandClaude Opus 5.5 bbd6254892 feat(구조물표): 322-1 부품 상자 · 표 읽기 — 목록 · 측점 · 카드 이름 · 형상 고르기 · 3D 몸체 · 알약 띠가 종류 키 · 군 글자 · 자리꼴 · managed_by 대신 구조물 표(catalog) 한 줄을 읽음 · 코드 숨김 목록 걷음 · ★ 넷 + 관 공식 한 벌
- 부품 상자 common_util_structure_parts.json(갈래마다 부품 · 읽는 칸 · 자리꼴 · 정본 · 42종 처음값) · 표 읽기 common_util_structure_catalog.ts / .py 짝(정의 반영 ⊕ 처음값 ⊕ 부품 끔) · 거울 시험 test_322_1_catalog_mirror
- 목록: pickGroups 군 차례 = 구조물군 열쇠 · 페이지 거르기 listPick(B05 both · B06 both + b06) · HIDDEN_PICK_GROUPS · HIDDEN_PICK_TYPES · pickable 걷음(옛 대피 셋 asRoad 그대로)
- 측점: structureStationMarks · pipeStationMarks · is_station_planting_type · resolve_extra_stations = 표 횡단도(anchor · span3 · none)
- 카드 이름: cardTitle / card_title 한 공식(빈칸 = 그 종류 정의 기본값) · 표시 이름이 정의 관종도 앎
- 형상: 벽 = shape wall(형태 = 형상칸.form · 물빼기 = 형상칸.weep) · 포장 = shape pavement(「사용 중」 거르기 없앰) · 맹암거 · 사토 · 관 지점 시설 세트 = shape
- 3D 몸체 = 그림 자료 주인 종류의 표 3D 부품 · 알약 띠 = 표 자리꼴 구간(관 지점 시설은 알약만)
- ★1 노출형 · 개거 횡단도 none · ★2 쇄석 화면에서도 포장층 · ★3 기슭막이 표 자리꼴 interval(정의 파일 그대로) · ★4 코드 숨김 10종 list hidden · ★5 관 빈칸 D800 → 정의 기본 D1000
- 옛 시험: 도움 파일 로더 27개가 표 모듈 공용 한 벌(helper_322_catalog_module) 씀 · 기대값 고침(272_1 · b05_extra_stations · roundtrip · 314 · 52_7 · walls 거울 · wall_drain_pipe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 15:09:45 +09:00
eomsangdonandClaude Opus 5.5 863abc7766 feat(구조물표): 322-0 안전망 — 42종 전수 표 시험(지금 결과 기록 · ★ 바뀔 칸 표시) · 「기타」 유령 길 막기(지금 · 지난 판 종류 이름 전부) · 종류 캐시 판 확인(보기 열 때 · 탭 다시 보일 때) · 서버 관 기본값 캐시 걷음
- 전수 표 시험 test_322_0_golden + helper_322_0_golden.cjs · 기록 golden_322_structures.json(측점 · 카드 이름 · 형상 열쇠 · 3D 몸체 · 알약 띠 · 고르기 목록 · 물빼기 · ★ 27칸)
- 이관 = 지금 판 + 구조물정의_이력 판_N 종류 이름 측점은 안 옮김(all_type_names)
- GET /api/m02/structure-definition/version · dropStaleStructureTypes · onStaleStructureTypes
- _registry_defaults lru 걷음(정의 읽기가 이미 파일 시각 캐시)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 14:16:27 +09:00
eomsangdonandClaude Opus 5.5 6327fd99c3 chore(요구): 221 구조물 관리 표 재설계(PLAN 322 · 323) 전체 = 랩탑 sub1 에만 격리 반영 · 사용자 직접 확인 뒤 병합 여부 결정 · 조심 · 브레인 브라우저 검증
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 13:56:04 +09:00
eomsangdonandClaude Opus 5.5 8dac0bf3f0 chore(plan): 321 · 322 구조물 관리 표 조사 기록 — M02 정의 칸마다 따르나 · 코드 숨김 10종 · 군 순서 안 따름 · 「안 씀」 B05 만 · 종류 이름 바꿈 = 「기타」 유령 · 바꿈 안전 표 · 그리는 쪽 부품 목록(요구 220 다시 정밀 점검)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 13:22:30 +09:00
eomsangdonandClaude Opus 5.5 f8d5e0cbe1 chore(요구): 220 구조물 관리 표 = 사용자 제어 — 코드는 부품을 만들어 두고 켬 · 끔 · 드롭다운에 들어올 메뉴는 사용자가 정함 · M02 구조물 옵션 글 고치는 메뉴와 합쳐 운영 · 다시 정밀 점검 · 끝나면 신규 계획서
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 12:48:46 +09:00
eomsangdonandClaude Opus 5.5 74a6cd9134 chore(요구): 219 구조물 반영 구조 정리 제안 — 드롭다운 = 최신 구조물 현황 · 횡단도 · 하이라이트 · 횡단 형상 · 종단 측점 · 알약 · 3D 측점 형상이 조건마다 달리 반영 · 다이어그램 동작 흐름 · 현재 대비 비교 분석 보고서(코드 수정 없음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 09:10:54 +09:00
eomsangdonandClaude Opus 5.5 d5c322ffa0 chore(요구): 218 구조물 점검 — 드롭다운 구조물 목록 현황표(브라우저 직접 확인) · 규칙 = 횡단배수군 · 그 밖 · 군 넘어가면 패널 리셋 · 군 안 종류 바꿈 = 그 그림 · 그림 없는 종류 = 횡단도 안 더함 · 있으면 지움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 07:00:58 +09:00
eomsangdonandClaude Opus 5.5 2641c15e29 fix(b05·b06): 정본 다른 종류(관 ↔ 맹암거 · 개거 등)로 바꿈 = 옛 것 지우고 같은 측점에 새 종류 · 일반 측점 자리 종류 바꿈 · 지움 = 구조물만 뺀 사본에 새 종류 · 중심고 상자에 겹친 Y 눈금 숨기지 않음(상자 위 · 안 비치는 바탕) (319 · 요구 217)
- 공용 구조물 패널 종류 바꿈: 관 지점 ↔ 구조물이면 새로 놓기 폼으로 빠지던 것 → 옛 것 지움 → 우클릭 추가 길(addAt)로 같은 측점 · 옛 측점 조정값 버림(318-4)
- 횡단 초안 맞춤: 일반 측점 자리(grid)는 종류가 바뀌어도 저장분(옛 관 그림)이 남던 것 → 구조물만 뺀 사본(지형 쪽 고친 값 그대로) + 새 종류
- 시험: test_319_kind_swap 8 · test_318 중심고 겹침 기대값 바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 06:52:02 +09:00
eomsangdonandClaude Opus 5.5 38e09559c4 chore(요구): 217 중심고 라벨 = 겹친 눈금 숨기지 않고 위에 놓음 · 안 비치는 바탕 · 횡단배수 종류 바꿈 때 맹암거로 안 바뀜(형상 없는 종류 = 지우고 다시)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 06:28:06 +09:00
eomsangdon 397096a9d9 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-14 05:57:16 +09:00
eomsangdonandClaude Opus 5.5 6d914b7647 fix(b06): 관 종류 바꿈 = 그 측점 옛 조정값(세션 아홉 칸 · 미리 뜬 횡단 사본) 버림 · [저장] 때 structure_reset 으로 정본 옛 조정값 비움 · 같은 행 여러 줄 이어 얹음(CASE 첫 WHEN 만 쓰던 것) (318-4 · 요구 216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 21:24:37 +09:00
eomsangdonandClaude Opus 5.5 6329ea2a3a feat(b06): 횡단 카드 — 단추 줄 빈자리 누름 = 카드 고름 · 중심고 = Y축 라벨 둥근 상자(눈금과 같은 글자 · 소수 첫째 자리 · 축선 앞에서 멈춤 · 카드 · 상세 모달) · 아래 띠 중심고 글 뺌 · 암 경계 왼쪽 · 카드 = 내용 높이(아래 여유 없앰 · 빈 띠 높이 그대로) (318-1~3 · 요구 215 · 216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 21:24:30 +09:00
eomsangdonandClaude Opus 5.5 6e981b1c90 chore(요구): 216 종류 바꿈 옛 조정값 저장 섞임 막음 · 중심고 = Y축 라벨 상자 · 아래 띠 여유 없앰 · 단추 줄 빈자리 고르기(브레인 직접)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:47:03 +09:00
eomsangdonandClaude Opus 5.5 08e72ab323 chore(요구): 215 B06 횡단 카드 단추 줄 빈자리 누름이 카드 고르기에서 빠짐(확인만)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:40:03 +09:00
eomsangdon a8efd834b7 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 20:35:20 +09:00
eomsangdon 53d502d9a4 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_8 2026-10-13 20:33:00 +09:00
eomsangdonandClaude Opus 5.5 27ec7da7be chore(요구): 214 구조물 종류 바꿈 뒤 겹침 · 새로고침하면 맞음(캐시) — 브레인 직접
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:31:42 +09:00
eomsangdonandClaude Opus 5.5 fab776eb31 fix(b06): 구조물 종류를 여러 번 바꾸면 앞 종류 제원이 남아 겹치던 것 — 미리 뜬 횡단은 다시 쓰므로 얹기 전에 관 계열 제원을 비움 (317-3 · 요구 214)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:31:32 +09:00
eomsangdon 3428e0abbd Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 20:26:12 +09:00
eomsangdon 5180d001fc Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_8 2026-10-13 20:24:41 +09:00
eomsangdonandClaude Opus 5.5 972baf989d fix(b05): [3D 업데이트] = 낡은 횡단 설계부터 다시 셈한 뒤 예상형상 · 그린 뒤 생긴 낡음도 다시 셈(같은 낡음은 한 번) (317-1 · 요구 213)
- 공용 낡음 지문 staleKey(Section_Common) · 그래프가 그릴 때 잰 지문을 패널에 넘김(Render onStaleDesigns)
- 패널 = 새 지문일 때만 다시 셈 예약(Preview scheduleIfStale · 서버 폴백 반복 막음)
- 단추 refreshCorridor = 짓기 전 refreshStaleDesigns(세션 계획선 초안 → 저장분 · B06 과 같은 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:17:05 +09:00
eomsangdonandClaude Opus 5.5 5f54a09267 fix(b06): 구조물 측점에서 종류를 바꾸면 횡단을 구조물 없는 상태부터 새로 — 저장분 횡단은 빼 두고 새 자리처럼 미리 뜬 횡단 · 되돌리면 저장분 복귀 (317-2 · 요구 213)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:04:26 +09:00
eomsangdonandClaude Opus 5.5 74a318a459 chore(요구): 213 3D 형상 업데이트 단추가 안 맞춤 · 구조물 종류 바꿈 = 지우고 새로 놓기(브레인 직접)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:59:56 +09:00
eomsangdon d7845a6a21 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 19:56:50 +09:00
eomsangdon 0bf2c6fa8c Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_8 2026-10-13 19:55:03 +09:00
eomsangdonandClaude Opus 5.5 fbaa5b4838 fix(b05·b06): 저장 전 구조물 자리 값 다시 셈 — 물넘이포장 · 맹암거 측구 없음 · 종류 바꿈 · 기준측점 옮김 (314 · 요구 210 · 211)
- 낡음 판정(B05 · B06 공용 isStaleSection)에 서버 _enforce_stored_designs 짝 셋: 포장 · 기울기(물넘이 · 맹암거) · 세월교 노면 하강
- 구조물 바뀐 뒤 횡단 다시 받으면 낡은 측점 다시 셈(refreshDetailForStructures)
- 다시 셈의 세월교 노면 하강 = 그 측점에 얹힌 세월교 제원(옛 저장값 안 이음)
- 물넘이포장: 옮기거나 지운 옛 자리 걷고 새 자리 · 걸친 기존 측점에 얹음(빈 관 목록도)
- 월류 높이 기다림: 빈 글자도 빈칸(채우는 쪽과 같은 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:54:27 +09:00
eomsangdonandClaude Opus 5.5 b7eb44cd5c fix(b06): 물넘이포장 도수로/돌붙임 길이 = 성토 사면보다 길면 원지반 이음 조각까지 한 선 · 월류 높이선 성토 쪽 = 성토선 점선 연장과 교점 (315)
- 315-1 fordChuteLine — 길이 맞춘 선의 첫 조각만 쓰던 흠(원지반 이음 조각 버림) · 이은 조각까지 한 선 · 길이 없음 · 짧은 길이 · 절토 쪽 그대로
- 315-2 overflowLine — 성토 쪽(노면 끝 밖 설계선이 내려감) = 성토 사면선을 노면 끝 너머 위로 연장해 월류 높이선과 만나는 자리에서 끝 · 절토 · 늘인 노면 끝(294-3) 그대로 · 같은 점선 양식(B06 카드 · B07 [상세] 같은 그림)
- 시험 test_315_ford_chute_overflow 새로 · test_287 월류선 기대값 315 규칙

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-13 19:46:28 +09:00
eomsangdonandClaude Opus 5.5 bcca4163df feat(b05·b06): 기본값 둘 — 횡단 복합경사 기본 꺼짐(파이썬 · TS 짝 함께 · 저장된 고른 값 그대로 · 값 없는 측점 · 새 측점만 꺼짐) · B05 3D 첫 보기 원근(화면 설정 b05-3d-projection 기억 우선) (316)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 19:44:45 +09:00
eomsangdonandClaude Opus 5.5 1dc815eae3 chore(요구): 212 B05 3D 영역 기본 보기 = 원근
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:36:49 +09:00
eomsangdonandClaude Opus 5.5 b72ab3a3ed chore(요구): 211 물넘이포장 도수로 길이 · 기준측점 이동 · 성토 점선 연장 · 맹암거 기준측점 이동 · 횡단 복합경사 기본 꺼짐
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:33:08 +09:00
eomsangdonandClaude Opus 5.5 64a64068e0 chore(요구): 210 물넘이포장 · 맹암거 생성 뒤 측구 생김 · 종류 바꿀 때 캐시 안 비움 · 월류 높이 첫 자동 계산 — 브레인 직접 · 전역 싱크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:24:35 +09:00
eomsangdon d38a680bf8 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 19:17:08 +09:00
eomsangdonandClaude Opus 5.5 8500d4724f chore(요구): 208 · 209 docs 정션 걷기(창 폴더 = git 으로 받음) · 깃 싱크 한 번에 스크립트 · 일하는 창 있으면 싱크 안 함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:14:00 +09:00
eomsangdon c278acb35a Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 18:54:46 +09:00
eomsangdonandClaude Opus 5.5 1ab59256e3 chore(동기화): 공유 docs 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141RHfyDdi6VJY8Yiuch6DX
2026-10-13 18:54:46 +09:00
eomsangdon 4bf8bb0e82 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-13 18:51:56 +09:00
eomsangdon b60eef65d1 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-13 18:51:53 +09:00
eomsangdonandClaude Opus 5.5 f3c8436551 fix(b05,b06): [구조물 전체 삭제] 단추 높이 = 위 구조물 단추와 같게 — 공용 --panel-control-h 규칙에 목록 단추 보탬 (313-2)
- 위 [추가] · [삭제] · [리셋]만 205-1 높이 규칙(--b05-control-h)을 받고 목록 맨 아래 단추는 빠져 34px · 위 26px
- 같은 규칙 · 같은 변수에 `.b05-route__irregular-clear` 보탬(px 안 박음) · 위험 색 그대로
- 시험: test_313_clear_all 실제 브라우저 밝음 · 어두움 높이 같음(옛 26 · 34)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-13 18:50:11 +09:00
eomsangdonandClaude Opus 5.5 ec6689ecfd chore(요구): 207 [구조물 전체 삭제] 단추 높이 = 위 구조물 단추 · 다시 전역 깃 싱크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:47:13 +09:00
eomsangdon 7e056d182d Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4 2026-10-13 18:45:52 +09:00
eomsangdonandClaude Opus 5.5 e40cd1f687 fix(b05·b06): 관 목록 빈 목록 = 관 0개 — 마지막 관 지움이 새로고침 · [저장] 뒤 되살아나던 것 (312)
- 서버 _resolve · _build · build_detail: 빈 목록과 None 을 가름 — None(칸 없음 · 파일 없음 · 노선 바뀜) = 자동 배치 · [] = 관 0개 · 자동으로 되돌리기는 [초기화](DELETE)만
- B05 배수유역도 초안 읽기: 빈 초안을 저장분으로 안 넘김(B06 ?? 규칙과 같음)
- B06 횡단 벽 초안: 빈 목록 = 다 지움(목록과 같은 뜻) · 09-09 옛 결함 까닭(옛 배열 참조가 [] 알림) 막이 시험
- 시험 test_312_empty_pipes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-13 18:44:03 +09:00
eomsangdon 5284402ce9 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-13 18:40:37 +09:00
eomsangdonandClaude Opus 5.5 3de9ca043a feat(b05,b06): 좌측 구조물 목록 맨 아래 [구조물 전체 삭제] — 빈 목록 숨김 · 공용 확인창 한 번 · 하나씩 지우기와 같은 길 (313)
- 목록 그리기(renderStructureList)가 맨 아래에 위험 단추 줄 — 목록이 비면 그리지 않음(다시 생기면 보임)
- 확인 「구조물 N개를 모두 지울까요?」(showConfirmDialog) · 취소 = 그대로
- 관(자동 횡단배수 · 손 배치) = 보기마다 관 빼기 길(B05 배수유역도 · B06 관 초안) · 구조물 = 정본 배열 비우고 알림 한 번 → 캐시 · [저장] 때 작업본
- 저장 길(초안 읽기 · [저장] · 서버 · 자동 배치)은 안 고침(312 조사 몫)
- 시험: test_313_clear_all 새로 · test_116 기대값(return { startNew, clearAll })

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-13 18:38:50 +09:00
eomsangdon 8227972eec Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-13 18:38:16 +09:00
eomsangdonandClaude Opus 5.5 e12c4d6c28 chore(요구): 조사 AO — 312 관(자동 횡단배수) 빈 목록이 「없음」으로 읽혀 마지막 하나가 되살아남(원인 넷 · 고칠 자리 제안)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:35:54 +09:00
eomsangdonandClaude Opus 5.5 8cf68188b7 chore(요구): 206 B05 · B06 좌측 구조물 목록 맨 아래 [구조물 전체 삭제] · 비면 숨김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:32:50 +09:00
eomsangdon b93a9ee24d Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-13 18:29:18 +09:00
eomsangdonandClaude Opus 5.5 63dfc9a357 chore(요구): 205 B05 · B06 좌측 구조물 목록 — 마지막 하나 지움이 새로고침 뒤 되살아남(조사)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:28:48 +09:00
eomsangdon cf2201723f Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 18:23:52 +09:00
eomsangdonandClaude Opus 5.5 75730c43f3 chore(동기화): 공유 docs 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141RHfyDdi6VJY8Yiuch6DX
2026-10-13 18:23:50 +09:00
eomsangdon 04b645fd90 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_8 2026-10-13 18:19:47 +09:00
eomsangdonandClaude Opus 5.5 d3d038349b fix(b06): 고름 강조 정리 — 측점 길은 형상 안 켬 · 연동 관 콘크리트만 · 측점 없는 구조물 스크롤 없음 (307)
- 노면공 포장층 강조 = 포장층을 직접 누른 턴만(paveShapeId) · 알약 · 목록 · 측점선 고름은 범위 테두리만
- 물넘이 콘크리트(pipe-…)는 측점 길 강조(highlightShapes)에서 뺌 — 형상 누름 「관」 고름만 켬 · 맹암거 그대로
- 벽 고름 「관」에 콘크리트 표식(형상 누름 ownerM · 관 없는 카드) · 카드 「관」 setter 를 관 그림 / 콘크리트로 가름(splitPipePick)
- 연동 관 카드 콘크리트 누름 = 관 형상 안 켬 · 콘크리트 ↔ 관 바꿔 누르면 풀지 않고 옮김 · 좌측 하위 컨테이너 켬 그대로
- 측점 없는 구조물 = 카드 고름 · 스크롤 없음(revealStation 스크롤 걷음 · 넘김 focusPicked)
- 시험: test_307_pick_highlight 새로 · 118 · 177_4 · 287 · 294_6 · 294_7 기대값 307 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-13 18:18:20 +09:00
eomsangdonandClaude Opus 5.5 e657067bd3 feat(b09): 탭 상세 안내 글 영역 = 줄 수 변수(--b09-hint-lines 기본 3)만큼 · 넘치면 영역 안 스크롤 · 탭 표 = 공용 fillViewport(창 끝 − 안내 글) · 설계내역서 레벨 안내 + 탭 안내 한 영역 · 공용 spaceBelow 형제 바깥 여백도 셈(페이지 스크롤 7px 없앰) (310 · 요구 203)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-13 18:15:53 +09:00
eomsangdonandClaude Opus 5.5 608b6c80d9 feat(b07): 311-2 평면도 · 종단도 · 횡단도 기본 접힘 · 다시 세워도 접힘 이어 받음 · 첫 장 표지(기억한 장 있으면 그 장) · 시험 (요구 204)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 18:14:45 +09:00
eomsangdonandClaude Opus 5.5 0d3bb1154a feat(panel): 311-1 좌측 패널 기본 접힘 — B09 데이터 점검 · 공기 옵션 / B06 횡단 보기 설정 / B05 계획노선 (요구 204)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 18:14:45 +09:00
eomsangdonandClaude Opus 5.5 e275bf353b feat(b05·b06): 종단 누가토량 알약 — 바탕 불투명 · 두 테마 글자 대비 4.5 넘음(붉은 계통 그대로) · 그래프 영역 안 끌어 옮김(마우스 · 손가락 pointer 한 길 · 끝 막힘 · 자리 = 영역 안 비율 · 화면 설정 한 칸 B05 · B06 공용) · 끈 뒤 click 삼킴 · 바닥 서브패널 위 (309)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 18:11:04 +09:00
eomsangdonandClaude Opus 5.5 0a8008ce09 feat(B06): 306 횡단 맹암거 안 엇갈려 겹친 돌 무늬 · 그림 전용 · 고르기 차례 = 안쪽 판정면 맨 아래 · 테두리 판정 면 판정 맨 위
- 돌 무늬: 켜마다 반 칸 엇갈림 · 이웃 · 위아래와 겹침 · 기울기 ±22° 번갈아 교차 · 맹암거 색(카드 · [상세] 모달 같은 그림)
- 맹암거 무리 pointer-events none — 성토 띠 · 포장 · 넓은 관 판정면이 맹암거 안에서도 골라짐
- 차례: 보라 선 > 맹암거 테두리 > 구조물 판정면(작은 것 위) > 그림 · 띠 > 맹암거 안쪽
- 시험 test_306_underdrain_pick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-13 18:07:53 +09:00
eomsangdon 5076c44425 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_6 2026-10-13 18:04:54 +09:00
eomsangdonandClaude Opus 5.5 17c785596a chore(요구): 204 좌측 패널 기본 접힘(B09 · B07 · B06 · B05) · B07 첫 쪽 표지 · 실험 이름표 지움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:03:51 +09:00
eomsangdonandClaude Opus 5.5 4ecf51c223 feat(b09): 연결 정보 팝업 Esc = 모두 닫힘 · 맨 위 팝업 밖 누름(아래 팝업 · 빈 곳 · 톡 · 우클릭) = 모두 닫힘 · 그 누름은 아래 화면에 안 넘김 · 맨 위 팝업 안 누름 · 끌기 그대로 (308-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 18:01:49 +09:00
eomsangdonandClaude Opus 5.5 864114ee2b chore(요구): 203 B09 탭 상세 안내 글 영역 3줄 + 스크롤 · 표 넓게 · 합계 줄 · 안내 글 끌려 올라옴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 17:59:32 +09:00
eomsangdonandClaude Opus 5.5 c06ad0835f chore(요구): 201 · 202 B09 연결 팝업 Esc · 바깥 누름 = 모두 닫힘 · 계획서 사용자 정리 · 누가토량 알약 테마 색 · 그래프 안 끌기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 17:54:43 +09:00
eomsangdon bf61251ff8 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 17:50:16 +09:00
eomsangdonandClaude Opus 5.5 dd0650d8f2 chore(동기화): 공유 docs 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141RHfyDdi6VJY8Yiuch6DX
2026-10-13 17:50:16 +09:00
eomsangdon 4821e3b060 Merge branch 'sub_laptop_1' into main_laptop_1 2026-10-13 17:47:08 +09:00
eomsangdonandClaude Opus 5.5 9e49b9c4db chore(요구): 200 B06 횡단 구조물 그림 · 고름 강조 개선 · sub1 포함 전역 싱크 · 측점 없는 구조물 횡단 이동 막음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 17:47:07 +09:00
eomsangdon 3cde4dcd32 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 17:30:44 +09:00
eomsangdonandClaude Opus 5.5 5cdf4e840a chore(동기화): 공유 docs 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141RHfyDdi6VJY8Yiuch6DX
2026-10-13 17:30:44 +09:00
eomsangdonandClaude Opus 5.5 fbe0f982d3 feat(device): 로그인 뒤 쪽은 데스크탑도 위 줄 대신 ☰ · ☰ 왼쪽 위 36px(손가락 높이 44) · 서랍 왼쪽에서 · 진행 판 대신 서랍 진행 단계 · 첫 줄 들여쓰기 = --app-burger-inset 하나 · 공용(좌측 패널 제목 · 일반 쪽 제목 · --wf-burger-pad) + 쪽마다 한 줄 · CAD 제목 줄은 틀 자리로 잼 · Esc 는 ☰ 포커스에서도 닫힘 (기기대응 7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 17:17:12 +09:00
eomsangdonandClaude Opus 5.5 3c4fc991c4 chore(요구): 199 랩탑 메인 = 임시 배포 — 「깃 싱크해」 = 랩탑 메인 빼고 · 「전역 깃 싱크해」 = 검증 사용자 쉴 때 랩탑 메인까지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 17:04:39 +09:00
eomsangdonandClaude Opus 5.5 b4d2b86da4 chore(요구): 198 기기 대응 실험 추가 — ☰ 단추 폭 조금 줄임(sub1 전용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 16:44:30 +09:00
eomsangdonandClaude Opus 5.5 1c635f3e02 chore(요구): 197 기기 대응 실험 추가 — 프로젝트 안 화면 위 줄 대신 ☰ 를 데스크탑에서도 · ☰ 왼쪽 위 · 제목은 ☰ 폭만큼 들여씀(sub1 전용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 16:37:56 +09:00
eomsangdonandClaude Opus 5.5 3fc50044e1 feat(device): 로그인 뒤 ☰ 상태 위 줄 걷기 · ☰ 는 위 오른쪽 모서리에 떠 있음 · 진행 단계를 ☰ 서랍 묶음으로(같은 요소를 빌려 옴 · 한 벌) · 서랍 차례 로고 ─ 진행 단계 ─ 메뉴 ─ 설정 · 위 줄 높이 = --app-header-h 하나 · data-burger (기기대응 6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 14:44:21 +09:00
eomsangdonandClaude Opus 5.5 0f9807699e chore(요구): 196 기기 대응 실험 추가 — 로그인 뒤 맨 위 줄 걷기 · 진행 단계를 ☰ 안으로(sub1 전용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 14:35:21 +09:00
eomsangdonandClaude Opus 5.5 68e82f9de9 refactor(device): CAD 손가락 명령 단추 CSS 를 input-touch.css 로 — App.css(원래 700 넘음)에 더하지 않음 (기기대응 4-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 14:06:04 +09:00
eomsangdonandClaude Opus 5.5 e27753df82 fix(device): 접힌 좌측 패널 손잡이 터치 폭만 · 오른쪽 패널 손잡이는 그대로 · 데스크탑 대조 = HTML 상자 · 넘친 쪽만 한 번 더 잼 (기기대응 5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 14:05:18 +09:00
eomsangdonandClaude Opus 5.5 c02602dcc0 fix(device): 휴대폰 폭 남은 넘침 — B06 횡단 카드 한 장씩 · B05 3D 보기 단추 줄바꿈 · 접힌 서랍 내용 숨김 · 터치 손잡이 화면 안 · 재기 「넓은 상자」 = 어느 조상이든 가로 밀기면 닿음 (기기대응 5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:48:46 +09:00
eomsangdonandClaude Opus 5.5 a7cc8a304e feat(device): 단축키로만 되던 일에 터치 단추 — 시트 입력줄 단추 줄(찾기 · 모두 같은 값 · 줄바꿈 · 고침 취소 · 오늘 · $ 돌리기) · B05 자유지정 [점 빼기] [끝] · B06 상세 · M02 나무 [여럿] · 공용 .ui-touch-only (기기대응 4-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:45:53 +09:00
eomsangdonandClaude Opus 5.5 f5e7c839d0 feat(device): B05 · B06 손가락 고름 반경 — 공용 판정이 없어 touch_bridge pickPx 한 곳(접촉 크기 · 최소 24px · 마우스는 그대로) · 고름 상수 쓰는 자리 14곳을 감쌈 · 흉내 시험 넷에 stub 한 줄 (기기대응 4-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:39:48 +09:00
eomsangdonandClaude Opus 5.5 4e0d7d2a4a feat(device): 스프레드시트 손가락 — 톡 = 칸 고름(자판 안 뜸) · 고른 칸 · 머리 · 채우기 손잡이 끌기 = 범위 · 채우기 · 안 고른 칸 끌기 = 화면 밀기 · 두 번 톡 = 고침(손 동작 안에서 자판) · 손잡이 잡는 자리 20px · 두 손가락 = 격자 확대 (기기대응 4-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:34:50 +09:00
eomsangdonandClaude Opus 5.5 be3ae14b1b feat(device): B07 CAD 손가락 — 한 손가락 화면 옮기기 · 두 손가락 확대 · 길게 누름 정밀 십자선(손가락 위 70px) · 고름 반경 = 접촉 크기(최소 24px) · 자판 없는 명령 단추(Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅) · user-scalable=no 뺌 · 100vh → 100dvh (기기대응 4-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:31:10 +09:00
eomsangdonandClaude Opus 5.5 68a96e253f feat(device): 손가락 → 마우스 번역 — 길게 누름 = 우클릭 · 두 번 톡 = 더블클릭 · 벌림 = Ctrl+휠(touch_bridge) · 3D 두 손가락 확대 · 이동 · 공용 SVG 확대 두 손가락 · 당기면 확대 지도 벌림 방향 · M02 그림판 손가락 끌기 · 시트 ⋯ 칸 메뉴 · 그림 면 안내 (기기대응 3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:28:09 +09:00
eomsangdonandClaude Opus 5.5 32e3a6723a test(device): 데스크탑 대조 판정 = 원래 코드 두 번 재기 잡음 이하 (기기대응 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:20:45 +09:00
eomsangdonandClaude Opus 5.5 f5c4887056 test(device): 데스크탑 배치 서명 잡음 빼기 — 화면 안 · 움직이는(애니메이션 · 로딩 원) 요소 제외 · 불러오는 중 표시가 사라질 때까지 기다림 (기기대응 1-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:17:32 +09:00
eomsangdonandClaude Opus 5.5 9423bd4202 feat(device): 기기 알아채기 + 공통 틀 — <html> data-size · pointer · input · touch 표시 · ☰ 서랍(위 메뉴 · 언어 · 테마 · 로그인 · 터치 모드 스위치) · 좁은 폭 좌측 패널 서랍 + 덮개 · 진행 판 바텀시트 · 오른쪽 패널 아래로 쌓기 · 휴대폰 팝업 전체 화면(.ui-popup) · 표 첫 열 고정 · 터치 누를 자리 44px · 100vh → 100dvh · 호버 정보 톡 (기기대응 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:14:45 +09:00
eomsangdonandClaude Opus 5.5 aabd50a5c4 test(device): 기기 대응 자동 점검 — 22쪽 × 네 크기(390 · 820 · 1180 터치 · 1440 마우스) 가로 넘침 · 화면 밖 · 가려짐 · 44px 미만 · 못 닿는 메뉴 셈 · 데스크탑 사진 + 배치 서명 대조 (기기대응 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:08:10 +09:00
eomsangdonandClaude Opus 5.5 c672111d18 chore(요구): 193~195 조사 AN — 어느 기기든 같은 기능 · 기기 바뀜 알아채기 · 손가락 대응표 · 도구 · 출처
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 12:19:55 +09:00
eomsangdonandClaude Opus 5.5 54cc2a4ef4 chore(요구): 194 · 195 기기 대응 방향 — 어떤 기기든 같은 기능 · 기기별 따로 개발 없이 데스크탑 기준 자동 반영
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 12:02:09 +09:00
eomsangdonandClaude Opus 5.5 8883e7cee7 chore(요구): 193 태블릿 · 모바일 접근 대비 방향 검토 요청 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 11:46:15 +09:00
eomsangdon 9c7449cf67 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 09:02:58 +09:00
eomsangdonandClaude Opus 5.5 5bc017b07e chore(동기화): 공유 docs 요구 장부 받기 — 원격과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZVseAexp5wNFNVfRtxjkg
2026-10-13 09:02:57 +09:00
eomsangdonandClaude Opus 5.5 d4ad0d6685 fix(b09-print): 인쇄 미리보기 좌측 하단 두 줄(가로 넘침 없음) · 엑셀 시트 인쇄 설정 = PDF 방향 · A4 · 폭 1쪽 · 머리 줄 반복 (302-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 08:24:05 +09:00
eomsangdonandClaude Opus 5.5 13ba1fe298 feat(b09-print): B09 좌측 하단 [인쇄 미리보기] 단추 — 초안 입구(B09_Draft_Print_UI)가 DRAFT.actions 에 넣음 · 영역 셋 이음 끝 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 08:10:38 +09:00
eomsangdon 2303798186 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-13 08:07:48 +09:00
eomsangdonandClaude Opus 5.5 cd4eabbf0e test(b09-print): 수량산출 영역 이음매 — 302-3 쪽 → 인쇄 페이지 서버 몸 → 표 PDF(지도 그림) · 엑셀 차례 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 08:07:41 +09:00
eomsangdon 61e9adf369 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-13 08:06:38 +09:00
eomsangdonandClaude Opus 5.5 a53b499878 fix(b09-print): 도면 영역 이어 붙임 — 초안 router 가 스스로 단 /api/projects 머리를 사본 길에서 떼고 끼움(앞머리 두 번 겹침 없앰) · 못 넣은 그림 수 알림 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 08:06:32 +09:00
eomsangdon 5ea602d3a8 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-13 08:05:54 +09:00
eomsangdon 9832374487 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-13 08:04:38 +09:00
eomsangdonandClaude Opus 5.5 c7a438138d feat(b09-print): 인쇄 미리보기 수량산출 영역 — collectQuantitySheets · B08 탭 차례 PrintSheet(구조물 · 자재 셋 · 토공 넷 · 수리 측점마다 · 거리이정표 + 지도 그림 data:) · 화면과 같은 표 짜기 · 캐시 값 · 영역 실패 = 까닭 한 장 (302-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-13 08:03:31 +09:00
eomsangdonandClaude Opus 5.5 5e25f418cc refactor(b08): 탭 표 자료 읽기 · 표 짜기를 화면 없는 파일로 꺼냄 — 구조물 집계표 · 자재 · 토공 · 수리 입력 · 거리이정표 · 화면은 같은 함수를 씀 · 동작 그대로 (302-3)
- 새 파일 = B08_Quantity_Summary_Table · Material_Tables · Earthwork_Tables · Hydro_Table · Milestone_Table
- 화면 UI 파일 = 불러오기 · 그리기만 · 옛 B08 시험 = 옮긴 글 자리 따라 읽는 파일 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-13 08:03:23 +09:00
eomsangdon feea843595 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-13 08:03:21 +09:00
eomsangdonandClaude Opus 5.5 a4f55c3748 feat(b09): 행 고름 팝업 — 링크 줄 · 박스 카드 누름 = 대상 탭 표 조각 창(열 제목 + 강조 범위 줄 · 누른 줄 곁 · 이어진 창 겹침 · Esc · × = 맨 위 · 탭 바꿈 · 주소 바뀜 = 모두 · [탭으로]) · 하이퍼링크 모드 = 그대로 (303-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 08:02:26 +09:00
eomsangdonandClaude Opus 5.5 f4889352f9 feat(b09-print): 인쇄 미리보기 도면 PDF — B07 도면 + 구조물 조합 도면 · 도각 크기 쪽 · 숨긴 층 뺌 · 벡터 · 한글 (302-2)
- 길 = GET /api/projects/{id}/print/drawings · POST /api/projects/{id}/print/drawings-pdf {ids?} (draft 라우터 · 로그인 · 회사 · 프로젝트 권한 스스로)
- 차례 = B07 도면번호 → 구조물 조합 도면(도번 차례 · 도번마다 한 번 · 더한장)
- B07 = B07 단건 조회 그대로(확정본 · 미확정 조립) · 미확정 횡단 장 구조물 = 화면 TS 를 서버 Node 로(번들 tmp · package.json 안 건드림)
- 쪽 = 도각 양식 실치수(A1 840×594) · 색 = openwebcad 밝은 바탕 규칙 · 글 = 맑은 고딕 심음(subset) · 그림 = data URL 넣음 · 못 넣은 수 = 응답 머리
- 화면 쪽 = fetchDrawingList · fetchDrawingsPdf(약속 꼴) · skippedImagesOf
- requirements = PyMuPDF · ezdxf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 08:01:47 +09:00
eomsangdonandClaude Opus 5.5 8230788e96 refactor(b07): 횡단 구조물 얹기 몸통 = Cross_Overlay.placeStructureEntities · Node 가짜 DOM = Node_Dom 파일 (302-2 앞)
- 화면 appendStructureEntities = 정본 상세 읽기 + placeStructureEntities 부름 · 동작 그대로
- 가짜 문서에 addEventListener · removeEventListener 빈 함수 — 화면 모듈이 불러올 때 거는 문서 이벤트로 덧그림 범위 Node 번들이 시작부터 멈추던 흠 (10-04 무렵부터 서버 장 배치가 덧그림 범위 없이 섬)
- 시험 212 = 옮긴 자리에서 글 찾음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 08:01:33 +09:00
eomsangdonandClaude Opus 5.5 55e565f3bf feat(b09): 좌측 하단 고정 줄 — [박스보기|호표보기] 한 설정(묶음 탭 셋 · 옛 탭별 값 하나로 · 탭 줄 토글 걷음) · [하이퍼링크|팝업] 스위치(pref global · 기본 팝업) · DRAFT.actions 자리 · [?][저장][내역 초기화] 그대로 (303-1 · 304-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 07:56:53 +09:00
eomsangdonandClaude Opus 5.5 deab5a348f feat(b09-print): 원가계산 영역 이어 붙임 — 서버 몸(pdfBody · xlsxBody)을 표 파일로 · 원가 18장 → 표 PDF · 엑셀 이음매 시험 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 07:55:19 +09:00
eomsangdon 952ce4142a Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-13 07:52:59 +09:00
eomsangdonandClaude Opus 5.5 ed47306209 feat(b09-print): 인쇄 미리보기 틀 — draft 길 선언 glob 등록 · 페이지(영역 셋 · 쪽 체크 · PDF 보기 · 내려받기) · 서버 표 PDF(Playwright) · 엑셀(openpyxl) · B09 Router 초안 길 끼우기 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 07:52:49 +09:00
eomsangdonandClaude Opus 5.5 6c3831ce43 feat(b09-print): 인쇄 미리보기 원가계산 표 넘김 — B09 18장 = 기번3 내역서 시트 차례 · 이름 · 방향 · 화면 표 짜기(Builders) 그대로 · 단가연결 칸 · 손 줄 · 강조 · 누름 · 고침 뺌 (302-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-13 07:51:02 +09:00
eomsangdon 7993cdcd99 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-13 07:48:34 +09:00
eomsangdonandClaude Opus 5.5 bc92038071 refactor(b09): 탭 표 짜기 꺼냄 — B09_Estimation_UI_Builders.ts(BUILDERS · tabTableOf · tableOptionsOf) · Shell 이 그대로 씀 · 동작 그대로 (303-2 앞 조각)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 07:48:02 +09:00
eomsangdonandClaude Opus 5.5 5034fa4ea8 feat(b09-print): 인쇄 미리보기 약속 파일 — PrintSheet · CollectSheets · DrawingItem · FetchDrawingList · FetchDrawingsPdf (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 07:44:07 +09:00
eomsangdonandClaude Opus 5.5 d7ba07832c fix(b05): 도수로/돌붙임 면적 = 늘 손 입력 — 길이 · 월류 폭으로 면적 자동 셈(syncChuteArea · chuteAreaM2) 걷음 · 물넘이 · 세월교 폼 onChange 원래 흐름 상자로 · 길이 = 그림 전용 (301-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-13 07:32:34 +09:00
eomsangdonandClaude Opus 5.5 9a035bc188 chore(요구): 요구 192 원문 — 물넘이 관 없음 · 도수로 면적 수기 · 인쇄 미리보기 · B09 행 팝업 · 박스보기 · 호표보기 좌측 공용 세팅
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 07:19:41 +09:00
eomsangdon b0f354a7ff Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-12 23:02:09 +09:00
eomsangdonandClaude Opus 5.5 ed4df9e2c3 chore(동기화): 공유 docs 요구 장부 받기 — 원격과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZVseAexp5wNFNVfRtxjkg
2026-10-12 23:02:08 +09:00
eomsangdonandClaude Opus 5.5 a44b816773 refactor(b06): 카드 고름 폼 자리(formChainageOf)를 선택 배선 파일로 옮김 — design_view_cross 685줄 한도 안 · 동작 그대로 (294-8 후속)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:52:02 +09:00
eomsangdonandClaude Opus 5.5 2d356fac91 fix(b06): 안 골라진 카드 첫 누름 = 눌린 대상 — 카드 고름이 연동 관으로 폼을 옮기지 않음 · 폼 여는 동안 선 도수로 상자를 새 상자로 안 고름 (294-8 · 295-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:48:29 +09:00
eomsangdonandClaude Opus 5.5 a0a089a3d0 fix(b06): 안 골라진 카드 보라 사면선 첫 누름 = 그 선 — 남은 부재 고름(도수로 띠)을 먼저 풀어 카드 고름이 되살리지 않음 (295-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:36:01 +09:00
eomsangdonandClaude Opus 5.5 db5b7656db fix(b06): 물넘이 콘크리트 누름 — 얇은 형상 판정면 아래로 최소 두께 12 px(보라 띠 밖) · 누름 = 그 물넘이 고름(걸친 연동 관 · 자기 다른 관 아님) · 경사각도 상자 (294-7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:35:58 +09:00
eomsangdonandClaude Opus 5.5 e2e71ad3ca fix(b06): 물넘이 연동 범위 안 측점에 자기 관도 함께 얹음 — 키별 하나씩 · 같은 키는 소유 먼저 · 흙막이는 자기 관에 밀림 (299-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-12 22:18:40 +09:00
eomsangdon be5ef91f72 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_6 2026-10-12 22:14:16 +09:00
eomsangdonandClaude Opus 5.5 8ce01dfa35 fix(b06): 신규 관 계열 [추가] — B06 길도 빈 월류 높이 채움 · 알림 글 「뒤에서 다시 나눔」 · 다시 나눔 실패 토스트 (298-1)
- B05 · B06 [추가] = 그 측점 횡단(카드 · 측점선) 바로 + 세부유역 뒤에서 다시 나눔(세션 초안 · 저장 안 함 · 최신 응답만 · 지도 칸 표시만) — 있던 길 그대로
- 까닭 1: B06 [추가]는 높이 기다림을 판에 안 알려 새 세월교 · 물넘이 월류 높이(126-1)가 빈칸 — 기다림 표를 프로젝트마다 한 벌(fordWaitsOf)로 판 · B06 이 같이 씀 · 채운 높이를 B06 목록 · 폼이 받음(adoptFordHeights)
- 까닭 2: B06 알림 글이 「세부 배수유역은 [저장]·[확정] 뒤 계산」 — 실제는 뒤에서 바로 다시 나눔 · 넣기 · 빼기 · 옮기기 글 고침
- 다시 나눔 실패 = 지도 칸 문구 + 토스트 한 줄(analyzeFailed)
- 시험: test_298_add_reanalyze 새로 · test_126 · test_291_1 · helper_165 · helper_231_1 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 22:09:00 +09:00
eomsangdon d03eb1ccda Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-12 22:03:16 +09:00
eomsangdonandClaude Opus 5.5 8b87186a26 feat(b06): [상세] 모달 구조물 선 = 다른 선 나눔 기준만 · 고를 땐 한 선 통째 · 닫힌 영역 · 옛 저장 조각 · 서버 셈 그대로 + 295 시험 (295-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:02:04 +09:00
eomsangdonandClaude Opus 5.5 4eccca40d0 feat(b06): [상세] 모달 첫 보기 = 중심점이 칸 가운데 · 그림 전체가 들어가게 축마다 큰 쪽 반폭 · [⤢] · 더블클릭 원복 같은 보기 (295-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:01:18 +09:00
eomsangdonandClaude Opus 5.5 1ca7e61b08 feat(b06): 고르기 차례 = 좁은 것 위 — 카드 판정면 넓이 큰 것부터 아래 · 보라 선 맨 위 · 끌기 = 고름 아님 · 모달 선 > 작은 면 > 큰 면 · 띠 · 도수로 띠 위 성토 사면 고름 (295-1 · 294-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:01:08 +09:00
eomsangdon e24e3900f8 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 21:58:59 +09:00
eomsangdonandClaude Opus 5.5 209383ed17 feat(b05,b06,b07): 도수로/돌붙임 띠 = 낮은 쪽 끝단에서 · 원지반 닿으면 끝 · 길이 = 그 길이 · 모든 시설 같은 규칙과 길이 칸 (294-4)
- 공용 chuteRun(Wall_Hatch) — 길이 비움 = 원지반 닿으면 끝 · 넣음 = 사면 따라 그 길이 · 사면보다 길면 원지반 따라 이어 그 길이 · 끝단이 이미 원지반이면 안 그림
- 물넘이 — 띠 시작 = 늘인 노면 끝(294-3) · 아니면 유출 쪽 노견 끝 · 원지반 닿으면 끝(양측 절토 = 띠 없음)
- 배관 · 박스 · 세월교 — 띠 계산이 원지반(layout.ground)을 받아 같은 규칙 · 3D 띠도 같음 · 코리도 해시에 길이
- 명세 CHUTE_ITEMS 맨 아래 「길이 (m)」(chute_add_{n}_length_m · 그림 전용) · 물넘이는 공용 칸 그대로
- 면적 자동 = 물넘이 · 세월교만 월류 폭 × 길이(배관 · 박스 = 폭 정의 없어 손 입력 그대로 · 브레인 정함)
- 길이는 집계 열 · B08 수량(면적만)에 안 실림 · 마스터 정의 이름 표는 안 건드림(이름 표 대조 시험에서 이 칸만 밖)
- 시험 test_294_3 · 286_1 · 52 · 65 · 152_3 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:58:16 +09:00
eomsangdon d8413cf29b Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-12 21:55:31 +09:00
eomsangdon 5c1c5c32b7 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-12 21:55:29 +09:00
eomsangdonandClaude Opus 5.5 f3586ef8e1 chore(요구): 요구 191 원문 — 신규 관 계열 추가 때 횡단도 생성 · 배수유역 뒤에서 다시 나눔
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 21:54:49 +09:00
eomsangdon 7ac250a23a Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5 2026-10-12 21:54:48 +09:00
eomsangdonandClaude Opus 5.5 cf396d93ff fix(b09): [업데이트] 때 지운 복사 정보 뺌 — 화면 캐시 기준 수요 · M01 원본 없는 줄 굳힌 값 그대로 (296-1)
- [업데이트] = 화면 캐시(user)를 같이 보냄 · 캐시 그대로 둠(지운 줄 안 되살림 · 저장 안 함)
- 서버 수요 = 캐시 줄이 잇는 서고 사본만(지운 줄의 사본 단가 줄 안 살아남)
- M01 원본이 지워진 키 = 옛 책 줄 그대로 · 낡음 까닭에서 빠짐
- 시험 test_296_b09_update_skip_deleted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-12 21:54:43 +09:00
eomsangdonandClaude Opus 5.5 df05b1a1f3 feat(b06): 물넘이 월류 폭으로 옆 측점 카드에 걸친 포장 = 흙막이 연동 꼴 연결 표기 · 호버 주인 측점 (294-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-12 21:53:50 +09:00
eomsangdon 6330d1ca52 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_7' into sub_laptop_6 2026-10-12 21:49:26 +09:00
eomsangdon df574665b1 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_7' into sub_laptop_8 2026-10-12 21:49:19 +09:00
eomsangdonandClaude Opus 5.5 7b3bb72b54 feat(b06): 물넘이 낮은 쪽 노면 끝이 절토면 콘크리트 · 노면선을 노면 기울기로 늘여 원지반까지 (294-3)
- 설계 계산(파이썬 _SectionGeometry · TS 짝 SectionGeometry) — 기울인 자리(tilted) 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면 같은 기울기로 늘여 원지반과 처음 만나는 자리까지 · 그 쪽 절토 사면 없음 · 샘플 반폭 안에서 못 만나면 옛 길 · 성토 쪽 그대로 · 결과 tilt_reach
- 토공 면적 · 설계선이 늘인 노면을 따름(서버 정본 · [저장] 재계산 · 거울 시험 갈래)
- 횡단 그림 — 콘크리트(포장층) · 월류 높이선이 늘인 끝까지(fordEdges)
- 시험 test_294_3 새로 · 횡단 설계 거울 「물넘이포장 낮은 쪽 노면 연장」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:47:54 +09:00
eomsangdon bef62e411e Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-12 21:47:13 +09:00
eomsangdonandClaude Opus 5.5 e9084ff9a4 chore(요구): 요구 190 원문 — 고르기 차례 · 상세 모달 첫 보기 · 물넘이 연결 표기 · B09 업데이트 · 마무리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 21:46:35 +09:00
eomsangdonandClaude Opus 5.5 704e193c1b fix(b06): 물넘이포장 콘크리트 형상 고름 · 강조 = 좌측 「경사각도」 상자 (294-2)
- 형상 누름 키 "pipe" 가 배관 명세 「관」 상자를 찾다 물넘이엔 없어 강조가 안 섬 → 관 상자가 없거나 숨었으면 보이는 「경사각도」(slope) 상자
- 배관은 「관」 상자 그대로 · 알약 · 목록 · 카드 고름 · 다시 누름 = 풂 그대로
- 시험 test_294_1_chute_remove_pick(가짜 DOM 위 조정 강조 — [삭제] 단추 · 원래 글 · 경사각도 상자) 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 21:45:53 +09:00
eomsangdonandClaude Opus 5.5 f570729f7c fix(b06): 선택 컨테이너 [삭제] 단추 — B06 조정 강조 이름표는 글 칸만 고침 · 원래 글에 「삭제」 안 섞임 (294-1)
- setGroupLabel 이 legend 를 통째로 다시 써 [삭제] 단추가 지워지고 baseLabel 에 「도수로/돌붙임 삭제」가 섞이던 것
- 첫 글 마디만 고침 — 도수로/돌붙임 · 날개벽 등 [삭제] 가진 상자 모두

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 21:45:36 +09:00
eomsangdonandClaude Opus 5.5 b87c55c846 chore(요구): 요구 188 · 189 원문 — 도수로 삭제 단추 · 물넘이 고름 컨테이너 · 낮은 쪽 연장 · 도수로 끝단 · 성토 사면 고름
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 21:34:12 +09:00
eomsangdon 557dbf5b8f Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 21:33:10 +09:00
eomsangdon 6e00e2eebd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-12 21:33:09 +09:00
eomsangdonandClaude Opus 5.5 6c86612f65 fix(b05,b06): 월류 폭 → 높이 — 세월교 · 물넘이 한 벌 overflowSync · 안내 글 · 높이 비우면 다시 채움 (287-3)
- 까닭: 폭 → 높이 계산은 설계유량(담당 유역)이 있어야 돎 — 유량 없는 지점은 아무 일도 안 일어나고 안내도 없었음 · 높이를 비우면 안 채움
- overflowSync(design_structure_ford_calc) — 폭 바꿈 = 필요 수심 · 높이 비움 · 그 아래 = 필요 수심 · 상자 끝 안내 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」 · 유량이 오면 안내 다시 그림
- 세월교 · 물넘이 폼이 같은 것을 씀 · Facility.ts 줄 줄임(697 → 686)
- 시험 test_287 화면 길(폼 onChange 훅) — 유량 있음 · 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:24:35 +09:00
eomsangdon 67b8f1d568 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 21:17:41 +09:00
eomsangdon 74d8a071d6 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-12 21:16:39 +09:00
eomsangdonandClaude Opus 5.5 990343edc1 feat(b06): 맹암거 형상 누름 = 그 구조물 고름 — 물넘이와 같은 판정면 · 보라 손잡이 아래 · 끌기는 고름 아님 (288-2)
- 맹암거 둘레에 고름 표식(data-structure-id) · 287-5 판정면을 같이 탐(손잡이 아래 · 3 px 끌기 = 고름 아님)
- 형상 누름 창구 shapePickRoute — 관 지점(물넘이) = 그 카드 시설 고름 · 맹암거 = id 알림 → 구조물 목록이 id 로 고름(알약 · 폼 · 측점선 따라옴 · 노면공 포장 범위 고름과 따로)
- 목록 · 알약 고름 → 형상 강조(287-5 highlightShapes)
- 시험 test_288 · 287 대조 글

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:15:47 +09:00
eomsangdonandClaude Opus 5.5 fcf7daecb6 feat(b06,b07): 맹암거 측점 횡단 — 측구 없음 · 암거 경사로 지대 낮은 쪽 기울기 · 노면 아래 암거 높이 돌 무늬 형상 (288-1)
- 서버 B06_Section_Engine_Tilt — 기울기 자리(물넘이 범위 + 맹암거 기준 측점 · 경사 = 경사각도 · 암거 경사 · 비면 표준 횡단경사) · 맹암거 제원 얹기(attach_underdrains) · 상세 · 재생성 응답에 얹음
- TS 짝 common_util_structure_underdrain(초안 구조물로 다시 얹음) · 계획선 편집 재계산도 맹암거 자리 기울임
- 형상(B06_Section_UI_Cross_Underdrain) — 노면을 높이만큼 아래로 옵셋 · 성토 쪽 = 성토 사면과 만남 · 절토 쪽 = 절토 사면을 늘여 만남 · 안 = 돌 무늬 · 높이 비면 안 그림 · B06 카드 · B07 같은 그림
- tilt_ranges · tilt_args 는 Router_Design 에서 새 모듈로 옮김(700줄)
- 시험 test_288 새로 · 286_1 문자열 대조

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:12:47 +09:00
eomsangdon 0f05b642ee Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 21:09:16 +09:00
eomsangdonandClaude Opus 5.5 75050815fa feat(b06): 물넘이포장 형상 누름 = 다른 시설과 같은 한 고름 · 보라 손잡이 아래 · 끌기는 고름 아님 (287-5)
- 형상 고름 판정면(B06_Section_UI_Cross_Shape_Pick) — data-structure-id 단 형상마다 · 구조물 판정면 앞(아래)에 꽂아 보라 손잡이 판정이 위(요구 185 ①)
- 누름만 고름 — 3 px 넘게 움직이면 끌기라 고름 아님(②) · 노면공 포장층(범위 표식)은 밖 · 포장 고름과 안 섞임(③)
- 누름 → toggleRevet("pipe") = 세월교 · BOX 와 같은 창구(폼 · 알약 · 측점선) · 물넘이 범위 옆 카드도 그 물넘이 자리로
- 목록 · 알약 · 폼 고름 → 카드 형상 강조(highlightShapes) · 물넘이 포장층 표식 = 관 지점 알약 id
- 시험 test_287(판정면 차례 · 누름 · 끌기 · 강조)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:07:53 +09:00
eomsangdonandClaude Opus 5.5 1edf718523 feat(b07): CAD 글자 더블클릭 = 그 자리 입력 칸 — prompt 걷음 · Enter 적용 · Esc 취소 · 여러 줄 Ctrl+Enter (293-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-12 21:03:27 +09:00
eomsangdonandClaude Opus 5.5 2b9440a44e test(b05): 물넘이포장 월류 폭 → 높이 자동 = 세월교 한 벌 확인 · 시험으로 못박음 (287-3)
- 물넘이 「경사각도」 상자 폭 칸이 세월교와 같은 overflow · fordHeightM 길을 탐(이미 이어짐 · 코드 변경 없음)
- 설계유량이 없는 지점은 손 입력(높이 칸 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:59:10 +09:00
eomsangdonandClaude Opus 5.5 a5a9a7cec0 feat(b05,b06): 물넘이포장 콘크리트 윗면 = 설계 노면 · 포장층 노견까지 · 월류 높이 점선은 노면 위 (287-2 · 287-4)
- 설계 계산 tilted · tilt_pct(파이썬 · TS 짝) — 물넘이 범위 노면 = 경사각도로 지대 낮은 쪽 한 경사 · 측구 없음 · 중심점 = 계획고(콘크리트 윗면) · 노면 끝에서 사면이 이어짐(짧으면 성토)
- 서버 tilt_ranges · tilt_args · stored_tilt — 기본 설계 · [저장] 재계산 · 포장 범위 맞춤 · 저장분 재계산이 같은 값(옛 ford_paved 갈음)
- 횡단 그림 — 포장층 = 노면공 포장 형상(appendPavementOverlay)을 노견까지 · 물넘이 표식 · 월류 높이선 = 노면 + 높이 같은 각도 · 옛 파임 · 노면 걷기 그림 걷음
- 3D 노면 파기 걷음(설계 노면이 곧 윗면) · 코리도 빌드 판 94
- 시험 test_287 · 286_1 · 횡단 설계 거울(기울임 갈래) · 물넘이 명세 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:57:54 +09:00
eomsangdon e534e65091 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_7' into sub_laptop_6 2026-10-12 20:54:05 +09:00
eomsangdonandClaude Opus 5.5 17b095942d chore(plan): 10-12 요구 187(B07 CAD 글자 더블클릭 = 그 자리 입력)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:53:46 +09:00
eomsangdonandClaude Opus 5.5 f211ff42a5 fix(b05,b06): 종단도 누가토량 알약 테두리 · 글자 · 바탕 붉은 계통 · 음수는 더 진하게 (289-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-12 20:52:31 +09:00
eomsangdonandClaude Opus 5.5 62008429b0 chore(plan): 10-12 요구 186(누가토량 알약 테두리 · 색 붉은 계통)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:49:31 +09:00
eomsangdon 54aa635f0e Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-12 20:47:46 +09:00
eomsangdon cae69d1aa2 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-12 20:47:08 +09:00
eomsangdonandClaude Opus 5.5 e7c9fe3a05 fix(b05,b06): 구조물 배치 — 신규 A군 관은 [추가] 뒤에만 생김 · 기준 측점 입력은 관 정본에 안 넣음 (291-1)
- 원인 = 측점 확정 때 임시 배치(commitTempPipe)가 관 정본 초안에 바로 넣음 · B · C 군은 원래 [추가] 전 기록 없음
- 측점 확정은 옵션 잠금 풀기만 · 우클릭 「추가」(116-1)만 임시 배치 그대로(commitTempPipe(true))
- 새 세월교 · 물넘이 빈 월류 높이는 [추가] 뒤 재계산 유량으로 채움(126-1 fordWaits) — 미리보기 불요
- 시험 test_291_1_add_only_on_add 새로 · test_116 우클릭 길 판정 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 20:46:40 +09:00
eomsangdonandClaude Opus 5.5 743718fe01 chore(plan): 10-12 요구 185(물넘이 · 맹암거 형상 · 포장 고름이 보라색 손잡이 끌기와 안 섞이게)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:46:13 +09:00
eomsangdonandClaude Opus 5.5 3610206fa9 fix(b05,b06): 종단도 누가토량 알약 한 단 크게 — 공용 토큰 · 측점 ▲ 줄 아래로 (289)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-12 20:45:58 +09:00
eomsangdonandClaude Opus 5.5 0bf50a1837 feat(common): 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움 · 저장 안 한 고침은 확인창 (290-1)
- b_project_switch.enterProject — 공용 goToWorkflowStage 에서 부름 · 탭마다 들어온 표식이 다를 때만
- 다른 프로젝트 세션 칸(초안 · 결과 · 옛 키 · 키 마디에 번호가 든 페이지 칸) + 3D 보관함 지움 · 지금 프로젝트 · 계정 취향 그대로
- 다른 프로젝트에 저장 안 한 고침이 있으면 확인창(지우고 열기 / 남기기) · 남기기 = 그 초안만 둠
- showConfirmDialog 취소 단추 글 받는 칸(기본 「취소」)
- 시험 test_290_1(진짜 TS · 가짜 저장소)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-12 20:44:18 +09:00
eomsangdonandClaude Opus 5.5 0c7f71d103 feat(b05): 물넘이포장 도수로/돌붙임 길이 ↔ 면적 — 넣음 = 월류 폭 × 길이 · 비움 = 기본값 (287-1)
- syncChuteArea(design_structure_ford_calc) — 길이 · 월류 폭 칸이 바뀌면 면적 칸을 다시 채움
- 폭 = 띠가 도로 방향으로 걸치는 월류 폭(비면 물넘이 기본 폭) · 그림은 286-3 대로(길이 = 그 길이 · 비움 = 원지반까지)
- 시험 test_287 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:44:14 +09:00
eomsangdonandClaude Opus 5.5 71f8ee7248 chore(plan): 10-12 요구 183 · 184(물넘이포장 후속 · 맹암거 횡단 · 누가토량 알약 · 프로젝트 캐시 · 신규 구조물 [추가] 뒤 · 브레인 검증) · 그림_183
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:39:09 +09:00
eomsangdon 93400e3e5c Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 20:16:09 +09:00
eomsangdonandClaude Opus 5.5 6d25379469 feat(b05,b06): 물넘이포장 도수로/돌붙임 = 다른 구조물과 같은 칸 + 맨 아래 「길이 (m)」 — 그림 전용 (286-3)
- 명세 — 도수로/돌붙임 칸 = CHUTE_ITEMS(구조 · 형태 · 두께 · 면적) + 길이(ford_chute_{n}_length_m)
- 서버 _chute · TS chute 짝 — 길이 있으면 제원에 length_m
- 횡단 · 3D 띠 — 유출 성토사면을 노견에서 사면 따라 그 길이까지(비거나 사면보다 길면 원지반까지)
- 길이 = 그림 전용 · 집계 열 · B08 돌붙임 수량(면적)에 안 실림
- 시험 test_286_1 · 물넘이 명세 시험 기대값 새 칸으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:15:17 +09:00
eomsangdonandClaude Opus 5.5 7e1b5bd27e feat(b06): 물넘이포장 자리 횡단 = 측구 없음 · 노면은 표고 낮은 쪽으로 기움 (286-2)
- 설계 계산 ford_paved(파이썬) · fordPaved(TS 짝) — 물넘이 범위 안 측점은 측구가 안 섬(사용자 측구 선택보다 먼저)
- 서버 정본 — ford_pavement_ranges 로 기본 설계 · [저장] 재계산 · 포장 범위 맞춤이 판정 · 저장분 재계산은 되실은 값을 이음
- 화면 — 계획선 편집 재계산이 얹힌 물넘이 제원으로 같은 판정
- 노면 기울기 — 유입 = 설계선 양 끝 표고가 높은 쪽(같으면 단면유형) · 경사각도만큼 낮은 쪽(유출)으로 · 도수로 띠도 같은 쪽
- 시험 test_286_1 · 횡단 설계 거울에 물넘이 갈래 · 도수로 띠 시험 기대값 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:09:41 +09:00
eomsangdonandClaude Opus 5.5 0c0b169d9e chore(plan): 10-12 요구 181 · 182(프로젝트 오갈 때 고름 유지 · 횡단배수군 측점 없는 종류 — 물음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:05:05 +09:00
eomsangdonandClaude Opus 5.5 04d16e0e2f feat(b05,b06): 물넘이포장도 종단 기준 측점 하나 심음 — TS · 파이썬 짝 · B05 · B06 거름 걷음 (286-1)
- pipeStationMarks · pipe_station_label 물넘이포장 예외 걷음 · 라벨 「물넘이포장」
- B05 withoutFordPavement · B06 pipeStations · 초안 라벨 거름 걷음 · 알약 = 그 측점선 짝
- 시험 test_286_1 새로 · 272_1 · 284_1 · 옛 물넘이 측점 시험 기대값 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:03:11 +09:00
eomsangdonandClaude Opus 5.5 0c7d584ef9 chore(plan): 10-12 요구 180(고름 강조 저장 자리 확인 · 물넘이포장 종횡단 설계)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:57:14 +09:00
eomsangdon c5c21a7fa1 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-12 19:52:27 +09:00
eomsangdonandClaude Opus 5.5 54cc970746 fix(b05,b06): 종단도 알약 고름 · 풂 — 측점 없는 알약 = 측점선 안 켬 · B06 다시 누름 = 풀기 한 길 (284)
- 측점 있음 = 제 측점을 심는 알약(272 한 벌 — A군 · C · D 구간형 · 관은 물넘이포장 뺌) · 그 밖 알약은 같은 자리 · 근처 측점선을 짝으로 켜지 않음(plantsStation · stationIdAtStructure)
- B06 알약 다시 누름 = Esc 와 같은 풀기 한 길(카드 · 폼 · 알약 · 범위 테두리) — 옛 처리는 풂 신호를 버림
- B06 측점 없는 구조물 고름 = 노면공처럼 카드 안 고름 · 스크롤만 · 물넘이포장 알약 = 그 관 폼 · 알약 · 고름 값에 알약 id(selectPipeAt)
- B05 물넘이포장 알약 = 고름 값에 알약 id(같은 m 일반 측점선 안 켬)
- 시험: test_284_1 새로 · 옛 시험 글 대조 · 표지 카드 기대값(None) · 헬퍼 stub(진짜 plantsStation)
- 기준점 = 태그 base-284-pill-before(282e9fcfc) · 되돌리기 = 이 커밋 git revert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:52:13 +09:00
eomsangdonandClaude Opus 5.5 31a3b74d97 feat(b05,b06): 종단 도구줄 [새 창] → [창 분할] · [제자리로] → [창 결합] (285-1)
- 단추 글 · 풍선만 바꿈 — 자리 · 동작 · 팝업 막힘 안내 그대로
- test_282_1 글 대조 새 이름으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-12 19:41:50 +09:00
eomsangdonandClaude Opus 5.5 282e9fcfc8 chore(plan): 10-12 요구 179(알약 고름 · 풂 브레인 직접 · 새 창 → 창 분할 · 제자리로 → 창 결합)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:40:12 +09:00
eomsangdonandClaude Opus 5.5 019c79f9cc chore(plan): 조사 AM — 종단도 알약 고름 · 풂(B06 풂 끊김 · 측점 없는 구조물 근처 측점선 · 고치지 않음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:26:13 +09:00
eomsangdon 688472612b Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-12 19:19:59 +09:00
eomsangdonandClaude Opus 5.5 2fa68b95d8 feat(b05,b06): 종단 패널 도구줄 — [배수유역도] 켬끔 단추 지움 · [새 창] = [초기화] 왼쪽 (282)
- 배수유역 판 켬끔(setVisible · drainage-visible 값) 지움 — 판은 늘 보임 · 접기 · 펼치기는 판 손잡이 하나로 · 옛 값 false 무시
- [새 창] 단추 = 도구 묶음 맨 앞(toolLead) — 다시 그릴 때마다 [초기화] 바로 왼쪽에 옮겨 붙임 · 열림 · 닫힘 동작 그대로
- 시험 test_282_1 추가 · 옛 단추를 찾던 시험(187-1 · drainage_side · profile_single) 새 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-12 19:19:05 +09:00
eomsangdonandClaude Opus 5.5 0daebace79 chore(plan): 10-12 요구 178(종단도 알약 고름 강조 · 풂 — 고치지 않고 코드 확인만)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:17:31 +09:00
eomsangdon c2778280fe Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-12 19:16:26 +09:00
eomsangdon 50c6056e95 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-12 19:15:26 +09:00
eomsangdonandClaude Opus 5.5 219e7fc223 feat(b05,b06): 배수유역 지도 줌아웃 한계 = 노선 기준 — 노선 범위 2 배 · 유역 밖이면 유역까지 · 도엽 전체까지 · 창 크기 바뀌어 밖이면 노선 맞춤 (281)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 19:15:13 +09:00
eomsangdonandClaude Opus 5.5 190066ca86 feat(b05,b06): 구조물조합 이름 칸 누르면 고르기 · 「구조물 조합 선택」 모달 위 표 ↔ 나무 ↔ 카드 · 층 단추 = 그 층 표 (280)
- 280-1 이름 칸 = 고르기(누름 · Enter · Space · 손 커서 · 풍선 · 초점 테두리) · [선택] 단추 지움 · [해제] 그대로 · 본체 · 그룹 · 명세 잇기 같은 줄 부품
- 280-2 모달 제목 「구조물 조합 선택」 · 위 표 열 고름 → 나무 펼쳐 고름 · 카드 · 층 단추 → 위 표 = 그 층 표 · B08 연결 같은 모달
- 280-3 M02 구조물 조합 = 따로 짠 길로 두 고리 정상 · 고침 없음
- 시험 test_280_1_combo_label_pick 새로 · test_224_1 · test_b05_structures_form_summary 새 꼴에 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 19:14:50 +09:00
eomsangdonandClaude Opus 5.5 eaaa5ab71f chore(plan): 10-12 요구 177(종단 패널 도구줄 배수유역도 단추 지움 · 새 창 = 초기화 왼쪽)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:11:44 +09:00
eomsangdonandClaude Opus 5.5 9208de6435 chore(plan): 10-12 요구 176(B05 · B06 배수유역 지도 줌아웃 범위 = 노선 기준)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:09:21 +09:00
eomsangdonandClaude Opus 5.5 a12f28a7e5 chore(plan): 10-12 요구 175(B05 · B06 구조물조합 칸 · 구조물 조합 선택 모달 · M02 같은 흠 확인)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:02:27 +09:00
eomsangdonandClaude Opus 5.5 bb6ae4faf5 fix(b08,b09): 보조 탭 단추 높이 한 꼴 — 공용 토큰 24px · 공용 보조 탭 줄 양식 · 도구는 줄 높이 안 바꿈 (279)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-12 18:58:56 +09:00
eomsangdonandClaude Opus 5.5 fa5fa3bff9 chore(plan): 10-12 요구 174(B08 · B09 보조 탭 단추 높이 한 꼴)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 18:50:47 +09:00
eomsangdonandClaude Opus 5.5 04659a028f feat(b09): 운반 단가 창 왼쪽 폭 줄임 · 단가산출근거 박스보기 머리 줄 = 입력값 · 중간값 칸 (278-1·2)
- 운반 단가 창 몸 두 칸 비율 — 왼쪽(옵션 값) 약 0.8 배
- 단가산출근거 [박스보기] 머리 줄 = 운반 창 ③ 칸과 같은 꼴(이름 · 값 오른쪽 굵게 · 식 작게) · 출처 = 칸 맨 위
- 줄 나눔 · 그리기 한 벌 — Haul_Steps headSteps 내보내 같이 씀 · 공용 묶음 카드 = 선택 몸 위 칸(top)
- 호표 줄 · 총계 · 표 보기 · 다른 탭 카드 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-12 18:44:11 +09:00
eomsangdonandClaude Opus 5.5 76959c9d88 chore(plan): 10-12 요구 173(운반 단가 창 왼쪽 폭 · B09 단가산출근거 박스보기 중간값 꼴)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 18:38:49 +09:00
eomsangdonandClaude Opus 5.5 f43a36aa39 feat(b09): 운반 단가 창 오른쪽 계산 칸 나눔 · 운반거리 칸 자동계산 단추 (277-1~4)
- 오른쪽 글 상자 → 칸 차례(거리 · 사이클 8-2-8 · 단가산출) · 칸마다 제목 · 몸 · 바닥 결과
- 사이클 칸 = Cm · 사이클시간 · t2 받는 로직만 · 조합 = 담은 로직 줄마다 미리보기 · 하위 산근 이어 붙임
- logic-preview 응답에 blocks(머리 · 호표 줄 · 계 · 출처) 더함 · 셈 · lines 그대로
- 값 칸 숫자 칸 = ui-input--sm(고르기 칸과 같은 높이) · 「거리에서」 → 「자동계산」 단추

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-12 18:25:05 +09:00
eomsangdonandClaude Opus 5.5 9413b6c69a chore(plan): 10-12 요구 172(단가산출 로직 안 건드림 · 화면 먼저) · 조사 AL 답(GF000284 속도 = 원문 한 쌍 · 옵션 없음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 18:16:39 +09:00
eomsangdonandClaude Opus 5.5 be9250b7e7 chore(plan): 10-12 요구 170 · 171 · 조사 AL(운반 단가 창 오른쪽 계산 · 운반거리 칸)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 17:10:45 +09:00
eomsangdonandClaude Opus 5.5 71f87ea19e chore(config): 구글 시트 로그인 토큰 갱신(옛 토큰 만료 · invalid_grant)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 16:59:39 +09:00
eomsangdonandClaude Opus 5.5 cf704c8459 chore(plan): 271~274 · 276 이관(요구 166~169 · 사용자 10-11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 12:47:07 +09:00
eomsangdon e147242906 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-12 06:14:43 +09:00
eomsangdonandClaude Opus 5.5 7c6735613e feat(b08,b09): 단가연결 — 연결 없음 = 조회 → 옵션 모달(옛 길) · 연결 있음 = 작은 운반 단가 모달 (276)
- B08 거리이정표 우클릭 [단가연결] · B09 운반공 줄 — 연결 없으면 단가연결 목록 → 옵션 값 창(askInputs)
- 연결 있으면 「운반 단가」 모달 · 그 밖 B09 줄 그대로
- 운반 단가 모달 크기 96vw × 94vh → min(1100px, 94vw) × min(680px, 86vh) · 가운데 · 두 칸 따로 스크롤
- 옛 결정(연결 · 미연결 같은 모달)을 고정한 시험 두 줄 고침 · test_276_1 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjUPRxjDxULvh5VA7kX5py
2026-10-11 21:45:06 +09:00
eomsangdon 132935b395 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-10-11 21:31:39 +09:00
eomsangdonandClaude Opus 5.5 192d4feebe fix(b01): 챗봇 창 블랙 · 화이트 테마 색 맞춤 · Enter 보내기 · Shift+Enter 줄바꿈 (275-1·2)
- 없는 색 변수(--color-text-primary · --color-background) 걷고 테마 변수로 — 어두운 테마 제목 · 말풍선 글자 안 보임 해소
- 단추 · 내 말풍선 = primary 색 짝 · 답 말풍선 = 떠 있는 표면 + 테두리 · 입력칸 바탕 · 글자
- Enter = 보내기 · Shift+Enter = 줄바꿈 · 한글 조합 중 Enter 무시
- 화면 시험 ui_checks/275 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjUPRxjDxULvh5VA7kX5py
2026-10-11 21:28:54 +09:00
eomsangdonandClaude Opus 5.5 7b5ee62a42 fix(b06,b07): 표 위 CAD 배수관 원형 마크 — 아래 수직 지시선 없앰 (274-1)
- B06 상세 모달 표 위 CAD 미리 넣음(pipeMark)과 B07 짝(_pipe_mark) 모두 원 + 원 안 세 줄만
- 요구 169 원문 기록 · 시험 260 · 265 기대값 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 20:56:17 +09:00
eomsangdonandClaude Opus 5.5 dfd7427c46 feat(b06): 횡단도 바로 반영 — 새로 놓거나 옮긴 관 · 구조물의 횡단 카드를 [저장] 전에 보임 (273-2)
- 서버: 읽기 전용 미리 뜨기 POST /section/preview-stations — 로그인 · 회사 · 프로젝트 권한 그대로 · 한 번에 60곳 · 파일 · DB 안 씀 · 저장과 같은 길(sample_structure_sections · compute_default_designs crosses)
- 화면: 초안이 심는 자리 중 횡단 없는 곳만 묻고 한 번 뜬 자리는 기억(B06_Section_Section_Drafts) · 카드 이름 · 관 그림 얹음 · 늦은 옛 응답 무시 · 배수 재계산이 관경을 정하면 카드 다시 얹음 · B06 관 옮김도 카드 다시
- [저장]은 지금처럼 서버가 같은 계산으로 정본에 씀 — 미리 본 카드와 같은 값
- 알림 글: 횡단도는 바로 보이고 세부 배수유역만 저장 뒤 다시 셈
- 시험: test_273_2 새로 · 177-3 헬퍼에 revealPick 자리 채움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 20:48:32 +09:00
eomsangdonandClaude Opus 5.5 887c89bbf8 feat(b05,b06): 종단 그래프 — 다른 영역에서 고른 측점이 화면 밖이면 가운데로 이동 (273-1)
- 칠한 결과(고른 측점선 · 없으면 고른 구조물 알약)를 보고 보이는 칸 밖일 때만 가운데로 옮김(createSelectionReveal · design_profile_viewport)
- 같은 고름을 다시 칠하면 안 밂(사용자가 옮겨 본 자리 지킴) · 그래프 안 누름은 보이는 자리라 안 움직임 · 고른 뒤 다시 그리기가 끊지 않게 바로 옮김
- 요구 168 원문 기록 · 시험 test_273_1 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 20:20:24 +09:00
eomsangdonandClaude Opus 5.5 93e86f87c6 fix(b05,b06): 측점 한 벌 — 정수 m · 같은 m 측점 하나 · 목록 전체 다시 맞춤 (272)
- 규칙: 구조물 · 관 기준 측점 = 정수 m 사사오입(일반 측점 1 m 붙기 뺌) · 측점 심는 자리 TS 짝(plantedChainages ↔ resolve_extra_stations) · 같은 m 면 구조물 측점 하나(격자 자리 grid) · 떠나면 일반 측점(oneStationList ↔ _merge_irregular_into_longitudinal)
- 서버: B05 [저장] · B06 저장 · [측점 만들기]가 목록 전체 다시 맞춤(빈 목록도 · 옛 글 · 횡단 파일 글) · 계획선 측점 줄 다시 셈 · 정본에 없는 횡단 행은 0.5 m 안 새 측점으로 옮기고 나머지 지움 · 옛 소수 측점 꼭짓점 · 편집은 정수 자리로 · 맞춤 실패는 저장을 안 막음
- 화면: 종단 그래프 · 3D · 표 · ▲▼ 한 목록(초안 반영) · C군 구간형 · 독립 기슭막이 시작 · 끝 측점 · B06 카드는 초안에 없는 옛 구조물 측점 숨김 · 고름은 같은 m 구조물 측점 먼저 · 폼 거리 칸 정수 표기 · C군 계산 구간 · 포장 나눔 경계 정수
- 옛 자료: [정수 맞춤] 단추 없앰 · 종단 보기 열 때 한 번 자동 정수 · 알림 한 줄 · [저장]하면 남음
- 시험: test_272_1 · test_272_2 새로 · 234_1 · 238_1 · b05_extra_stations_supplier · b06_patch_station · b05_station_roundtrip · b05_b06_save_roundtrip 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 19:53:24 +09:00
eomsangdonandClaude Opus 5.5 06390f7a0d chore(plan): 조사 AK 8장 · PLAN 272 측점 한 벌 리팩터링 계획
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 18:37:15 +09:00
eomsangdonandClaude Opus 5.5 26437ef19c chore(plan): 10-11 요구 167(측점 정수 · 겹침 근본 해결)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 18:35:02 +09:00
eomsangdonandClaude Opus 5.5 c0626e3ccb chore(plan): 10-11 조사 AK(정수 맞춤 · 측점 겹침 · 강조)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 18:16:58 +09:00
eomsangdonandClaude Opus 5.5 2399c533c6 feat(b06,b07): 표 위 CAD 칸 미리 넣음 = 구조물 조합 이름 + 좌측 패널 옵션 줄 (166-1)
- 구조물마다 조합 줄 뒤에 좌측 패널 옵션 줄 — 폼과 같은 컨테이너 · 이름 · 보이는 칸(`structureOptionLines`) · 구조 갈래에 안 맞는 옛 값 뺌 · 필수 상자 빈 칸 = 폼 기본값
- B06 [상세] 모달 = 화면 TS 직접 · B07 = 같은 TS 를 Node 번들(B07_DesignDetail_Note_Options_Node · build:b07-note)로 받아 파이썬 짝이 붙임 · 실패는 조합 줄만
- 번들 낡음 판정에 A00_Common/design 더함
- 시험 test_166_1(진짜 명세 · B07 = 화면 같은 줄) · test_260 · test_265 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 17:55:45 +09:00
eomsangdon c61f9a9e9a Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-11 17:40:56 +09:00
eomsangdonandClaude Opus 5.5 d85529c782 chore(plan): 10-11 요구 166(CAD 글 옵션 · 정수 맞춤 검토)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 17:40:36 +09:00
eomsangdon af9d4195aa Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-11 17:32:34 +09:00
eomsangdonandClaude Opus 5.5 07862ff178 chore(plan): 262~270 이관(요구 164 · 165)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 17:31:22 +09:00
eomsangdonandClaude Opus 5.5 e09c6863f5 fix(design): 횡단 보기 배수유역도 = 고름 한 칸에서 파생 · 지도 고름 = 고름 한 칸 (270-1)
- 횡단 보기 구독이 낸 쪽과 무관하게 한 벌 배수유역도(관 마커 · 유역 · 측점 마름모)를 값으로 칠함 — 목록 · 카드 고름이 지도에 이어짐 · 종단 보기에서 칠한 옛 강조 안 남음
- 횡단 보기 종단도 알림에 유역 · 관 고름(onBasinSelected · onPipeSelected) = 고름 한 칸(from map) — 넘김 적용 길로 카드 · 폼 · 알약 · 표 열이 따라옴 · 같은 유역 다시 누름 = 모두 풂
- 배수 재계산(onPipesChanged) 뒤 횡단 보기가 보일 때 지도 강조 다시 얹음
- 시험 test_270_1_cross_map(진짜 bindCrossPick · paintMapPick)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 17:31:07 +09:00
eomsangdon f0dd4a8d75 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-11 16:56:47 +09:00
eomsangdon 8e0c3bfc03 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-11 16:56:46 +09:00
eomsangdonandClaude Opus 5.5 f1ed24d17b refactor(B09): 264 공기 옵션 카드 = 좌측 패널 공용 카드 꼴
- createPanelCard · 공용 칸(createInputField · createSelectField) · 공용 체크박스로 짜기
- B05 스타일 CSS 덧칠 import 지움 · 동작 · 저장 칸 그대로
- 숨김 = style.display(공용 카드 flex 가 hidden 을 덮음) · 두 칸 줄은 바깥 div 로 감싸 접기 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 16:56:14 +09:00
eomsangdonandClaude Sonnet 5.5 af8b667055 fix(test): 201-1 셸 cellColumn 개수 기준을 257-3 이후 코드에 맞춤(== 2 → >= 2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 16:54:13 +09:00
eomsangdon 06afa29d88 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-10-11 16:51:35 +09:00
eomsangdon 8c351bfbf5 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-11 16:51:33 +09:00
eomsangdonandClaude Opus 5.5 9e3c87edd5 feat(B05,B08,B09): 269-3 B05 이상 좌측 컨테이너 공용 양식 점검 · 맞춤
- B05 칸 틀(페이지 설정 · 계획노선 · 선택 포인트) = .ui-panel-form
- B08 옵션 카드 · B09 데이터 점검 카드 = createPanelCard · 틈 덧칠 정리
- 아래 단추 묶음은 그대로
- 시험 test_269 점검(빠진 곳 0) · 122 · 203-1 · 240-1 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 16:50:56 +09:00
eomsangdonandClaude Opus 5.5 2c8c715cc2 feat(B09): 263-2 사전조합 · 구조물 조합 운반 = 담은 로직 입력 + 거리 값 사본
- 모달 = 담은 로직 이름별 입력(library-inputs) · 거리 칸 거리에서 · 손 값(own) 이김
- 사본 폴더 = 종류@층@주인@키~값해시8 · 같은 값 = 같은 사본 · 다른 거리 = 따로 · 옛 이름 읽힘
- 셈 때 서버가 거리로 사본 다시 고름(resolve_hauls · fix_keys) · [저장] = 바뀐 연결 저장 값에도 · prune
- 연결 {tab dg, key, lib, given, own} 저장 꼴 · 원본 읽기 전 모양 검사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 16:50:28 +09:00
eomsangdon 9fbaf22565 Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_6' into sub_laptop_3 2026-10-11 16:49:13 +09:00
eomsangdon 23e97a8765 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-11 16:49:11 +09:00
eomsangdonandClaude Opus 5.5 a1d63ebfd6 feat(B06): 상세 창 CAD 칸 표 아래 접기 · 구조물 조합 줄 미리 넣음(265-1 · 265-2)
- 상세 창 차례 = 그림 → 표 → CAD 칸 · 칸 머리 한 줄 접기(공용 ui-collapsible) · 열 때 일반 측점 접힘 · 구조물 측점 폄 · 처음 펼 때 칸 만듦
- 미리 넣는 글 = 그 측점 구조물(관 지점 · 구조물 배치 기준 측점)마다 지정된 구조물 조합 이름 줄 · 미지정 = 「이름 조합 미지정」 · 배관 = 원형 마크(본체 줄 없음) · 옛 관보호공 · 관유출부보강 짜 맞춘 글 버림
- B07 짝(CulvertNote.py) 같은 규칙 · 정본 structures.json · 구조물정의 이름까지 읽음
- 시험 test_265_note_cad 넓힘 · test_260 · test_150 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 16:48:15 +09:00
eomsangdonandClaude Sonnet 5.5 d496517ce4 fix(B07): 횡단도 노란 측구 덧그림 걷음 · 배수관 · 박스암거 덧그림 색 따로(268)
- 측구 구간 구조물 선(b08-structure)도 design_ids 에 실음 — 화면 구조물 덧그림이 같은 자리를 그릴 때 서버 노란 측구가 한 겹 더 남던 것 걷음 (장 · 단일 도면)
- 박스암거 · 배수관 덧그림을 구조물(노랑)과 다른 청록(#22d3ee)으로 — 설계선 보라 · 지반선과 구분
- 시험 test_268_b07_cross_ditch_pipe_color.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 16:47:57 +09:00
eomsangdonandClaude Opus 5.5 c3e56f2e68 feat(B06): 269-2 좌측 컨테이너(횡단 보기 설정 · 표준 횡단면 설정) = 공용 양식
- createSectionFrame 횡단 칸 틀에 .ui-panel-form · 칸 · 라벨 · 단추 = 「구조물 배치」 기준
- B06 덧칠(칸 글자 · 색 · 오류 칸 접기) 걷음 · 줄 배치만 남김
- 시험 test_269_panel_form B06 항목

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 16:45:03 +09:00
eomsangdonandClaude Opus 5.5 3939cd11f9 feat(B07): 269-1 좌측 패널 컨테이너 공용 양식(구조물 배치 기준) · 공용 카드 함수
- ui_template: .ui-panel-form(칸 26px · 라벨 틈 2px · 캡션 글자 · 단추 크기) · .ui-panel-btn · createPanelCard
- 칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰
- B07 평면도 · 횡단도 묶음 = 공용 카드 · 도면 단추 = .ui-panel-btn · 페이지 덧칠 걷음
- 시험 test_269_panel_form · 212-3 기대값 · 130 · 153 헬퍼 공용 카드 연결

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 16:42:57 +09:00
eomsangdon a2b67626bb Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-11 16:41:14 +09:00
eomsangdonandClaude Opus 5.5 0db53a9032 feat(B09): 264-1 왼쪽 패널 「공기 옵션」 카드
- draft 새 파일 B09_Draft_Schedule_Panel — 착공일 · 기후 자료 · 쉬는 날 · 공휴일 넣기 · 일 작업시간 · 투입 기본 · 준비 · 정리 · 기타 · 반영률 · 주 장비 기준 · 중복 C 끝수 · 마지막 달
- 값 = user.schedule(캐시 → [저장]) · 표 칸과 같은 값 · 셈마다 카드 맞춤
- 훅 = DRAFT.panels(기본 빈 목록) · Shell 「데이터 점검」 아래 한 줄
- 공기집계 비고 = 고른 옵션을 따름 · 착공 칸 「연.월.일」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 16:40:29 +09:00
eomsangdonandClaude Opus 5.5 fe2ed66268 feat(B09): 264-2 공기 옵션 셈 짝 · 다듬기 · 시험
- 셈 짝(Py · TS) 새 옵션 — 착공일(그날부터 · A 날수 비례) · 기후 자료 고르기 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준(장비 시간 합) · 중복 C 끝수 · 마지막 달 비작업
- 원천 = 기후 셋(거창 25 · 울진 24 · 울진 소광 25 · 0에서 직접) · 단위당 장비 시간 합
- clean_schedule 새 칸(모르는 값 · 범위 밖 버림)
- 시험 test_264_2 — 기본값 = 지금 결과 · 옵션마다 값 바뀜 · 거울 TS = Py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 16:40:21 +09:00
eomsangdon ff738b7a3c Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-10-11 16:37:04 +09:00
eomsangdonandClaude Opus 5.5 d909d6aabe feat(B09): 263-1 운반 단가 모달 = 고른 항목 입력만 + 거리 자동
- 사이클시간 입력 · 8-2-8 표 고르기 칸 없앰 · 사이클 = 8-2-8 기본값(계산 절차에만)
- 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 · 거리 칸 없는 항목 = 사이클 줄 없음
- [변경] = 단가연결 한 목록(onPick) · [다른 종류] 없앰 · 조합 = [적용] 때 사본 들여옴 · 호표 = 그대로 연결
- 옛 cycle = 서버 읽기 그대로 · [적용]하면 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 16:36:35 +09:00
eomsangdon 6279ac547c Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-11 16:36:32 +09:00
eomsangdonandClaude Opus 5.5 3a08e4df9d feat(B06): 상세 창 처음 가로 범위 = 횡단 보기 반폭 (266-1)
- 칸 맞춤이 넓힌 가로를 처음 배율(homeScale)로 되좁힘 · 개별 표시 반폭 측점은 그 값
- 공용 줌 · 팬 homeScale 칸 — 처음 · [⤢] · 더블클릭 = 그 배율 · 줄이면 칸 맞춤 전체까지
- 시험 test_266 · 164 · 173 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 16:35:42 +09:00
eomsangdon f453a9ce43 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_3 2026-10-11 16:35:38 +09:00
eomsangdonandClaude Opus 5.5 c47e9f8615 fix(B06): 상세 CAD 칸 원형 마크 관 길이 사사오입 정수(265-3)
- 원형 마크 L = pipeLengthWhole 정수(8.5 → 9 · 8.4 → 8) · B07 짝 같은 규칙
- 거울 시험 test_265_note_cad · test_260 기대값 L=22m

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 16:34:15 +09:00
eomsangdon bd6f6d8836 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-10-11 16:34:03 +09:00
eomsangdonandClaude Sonnet 5.5 5fdfc64d10 fix(B06): 횡단도 관매설 · 세월교 정보 글 크기 = 노폭 라벨 글 크기(267-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-11 16:33:24 +09:00
eomsangdonandClaude Sonnet 5.5 b2ad4f7e40 feat(B08): 262 상자 창 제목 이름만 · 소재지 지도 시점 표지 고정
- 거래처 · 기준점 창 제목 = 이름만 · 시점 창 = 「시점 · 중점」
- 소재지 지도에 시점 표지(고정 · 끌기 없음) · 소재지 없으면 시점 중심

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 16:33:24 +09:00
eomsangdonandClaude Opus 5.5 81950486c7 chore(plan): 10-11 요구 165(B07 측구 · 관 색 · 좌측 패널 양식 · 하이라이트 연동)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 16:28:36 +09:00
eomsangdonandClaude Opus 5.5 e2ce3b5f22 chore(plan): 10-11 요구 164(거리이정표 · 공기 옵션 · B06 상세 CAD)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 16:11:34 +09:00
eomsangdonandClaude Opus 5.5 a3be7da0af chore(plan): 261 이관(내용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 15:42:01 +09:00
eomsangdonandClaude Opus 5.5 3136eb32c6 chore(plan): 261 이관 · 사용자 결정 거리 146-6 · 146-7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 15:41:47 +09:00
eomsangdonandClaude Opus 5.5 976bd868b9 fix(test): 164 줌 도우미에 표 위 CAD 칸 흉내 더함 (260 마무리)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 15:40:20 +09:00
eomsangdon 254e3c0c6d Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-11 15:38:07 +09:00
eomsangdonandClaude Opus 5.5 2bf429ec98 feat(b06): 상세 누름 판정 거리로 — 보통 배율 얇은 띠 = 영역 · 선 가까이 = 선 (261-3 걸음 1)
누름 = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 선 4 px 안 = 선 · 영역 안이면 맞은편 선까지 ⅓ 안이어야 선.
선 누름이 표 띠 누름보다 앞섬(stopImmediatePropagation) · 판정 선 굵기 10 → 8 px(걸침 표시만).
시험 test_261_3_detail_pick(견본 · 실측 4+15 그림) · 233-2 헬퍼 누름 꼴 맞춤.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 15:37:19 +09:00
eomsangdonandClaude Opus 5.5 c55f614d50 feat(b06): 상세 영역 — 벽 · 집수정 · 본체 둘레를 구조물 선으로 (261-3 걸음 2)
설계선 끝 ~ 깎기 비탈 밑을 집수정 둘레가 이어 깎기 암석(암선 ↔ 설계선) · 깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘.
배수관 측점(검증_랩탑메인 4+15) 깎기 암석 영역 18.24㎡ 생김 · 일반 깎기 측점은 그대로(그림 칠 면적과 같음).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 15:36:55 +09:00
eomsangdonandClaude Opus 5.5 b751f29b01 fix(b09): 운반공 줄 이름 = 거리이정표 항목명 그대로 · 행마다 개별 줄 (261-4)
- 「운반」 덧붙임 없앰 · 빈 항목명 = 거래처 · 그것도 없으면 묶음 이름 · 「운반」
- 자동 상자 둘째 행부터 `haul:{묶음}.n` 개별 줄(묶음 줄 바로 뒤 · 수량 빈칸)
- 시험 기대값 고침(253_1 · 251_1 · 145_3) · 새 시험 test_261_4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 15:19:06 +09:00
eomsangdonandClaude Sonnet 5.5 4737756502 fix(b08): 거리이정표 km 칸 끝수 정리 — 3자리 반올림 · 끝 0 지움 · 안 고치면 저장 m 그대로 (261-1)
- 시점 창 · 구간 창 · 다이어그램 선 위 km 칸 · 자동 값 글 = 같은 도우미(fmtKm)
- 안 고친 칸은 반올림 값으로 덮지 않음
- 시험 test_261_1 추가 · test_255_1 기대값만 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 15:17:40 +09:00
eomsangdonandClaude Sonnet 5.5 f0f35c5749 fix(b09): 공사원가계산서 직접재료비 이동 대상을 총괄 가. 순공사비계(direct)로 (261-2)
- 이동 대상 키 material 이 총괄 줄에 없어 빨강 상자가 안 뜸
- 시험 추가 - 모든 원가 줄 이동 대상이 총괄 줄 키에 있는지

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 15:16:12 +09:00
eomsangdonandClaude Opus 5.5 c8e861e90a chore(plan): 10-11 요구 163(화면 검증 흠 답)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 15:12:55 +09:00
eomsangdonandClaude Sonnet 5.5 6e53acfcac fix(b09): 설계내역서 안내 줄 · 머리 주석 단가연결 칸 램프 꼴로 (257 마무리)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 14:22:19 +09:00
eomsangdon 53e49063d4 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-11 14:19:38 +09:00
eomsangdonandClaude Opus 5.5 9ae5ee1439 chore(plan): 253~260 이관(요구 162 묶음 끝)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 14:18:47 +09:00
eomsangdon 2f93c84bb7 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-11 14:17:37 +09:00
eomsangdonandClaude Opus 5.5 96c9eaed37 feat(b07): 안 연 측점 횡단배수 미리 넣음 — 파이썬 짝 (260-4)
- 새 B07_DesignDetail_Engine_Cad_CulvertNote = TS culvertNoteEntities · stationNoLabel 짝(두 파일 머리 표시)
- 횡단 장 만들 때 CAD 칸 없음 · 옛 글 없음 · 배수관 주인 측점 = 정본 pipe_points 로 미리 넣음 칸을 얹은 사본
- 지운 칸(빈 엔티티) · 옛 글 · 설계 없음 · 이웃 측점 = 그대로
- 시험 test_260_note_cad 거울 9쌍(같은 입력 → 같은 엔티티) · 정본 읽기 · B07 그림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 14:16:46 +09:00
eomsangdon 83735340ab Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into sub_laptop_6 2026-10-11 14:12:41 +09:00
eomsangdon 75bfe784cb Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-11 14:12:33 +09:00
eomsangdonandClaude Opus 5.5 41f68b8178 feat(B08): 거리이정표 상자 모달 제목 · 상자 안 장소 그림(254-3)
- 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 항목 정보 없음)
- 장소 그림(256-1 place_images) = 상자 글 아래 132 × 88 · 그림 없으면 자리 없음 · 키 바뀔 때만 다시 받음
- 시험 = test_254_3 새로 · 244_6 · 244_10 node 입력 = stdin(명령 줄 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 14:12:24 +09:00
eomsangdonandClaude Opus 5.5 2c79ff9046 feat(b08): 거리이정표 경로 그림 = 전체 경로(거래처 → 기준점 사슬 → 시점) · 저장 뒤 지문 바뀐 경로만 캡처 (256-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 14:11:39 +09:00
eomsangdon 6c7fc62e08 Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_1 2026-10-11 14:11:07 +09:00
eomsangdon 0e411abdf3 Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-11 14:11:06 +09:00
eomsangdon 8052142cf8 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-11 14:11:03 +09:00
eomsangdonandClaude Opus 5.5 cb2db4ff49 feat(b09): 운반 단가 모달 · 로직 미리보기 길 (253-3)
- POST /api/projects/{id}/estimation/logic-preview — 로직 한 산근을 프로젝트 단가로 풀어 계산 절차 글 · 단가(저장 안 함)
- 「운반 단가」 모달 — 단가산출 이름 · [변경] · [다른 종류] · 옵션 값(로직 입력 + 사이클 입력 · 표 고르기 CC000054~058) · 계산 절차(사이클 식 + 미리보기)
- 여는 곳 = B08 거리이정표 표 우클릭 [단가연결] · B09 설계내역서 운반공 줄 단가연결 · 운반 항목에 거리(dist)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 14:10:38 +09:00
eomsangdonandClaude Opus 5.5 6dd6ced454 feat(b06): 상세 닫힌 영역 고름 · 면적 · 둘레 · 표 칸 → 그래프 · 표 강조 바깥 한 줄 (259-2 · 259-3)
- 나눈 조각으로 면 찾기(열린 영역 제외) · 영역 안 누름 = 고름 · 채움 강조 · 면적 · 둘레
- 선 고름 경사비율 · 닫힌 선 둘레 · 띠 고름 값 글
- 표 칸 누름 = 저장된 조각 그래프 고름 · 띠 강조 + 값 글
- 고른 칸 묶음 바깥 한 줄(겹선 없음) · 줄 물들임 없앰 · 합친 묶음 이름 칸 키 맞춤
- 259-2 · 259-3 이 같은 파일(상세 표 배선)이라 한 커밋

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 14:10:35 +09:00
eomsangdonandClaude Opus 5.5 a00bf2786a feat(b07): 횡단도 표 위 CAD 칸 배치 · 없으면 옛 글 줄 (260-3)
- design.structure_note_cad 엔티티 = 범위 왼쪽 위를 표 왼쪽 · 윗변에 맞춤 · 표 층 · 배율 따름
- 표 · 테두리 · 시트 칸 높이 = 범위 높이 · 칸이 비면 아무것도 안 놓음 · 칸 없으면 옛 글 줄
- 시험 test_260_note_cad B07 몫

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 14:10:20 +09:00
eomsangdon 8ced94e044 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3 2026-10-11 14:10:07 +09:00
eomsangdon 3474e55658 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-11 14:10:05 +09:00
eomsangdon b3325e8e24 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1 2026-10-11 14:10:03 +09:00
eomsangdonandClaude Sonnet 5.5 16854e831e feat(b09): 자재단가대비표 적용 단가 초록 테두리 · 우클릭 적용 단가 바꾸기 (257-3)
- 고른 출처 단가 칸 = 바탕 색 대신 초록 테두리(안쪽 상자 그림자)
- 적용 단가 칸 · 고른 출처 단가 칸 우클릭 = 「적용 단가 바꾸기」 → 출처 목록(왼쪽 누름 메뉴와 같은 항목)
- 칸 우클릭 메뉴 한 자리(TabTable cellMenu · 쉘 배선)
- 시험 test_257_3 · test_183_3 기대값

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 14:09:01 +09:00
eomsangdonandClaude Opus 5.5 53a7b14f94 feat(b08): 거리이정표 거래처 · 기준점 모달 — 칸 이름 = 내용 글 크기 · 소재지 머리 줄 지움 · 기준점 이름 한 줄 · 소재지 위 가로선 · 장소 그림 캡처 부름 (255-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 14:08:59 +09:00
eomsangdon f0febb0d0f Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-11 14:08:13 +09:00
eomsangdonandClaude Opus 5.5 29803ada06 feat(b06): 상세 모달 표 위 빈 CAD 칸 · 횡단배수 미리 넣음 (260-1 · 260-2)
- 새 칸 design.structure_note_cad — 스키마 · 재계산 남김 · 세션 초안 structure-note-cads · [저장] patch
- 상세 모달 글 상자 → 빈 CAD(cad_host bare · 높이 고정 · 닫으면 destroy · 고칠 때마다 초안)
- 처음 열 때 칸이 없으면 옛 글 줄마다 Text · 없으면 횡단배수 주인 측점만 미리 넣음
- 시험 test_260_note_cad · helper_260_note_cad

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 14:07:38 +09:00
eomsangdon 5f31458967 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-11 14:07:31 +09:00
eomsangdonandClaude Opus 5.5 2584325b16 feat(B08): 거리이정표 다이어그램 — 기준점 연결 점 · 주소 줄바꿈 · 선 글 두 줄 · 거리 비례 선 길이(254-2)
- 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림)
- 기준점 상자 주소 · 주소 줄바꿈(최대 3줄) + 상자 높이 = 내용 · 항목 글 13px
- 선 글 = 구간마다 두 줄(도로 구분 / km) · 처음 자리 선 길이 = 100 + 90·log10(1 + km)(100 ~ 320)
- 밀어 냄 = 놓인 상자 · 놓인 선 둘 다 피함
- 시험 = test_254_2 새로 · 244 · 250 기대값(두 줄 · 짧아진 선) · 244_9 node 입력 = stdin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 14:07:14 +09:00
eomsangdon 2f718bc8df Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1 2026-10-11 14:06:10 +09:00
eomsangdon 4dec149964 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-11 14:06:07 +09:00
eomsangdon b6b1201800 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1 2026-10-11 14:05:58 +09:00
eomsangdonandClaude Opus 5.5 5c1eeb759b feat(b08): 거리이정표 경로 탭 — 시점 끌기 = 길찾기 덮개(site.start_pin) · 닿는 구간 모두 다시 길찾기 · 열 때 사슬 구간 모두 · 실패 안내 (255-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 14:05:23 +09:00
eomsangdonandClaude Sonnet 5.5 f468a3a3fa feat(b09): 이동해 온 줄 빨강 모든 탭 · 비고 빈 줄 지움 · 총공사비 아래 고정 줄 (257-2)
- 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(줄 누름 · Esc · 방향키로 풂)
- 설계내역서 비고 「빈 줄 N」 지움(Py · TS 짝 · 「(제외금액)」 둠)
- 총괄설계내역서 · 공사원가계산서 총공사비 = 템플릿 아래 고정 줄(줄 이동 누름 빠짐)
- 시험 test_257_2 · 기대값 고침(145_3 · 145_4 · 182 · 185_1 · 242_1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 14:05:02 +09:00
eomsangdon 560e71cc71 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-10-11 14:04:44 +09:00
eomsangdon 8c6b0401a4 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-11 14:04:41 +09:00
eomsangdonandClaude Opus 5.5 4c7cb49aa7 merge: dev(254-1 여러 기준점) 합침 — 장소 그림 상자 id = bases id (256-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 14:04:14 +09:00
eomsangdonandClaude Opus 5.5 01bca00e3c feat(b08): 운반 사이클시간 짝 · 로직 입력 거리 채움 (253-2)
- B08_Quantity_Haul_Cycle.py · .ts 짝 — 건설품셈 8-2-8 덤프트럭 · 거창 25 단가산출 1308~1347행 재현(t1 5.33 · t2 101.23 · Cm 109.78)
- 로직 입력 이름 채움 Cm · 사이클시간 · t2 · 운반거리 · 덮은 칸(own) 그대로
- B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움 · 덮개 ref 에 cycle · own 남김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 14:03:54 +09:00
eomsangdon 3290b736e5 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-11 14:03:00 +09:00
eomsangdon 29ce206496 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-10-11 14:02:19 +09:00
eomsangdonandClaude Opus 5.5 eaf9337638 fix(b06): 상세 고름 — 공용 팬 누름마다 moved 풂 · 암 경계선 나눔 · 고름 (259-1)
- 가운데 단추 팬 뒤 왼쪽 누름 click 삼킴 없앰(거리이정표 다이어그램 같은 줄)
- 암 경계선 조각 고름 · 서버 pick_lines rock 짝 · 스키마 · 타입 rock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 14:02:18 +09:00
eomsangdon f4e2fc0ba4 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4 2026-10-11 14:01:55 +09:00
eomsangdon 7c7888235e Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_4 2026-10-11 14:01:54 +09:00
eomsangdon 910dc8d13b Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-10-11 14:01:52 +09:00
eomsangdonandClaude Opus 5.5 0b5b54c168 merge(dev): 254-1 여러 기준점 받기 — 255-1 시점 모달 km 시험과 충돌 풂(양쪽 살림)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 14:01:29 +09:00
eomsangdonandClaude Opus 5.5 8752bb0d1a feat(b08): 거리이정표 장소 그림 영구 저장 — place/<상자 id>.png · 저장 뒤 바뀐 상자만 캡처 · place_images (256-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 14:01:22 +09:00
eomsangdon 769ff14f20 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1 2026-10-11 14:00:29 +09:00
eomsangdon 7444d0c882 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-10-11 14:00:28 +09:00
eomsangdonandClaude Sonnet 5.5 b342f8f784 feat(b09): 위 탭 설계내역 한 묶음 · 설계내역서 단가연결 칸 램프 (257-1)
- 위 탭 총괄설계내역서 · 설계내역서 = 「설계내역」 한 묶음(총괄 · 설계내역서)
- 단가연결 칸 = 램프(초록 연결 · 노랑 없음) · 풍선 = 글 · 머리 「단가↵연결」 · 폭 44
- 시험 test_257_1 · test_181_1 · test_229_1 기대값

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 14:00:21 +09:00
eomsangdon bd7fe3c679 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-11 13:59:23 +09:00
eomsangdonandClaude Opus 5.5 a1c033c3cb feat(b09): 운반공 줄 = 거리이정표 행 1:1 (253-1)
- 자동 상자 = 옛 키 · 사용자 상자 = haul:r-{id} · 둘째 행 haul:r-{id}.{n}
- 이름 = 항목 + 운반 · 수량 빈칸 · 연결 = 경로 ref(설계내역서 덮개가 이김)
- B09 가 거리이정표 표(compute_milestone)를 읽어 행 · 거리(L1 포장 · L2 비포장)를 셈 덧값으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 13:59:10 +09:00
eomsangdonandClaude Sonnet 5.5 c2d3b8ccd9 feat(table): 표 폭 CSS 변수 · 고칠 칸 옅은 테두리 되살림 · 구조물 조합 미지정 회색 (258-1 · 258-2 · 258-3)
- 템플릿 표 인라인 width → --dt-w · css width var(--dt-w) · 거리이정표 표 틀 폭 전체
- 고칠 수 있는 칸 td.is-can-edit outline 1px accent 28% (box-shadow 아님)
- B05 구조물 조합 미지정 = 구조물 리스트 램프 회색
- 시험 기대값 고침(243_1 · 246_1 · 177_1 · ui_data_table) · 새 시험 258_1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-11 13:58:29 +09:00
eomsangdonandClaude Opus 5.5 7fdbcc4794 feat(B08): 거리이정표 여러 기준점 — bases[] · 경로 to · 옛 base 옮김 · 사슬 셈(254-1)
- 데이터 = bases[](id · to = 시점 또는 다른 기준점 · linked) · 경로 via_base 대신 to
- 옛 꼴(base · via_base) = 읽을 때 · 받을 때 새 꼴로 옮김(migrate 짝 Py · TS · 저장 = 새 꼴)
- 행 합 = 거래처 구간 + 기준점 사슬 구간들(방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤)
- 도로 구분 = 시점에 닿는 기준점 구간 1차 · 그 밖 기준점 구간 · 거래처 → 기준점 2차
- 화면 = 기준점 여럿 등록 · 삭제(이은 상자 안 이음) · 연결 놓기 = 기준점 id · 길찾기 · 표지 사슬
- 시험 = test_254_1 새로 · 244 · 250 기대값 새 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 13:58:00 +09:00
eomsangdonandClaude Opus 5.5 24be69f325 feat(b08): 거리이정표 시점 모달 — 중점 측점 km · 설명 줄 지움 · 카카오 지도 시점 · 중점 표지 (255-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 13:58:00 +09:00
eomsangdonandClaude Opus 5.5 c172c4a462 chore(plan): 요구 162 조사 AJ · 250~252 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 13:40:02 +09:00
eomsangdonandClaude Opus 5.5 df2145e853 chore(plan): 10-11 요구 162(거리이정표 · B09 · B06 상세 · 23 묶음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 13:19:32 +09:00
eomsangdonandClaude Opus 5.5 f87831533c feat(b09): 공기 초안 — 메인 탭 공기 · 공기집계 · 공기산출 (draft 폴더 · 252)
설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:51:32 +09:00
eomsangdonandClaude Opus 5.5 f598a9a530 chore(plan): 10-11 요구 161
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:30:53 +09:00
eomsangdonandClaude Opus 5.5 8eb389eb39 feat(b08): 거리이정표 단가연결 램프 · 우클릭 단가연결 · 다이어그램 틀 높이 맞춤 (251-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:29:32 +09:00
eomsangdonandClaude Opus 5.5 433d6d204b feat(b09): 운반 줄 단가연결 = 거리이정표 연결 · 연결 줄 모음 한 곳(ref_rows) (251-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:23:01 +09:00
eomsangdonandClaude Opus 5.5 8e7a795abc feat(b09): 단가연결 모달 — 연결 하위 메뉴 다섯 → 한 창(종류 고르기 · 검색) · 이름 단가연결 (251-3 · 251-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:18:31 +09:00
eomsangdonandClaude Opus 5.5 0e6da95c95 feat(b08): 거리이정표 읽기 = 운반 행마다 상자 자동 · 모델 haul · ref (251-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:10:49 +09:00
eomsangdonandClaude Opus 5.5 5770cb4ce7 feat(b08): 운반 묶음 함수 — 총괄자재 → 운반 행 · B09 운반공 줄 같은 원천 (251-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:09:11 +09:00
eomsangdonandClaude Opus 5.5 6fa684e0fe chore(plan): 10-11 조사 AI(거리이정표 운반 행 · 단가연결)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:07:09 +09:00
eomsangdonandClaude Opus 5.5 1cd614da14 chore(plan): 10-10 요구 160
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:05:31 +09:00
eomsangdonandClaude Opus 5.5 252a49e916 fix(b08): 거리이정표 [자동 정렬] → [방위 배치] · 늘 보임 (159-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:00:26 +09:00
eomsangdonandClaude Opus 5.5 35aaed1506 chore(plan): 10-10 요구 159
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:59:31 +09:00
eomsangdonandClaude Opus 5.5 bde0b0098b chore(plan): 10-10 요구 156~158
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:41:01 +09:00
eomsangdonandClaude Opus 5.5 e6be11cdd9 feat(b08): 거리이정표 도구 왼쪽 아래 · 거래처 탭 = 길찾기 지도 + 그림 변경 · 초기화 (250-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:41:01 +09:00
eomsangdonandClaude Opus 5.5 eaadf27782 refactor(b08): 거리이정표 거래처 탭 = 그림만 — 새 창 길찾기 · 탭 지도 칸 없앰 (250-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:27:46 +09:00
eomsangdonandClaude Opus 5.5 edbce254ec chore(plan): 10-10 요구 155
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:24:53 +09:00
eomsangdonandClaude Opus 5.5 2d5d357bf3 feat(b08): 거리이정표 [요소 추가] · 선 없는 새 거래처 · 연결선 수기 입력 (250-4)
- 공사지 · 기준점 [+] 없앰 → [자동 정렬] 옆 [요소 추가]
- 등록 = 상자만(linked false) · 연결 점으로 이음 · 안 이은 거래처 = 구간 km 안 셈(파이썬 · TS 짝)
- 연결선 모달 [수기 입력] = 카카오 길찾기 연결 끊음(manual)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:24:53 +09:00
eomsangdonandClaude Opus 5.5 088bb1289c chore(plan): 10-10 요구 155
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:20:38 +09:00
eomsangdonandClaude Opus 5.5 e012a09cf2 feat(b08): 거리이정표 선 연결 = 연결 점 끌기 (250-3)
- 거래처 상자 ● 를 끌어 기준점 = 기준점 거침 · 공사지 = 시점으로 바로
- 바뀌면 그 구간 km 비움 + 길찾기 한 번 · 등록 모달 「기준점 거침」 체크 없앰

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:17:46 +09:00
eomsangdonandClaude Opus 5.5 46db8e56c3 chore(plan): 10-10 요구 154
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:14:44 +09:00
eomsangdonandClaude Opus 5.5 5c6c8a85b4 chore(plan): 10-10 요구 151
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:13:15 +09:00
eomsangdonandClaude Opus 5.5 5a249afb21 feat(b08): 거리이정표 — 카카오 지도 보기 유지 · 상자 색 · 상자 끌어 옮김 · 자동 정렬 (250-2)
- 카카오 지도 처음 한 번만 맞춤 · 고르기 · 끌기 뒤 중심 · 줌 그대로
- 공사지 강조색 · 기준점 경고색 · 거래처 성공색(테마 변수 · 밝음 · 어두움)
- 거래처 · 기준점 상자 끌어 옮김(pos 저장) · [자동 정렬]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:13:15 +09:00
eomsangdonandClaude Opus 5.5 d70b66f57e feat(b08): 거리이정표 — 수정 글 · 상자 하나 = 항목 하나 · 폭 전체 · 처음 배율 맞춤 · 지도 높이 · 도로 구분 = 선 자리 (250)
- 등록 모달 [+ 줄] · 같은 이름 합침 없앰 · 기준위치 = 항목 · 목적지 없음 · 「고침」 → 「수정」
- 다이어그램 · 표 폭 전체 · 틀 높이 고정 · 틀 비율 그림 범위 · 처음 배율 = 상자 범위 맞춤
- 카카오 지도 높이 어디서나 480px
- 도로 구분 = 선 자리(거래처 → 기준점 2차포장 · 기준점 → 시점 1차포장 · 그 밖 포장 · 비포장만 고름) · 파이썬 · TS 짝

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:08:57 +09:00
eomsangdon 0a450d47f1 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 23:57:16 +09:00
eomsangdon a870e9a3a4 sync: 임시 반영(목표와 동일 내용) 2026-10-10 23:57:15 +09:00
eomsangdonandClaude Opus 5.5 f57c18ee10 chore(plan): 10-10 요구 151
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:57:04 +09:00
eomsangdon a518afaba2 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 23:56:53 +09:00
eomsangdon 0740592b7b sync: 임시 반영(목표와 동일 내용) 2026-10-10 23:56:52 +09:00
eomsangdonandClaude Opus 5.5 21bd62b18a chore(plan): 10-10 요구 150
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:55:32 +09:00
eomsangdonandClaude Opus 5.5 84bb7a1b72 chore(plan): 10-10 요구 149
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:53:57 +09:00
eomsangdonandClaude Opus 5.5 91aa771878 chore(plan): 10-10 249장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:52:44 +09:00
eomsangdonandClaude Opus 5.5 b5cbe2c589 fix(B08): 거리이정표 자동 그림 캡처 = 자식 파이썬 프로세스(249-1)
서버(uvicorn · Windows Selector 루프) 스레드 안 Playwright 드라이버 기동 실패 해소 ·
입력 JSON stdin · 결과 JSON 한 줄 · 25초 넘김 · 잠금 그대로 · Selector 정책 시험 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 23:51:19 +09:00
eomsangdonandClaude Opus 5.5 9ac0e395af chore(plan): 10-10 요구 148
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:48:15 +09:00
eomsangdonandClaude Opus 5.5 3d45038658 chore(env): 카카오 지도 JavaScript 키 이름 추가 (KAKAO_JS_KEY)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:45:20 +09:00
eomsangdonandClaude Opus 5.5 9ac7a3fd12 chore(plan): 10-10 244 · 246~248장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:44:51 +09:00
eomsangdonandClaude Opus 5.5 6ff34a92f2 feat(B08): 거리이정표 등록 모달 항목 = 손 입력 + 분류별 제안 목록(244-14)
- 항목 칸 = 손 입력 + datalist 제안(거래처 = 견본 다섯 · 기준위치 = 기준점) · 목록 밖 글도 받음
- 분류 바꿈 = 제안만 바뀜 · 쓴 글 그대로 · 줄마다 제안 목록 따로
- 시험 test_244_14 (실제 rowField 를 node 로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 23:43:55 +09:00
eomsangdon 078e8356d0 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 23:40:26 +09:00
eomsangdonandClaude Opus 5.5 2afb1af1ca feat(B08): 거리이정표 등록 모달 2열 · 소재지 다음 지도 · 연결선 모달 길찾기 · 도로 구분 갈래 열(244-13)
- 등록 모달 한 벌(추가 · 상자 고침 · 삭제) — 분류(기준위치 · 거래처) · 항목 고르기 · 거래처 이름 · 목적지 2열 · 큰 창
- 소재지 = 주소 찾기 · 다음 지도 누름 · 표지 끌기(직접 입력 없음) · 좌표 → VWorld 주소 서버 길
- 다이어그램 [기준점 +] 없앰 · 연결선 누름 = 도로 구분 · 거리 · 다음 지도 경로 · 끝점 끌기 = 다시 길찾기
- 시점 → 중점 = 손 입력만 · 자동 그림 = 거래처 쪽 구간
- 도로 구분 = 기준점 거침 2차 · 1차 · 비포장 · 그 밖 포장 · 비포장(파이썬 · TS 짝) · 표 열 기준점 따라

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 23:36:39 +09:00
eomsangdonandClaude Opus 5.5 1cd8668d8a fix(b09): 묶음 탭 이동 빨강 = 설계내역서 규칙 — 빨강 하나 · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · 누른 줄만 고름 (248-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 23:28:49 +09:00
eomsangdon 1173d237dd Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 23:21:13 +09:00
eomsangdon d9578fedd6 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 23:21:12 +09:00
eomsangdon d449c38598 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 23:20:52 +09:00
eomsangdonandClaude Opus 5.5 06b676f914 chore(plan): 10-10 요구 147
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:20:19 +09:00
eomsangdonandClaude Opus 5.5 0b698cb1b6 feat(B08): 거리이정표 다이어그램 넉넉한 고정 바탕 · 시점 → 중점 선 · 상자 주소 · 좌표 · 여유 자리 피함(244-12)
- 바탕 = 기준점 · 공사지 둘레 거래처 자리까지 담는 고정 범위 · 넘는 상자가 있을 때만 넓힘
- 시점 → 중점 선 = 공사지 바탕 위(가림 없음) · 시점 · 중점 상자 작은 글씨 주소 · 좌표(소수 5자리)
- 중점 주소 = 지금 중점 측점(site-address ?mid_m=) · 없으면 좌표만
- 거래처 상자 = 항목 · 이름 · 주소 세 줄 · 길면 줄임표 + 호버
- 상자 사이 여유(겹치면 바깥으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · 다른 선 · 다른 글 피한 자리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 23:13:07 +09:00
eomsangdon 6df12153c5 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 23:02:08 +09:00
eomsangdonandClaude Opus 5.5 a137c177f8 chore(plan): 10-10 요구 145 · 146
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:01:42 +09:00
eomsangdon 916d610ec8 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 23:00:52 +09:00
eomsangdon baa6d10c08 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 23:00:50 +09:00
eomsangdon 767bfe8e00 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 23:00:10 +09:00
eomsangdon 21048c65fc Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 23:00:10 +09:00
eomsangdonandClaude Opus 5.5 877ea70aca chore(plan): 10-10 요구 142~144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:59:48 +09:00
eomsangdonandClaude Opus 5.5 8f96b16380 feat(b08): 거리이정표 경로 그림 = 서버 자동 캡처 · 브라우저 캡처 없앰 (244-11)
- [등록] · [길찾기] 뒤 길 선 · 표지 → 서버 헤드리스 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:59:25 +09:00
eomsangdonandClaude Opus 5.5 c91d5aa6c9 feat(b08): 구조물 집계표 모두 연결 측점 줄 초록 램프 (247-1)
- 측점 줄 시설이 모두 조합에 연결 = 측점 칸 왼쪽 초록 램프(노랑과 같은 자리 · 크기) · 호버 「모두 연결됨」
- 미연결 = 노랑 그대로 · 시설 없는 줄 · 합계 줄 = 램프 없음 · 색 = 성공 토큰(밝음 · 어두움)
- 새 시험 test_247_1 · 198_1 시험 정규식 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 22:58:50 +09:00
eomsangdonandClaude Opus 5.5 c2184625fa chore(plan): 10-10 요구 141
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:54:43 +09:00
eomsangdon a48c1a3b9b Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 22:50:35 +09:00
eomsangdonandClaude Opus 5.5 2ce1e66095 chore(plan): 10-10 요구 139 · 140
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:50:10 +09:00
eomsangdonandClaude Opus 5.5 50cc45731c feat(b08): 거리이정표 경로 지도 테일스케일 주소 안내 (244-10)
- 보안 문맥 아닌 주소(getDisplayMedia 없음) = [지도 캡처] 끔 + 까닭 한 줄 「캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요」
- SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 필요 안내 한 줄 · 사진 올리기
- 새 시험 test_244_10 · test_244_9 에 보안 문맥 navigator 가짜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:49:44 +09:00
eomsangdon 0a6f4f5ec1 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 22:47:18 +09:00
eomsangdon a7daf042b5 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 22:47:17 +09:00
eomsangdon 86f9a831f7 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 22:46:51 +09:00
eomsangdonandClaude Opus 5.5 0816595e2e feat(b08): 거리이정표 경로 지도 = 카카오 지도 JS 위 길 선 · 지도 캡처 (244-9)
- 서버 길찾기 응답에 경로 좌표 [위도, 경도] 목록(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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:34:31 +09:00
eomsangdonandClaude Opus 5.5 24f39bcb54 chore(plan): 10-10 요구 138
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:25:53 +09:00
eomsangdonandClaude Opus 5.5 2a969a4f1d chore(plan): 10-10 요구 137
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:21:33 +09:00
eomsangdonandClaude Opus 5.5 09b57398b7 feat(b08): 거리이정표 km 자동 = 카카오 길찾기 REST · 서버 단독 · 사용자 km 우선 · 도로 구분 고르기 (244-7)
- 서버 새 길 POST …/quantity/milestone/directions — 두 좌표 → 카카오 길찾기 → km 소수 1자리 · 키 없음 · 실패 = 까닭 한 줄 · 키 서버만 · 응답 원문 · 경로 좌표 안 돌려줌
- 거래처 모달 [길찾기] · [등록] 뒤 한 번 — 거래처 → 기준점 · 기준점 → 시점(기준점 없으면 거래처 → 시점) 자동 km(auto_km 만 저장)
- 사용자 km 우선 · 다르면 호버 「자동 값 ○○ km」 · 도로 구분 안 고름 = 합에 안 듦 · 표 합계 칸 「도로 구분 고르기」
- 표 합 파이썬 · TS 짝 같이 고침 · 새 시험 test_244_7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:11:36 +09:00
eomsangdonandClaude Opus 5.5 e299e1cdb8 chore(env): 카카오 길찾기 REST 키 이름 추가 (KAKAO_REST_API_KEY)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:01:47 +09:00
eomsangdonandClaude Opus 5.5 59b17f3a7d chore(plan): 10-10 요구 136
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:56:32 +09:00
eomsangdonandClaude Opus 5.5 4b7402270d fix(b08): 거리이정표 다이어그램 영역 스크롤 없앰 · 휠 = 줌만 (244-8)
- 틀 overflow hidden · max-height 없앰 — 그림 = 틀 안 맞춤(높이 48vh 넘으면 비율대로 좁힘)
- 틀 wheel 듣개(passive false · preventDefault) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 안 샘
- 새 시험 test_244_8 — 진짜 wheel 사건으로 줌 · 기본동작 막힘 판정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:51:40 +09:00
eomsangdonandClaude Opus 5.5 f103a3ad57 chore(plan): 10-10 요구 135
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:49:05 +09:00
eomsangdon 19857d9ee1 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 21:47:24 +09:00
eomsangdonandClaude Opus 5.5 4b70627682 feat(b08): 거리이정표 표 폭 = 다이어그램 폭 · 거래처 상자 항목 글 · 좌측 카드 없앰 · 팬 고침 · 거래처 글 (244-6)
- 틀 너비 = 표 너비 · 다이어그램은 그 너비로 늘음
- 거래처 상자 = 항목(윗줄) + 거래처 이름
- 좌측 패널 거리이정표 카드 없앰 — 중점 측점 · 도로는 공사지 모달 · 기준점은 기준점 모달([기준점 +] · 기준점 없앰)
- 팬 고침: 원배율에서 공용 팬 범위가 0 이던 것 — 범위 줌 · 빈 곳 왼쪽 끌기 더함(공용 panStart 선택 칸 · 기본 그대로)
- 화면 글 「업체」 → 「거래처」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:46:37 +09:00
eomsangdonandClaude Opus 5.5 dba853b87e chore(plan): 10-10 요구 134
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:43:03 +09:00
eomsangdonandClaude Opus 5.5 7964fbcf31 chore(plan): 10-10 요구 133
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:41:14 +09:00
eomsangdon d9bf515d47 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 21:41:06 +09:00
eomsangdonandClaude Opus 5.5 2c174f0b3d chore(plan): 10-10 요구 131 · 132
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:39:16 +09:00
eomsangdonandClaude Opus 5.5 ecf99aefde feat(b08): 거리이정표 기준점 방위 칸 없앰 — 주소 좌표로 자리 · 없으면 공사지 서쪽 (244-5)
- 좌측 패널 「공사지에서 본 방위」 칸 · 8방 고르기 지움 · 옛 저장 값 방위는 무시
- 기준점 모달에 「주소를 고르면 실제 방위로 놓임」 안내 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:39:16 +09:00
eomsangdon 1b84907500 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 21:37:37 +09:00
eomsangdonandClaude Opus 5.5 7be4feb1cf feat(b08): 거리이정표 다이어그램 선 글 더블클릭 = km · 도로 구분 바로 고침 (244-4 ⑥)
- 선 글 한 줄 = 그 구간(업체 · 공통 · 시점 → 중점) · 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소
- 값은 모달과 한 벌(legAt · setLeg) · 시점 → 중점 km = 중점 측점 · 표 셈 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:36:56 +09:00
eomsangdonandClaude Opus 5.5 5f2e6e18b6 feat(b08): 거리이정표 [+] 모달 · 주소 검색 재활용 · 방위 자동 · 시점 모달 · 팬 줌 · 경로 탭 = 길찾기 그림 (244-4)
- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리
- 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간)
- 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan
- 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰
- 서버 site-address(좌표 → 주소 · VWorld getAddress)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:33:53 +09:00
eomsangdon d027304804 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 21:29:04 +09:00
eomsangdonandClaude Opus 5.5 8d50aa0582 feat(B09): 설계내역서 입력 칸 테두리 되돌림 · 우클릭 「항목 추가」 하나 · 연결 이름 셋 · 「줄 삭제」 · 하위 메뉴 옆 판 (246-1 · 246-2)
- 공용 표 입력 칸 = 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 21:28:09 +09:00
eomsangdonandClaude Opus 5.5 08dd166da3 chore(plan): 10-10 요구 130
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:25:45 +09:00
eomsangdonandClaude Opus 5.5 0dce57b0aa chore(plan): 10-10 요구 130
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:24:56 +09:00
eomsangdonandClaude Opus 5.5 2402497284 chore(plan): 10-10 요구 129
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:13:08 +09:00
eomsangdonandClaude Opus 5.5 72e2c10824 chore(plan): 10-10 요구 128
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:11:15 +09:00
eomsangdon c048788909 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 20:58:36 +09:00
eomsangdonandClaude Opus 5.5 7f0417a010 chore(plan): 10-10 237~245장 기록 이관(244 빼고)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:58:15 +09:00
eomsangdon 4d298a0d23 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 20:58:08 +09:00
eomsangdon d7007591fc Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 20:58:07 +09:00
eomsangdon d63912f101 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 20:57:44 +09:00
eomsangdonandClaude Opus 5.5 924b1f61eb feat(b08): 거리이정표 다이어그램 — 공사지 · 기준점 · 업체 상자 방위 자리 · 선 km 글 · 상자 누름 · [+] 항목 추가 (244-2)
- 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체 · 선마다 「도로 구분 km」
- 업체 자리 = 기준점(없으면 공사지) 기준 지도 방위 — 경위도 방위각 › 8방 › 빈 방위 차례 · 같은 방위 나란히
- 업체 상자 누름 = 그 경로 하위 탭 · 공사지 · 기준점 [+] = 작은 창(항목 · 업체 · 목적지 · 방위) → 경로 탭

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 20:57:12 +09:00
eomsangdon 492ad3a48a Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 20:53:50 +09:00
eomsangdon 959fab65ba Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 20:53:49 +09:00
eomsangdonandClaude Opus 5.5 a52fe78751 feat(b08): 거리이정표 메인 탭 · 경로 하위 탭 추가 · 삭제 · 공사지 · 기준점 · 표 합 짝 · 그림 올리기 (244-1)
- 메인 탭 「거리이정표」 · 하위 탭 [거리이정표] 표 + 경로 탭(이름 = 업체 이름) · [+ 추가] · [삭제]
- 경로 탭 = 구간 km(더블클릭) · 도로 구분 ▾ · 표 줄(항목 · 목적지 · 비고) · 경로 그림 하나
- 좌측 패널 카드 = 공사지(중점 측점 · 기본 노선 ÷ 2) · 기준점(있음 · 이름 · 주소 · 방위)
- 표 합 = 파이썬 · TS 짝(거울 시험) · 정본 = storage B08_Quantity/milestone · [저장] 한 줄에서 PUT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 20:53:01 +09:00
eomsangdon eb3b5b4918 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-10 20:44:41 +09:00
eomsangdon 4f13829326 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-10 20:44:40 +09:00
eomsangdon 21a95f8e5a Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 20:44:28 +09:00
eomsangdonandClaude Opus 5.5 efe0eb5417 style(table): 병합 칸 = 줄 강조 밖 · 호버 · 고른 줄 · 줄무늬 안 칠하고 그 줄 나머지 칸만 (245-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 20:43:52 +09:00
eomsangdon 973ec7cb5b Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 20:43:07 +09:00
eomsangdon ffa969e311 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 20:42:38 +09:00
eomsangdon 77afadf8c7 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_8 2026-10-10 20:41:36 +09:00
eomsangdonandClaude Opus 5.5 ffd94423c6 chore(plan): 10-10 요구 120 답 · 127
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:40:16 +09:00
eomsangdon 86d86db25c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 20:38:50 +09:00
eomsangdonandClaude Sonnet 5.5 e3266a4eaa feat(table): B08 · B09 표 수기 입력 = 더블클릭 · Enter · F2 · 고칠 칸 테두리 늘 표시 (243-1)
- 공용 표: 고칠 수 있는 칸(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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 20:35:08 +09:00
eomsangdonandClaude Opus 5.5 f1f988b9f9 chore(plan): 10-10 조사 AH(거리이정표)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:31:45 +09:00
eomsangdonandClaude Opus 5.5 5c1035a5e8 fix(b09): 총괄 → 설계내역서 빨강 = 그 줄 + 하위 전부 · 고름 표시와 한때 하나 · 줄 누름 · 방향키 · Esc = 빨강 풂 (242-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 20:31:43 +09:00
eomsangdonandClaude Opus 5.5 4689576d7d chore(plan): 10-10 요구 126
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:29:45 +09:00
eomsangdonandClaude Opus 5.5 4027005193 chore(plan): 10-10 요구 125
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:26:51 +09:00
eomsangdonandClaude Sonnet 5.5 5102f775ae style(table): 입력 칸 테두리 색 = 두 테마 변수 혼합(accent 60% · canvas) (241-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 20:20:44 +09:00
eomsangdon 8576123f6d Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 20:20:03 +09:00
eomsangdonandClaude Sonnet 5.5 e8c6e35443 style(table): B08 · B09 표 입력 칸 = 채움 색 대신 칸 안쪽 테두리 (241-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 20:18:38 +09:00
eomsangdonandClaude Opus 5.5 1268179cc8 chore(plan): 10-10 요구 123
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:17:00 +09:00
eomsangdonandClaude Opus 5.5 d416ac5922 chore(plan): 10-10 요구 123
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:16:10 +09:00
eomsangdonandClaude Opus 5.5 fe34f561a0 fix(B09): 설계내역서 폴더 줄 글꼴 한 벌 · 들여쓰기 넓힘 · 끌기 놓을 자리 표시 · 데이터 점검 틈 (240 · 121 · 122)
- 레벨 고쳐 생긴 폴더 줄도 처음 폴더 줄과 같은 꼴(is-title · 크기 = 레벨)
- 들여쓰기 한 단 22px · 끌기 좌우 한 단 = 들여쓰기 폭
- 끌기 미리 보기 = 레벨 시작 점 · 「○○ 안 · 레벨 N」 · 들어갈 폴더 줄 테두리 · 끄는 줄 흐림
- 데이터 점검 [업데이트] 위 틈 = 카드 왼쪽 여유

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:13:30 +09:00
eomsangdon 58b35b3ab9 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 20:06:04 +09:00
eomsangdonandClaude Opus 5.5 dd360094b3 feat(b09): 총괄설계내역서 맨 위 설계내역서 레벨 1 · 2 집계 · 누름 = 설계내역서 그 줄 빨강 (239-1)
- 총괄 표 맨 위 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목 포함) · 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계
- 줄 누름 = 설계내역서 탭 그 줄로 · 이동해 온 줄 = is-pick 빨강 외곽선(고른 줄 상자와 따로)
- 새 시험 test_239_1 · 200_2 셸 배선 문구 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 20:05:05 +09:00
eomsangdonandClaude Sonnet 5.5 b099add036 feat(spreadsheet): 칸 맞춤 기본 = 가로 가운데 · 세로 가운데 (237-1)
안 준 칸(가로 없음 · 일반 · 세로 없음)의 칸 · 도구줄 · 서식 창 · 편집창 · 영역 표 · 복사 HTML 기본을 가운데로 · 준 칸은 그대로 · 새 시험 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 20:03:39 +09:00
eomsangdonandClaude Opus 5.5 f262007f56 fix(profile): B05 [저장] 구조물 측점 목록 = 서버 측점 규칙 · 소단 · 옹벽 · 흙막이 · 기슭막이 측점 지움 막음 (238-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 20:03:02 +09:00
eomsangdonandClaude Opus 5.5 a764cd75cc chore(plan): 10-10 요구 122
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:01:56 +09:00
eomsangdonandClaude Opus 5.5 4e5bbac50d chore(plan): 10-10 요구 121
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:00:52 +09:00
eomsangdonandClaude Opus 5.5 be1c707da0 chore(plan): 10-10 요구 114~120
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 19:56:53 +09:00
eomsangdon 4dd8dcf30f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 19:19:59 +09:00
eomsangdonandClaude Opus 5.5 493f861e06 chore(plan): 10-10 229~236장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 19:19:31 +09:00
eomsangdon 07db7e2023 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 19:19:14 +09:00
eomsangdonandClaude Opus 5.5 78bab9eeda fix(design): 종단 · 유토곡선 · 횡단 그리드 빈 곳 누름도 고름 안 풂 (233-4)
- 빈 곳 누름 해제 듣개 뗌 — 3D 와 같은 한 규칙
- 풀기는 Esc · 같은 것 다시 누름 한 길 · 도구 중 빈 곳 누름은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 19:18:19 +09:00
eomsangdon f98480e2f0 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-10 19:15:30 +09:00
eomsangdon 68e3408068 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-10 19:15:28 +09:00
eomsangdon 0c0d7f7f7f Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 19:15:07 +09:00
eomsangdon c2afe5f640 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 19:14:12 +09:00
eomsangdonandClaude Sonnet 5.5 3cb67d1762 fix(B09): 설계내역서 레벨 안내 글 짧은 한 줄 · 잘릴 때만 호버 · 시험 기대값 정리(236-2)
- 레벨 안내 글에서 「=」 꼴 설명 · 긴 호버 설명 지움
- test_181_2 danger 색 수 5(204-1 끌기 못 놓음 점선) · test_200_2 안내 글 기대값

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 19:13:43 +09:00
eomsangdonandClaude Opus 5.5 b3c1762229 feat(design): 목록 · 폼 · 배수유역도 강조를 고름 한 칸에서 · 풀기 = Esc 한 길 (233-3)
- 종단 보기 구독이 배수유역도 관 마커 · 유역 · 마름모까지 알림 없이 칠함 · 재계산 뒤 다시 얹음
- 지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에 적고 칠하기는 구독 몫
- 목록 관 줄 폭 PIPE_MATCH_M · 조금 옮겨진 관도 고름 따라감 · 저장 뒤 목록 주입이 관 폼 안 닫음
- design_pick_escape — Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 19:13:39 +09:00
eomsangdon 7643c2d548 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_7 2026-10-10 19:13:13 +09:00
eomsangdon 2df5054fd3 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-10 19:13:12 +09:00
eomsangdon 9bf96b4442 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 19:12:59 +09:00
eomsangdon b6d8983dd7 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts
2026-10-10 19:12:04 +09:00
eomsangdonandClaude Opus 5.5 e6315f8581 test(B05·M02): 빨강 둘 정리 — 213-1 고정 사본 · 모달 카드 시험 낡은 기대값(234-2)
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 19:10:37 +09:00
eomsangdon 5a079fdbf4 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 19:09:56 +09:00
eomsangdonandClaude Sonnet 5.5 88dd941d73 fix(B08·B09): 표 아래 안내 글 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0(236-1)
- B09 표 탭 안내 글 = 쓸모 있는 한 줄(lineHint) · 묶음 탭과 같은 줄임표
- B08 표 아래 안내 · 경고 줄 = footNote 한 줄 · 서버 알림은 한 줄로 이어 붙임
- 새 시험 test_236_1 · 창 높이별 창 끝까지 셈 헬퍼

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 19:09:56 +09:00
eomsangdonandClaude Opus 5.5 3e70362b14 feat(B09): 설계내역서 접히지 않는 트리 — 폴더 줄 수식 · 파일 줄 입력 · 안내선 · 더블클릭 입력 · 고른 줄 상자(235-1)
- 공용 표 editOnDouble — 고칠 칸 = 글 · 더블클릭 · Enter · F2 = 그 칸만 글 상자 · Enter · 초점 떠남 = 입력 끝
- 공용 표 level.tree — ├ └ │ 안내선 + 폴더 · 파일 아이콘(공용 나무와 한 벌 TREE_ICON)
- 설계내역서 — 폴더 줄 금액 = 자손 합 수식 풍선 · 고른 줄 = accent 상자 테두리
- test_235_1 새 시험 · test_228_1 들여쓰기 식 개수 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 19:09:00 +09:00
eomsangdonandClaude Opus 5.5 785307dcd0 chore(plan): 10-10 조사 AG(구조물 목록 대 정의)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 19:07:29 +09:00
eomsangdon 710272f6a2 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 19:07:02 +09:00
eomsangdonandClaude Opus 5.5 994b91636c feat(B05·B06): 구조물 · 관 기준 측점 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추(234-1)
- 맞춤 규칙 한 벌 — 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 19:05:52 +09:00
eomsangdonandClaude Opus 5.5 a2b3217d80 feat(design): 3D · 횡단 카드 · [상세] 강조를 고름 한 칸에서 파생 (233-2)
- 종단 보기 구독이 3D 측점 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이 칠하기만)
- B06_Section_UI_Page_Pick_Sync — 횡단 보기 구독 · 다른 곳이 바꾼 값은 넘김 적용 길로 다시 세움
- 풀기 한 길 — 카드 · 폼 관 · 범위 테두리 · 포장층 · 형상 모두 끔(카드 다시 누름도 이 길)
- [상세] 선 조각 — 다시 그려도 같은 자리 조각 고름 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 19:03:14 +09:00
eomsangdonandClaude Opus 5.5 760972bb9a chore(plan): 10-10 요구 113
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:59:17 +09:00
eomsangdon 6cc3edc153 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 18:58:04 +09:00
eomsangdonandClaude Opus 5.5 21c1428772 chore(plan): 10-10 요구 111~112
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:56:14 +09:00
eomsangdonandClaude Opus 5.5 aa2c2a870f feat(design): 고름 한 칸 구독 · 종단 강조를 그 값에서 파생 (233-1)
- design_pick_profile — 고름 한 칸 구독 → 종단 측점선 · 알약 · 띠를 값으로 다시 세움
- 관 · 구조물 목록이 바뀐 뒤 · 폼이 닫힌 뒤(저장 뒤 관 폼 등)에도 같은 값으로 다시 얹음
- 관 알약 id 는 그릴 때마다 지금 관 목록에서 찾음(관이 옮겨져도 이어짐)
- 숨은 보기는 안 그림 · 보기를 떠나면 구독 끊음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 18:55:50 +09:00
eomsangdonandClaude Opus 5.5 b521328bf9 fix(b05): 3D 띠 누름 뒤 강조 유지 · 숨은 핀 · 꺼 둔 측점선 누름 안 가로챔 (232-1)
- 마커 · 띠에 맞은 누름은 3D 부재 고름이 빈 곳 누름으로 읽지 않음(pressedMarker)
- 3D 빈 곳 누름 = 고름 안 풂 · 같은 띠 · 같은 부재 다시 누름 = 풂
- 핀 · 라벨 = 표시만(레이캐스트 뺌) · 숨은 측점선은 누름 대상에서 뺌
- 3D 누름 길 통째 시험(three + 가짜 캔버스)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 18:48:57 +09:00
eomsangdonandClaude Opus 5.5 2b9e2476a9 chore(plan): 10-10 요구 109~110
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:44:26 +09:00
eomsangdon 6a86bd0a2b Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-10 18:43:57 +09:00
eomsangdon d34f6b85ca Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 18:43:25 +09:00
eomsangdonandClaude Opus 5.5 2a272e40c4 chore(plan): 10-10 요구 108
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:42:19 +09:00
eomsangdonandClaude Opus 5.5 4f1803b733 chore(plan): 10-10 요구 106~107 · 조사 AF(강조 체계 · 소수 측점)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:28:28 +09:00
eomsangdon 99b93585ad Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-10 18:01:12 +09:00
eomsangdon 595542f8b2 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-10 18:01:11 +09:00
eomsangdon a22deacf6f Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 18:00:58 +09:00
eomsangdonandClaude Opus 5.5 49f9dc73f8 test(B05): 구조물 폼 요약 시험 헬퍼 — @util 별칭 · 가짜 요소 보탬 · 숨긴 길이 기대값 54-26 맞춤(230-3)
헬퍼가 @util/common_util_cross_structure_areas 를 못 찾던 것 — common_util 폴더로 별칭.
가짜 요소에 style.setProperty · select.options · Option 보탬.
숨긴 길이는 칸 없이 값만 쥠(54-26) — 기대값 맞추고 그린 칸 확인 추가 · 코드 안 고침.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 18:00:22 +09:00
eomsangdon 63d4b3da19 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 17:59:31 +09:00
eomsangdon 95a4d29bd9 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 17:59:29 +09:00
eomsangdon 4db7931175 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 17:59:04 +09:00
eomsangdon 7e9563a2c9 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 17:57:47 +09:00
eomsangdonandClaude Opus 5.5 a8dc1bd7e1 chore(plan): 10-10 요구 105
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:57:16 +09:00
eomsangdonandClaude Opus 5.5 d35743870d fix(B06): 목록 고름도 늘 고름 기록 — 같은 카드 안 구조물도 오감 · 새로 고침 뒤 그 폼(231-2)
- 목록 · 알약 · 형상 고름은 노면공이 아니어도 그 구조물 id 를 고름 한 칸에 적음 · 측점 고름이 연 것은 측점만(189-1)
- 새 시험 test_231_2 · test_231_1 · test_177_4 기대값 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 17:52:53 +09:00
eomsangdonandClaude Opus 5.5 3ec5779169 fix(B05): 종단 새 창 최대화 때 편집 · 다시 그리기 — 놓인 창 화면 틀 · 시계 공용 함수로 통일(230-2)
공용 common_util_view_window(viewOf · frameOf) 신설.
design_profile rebuild · 접기 · render, Heights 높이 맞춤, 표 · 유토곡선 크기 끌기, 유토곡선 세로창 미끄러짐,
끌기 뒤 그리기, ▲▼ · 방향키 길게 누르기, 배수유역도, 구조물 말풍선 — 메인 창 전역 틀 · 시계 대신 요소가 놓인 창 것.
새 시험 test_230_2_popout_frames(메인 창 틀 멈춤 흉내 · 새 창 패널 코드 훑기).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 17:52:01 +09:00
eomsangdon 64c9470f95 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 17:50:13 +09:00
eomsangdonandClaude Opus 5.5 42673505e7 fix(B06): 측점만 남은 고름의 노면공 범위 강조 막음 · B05 3D 같은 구멍 · 카드 옮겨 감 · 관 넘김 카드(231-1)
- B06 폼 다시 세움(load) 의 selectAt 에 노면공 거름(coverable) · 측점 고름이 연 폼은 카드 그대로 · 범위 테두리 없음
- B05 3D 부재 고름 resolveAt · selectAt 에 같은 거름 — 노면공 id 를 고름 한 칸에 안 적음
- 넘김 되얹기 카드 = 찾는 거리 안 가장 가까운 카드(관 80.17 이 카드 80 으로 안 감)
- 새 시험 test_231_1 · test_106 배선 문자열 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 17:49:12 +09:00
eomsangdonandClaude Opus 5.5 057cbaa5b5 chore(plan): 10-10 요구 104
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:48:39 +09:00
eomsangdon edc98bad57 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 17:44:06 +09:00
eomsangdon 33a74ef46e Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1 2026-10-10 17:43:35 +09:00
eomsangdonandClaude Opus 5.5 dd483a8fbd fix(B05): 종단 새 창 최대화 때 휠 이동 — 스크롤러가 놓인 창의 화면 틀 · 시계 사용(230-1)
메인 창 requestAnimationFrame 은 새 창 최대화로 메인 창이 가려지면 멈춤 · 휠은 막히고 이동 0.
휠 이동 · 끌기 뒤 클릭 삼킴 · 글자 선택 풀기 · 스크롤 뒤 다시 그리기 시계를 ownerDocument.defaultView 로.
새 시험 test_230_1_popout_wheel(가려진 메인 창 · 새 창 보통 · 최대화 · 원래 창).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 17:42:52 +09:00
eomsangdon 315accf9ca feat(B09): 설계내역서 레벨 단추 보조 탭 줄로 이동 · 표 위 도구 줄 비움(229-1) 2026-10-10 17:42:26 +09:00
eomsangdonandClaude Opus 5.5 dc027cd738 chore(plan): 10-10 요구 101~103
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:37:55 +09:00
eomsangdonandClaude Opus 5.5 1357c859c8 chore(plan): 10-10 228장(표 레벨 글꼴) 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:37:30 +09:00
eomsangdon ba58637ca2 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 17:37:04 +09:00
eomsangdonandClaude Opus 5.5 833e4146a9 feat(table): 표 레벨 글꼴 자동 — 레벨 크기 · 묶음 굵게 · 설계내역서 · 보기 전용 표 같은 규칙(228-1)
- 공용 표 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 17:36:07 +09:00
eomsangdonandClaude Opus 5.5 ac8dc77c00 chore(plan): 10-10 조사 AD(노면공 범위 고름)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:32:39 +09:00
eomsangdonandClaude Opus 5.5 ef59f49896 chore(plan): 10-10 조사 AE(표 레벨 외부 사례)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:29:58 +09:00
eomsangdonandClaude Opus 5.5 a23ccc2b04 chore(plan): 10-10 요구 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:25:34 +09:00
eomsangdonandClaude Opus 5.5 350853997d chore(plan): 10-10 요구 99
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:14:58 +09:00
eomsangdonandClaude Opus 5.5 78ffc8743c chore(plan): 10-10 요구 98
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:11:07 +09:00
eomsangdon 4e7704998a Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 16:53:45 +09:00
eomsangdon aa8f846446 sync: 임시 반영(목표와 동일 내용) 2026-10-10 16:53:40 +09:00
eomsangdon 0e85be7e06 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 16:53:08 +09:00
eomsangdon efb5f246d9 sync: 임시 반영(목표와 동일 내용) 2026-10-10 16:53:03 +09:00
eomsangdon 150260c55e Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 16:49:17 +09:00
eomsangdonandClaude Opus 5.5 3f5bbe509e chore(plan): 10-10 221~227장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:48:47 +09:00
eomsangdon 983638473b Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-10 16:48:33 +09:00
eomsangdon fa23d807c2 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-10 16:48:31 +09:00
eomsangdon 67aae7e7d6 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 16:48:17 +09:00
eomsangdonandClaude Opus 5.5 48cff44a0c chore(plan): 10-10 조사 AC(로직 기본값)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:47:24 +09:00
eomsangdonandClaude Sonnet 5.5 265c23d5f2 fix(test): 관 보호공 기본 자리 유입 d 기대값 0.6 → 0.4 · 윗면 노견 − 0.3 (211-1 벽 0.2 높아진 당연한 결과)(225-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 16:47:04 +09:00
eomsangdon 9e5d60fc78 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-10 16:45:31 +09:00
eomsangdon abda7002fa Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-10 16:44:26 +09:00
eomsangdonandClaude Opus 5.5 c98706be06 feat(b06): 관 정보 글 노폭 라벨 위 · 관보호공 0.1 m 이동 · 관 길이 표시 「8 m (7.6)」 · B08 관연장 사사오입 (225-1)
- 관 길이 = 유출 벽 자리에서 셈(0.1 m) · 정수 올림 연장 · 길이 맞춤 이동(_Culvert_Fit) 없앰
- 관보호공 ◀▶▲▼ · 끌기 0.1 m 걸음(세월교 흙막이만 1 m) · ◀▶ 때 d 빈 칸은 지금 자리로 묶음
- 관 · 세월교 정보 글 = 노폭 · 노견 라벨 바로 위 줄 · 카드 · 좌측 패널 「정수 m (소수 첫째)」
- 구조물 집계(M02_Table_Fill) 관연장 = 사사오입 정수 · TS pipeLengthWhole 짝 · 새 시험 test_225_1
- 정수 맞춤 없앰에 맞춰 139_6 · 186_1 · 108 · 79 기대값 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 16:43:08 +09:00
eomsangdon 61419af563 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-10 16:40:45 +09:00
eomsangdon 1f2ecf42b1 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-10 16:40:43 +09:00
eomsangdon 9941b976dc Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 16:40:06 +09:00
eomsangdonandClaude Opus 5.5 9a850212d5 fix(m02): 일위대가 별도계상 자재 셈에서 뺌 · 원문 같은 책 절 모두 · 단위 까닭 시험(222-1)
- 로직 자재 별도계상(빈 칸 = 기본) = 기준에서 빼고 일위대가 상세 줄에서 지움 · 자재목록엔 남김
- 사용자 자재 별도계상 = 합에서 뺌(파이썬 · TS settle 짝 · 거울 시험)
- 사전조합 안 줄 · 프로젝트 사본 셈도 같은 길
- 별도계상 바꾸면 다시 셈(로직 자재 = 서버 · 사용자 자재 = 짝 셈)
- 원문 출처 = 로직 출처 + 호표 · 입력 출처 같은 책 절(산림품셈 12-1-2 · 12-1-3)
- 기존 시험 셋 = 직접계산 표시 · 대조용 apart 끔

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 16:39:00 +09:00
eomsangdon b7a434cf1d Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-10 16:36:00 +09:00
eomsangdon e54d5bbb94 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 16:35:48 +09:00
eomsangdon 2202b6f28c Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_8 2026-10-10 16:35:31 +09:00
eomsangdon a49454325c Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 16:34:57 +09:00
eomsangdonandClaude Sonnet 5.5 ab71a2e06a fix(test): 합친 뒤 빨강 둘 정리 — M02 카드 틀 Ctrl+S 기대값 · B09 안내 글 「더하기」→「추가」 (224-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 16:34:52 +09:00
eomsangdon cc02d1da0d Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_5 2026-10-10 16:34:07 +09:00
eomsangdonandClaude Opus 5.5 66e84b56bf fix(cad): 줌아웃 느려짐 — 공간 색인 칸 범위 자름 · 모눈을 장면 비트맵에서 뺌(227-1)
- 까닭: 화면 상자 조회가 엔티티 칸(64x64) 밖 빈 칸까지 돎 — 0.01배에서 한 화면 19.3 초
- 칸 범위를 0~64 로 잘라 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms
- 모눈은 화면 clear() 만 그림 — 모눈 켜도 정지 화면에서 장면 다시 그리지 않음
- 측정 · 회귀 시험 zoom-out-perf.test.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 16:33:00 +09:00
eomsangdon 8c7a3379cc Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-10 16:32:15 +09:00
eomsangdon 233606048b Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_6 2026-10-10 16:31:43 +09:00
eomsangdon b384715d91 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-10 16:31:42 +09:00
eomsangdonandClaude Sonnet 5.5 91e85cfd95 feat(ui): B05/B06 구조물 조합 고르기 모달 상단에 M02 구조물집계표 보기 전용 표 (224-1)
- 고른 항목이 속한 층 · 주인의 구조물집계표를 M02 와 같은 표 부품 · 줄 글 도우미(numberLabels) · 숨김 열 규칙(comboView)으로 보임
- 같은 층 다른 조합 = 열만 옮김 · 다른 층 = 표 바뀜 · 편집 화면에서는 숨김
- 새 시험 test_224_1 · test_m02_combo_table 단정 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-10 16:31:18 +09:00
eomsangdon fcf4720066 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-10 16:31:09 +09:00
eomsangdon db03913b90 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 16:30:30 +09:00
eomsangdonandClaude Opus 5.5 02e0727170 feat(cad): 축척 기준점 지정 뒤 숫자 배율 입력 · Enter 적용 · Esc 취소 · 되돌리기 한 칸 · 명령 줄 안내 한글 (226-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-10 16:29:39 +09:00
eomsangdonandClaude Opus 5.5 8379836748 fix(ui): 돋보기 스크롤 따라감 — 다시 켤 때 원을 먼저 보여 스크롤 옮김이 먹게 · 켠 채 스크롤은 rAF 한 번에 칸 · 페이지 위치 맞춤 · 0.5 초 뒤 다시 뜸 (221-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 16:29:34 +09:00
eomsangdon a03174b1bf Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 16:28:46 +09:00
eomsangdonandClaude Opus 5.5 74c7815475 chore(plan): 10-10 요구 97
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:27:30 +09:00
eomsangdonandClaude Opus 5.5 a5f5cac739 feat(ui): 돋보기 CAD 영역 빼기 — CAD 위 안 켬 · 켜진 채 들어가면 숨김 · 나오면 배율 그대로 · CAD 위 휠 그대로 · 복제에서 CAD 빈칸 (221-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 16:26:31 +09:00
eomsangdonandClaude Opus 5.5 9085a51056 chore(plan): 10-10 요구 96
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:23:13 +09:00
eomsangdonandClaude Opus 5.5 3af0c45306 chore(plan): 10-10 요구 86~95(넷째 붙임)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:22:26 +09:00
eomsangdon 3b976113c3 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 16:20:53 +09:00
eomsangdonandClaude Opus 5.5 ff19a0bc55 chore(plan): 10-10 요구 85
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:20:22 +09:00
eomsangdon 2c874a1066 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 16:20:14 +09:00
eomsangdonandClaude Opus 5.5 50b3e15e54 chore(plan): 10-10 220장(돋보기 휠 배율) 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:16:19 +09:00
eomsangdon eceda4ee35 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 16:15:45 +09:00
eomsangdonandClaude Opus 5.5 64d5921b94 feat(ui): 돋보기 지름 360 · 켜진 동안 휠 배율(1.5~6 · 0.25) · 스크롤 막음 · 배율 글 · 붙는 페이지 B01~B09 · M01 · M02 (220-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 16:14:35 +09:00
eomsangdonandClaude Opus 5.5 1de0e62949 chore(plan): 10-10 요구 84
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:12:32 +09:00
eomsangdon d027e5b617 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 16:12:02 +09:00
eomsangdon 536dacefac Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 16:11:58 +09:00
eomsangdon 1cbeea6bfd chore(sync): 로컬 미반영 수정분 커밋 (원격과 동일 내용) 2026-10-10 16:11:53 +09:00
eomsangdon add01ef5de chore(sync): 로컬 미반영 수정분 커밋 (원격과 동일 내용) 2026-10-10 16:11:52 +09:00
eomsangdonandClaude Opus 5.5 e95487967b chore(plan): 10-10 요구 83
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:10:51 +09:00
eomsangdon e0136d4db6 Merge commit 'f1ddb82c411de4c08116b50d22ca8886b869907b' into main_laptop_1 2026-10-10 16:04:51 +09:00
eomsangdon 81db8ee970 Merge commit 'b6c4e98d38b410d5d58591a64e0f26df46e7006e' into main_laptop_1 2026-10-10 16:04:50 +09:00
eomsangdon 0038012872 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 16:04:50 +09:00
eomsangdonandClaude Opus 5.5 98b9d1b459 chore(plan): 10-10 200~219장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:04:25 +09:00
eomsangdon 9c747ed13d Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 16:03:41 +09:00
eomsangdonandClaude Opus 5.5 08b34194af chore(plan): 10-10 요구 77~82 원문 되살림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:03:21 +09:00
eomsangdonandClaude Sonnet 5.5 d540395938 test(B08,M02): 낡은 시험 둘 기대값을 지금 코드 꼴에 맞춤 — 자재 할증 editable(rows[keep[i]]) · 스프레드시트 미리보기(도구 줄 없음 · 메뉴 ss--preview)(205-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 16:02:15 +09:00
eomsangdon d1999ace14 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-10 15:54:49 +09:00
eomsangdonandClaude Opus 5.5 e0ad372e70 refactor(B05·B06): 종단도 700줄 정리 — 오버레이 · 스크롤 듣개 새 파일로 떼어 641줄(213-3)
- design_profile_overlays.ts — 유토곡선 · 테이블 오버레이 · 유토 총괄 배지 만들기
- design_profile_viewport.ts — 가로 스크롤 다시 그림 · 휠 가로 스크롤 · 크기 바뀜 듣개(dispose 때 뗌)
- 동작 그대로 · test_215_1 줄 수 기준 = 700(규칙) · 새 두 파일도 검사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 15:53:42 +09:00
eomsangdon dc8f201877 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 15:45:43 +09:00
eomsangdonandClaude Opus 5.5 ffa7f9d236 fix(B05·B06): 유령 계획고 편집 근본 막음 — 옮긴 관 편집 따라감 · [저장] 때 자리 없는 편집 버림 · 알림(213-2)
- ① 관 옮김 = 편집 키도 새 자리로: B06 movePipeTo 가 resetStationEdit(옛, 새) · B05 측점 갱신이 사라진 것 하나 · 새것 하나(또는 같은 id · 0.006 m 안)를 짝으로 옮김(followedStation) — 세션 초안도 새 키라 옛 키가 다시 안 나감
- ② [저장](PUT profile-alignment)에서 서버가 측점 · 관 · 구조물 자리 어디에도 없는 편집 키를 버림(drop_unplaced_edits · 저장 차례가 관 · 구조물 뒤라 최신) · 응답 dropped_edit_chainages · 화면 알림 「계획고 편집 N곳을 지웠습니다」 · 자리 못 읽으면 안 버림
- ③ test_192_1 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json · 226fc805 460~840 m) · 새 시험 test_213_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 15:44:28 +09:00
eomsangdonandClaude Opus 5.5 37b653eb8a test(B05): 219-1 헬퍼 가짜 모듈에 [자유지정] 두 모듈 추가 · 합친 뒤 setup 실패 해소(219-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 15:44:17 +09:00
eomsangdon 6f1e0c8202 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 15:43:09 +09:00
eomsangdon b9f9795a83 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
# Conflicts:
#	B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts
2026-10-10 15:43:07 +09:00
eomsangdonandClaude Opus 5.5 cfde9dc0bd refactor(공용): 돋보기 Z 키 판정 — 공용 key_shortcut keyLetter 로 바꿈 · 동작 그대로(218-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 15:41:39 +09:00
eomsangdonandClaude Opus 5.5 ca37861159 fix(B06): 횡단 보기 방향키가 관을 몰래 옮기던 흠 · 측점 없는 구조물 판정 폭 0.006 · 노선 밖 뺌(219-1)
- 까닭: 관 · 도수로/돌붙임 띠 고름은 조정창이 없어 방향키를 받는 쪽이 없음 → 종단도 창 듣개가 받아 고른 관 측점 0.1 m 이동 → [저장] 때 pipe_points +0.1(192 · 213 숨은 측점 씨앗)
- 종단도 좌우 이동 = 마지막 누름이 종단도 안일 때만(arrowsBelongToProfile 한 벌 · 새 창 종단도 누름도 closest 로 가림)
- 관 · 도수로 띠 고름 동안 방향키 삼킴(swallowsArrows · 종단도를 누르면 종단도 몫)
- 측점 없는 구조물 판정 폭 = SAME_STATION_SNAP_M(0.006) · 노선 밖(0 미만 · 끝 넘음) 뺌
- 새 시험 test_219_1_arrow_owner · test_b06_missing_stations 폭 0.006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 15:40:33 +09:00
eomsangdon 9749e3ec68 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 15:39:56 +09:00
eomsangdonandClaude Opus 5.5 0d560350bd feat(공용): B05~B09 돋보기 — 오른쪽 길게 누름 0.4 초 · Z 누름 동안 커서 따라감 · 지름 240 px · 2배(218-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 15:39:03 +09:00
eomsangdon b6c4e98d38 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-10-10 15:38:16 +09:00
eomsangdon f1ddb82c41 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 15:38:14 +09:00
eomsangdon 1fb027093c Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-10 15:38:13 +09:00
eomsangdon dcedf35506 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-10 15:38:11 +09:00
eomsangdon 8212da9c19 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 15:38:11 +09:00
eomsangdon bac6ff1853 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-10 15:38:08 +09:00
eomsangdon ce7ed33843 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-10 15:37:00 +09:00
eomsangdon 3fce063617 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
# Conflicts:
#	B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts
2026-10-10 15:37:00 +09:00
eomsangdon 2307854911 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-10 15:36:27 +09:00
eomsangdon b2282af8c5 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 15:36:26 +09:00
eomsangdonandClaude Sonnet 5.5 f8fff70d56 fix(단축키): 전역 Ctrl 단축키 한글 자판 대응 · 공용 판정 함수(214-2)
- A00_Common/key_shortcut.ts 새로 — 영문이면 글자 · 아니면 자판 자리(code) · CAD 와 페이지가 같이 씀
- CAD input-shortcut.ts 는 공용을 다시 내보냄 · tsconfig.app.json 에 공용 파일 포함
- B05 종단 Ctrl+Z · 노선 편집 Ctrl+Z · Y · M02 흐름도 Ctrl+Z · 카드 Ctrl+S · 나무 Ctrl+C · V · 시트 Ctrl+글자
- 시트 키는 한글 조합 중에도 Ctrl+글자 판정
- 새 시험 ctrl-shortcut-hangul · ctrl-shortcut-sheet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 15:32:58 +09:00
eomsangdonandClaude Opus 5.5 b628df3e93 feat(B05): 종단 [자유지정] — 범위 고름 · 클릭 점 측점선 붙음 · 실시간 점선 · 기울기 빨강 · 꺾인 점 종단곡선 · 계획고 편집 캐시(215-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-10 15:32:27 +09:00
eomsangdonandClaude Sonnet 5.5 c06661093c style(B06): 알림 칩 줄 위 여백 16→8 · 칩 줄 ↔ 첫 횡단 카드 간격 28→16 px(217-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 15:32:14 +09:00
eomsangdonandClaude Opus 5.5 473e3532e8 style(b07): 횡단도 배치 칸 단추 · 숫자 칸 한 규격 · 한 줄에 한 일 · 검은 선 = 인쇄 칸선 판정(212-3)
- 줄 = 배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기] · 한 줄 안 같은 너비
- 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(--btn-bar-* · 높이 16) · 옛 한 줄 감기 · 48px 고정 지움
- 안내선 숨김 뒤 남는 검은 선 = 인쇄 칸선(도각 층 · #8ea0b5 · 밝은 바탕에서 짙게) — 지우지 않음
- 새 시험 test_212_3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-10 15:32:00 +09:00
eomsangdonandClaude Opus 5.5 303342a56e feat(b07): 횡단 장 측점마다 도면층 · 옛 확정 장 원지반 잠금 풂(212-1 · 212-2)
- 까닭: 새로 만든 장은 서버 · CAD 걸름 모두 원지반이 잡힘 — 안 잡힌 것은 180-1 앞에 원지반 층 잠금째 저장된 확정 장 또는 반영 전 서버 · 페이지 메모리의 옛 도면
- 확정 장을 열 때 원지반 층 잠금 풂 · 측점 층 없는 확정 장은 stale_layers 안내([수정] 때 새 꼴)
- 장의 측점마다 층 하나(b08-station-{측점} · 「횡단 No.3」) = 원지반 · 계획선 · 구조물 · 암 그림(자식 선까지) · 종류는 색 · 선 꼴 · 표 · 글은 수량표 층 · 종류 층 지움
- 개체 id · groupId 그대로 · placement 에 layer_id · design_ids — 화면 덧그림도 측점 층 · 서버 설계선은 id 로 걷음
- DXF 층 이름 = 측점 층 id
- 새 시험 test_212 · 174 · 180 · 180_2 원지반 찾기를 색으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-10 15:29:22 +09:00
eomsangdon db22c14c16 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-10-10 15:08:32 +09:00
eomsangdon 8fc456657b Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 14:12:11 +09:00
eomsangdon a4f5997c91 sync: 로컬 정본 파일 반영 2026-10-10 14:12:03 +09:00
eomsangdon a1c5be2361 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 14:11:39 +09:00
eomsangdon 818d7ec757 sync: 로컬 정본 파일 반영 2026-10-10 14:11:38 +09:00
eomsangdon 9fd5594656 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 13:40:48 +09:00
eomsangdon 15a4087bf2 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 13:28:28 +09:00
eomsangdon 828e5f1a13 sync: docs 파일 임시 커밋(내용 동일) 2026-10-10 13:28:27 +09:00
eomsangdon 42a17a5fb4 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 12:19:03 +09:00
eomsangdon ac768dab94 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-10-10 11:58:24 +09:00
eomsangdon 9370233fbd Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-10-10 11:57:59 +09:00
1130 changed files with 160977 additions and 8563 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000
# Public URL for email links (프론트엔드 주소) # Public URL for email links (프론트엔드 주소)
APP_PUBLIC_BASE_URL=http://localhost:5173 APP_PUBLIC_BASE_URL=http://localhost:5173
KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08
KAKAO_JS_KEY=98ac6edef14ffbca300d90e540644b68
# kakao app ID 1603330
# Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f # Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA # 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B # 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B
+2
View File
@@ -71,6 +71,8 @@ config/formula_node/
config/spreadsheet_node/ config/spreadsheet_node/
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물. # 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
config/b07_extent_node/ config/b07_extent_node/
# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1).
config/b07_note_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
+53
View File
@@ -0,0 +1,53 @@
/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 · 휠 방향 고름 · 표 */
.app-settings__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
.app-settings__radios {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-16);
}
.app-settings__radios > label {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
min-height: 32px;
cursor: pointer;
}
.app-settings__table {
width: 100%;
border-collapse: collapse;
}
.app-settings__table th,
.app-settings__table td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
}
.app-settings__table thead th {
position: sticky;
top: 0;
background: var(--color-surface-raised);
}
.app-settings__table input[aria-invalid="true"] {
border-color: var(--color-danger);
}
.app-settings__error {
color: var(--color-danger);
}
.app-settings__error:empty {
display: none;
}
+159
View File
@@ -0,0 +1,159 @@
/* =============================================================================
* app_settings.ts
* ☰ [설정] 창(PLAN 332-2 · 요구 224-8) — 탭 셋: ① 마우스 ② CAD 단축키 ③ 다른 곳 단축키(읽기 전용).
*
* - 고침은 창 안 사본에만 · [저장] 때만 계정 pref(`input-prefs`)로 한 번 씀 — 읽기는 `input_prefs.ts`
* - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭은 저장 안 함
* - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에)
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { askLeave } from "@ui/ui_template_leave_dialog";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { writeState } from "./b_page_state";
import { inputPrefs, type InputPrefs, type WheelZoom } from "./input_prefs";
import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad";
import "./app_settings.css";
type LocaleKey = Parameters<typeof L>[0];
type TabId = "mouse" | "cad" | "keys";
const TABS: [TabId, LocaleKey][] = [
["mouse", "App_Settings_Tab_Mouse"],
["cad", "App_Settings_Tab_Cad"],
["keys", "App_Settings_Tab_Keys"],
];
const WHEEL: [WheelZoom, LocaleKey][] = [
["pull", "App_Settings_Wheel_Pull"],
["push", "App_Settings_Wheel_Push"],
];
const MOUSE_RULES: LocaleKey[] = [
"App_Settings_Rule_Pan",
"App_Settings_Rule_Pick",
"App_Settings_Rule_Right",
"App_Settings_Rule_Pinch",
];
/** ③ 안내표 줄 — [자리, 키] */
const KEY_ROWS: [LocaleKey, LocaleKey][] = [
["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"],
["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"],
["App_Settings_Keys_RouteEdit", "App_Settings_Keys_RouteEdit_Keys"],
["App_Settings_Keys_Section", "App_Settings_Keys_Section_Keys"],
["App_Settings_Keys_Cad", "App_Settings_Keys_Cad_Keys"],
["App_Settings_Keys_Table", "App_Settings_Keys_Table_Keys"],
["App_Settings_Keys_M02", "App_Settings_Keys_M02_Keys"],
];
/** 비교 · 저장 꼴 — 별칭 명령 차례를 맞춤 */
const normalize = (p: InputPrefs): InputPrefs => ({
wheelZoom: p.wheelZoom,
cadAliases: Object.fromEntries(Object.entries(p.cadAliases).sort(([a], [b]) => (a < b ? -1 : 1))),
});
const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p));
function mouseTab(draft: InputPrefs): HTMLElement {
const group = el("div", {
className: "app-settings__radios",
attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") },
});
for (const [mode, key] of WHEEL) {
const input = el("input", {
attrs: { type: "radio", name: "app-settings-wheel", value: mode },
});
input.checked = draft.wheelZoom === mode;
input.addEventListener("change", () => (draft.wheelZoom = mode));
group.append(el("label", { children: [input, L(key)] }));
}
return el("div", {
children: [
el("h4", { text: L("App_Settings_Wheel") }),
group,
el("h4", { text: L("App_Settings_Rules") }),
el("ul", { children: MOUSE_RULES.map((key) => el("li", { text: L(key) })) }),
],
});
}
function keysTab(): HTMLElement {
const rows = KEY_ROWS.map(([place, keys]) =>
el("tr", { children: [el("th", { text: L(place) }), el("td", { text: L(keys) })] }),
);
return el("table", {
className: "app-settings__table",
children: [el("tbody", { children: rows })],
});
}
/** ☰ [설정] — 창을 열고 지금 설정을 사본으로 고침 */
export function openAppSettings(): void {
const draft = normalize(inputPrefs());
let saved = snapshot(draft);
const cad = buildCadAliasTab(draft.cadAliases);
const panels: Record<TabId, HTMLElement> = {
mouse: mouseTab(draft),
cad: cad.root,
keys: keysTab(),
};
const tabs = el("div", {
className: "app-settings__tabs ui-subtabs",
attrs: { role: "tablist" },
});
const show = (id: TabId): void => {
tabs.replaceChildren(
...TABS.map(([tab, key]) => {
const button = createButton({
label: L(key),
variant: tab === id ? "filled" : "ghost",
size: "sm",
onClick: () => show(tab),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(tab === id));
button.dataset.settingsTab = tab;
return button;
}),
);
for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id;
};
/** [저장] — 잘못 있는 별칭이면 CAD 탭을 보이고 안 씀 */
const save = (): boolean => {
const errors = checkCadAliases(draft.cadAliases);
cad.mark(errors);
if (Object.keys(errors).length) {
show("cad");
showToast(L("App_Settings_Cad_Invalid"), "error");
return false;
}
if (snapshot(draft) !== saved) writeState("input-prefs", normalize(draft));
saved = snapshot(draft);
showToast(L("App_Settings_Saved"), "success");
return true;
};
const saveButton = createButton({
label: L("Common_Btn_Save"),
variant: "filled",
size: "sm",
onClick: () => void save(),
});
openModal({
title: L("App_Settings"),
closeLabel: L("Common_Btn_Close"),
dialogClass: "app-settings",
actions: [saveButton],
beforeClose: async () => {
if (snapshot(draft) === saved) return true;
const choice = await askLeave(L("App_Settings_Leave"));
return choice === "discard" || (choice === "save" && save());
},
mount: (body) => {
show("mouse");
body.append(tabs, ...Object.values(panels));
},
});
}
+118
View File
@@ -0,0 +1,118 @@
/* =============================================================================
* app_settings_cad.ts
* ☰ [설정] ② CAD 단축키 탭(PLAN 332-2) — 명령 표(명령 · 한글 이름 · 기본 별칭 · 내 별칭) · 검사.
*
* - 명령 목록 = `openwebcad/src/commands/command-catalog.json`(registry 거울 · commands.*.ts 는 xstate 가 딸려 와서 JSON 한 벌)
* - acad.pgp 식 덮어쓰기 — CAD 는 명령 이름 > 내 별칭 > 기본 별칭 순서로 찾음(배선은 332-3)
* - 검사 = 영문 · 숫자만 · 내 별칭끼리 겹침 금지 · 명령 이름과 같은 별칭 금지
* ========================================================================== */
import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
export interface CadCommandRow {
id: string;
label: string;
aliases: string[];
}
export const CAD_COMMANDS = catalog as CadCommandRow[];
export type AliasError = "chars" | "dup" | "name";
const ERROR_TEXT: Record<AliasError, Parameters<typeof L>[0]> = {
chars: "App_Settings_Cad_ErrChars",
dup: "App_Settings_Cad_ErrDup",
name: "App_Settings_Cad_ErrName",
};
/** 칸 글 → 별칭 목록(쉼표 · 빈칸으로 가름 · 대문자) */
export const parseAliases = (text: string): string[] =>
text
.toUpperCase()
.split(/[\s,]+/)
.filter(Boolean);
/** 내 별칭 검사 — `{명령: 첫 잘못}` · 비면 통과 */
export function checkCadAliases(
aliases: Record<string, string[]>,
commands: CadCommandRow[] = CAD_COMMANDS,
): Record<string, AliasError> {
const ids = new Set(commands.map((c) => c.id));
const owner = new Map<string, string>();
const errors: Record<string, AliasError> = {};
for (const [id, list] of Object.entries(aliases)) {
for (const alias of list) {
const first = owner.get(alias);
const error: AliasError | null = !/^[A-Z0-9]+$/.test(alias)
? "chars"
: ids.has(alias)
? "name"
: first !== undefined
? "dup"
: null;
if (!error) owner.set(alias, id);
else {
errors[id] ??= error;
if (first !== undefined) errors[first] ??= "dup";
}
}
}
return errors;
}
/** 탭 몸 — `aliases` 를 칸 입력대로 고쳐 씀 · `mark` 로 잘못 칸 표시 */
export function buildCadAliasTab(aliases: Record<string, string[]>): {
root: HTMLElement;
mark: (errors: Record<string, AliasError>) => void;
} {
const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } });
const inputs = new Map<string, HTMLInputElement>();
const rows = CAD_COMMANDS.map((command) => {
const input = el("input", {
className: "ui-input ui-input--sm",
attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` },
});
input.value = (aliases[command.id] ?? []).join(", ");
input.dataset.cadCommand = command.id;
input.addEventListener("input", () => {
const list = parseAliases(input.value);
if (list.length) aliases[command.id] = list;
else delete aliases[command.id];
});
inputs.set(command.id, input);
const cells = [command.id, command.label, command.aliases.join(", ")].map((text) =>
el("td", { text }),
);
return el("tr", { children: [...cells, el("td", { children: [input] })] });
});
const head = el("tr", {
children: (
[
"App_Settings_Cad_Command",
"App_Settings_Cad_Label",
"App_Settings_Cad_Default",
"App_Settings_Cad_Mine",
] as const
).map((key) => el("th", { text: L(key) })),
});
const table = el("table", {
className: "app-settings__table",
children: [el("thead", { children: [head] }), el("tbody", { children: rows })],
});
const mark = (errors: Record<string, AliasError>): void => {
for (const [id, input] of inputs) {
const error = errors[id];
if (error) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
input.title = error ? L(ERROR_TEXT[error]) : "";
}
const kinds = [...new Set(Object.values(errors))];
message.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · ");
};
const root = el("div", {
children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table],
});
return { root, mark };
}
+281 -31
View File
@@ -11,6 +11,7 @@ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_l
import { createButton } from "@ui/ui_template_elements"; import { createButton } from "@ui/ui_template_elements";
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend"; import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
import { navigateTo, isAuthenticated } from "./router"; import { navigateTo, isAuthenticated } from "./router";
import { readTouchMode, setTouchMode, type TouchMode } from "./device_context";
const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href; const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href;
@@ -65,38 +66,12 @@ function buildHeader(): HTMLElement {
const header = document.createElement("header"); const header = document.createElement("header");
header.className = "app-header"; header.className = "app-header";
// 로고 (클릭 시 홈) const brand = buildBrand();
const brand = document.createElement("button");
brand.className = "app-brand";
brand.type = "button";
const brandIcon = document.createElement("img");
brandIcon.className = "app-brand__icon";
brandIcon.src = BRAND_ICON_URL;
brandIcon.alt = "";
const brandName = document.createElement("span");
brandName.className = "app-brand__name";
brandName.textContent = L("App_BrandName");
brand.append(brandIcon, brandName);
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
// 네비게이션 링크 // 네비게이션 링크
const nav = document.createElement("nav"); const nav = document.createElement("nav");
nav.className = "app-nav"; nav.className = "app-nav";
const navItems: [keyof typeof ui_locales, string][] = [ nav.append(...buildNavButtons());
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
["App_Nav_Support", ROUTES.A08_SUPPORT],
];
for (const [labelKey, route] of navItems) {
const link = createButton({
label: L(labelKey),
variant: "pill",
onClick: () => navigateTo(route as never),
});
nav.append(link);
}
// 우측 액션 (언어/테마/로그인) // 우측 액션 (언어/테마/로그인)
const actions = document.createElement("div"); const actions = document.createElement("div");
@@ -120,10 +95,171 @@ function buildHeader(): HTMLElement {
actions.append(authSlot); actions.append(authSlot);
void fillAuthActions(authSlot); void fillAuthActions(authSlot);
header.append(brand, nav, actions); header.append(brand, nav, actions, ...buildDrawer(header));
return header; return header;
} }
/** 로고 (클릭 시 홈) — 위 줄 · ☰ 서랍 첫 묶음이 같이 씀 */
function buildBrand(): HTMLButtonElement {
const brand = document.createElement("button");
brand.className = "app-brand";
brand.type = "button";
const brandIcon = document.createElement("img");
brandIcon.className = "app-brand__icon";
brandIcon.src = BRAND_ICON_URL;
brandIcon.alt = "";
const brandName = document.createElement("span");
brandName.className = "app-brand__name";
brandName.textContent = L("App_BrandName");
brand.append(brandIcon, brandName);
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
return brand;
}
/* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠.
* 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */
let drawerSteps: { node: HTMLElement; home: HTMLElement | null } | null = null;
/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(B01 · M01 · M02 바로가기) */
export function setDrawerSteps(node: HTMLElement | null): void {
drawerSteps = node ? { node, home: node.parentElement } : null;
}
const NAV_ITEMS: [keyof typeof ui_locales, string][] = [
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
["App_Nav_Support", ROUTES.A08_SUPPORT],
];
function buildNavButtons(): HTMLElement[] {
return NAV_ITEMS.map(([labelKey, route]) =>
createButton({
label: L(labelKey),
variant: "pill",
onClick: () => navigateTo(route as never),
}),
);
}
/* -----------------------------------------------------------------------------
* ☰ 서랍(PLAN_기기대응 2-2 · 7-1) — 공개 쪽은 폭 900 이하 · 터치 모드에서 · 로그인 뒤 쪽(B · M)은 늘 보임.
* 위 메뉴 · 언어 · 테마 · 로그인 단추와 같은 항목 + 터치 모드 스위치.
* -------------------------------------------------------------------------- */
function buildDrawer(header: HTMLElement): HTMLElement[] {
const burger = document.createElement("button");
burger.type = "button";
burger.className = "app-burger";
burger.textContent = "☰";
burger.setAttribute("aria-label", L("App_Menu"));
burger.setAttribute("aria-expanded", "false");
const drawer = document.createElement("div");
drawer.className = "app-drawer";
drawer.setAttribute("role", "dialog");
drawer.setAttribute("aria-label", L("App_Menu"));
const scrim = document.createElement("div");
scrim.className = "app-drawer__scrim";
const steps = document.createElement("div");
steps.className = "app-drawer__group app-drawer__steps";
const setOpen = (open: boolean): void => {
header.classList.toggle("is-drawer-open", open);
burger.setAttribute("aria-expanded", String(open));
if (!drawerSteps) return;
if (open) steps.append(drawerSteps.node);
else if (drawerSteps.home && steps.contains(drawerSteps.node))
drawerSteps.home.append(drawerSteps.node);
};
burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open")));
scrim.addEventListener("click", () => setOpen(false));
header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘
// 로고 · 진행 단계 · 메뉴 · 로그인 · [설정] 을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠)
drawer.addEventListener("click", (e) => {
const hit = (e.target as Element).closest("button");
if (
hit?.closest(
".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth, .app-drawer__settings",
)
)
setOpen(false);
});
// 묶음 차례 = 로고 · 홈 ─ 진행 단계(프로젝트 안) ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드
const brandRow = document.createElement("div");
brandRow.className = "app-drawer__group app-drawer__brand";
brandRow.append(buildBrand());
const navList = document.createElement("nav");
navList.className = "app-drawer__group app-drawer__nav";
navList.append(...buildNavButtons());
const prefs = document.createElement("div");
prefs.className = "app-drawer__row";
prefs.append(
createButton({
label: currentLanguageIndex === 0 ? "EN" : "한",
variant: "pill",
onClick: onApp_Lang_Toggle_Click,
}),
createButton({
label: document.documentElement.getAttribute("data-theme") === "dark" ? "☀" : "☾",
variant: "pill",
onClick: onApp_Theme_Toggle_Click,
}),
);
// ☰ [설정](332) — 마우스 · 단축키 창 · 처음 누를 때 불러옴
const settingsButton = createButton({
label: L("App_Settings"),
variant: "pill",
onClick: () => void import("./app_settings").then((m) => m.openAppSettings()),
});
settingsButton.classList.add("app-drawer__settings");
prefs.append(settingsButton);
const auth = document.createElement("div");
auth.className = "app-drawer__row app-drawer__auth";
void fillAuthActions(auth);
const settings = document.createElement("div");
settings.className = "app-drawer__group";
settings.append(prefs, auth, buildTouchModeSwitch());
drawer.append(brandRow, steps, navList, settings);
return [burger, scrim, drawer];
}
function buildTouchModeSwitch(): HTMLElement {
const box = document.createElement("div");
box.className = "app-drawer__touch";
const label = document.createElement("span");
label.textContent = L("App_TouchMode");
const group = document.createElement("div");
group.className = "app-drawer__row";
group.setAttribute("role", "radiogroup");
group.setAttribute("aria-label", L("App_TouchMode"));
const modes: [TouchMode, keyof typeof ui_locales][] = [
["auto", "App_TouchMode_Auto"],
["on", "App_TouchMode_On"],
["off", "App_TouchMode_Off"],
];
const buttons = modes.map(([mode, key]) => {
const b = createButton({
label: L(key),
variant: "pill",
onClick: () => {
setTouchMode(mode);
mark();
},
});
b.setAttribute("role", "radio");
b.dataset.touchMode = mode;
return b;
});
const mark = (): void => {
const now = readTouchMode();
for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now));
};
mark();
group.append(...buttons);
box.append(label, group);
return box;
}
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */ /** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
async function fillAuthActions(slot: HTMLElement): Promise<void> { async function fillAuthActions(slot: HTMLElement): Promise<void> {
try { try {
@@ -188,6 +324,7 @@ function buildFooter(): HTMLElement {
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */
export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement { export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement {
appRoot.innerHTML = ""; appRoot.innerHTML = "";
drawerSteps = null; // 새 쪽이 진행 판을 세우면 다시 맡김
const header = buildHeader(); const header = buildHeader();
const outlet = document.createElement("main"); const outlet = document.createElement("main");
outlet.className = "app-outlet"; outlet.className = "app-outlet";
@@ -259,10 +396,10 @@ const SHELL_CSS = `
gap: var(--spacing-8); gap: var(--spacing-8);
} }
.app-outlet { .app-outlet {
min-height: calc(100vh - 64px - 56px); min-height: calc(100dvh - var(--app-header-h) - 56px);
} }
.app-outlet--without-footer { .app-outlet--without-footer {
min-height: calc(100vh - 64px); min-height: calc(100dvh - var(--app-header-h));
} }
.app-footer { .app-footer {
height: 56px; height: 56px;
@@ -285,7 +422,120 @@ const SHELL_CSS = `
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.app-nav { display: none; } .app-nav { display: none; }
.app-actions { margin-left: auto; }
} }
/* ☰ 서랍 — 공개 쪽은 폭 900 이하 · 터치 모드에서만 · 로그인 뒤 쪽(B · M)은 늘 */
.app-burger,
.app-drawer,
.app-drawer__scrim {
display: none;
}
.app-burger {
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
font-size: 20px;
cursor: pointer;
}
html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; }
.app-header.is-drawer-open { z-index: var(--z-overlay); }
.app-header.is-drawer-open .app-drawer__scrim {
display: block;
position: fixed;
inset: var(--app-header-h) 0 0 0;
background: rgba(38, 17, 74, 0.35);
}
.app-header.is-drawer-open .app-drawer {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
position: fixed;
top: var(--app-header-h);
right: 0;
bottom: 0;
width: min(320px, 100vw);
padding: var(--spacing-16);
overflow-y: auto;
background: var(--color-surface-raised);
border-left: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
}
.app-drawer__nav {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; }
.app-drawer__row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.app-drawer__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.app-drawer__group + .app-drawer__group {
padding-top: var(--spacing-16);
border-top: 1px solid var(--color-border);
}
.app-drawer__steps:empty { display: none; }
.app-drawer__touch {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.app-drawer__touch [aria-checked="true"] {
background: var(--color-mist-violet);
color: var(--color-accent);
}
/* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */
html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); }
html[data-size="compact"] .app-actions { display: none; }
html[data-size="compact"] .app-burger { margin-left: auto; }
/* 로그인 뒤 쪽(B · M) — 폭 · 입력과 상관없이 위 줄을 걷고 ☰ 만 화면 왼쪽 위 모서리에 띄움(7-1)
본문이 화면 높이를 다 씀 · 서랍은 왼쪽에서 열림 · 위 줄 항목은 모두 서랍에 있음 */
html.is-bm-route .app-header {
height: 0;
padding: 0;
border: 0;
z-index: calc(var(--z-dropdown) + 1);
}
html.is-bm-route .app-header > :is(.app-brand, .app-nav, .app-actions) {
display: none;
}
/* ☰ 폭 36(7-4) · 높이는 손가락 44 · 마우스 36 */
html.is-bm-route .app-burger {
min-width: var(--app-burger-w);
min-height: var(--app-burger-w);
position: fixed;
top: var(--spacing-8);
left: var(--spacing-8);
z-index: 1;
margin: 0;
padding: 0;
box-shadow: var(--shadow-lg);
}
html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; }
html.is-bm-route .app-header.is-drawer-open .app-drawer {
left: 0;
right: auto;
border-left: 0;
border-right: 1px solid var(--color-border);
}
html.is-bm-route .app-drawer__brand {
min-height: 44px;
padding-left: calc(var(--app-burger-inset) - var(--spacing-16));
}
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; }
`; `;
export function injectShellStyles(): void { export function injectShellStyles(): void {
+1 -1
View File
@@ -174,7 +174,7 @@ const SCAFFOLD_CSS = `
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.b-scaffold-wf { height: calc(100vh - 64px - 56px); } .b-scaffold-wf { height: calc(100dvh - var(--app-header-h) - 56px); }
.b-pending { .b-pending {
display: flex; display: flex;
+37 -16
View File
@@ -57,8 +57,6 @@ export const STATE_REGISTRY = {
legacy: () => "b05-route-drainage-collapsed", legacy: () => "b05-route-drainage-collapsed",
}, },
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
"drainage-visible": { bucket: "pref", scope: "global" },
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */ /** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"m02-combo-panel-width": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" },
@@ -66,9 +64,11 @@ export const STATE_REGISTRY = {
"m02-card-pills": { bucket: "pref", scope: "global" }, "m02-card-pills": { bucket: "pref", scope: "global" },
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
"m01-logic-panel": { bucket: "pref", scope: "global" }, "m01-logic-panel": { bucket: "pref", scope: "global" },
/** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */ /** B09 묶음 탭(171-3) 보기 `cards · table`(304-1 셋 탭 한 값) · 가로 열 수 `{탭: 1~4}` */
"b09-group-view": { bucket: "pref", scope: "global" }, "b09-group-view": { bucket: "pref", scope: "global" },
"b09-group-cols": { bucket: "pref", scope: "global" }, "b09-group-cols": { bucket: "pref", scope: "global" },
/** B09 행 누름(303-1) `popup · link` */
"b09-link-mode": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" }, "routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
@@ -101,6 +101,12 @@ export const STATE_REGISTRY = {
}, },
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */ /** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
"cross-columns": { bucket: "pref", scope: "global" }, "cross-columns": { bucket: "pref", scope: "global" },
/** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */
"mass-haul-badge-pos": { bucket: "pref", scope: "global" },
/** ☰ [설정] 마우스 · 단축키(332) — 읽기는 `input_prefs.ts`(같은 키 글자) · [저장] 때만 씀. */
"input-prefs": { bucket: "pref", scope: "global" },
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
"b05-3d-projection": { bucket: "pref", scope: "global" },
"section-panel-height": { "section-panel-height": {
bucket: "pref", bucket: "pref",
scope: "global", scope: "global",
@@ -110,6 +116,8 @@ export const STATE_REGISTRY = {
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
"structure-notes": { bucket: "draft", scope: "route" }, "structure-notes": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */
"structure-note-cads": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
"detail-table": { bucket: "draft", scope: "route" }, "detail-table": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
@@ -127,10 +135,14 @@ export const STATE_REGISTRY = {
scope: "project", scope: "project",
legacy: (p) => `aislo:structure-pick:${p}`, legacy: (p) => `aislo:structure-pick:${p}`,
}, },
/** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */
"pipe-ids": { bucket: "draft", scope: "project" },
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */ /** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
selection: { bucket: "draft", scope: "project" }, selection: { bucket: "draft", scope: "project" },
/** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */ /** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */
"structure-type-cache": { bucket: "draft", scope: "project" }, "structure-type-cache": { bucket: "draft", scope: "project" },
/** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */
kindreset: { bucket: "draft", scope: "project" },
/** 배수관·암거 조정창에서 예약한 옵션 값. */ /** 배수관·암거 조정창에서 예약한 옵션 값. */
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
/** 배수관 이동(측점 옮김) 예약. */ /** 배수관 이동(측점 옮김) 예약. */
@@ -185,6 +197,8 @@ export const STATE_REGISTRY = {
"b08-hydro": { bucket: "draft", scope: "project", version: 3 }, "b08-hydro": { bucket: "draft", scope: "project", version: 3 },
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
"b08-options": { bucket: "draft", scope: "project" }, "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 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
"b09-estimation": { bucket: "draft", scope: "project" }, "b09-estimation": { bucket: "draft", scope: "project" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
@@ -477,29 +491,36 @@ export function clearState(
routeId?: number | string | null, routeId?: number | string | null,
): void { ): void {
const entry = entryOf(name); const entry = entryOf(name);
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다. // 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다(`routeStateKeys`).
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`), // 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169 // 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 — // 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다. // 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) { if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; routeStateKeys(name, projectId).forEach((key) => writeRaw(key, null));
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
try {
const doomed: string[] = [];
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) doomed.push(key);
}
doomed.forEach((key) => writeRaw(key, null));
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
}
return; return;
} }
writeStateRaw(name, null, projectId, routeId); writeStateRaw(name, null, projectId, routeId);
} }
/** 노선 범위 이름의 그 프로젝트 키 전부(모든 노선 · 세션에 있는 것만). */
export function routeStateKeys(name: StateName, projectId: string): string[] {
const entry = entryOf(name);
if (entry?.scope !== "route") return [];
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
const keys: string[] = [];
try {
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
}
return keys;
}
/** 등록표에서 한 통에 속한 이름만 고른다. */ /** 등록표에서 한 통에 속한 이름만 고른다. */
export function namesInBucket(bucket: StateBucket): StateName[] { export function namesInBucket(bucket: StateBucket): StateName[] {
return (Object.keys(STATE_REGISTRY) as StateName[]).filter( return (Object.keys(STATE_REGISTRY) as StateName[]).filter(
+122
View File
@@ -0,0 +1,122 @@
/* =============================================================================
* A00_Common/b_project_switch.ts
* 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1).
*
* 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과
* 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage).
* 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다.
* 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식).
* ========================================================================== */
import { showConfirmDialog } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "./b_asset_cache";
import { STATE_REGISTRY, type StateEntry } from "./b_page_state";
/** 이 탭이 마지막으로 들어온 프로젝트. */
const ENTERED_KEY = "aislo:entered-project";
const MARK_P = "\u0001";
const MARK_R = "\u0002";
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
const out: Array<{ prefix: string; draft: boolean }> = [];
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
if (entry.bucket === "pref" || entry.scope === "global") return;
const draft = entry.bucket === "draft";
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
const legacy = entry.legacy?.(MARK_P, MARK_R);
const at = legacy?.indexOf(MARK_P) ?? -1;
if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft });
});
return out;
}
const filled = (value: string | null): boolean =>
value !== null && value !== "" && value !== "{}" && value !== "[]";
export interface ProjectPurgePlan {
/** 저장 안 한 고침이 남은 다른 프로젝트. */
draftProjects: string[];
/** 지울 키 — `keep` 프로젝트의 초안은 뺀다. */
doomed: (keep: ReadonlySet<string>) => string[];
}
/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */
export function planProjectPurge(
session: Record<string, string>,
projectId: string,
): ProjectPurgePlan {
const prefixes = projectPrefixes();
const owners = new Map<string, { project: string; draft: boolean }>();
Object.keys(session).forEach((key) => {
const hit = prefixes.find((item) => key.startsWith(item.prefix));
if (!hit) return;
const project = key.slice(hit.prefix.length).split(":")[0];
if (project) owners.set(key, { project, draft: hit.draft });
});
const others = new Set(
[...owners.values()].map((owner) => owner.project).filter((id) => id !== projectId),
);
const draftProjects = [
...new Set(
[...owners.entries()]
.filter(([key, owner]) => owner.draft && others.has(owner.project) && filled(session[key]))
.map(([, owner]) => owner.project),
),
];
return {
draftProjects,
// 등록표 밖 칸도 키 마디에 다른 프로젝트 번호가 있으면 그 프로젝트 몫으로 본다.
doomed: (keep) =>
Object.keys(session).filter((key) => {
const owner = owners.get(key);
if (owner) return others.has(owner.project) && !(owner.draft && keep.has(owner.project));
return key.split(":").some((part) => others.has(part) && !keep.has(part));
}),
};
}
function sessionEntries(): Record<string, string> {
const out: Record<string, string> = {};
try {
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
const value = key === null ? null : window.sessionStorage.getItem(key);
if (key !== null && value !== null) out[key] = value;
}
} catch {
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
}
return out;
}
/**
* 프로젝트에 들어감 — 들어온 표식이 다르면 그 밖 프로젝트 캐시를 지운다. 실패해도 이동은 막지 않는다.
* `nameOf` 는 확인창에 적을 프로젝트 이름.
*/
export async function enterProject(
projectId: string,
nameOf: (projectId: string) => Promise<string>,
): Promise<void> {
try {
if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return;
const plan = planProjectPurge(sessionEntries(), projectId);
const keep = new Set<string>();
for (const other of plan.draftProjects) {
const name = await nameOf(other).catch(() => other);
const drop = await showConfirmDialog(
`「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`,
"지우고 열기",
"남기기",
);
if (!drop) keep.add(other);
}
plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key));
window.sessionStorage.setItem(ENTERED_KEY, projectId);
await purgeOtherProjects(projectId);
} catch {
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
}
}
+60 -12
View File
@@ -14,6 +14,8 @@
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다. * 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
* ========================================================================== */ * ========================================================================== */
import { wheelEventIn } from "./input_prefs";
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ /** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState { export interface ZoomPanState {
scale: number; scale: number;
@@ -61,12 +63,18 @@ export interface SvgZoomPanOptions {
maxScale?: number; maxScale?: number;
/** 이전 상태 — 다시 그려도 배율이 살아남게. */ /** 이전 상태 — 다시 그려도 배율이 살아남게. */
initial?: ZoomPanState; initial?: ZoomPanState;
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
homeScale?: number;
/** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */
home?: ZoomPanState;
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
onChange?: (state: ZoomPanState) => void; onChange?: (state: ZoomPanState) => void;
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds; content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */ /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean; wheelZoom?: boolean;
/** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */
panStart?: (event: PointerEvent) => boolean;
} }
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */ /** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
@@ -83,9 +91,17 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
height: Math.max(heightPx - pad.top - pad.bottom, 1), height: Math.max(heightPx - pad.top - pad.bottom, 1),
}; };
const maxScale = options.maxScale ?? 8; const maxScale = options.maxScale ?? 8;
let scale = initial?.scale ?? 1; const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
let tx = initial?.tx ?? 0; const home: ZoomPanState = options.home ?? {
let ty = initial?.ty ?? 0; scale: homeScale,
tx: (plot.x + plot.width / 2) * (1 - homeScale),
ty: (plot.y + plot.height / 2) * (1 - homeScale),
};
/** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */
const minScale = Math.min(1, home.scale);
let scale = initial?.scale ?? home.scale;
let tx = initial?.tx ?? home.tx;
let ty = initial?.ty ?? home.ty;
const applyTransform = (): void => { const applyTransform = (): void => {
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty }); onChange?.({ scale, tx, ty });
@@ -122,7 +138,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */ /** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
const zoomAt = (factor: number, cx: number, cy: number): void => { const zoomAt = (factor: number, cx: number, cy: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor)); const next = Math.min(maxScale, Math.max(minScale, scale * factor));
tx = cx - ((cx - tx) / scale) * next; tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next; ty = cy - ((cy - ty) / scale) * next;
scale = next; scale = next;
@@ -146,8 +162,10 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
if (event.button === 1) event.preventDefault(); if (event.button === 1) event.preventDefault();
}); });
svg.addEventListener("pointerdown", (event) => { svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다. // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1)
if (event.button !== 1) return; moved = false;
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
event.preventDefault(); event.preventDefault();
panning = true; panning = true;
moved = false; moved = false;
@@ -178,7 +196,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/* 이미 해제됨 */ /* 이미 해제됨 */
} }
}; };
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) { if (scale !== 1 || tx !== 0 || ty !== 0) {
clampPan(); clampPan();
applyTransform(); applyTransform();
} }
@@ -197,17 +215,47 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
const rect = svg.getBoundingClientRect(); const rect = svg.getBoundingClientRect();
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx; const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx; const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`). // ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐.
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy); zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy);
}, },
{ passive: false }, { passive: false },
); );
} }
// 손가락 둘 = 벌려 확대 · 같이 끌어 이동(기기대응 3-2) — 한 손가락은 그 화면의 고르기 · 목록 밀기 몫.
// 휠 줌 화면은 그림 면이 손가락을 다 받음 · 아니면(B06 카드 목록) 한 손가락 밀기는 브라우저에 둠.
svg.setAttribute?.("data-touch-own", "pinch"); // ?. = 노드 시험의 흉내 SVG
svg.style?.setProperty("touch-action", options.wheelZoom ? "none" : "pan-x pan-y");
const touches = new Map<number, { x: number; y: number }>();
const touchPair = (): { x: number; y: number; dist: number } => {
const [a, b] = [...touches.values()];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) };
};
svg.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch")
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
});
svg.addEventListener("pointermove", (event) => {
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
const before = touches.size === 2 ? touchPair() : null;
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (!before) return;
const after = touchPair();
const rect = svg.getBoundingClientRect();
const w = Math.max(rect.width, 1);
const h = Math.max(rect.height, 1);
tx += ((after.x - before.x) / w) * widthPx;
ty += ((after.y - before.y) / h) * heightPx;
moved = true;
const cx = ((after.x - rect.left) / w) * widthPx;
const cy = ((after.y - rect.top) / h) * heightPx;
zoomAt(before.dist > 0 && after.dist > 0 ? after.dist / before.dist : 1, cx, cy);
});
const liftTouch = (event: PointerEvent): void => void touches.delete(event.pointerId);
svg.addEventListener("pointerup", liftTouch);
svg.addEventListener("pointercancel", liftTouch);
const currentScale = (): number => scale; const currentScale = (): number => scale;
const reset = (): void => { const reset = (): void => {
scale = 1; ({ scale, tx, ty } = home);
tx = 0;
ty = 0;
applyTransform(); applyTransform();
}; };
// 더블클릭 원복. // 더블클릭 원복.
+15 -8
View File
@@ -10,6 +10,7 @@ import {
fetchSurfaceStatus, fetchSurfaceStatus,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { enterProject } from "./b_project_switch";
import { navigateTo } from "./router"; import { navigateTo } from "./router";
export interface WorkflowState { export interface WorkflowState {
@@ -93,14 +94,20 @@ export async function dropGoneProject(): Promise<boolean> {
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE]; const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
export function goToWorkflowStage(projectId: string, route: RoutePath): void { export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); // 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. void enterProject(
clearWorkflowStateCache(); projectId,
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { async (id) => (await fetchWorkflowState(id)).project_name || id,
navigateTo(route); ).then(() => {
return; localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
} // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
void routeAfterPreloadCheck(projectId, route); clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
navigateTo(route);
return;
}
void routeAfterPreloadCheck(projectId, route);
});
} }
/** /**
+30 -1
View File
@@ -50,11 +50,40 @@
display: none; display: none;
} }
.b05-route-profile__trailing > .b05-route-profile__popout { .b05-route-profile__tools > .b05-route-profile__popout {
padding: var(--btn-bar-padding); padding: var(--btn-bar-padding);
font-size: var(--btn-bar-font-size); font-size: var(--btn-bar-font-size);
} }
/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */
.b05-subpanel__popout {
flex: none;
padding: var(--btn-bar-padding);
font-size: var(--btn-bar-font-size);
}
.b05-profile__masshaul-overlay > .b05-subpanel__popout {
position: absolute;
top: 4px;
left: var(--spacing-8);
z-index: 4;
}
/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */
.b05-profile__masshaul-overlay.is-popout,
.b05-drainage.is-popout {
position: relative;
width: 100%;
max-width: none;
height: 100dvh;
}
.b05-profile__masshaul-overlay.is-popout > .ui-resizer,
.b05-drainage.is-popout > .ui-resizer,
.b05-drainage.is-popout > .ui-workflow-overlay__handle {
display: none;
}
/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼 /* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼
안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */ 안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */
.b05-route__panel .b06-adjust-dock, .b05-route__panel .b06-adjust-dock,
+2
View File
@@ -29,6 +29,7 @@ import {
import { currentRoute, navigateTo, type MountedPage } from "../router"; import { currentRoute, navigateTo, type MountedPage } from "../router";
import type { DesignView } from "./design_dock"; import type { DesignView } from "./design_dock";
import type { DesignPageShared } from "./design_shared"; import type { DesignPageShared } from "./design_shared";
import { usePipeIds } from "./design_pipe_ids";
import "./design_page.css"; import "./design_page.css";
export type { DesignPageShared } from "./design_shared"; export type { DesignPageShared } from "./design_shared";
@@ -105,6 +106,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
page.className = "design-page"; page.className = "design-page";
root.replaceChildren(page); root.replaceChildren(page);
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다. // 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
const left = document.createElement("div"); const left = document.createElement("div");
+3 -2
View File
@@ -82,15 +82,16 @@ export function createSectionFrame(
heading.textContent = title; heading.textContent = title;
if (view === "profile") { if (view === "profile") {
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추) — 두 보기 모두.
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫. // 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
heading.className = "ui-collapsible__title"; heading.className = "ui-collapsible__title";
const body = document.createElement("div"); const body = document.createElement("div");
body.className = "b05-route__panel-body"; body.className = "b05-route__panel-body";
root.append(heading, body); root.append(heading, body);
return { root, body }; return { root, body };
} }
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
heading.className = "b06-profile__group-legend ui-collapsible__title"; heading.className = "b06-profile__group-legend ui-collapsible__title";
root.append(heading); root.append(heading);
return { root, body: root }; return { root, body: root };
+31
View File
@@ -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));
}
+53
View File
@@ -0,0 +1,53 @@
/* =============================================================================
* A00_Common/design/design_pick_match.ts
* 고름 강조 한 규칙(323-5) — 그린 것(카드 형상 · 3D 부재 · 알약 · 측점선)마다 신원표 `{구조물 id · 부분 · 자리}`
* 를 달고, 고름 한 칸(`structure-pick`)과 이 함수 하나로 켜짐을 가름.
*
* ① 고름 · 그림 둘 다 구조물 id 가 있으면 id 가 같아야(관 = 세션 id `design_pipe_ids` · 옮겨도 그대로)
* ② 아니면 같은 자리(`PIPE_MATCH_M` — 같은 자리 거리는 이 한 값)
* ③ 고른 부분(부재키)이 있으면 그 부분만 · 부분을 안 골랐으면 그 구조물 그림 모두
* DOM 은 `data-structure-id` · `data-part`(`tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은
* 조정창 손잡이가 같은 고름으로 칠함(`paintPicked` 는 부분 없는 형상만 · 307 「측점 길은 부분 안 켬」).
* ========================================================================== */
import { isNear, type DesignSelection } from "./design_selection";
export interface PickTag {
structureId?: string | null;
part?: string | null;
chainageM?: number | null;
}
export type PickSelection =
(Partial<DesignSelection> & { partKey?: string | null }) | null | undefined;
/** 이 그림이 고름에 켜지나 — 위 ① ② ③. */
export function pickLights(selection: PickSelection, tag: PickTag): boolean {
if (!selection) return false;
let here: boolean;
if (selection.structureId && tag.structureId) here = selection.structureId === tag.structureId;
else if (selection.chainageM == null || tag.chainageM == null) return false;
else here = isNear(tag.chainageM, selection.chainageM);
if (!here) return false;
return !selection.partKey || tag.part === selection.partKey;
}
/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */
export function tagShape(el: Element, structureId: string | null, part?: string | null): void {
if (structureId) el.setAttribute("data-structure-id", structureId);
if (part) el.setAttribute("data-part", part);
}
/** 카드 안 통째 형상 칠하기 — 고른 구조물 id 와 같은 형상만. 부분 그림(`data-part`) · 관 계열(`pipe-`) ·
* 노면공 포장층은 제 손잡이(조정창 · 포장층 고름)가 칠함 — 측점 길(알약 · 목록 · 측점선)은 부분을 안 켬(307). */
export function paintPicked(root: ParentNode, selection: PickSelection): void {
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
const id = shape.getAttribute("data-structure-id") ?? "";
if (id.startsWith("pipe-") || shape.getAttribute("data-part") !== null) continue;
if (shape.getAttribute("data-pave-type") !== null) continue;
shape.classList.toggle(
"is-active",
!!selection?.structureId && pickLights(selection, { structureId: id }),
);
}
}
+160
View File
@@ -0,0 +1,160 @@
/* =============================================================================
* A00_Common/design/design_pick_profile.ts
* 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) ·
* 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤).
*
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
* 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는
* 그릴 때마다 지금 관 목록에서 다시 찾고 관 id 는 세션 id(323-5)라 관이 옮겨져도 끊기지 않는다.
* ========================================================================== */
import {
nearestWithin,
readSelection,
subscribeSelection,
type DesignSelection,
} from "./design_selection";
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
import { pipeIdAt } from "./design_pipe_ids";
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;
/** 배수유역도 — 관 마커 · 유역(알림 없이) · 측점 마름모. */
drainage?: {
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
markStation: (chainageM: number | null) => void;
} | null;
}
/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */
const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6;
/**
* 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1)
* ② 그 자리 구조물 측점(같은 m 면 측점 하나 · 구조물이 위 · 272 — 일반 카드 id 는 종단도가 숨긴 측점)
* ③ 고른 측점 그대로(일반 카드) ④ 그 자리 관 측점 · 관 알약 · 카드(`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 irregular = source.irregular();
const exact = irregular.find((entry) => sameAt(entry.chainage_m, at));
if (exact) return { stationId: irregularStationId(exact.id) };
const card = source.sections().find((entry) => sameAt(entry.chainage_m, at));
if (card) return { stationId: card.station_id };
const own = 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: pipeIdAt(target.pipeChainageM) } : target;
}
export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void {
if ("structureId" in pick) target.setSelectedStructure(pick.structureId);
else target.setSelectedStation(pick.stationId);
}
/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(a3 · a6). */
export function paintMapPick(
map: NonNullable<ProfilePickTarget["drainage"]>,
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: {
select: (
pick: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null,
) => void;
};
}
/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) ·
* 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */
export function viewerPickOf(
selection: (DesignSelection & { partKey?: string }) | null,
source: ProfilePickSource,
): {
stationId: string | null;
member: { chainageM: number; kind: string; key?: string; structureId?: string | null } | 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, structureId: selection.structureId },
};
}
export function paintViewerPick(
target: ViewerPickTarget,
pick: ReturnType<typeof viewerPickOf>,
): void {
target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게
target.structurePick.select(pick.member);
}
/**
* 종단 보기(종단도 · 3D · 배수유역도)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이
* 바뀐 뒤 · 재계산 뒤 · 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠)
* 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. Esc(풀기 한 길)는 좌측 폼까지 닫음.
*/
export function bindProfilePick(
projectId: string | null,
target: {
profile: ProfilePickTarget;
viewer?: ViewerPickTarget;
form?: { hasSelection: () => boolean; selectById: (id: null) => void };
},
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
source: ProfilePickSource,
): { paint: () => void } {
const paint = (): void => {
if (!host.visible()) return;
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, (_selection, from) => {
paint();
if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection())
target.form.selectById(null);
}),
);
bindEscapeRelease(projectId, host);
return { paint };
}
+97
View File
@@ -0,0 +1,97 @@
/* =============================================================================
* A00_Common/design/design_pipe_ids.ts
* 관 세션 id(323-5) — 관을 처음 볼 때 자리마다 붙이는 id · 옮겨도 그대로 · 저장 안 함(파일 모양 그대로).
*
* id = `pipe-s` + 차례(`pipe-s3`) — 머리 `pipe-` 로 관임을 가름(옛 자리 이름 `pipe-{누가거리}` 와 같은 머리) ·
* 누가거리는 id 에서 읽지 않음(`pipeChainageOf`). 장부 = 세션 `pipe-ids`(고름 `structure-pick` 과 같은 수명 ·
* 초안 비우기에 함께 비움). 자리 → id = 찾는 거리(`PIPE_MATCH_M`) 안 가장 가까운 것 · 없으면 새 차례 ·
* 옮김 = `movePipeId`(고름 따라가기 `followMovedPipe` 가 부름).
* 프로젝트 = 보기가 열 때 정함(`usePipeIds`) — 그리는 함수(카드 · 알약)가 프로젝트를 몰라도 같은 id.
* 프로젝트가 없으면(시험 · 도움 파일) 옛 자리 이름.
* ========================================================================== */
import { readState, writeState } from "../b_page_state";
import { isNear, nearestWithin, readSelection, selectDesign } from "./design_selection";
/** 관 찾는 거리 — 정의는 `design_selection` 한 곳(배수 패널이 관 옮김과 함께 가져감). */
export { PIPE_MATCH_M } from "./design_selection";
interface Book {
next: number;
ids: Array<{ id: string; at: number }>;
}
let active: string | null = null;
/** 지금 프로젝트 — 종단 · 횡단 보기가 열 때. */
export function usePipeIds(projectId: string | null): void {
active = projectId;
}
const bookOf = (projectId: string): Book =>
readState<Book>("pipe-ids", projectId) ?? { next: 1, ids: [] };
/** 그 자리 관의 id — 장부에 없으면 새 차례로 적음. */
export function pipeIdAt(chainageM: number, projectId: string | null = active): string {
if (!projectId) return `pipe-${chainageM.toFixed(2)}`;
const book = bookOf(projectId);
const hit = nearestWithin(book.ids, chainageM, (entry) => entry.at);
if (hit) return hit.id;
const id = `pipe-s${book.next}`;
writeState(
"pipe-ids",
{ next: book.next + 1, ids: [...book.ids, { id, at: chainageM }] },
projectId,
);
return id;
}
/** id 의 관 자리 — 장부 · 옛 자리 이름 순 · 관 id 가 아니면 null. */
export function pipeChainageOf(id: string, projectId: string | null = active): number | null {
if (!id.startsWith("pipe-")) return null;
const hit = projectId ? bookOf(projectId).ids.find((entry) => entry.id === id) : undefined;
if (hit) return hit.at;
const legacy = Number(id.slice("pipe-".length));
return Number.isFinite(legacy) ? legacy : null;
}
/** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */
export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string {
if (!projectId) return `pipe-${toM.toFixed(2)}`;
const book = bookOf(projectId);
const moving = nearestWithin(book.ids, fromM, (entry) => entry.at);
if (!moving) return pipeIdAt(toM, projectId);
const stale = nearestWithin(
book.ids.filter((entry) => entry !== moving),
toM,
(entry) => entry.at,
);
const ids = book.ids
.filter((entry) => entry !== stale)
.map((entry) => (entry === moving ? { ...entry, at: toM } : entry));
writeState("pipe-ids", { next: book.next, ids }, projectId);
return moving.id;
}
/** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */
export function withPipeIds<T extends { chainage_m: number }>(
pipes: ReadonlyArray<T>,
projectId: string | null = active,
): Array<T & { id: string }> {
return pipes.map((pipe) => ({ ...pipe, id: pipeIdAt(pipe.chainage_m, projectId) }));
}
/** 관을 옮김 — 관 세션 id 가 새 자리로(323-5 · id 그대로) · 그 관을 골라 둔 고름이면 자리만 새로(부재키
* 그대로 · 옛 자리 이름 id 면 세션 id 로). 다른 것을 골랐으면 그대로. */
export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void {
const id = movePipeId(fromM, toM, projectId);
const picked = readSelection(projectId);
if (!picked || picked.chainageM === null || !isNear(picked.chainageM, fromM)) return;
const sid = picked.structureId;
if (sid !== null && !sid.startsWith("pipe-")) return;
selectDesign(
projectId,
{ chainageM: toM, structureId: sid === null ? null : id },
{ partKey: picked.partKey },
);
}
+109 -107
View File
@@ -15,13 +15,10 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drain
import { readState, writeState } from "../b_page_state"; import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import type { import type {
StructureInstance, StructureInstance,
StructureType, StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures"; } from "../../B05_Profile/B05_Profile_Api_Structures";
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
import { import {
hasLegacyAlignment, hasLegacyAlignment,
readAlignment, readAlignment,
@@ -45,7 +42,6 @@ import type {
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { import {
buildAlignment, buildAlignment,
chainageKey,
emptyEdits, emptyEdits,
toAlignmentBase, toAlignmentBase,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
@@ -56,11 +52,7 @@ import {
structureIdKeeping, structureIdKeeping,
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; } from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import { import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
createRouteMassHaulPanel,
type MassHaulSummaryValues,
type RouteMassHaulContext,
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance"; import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights"; import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render"; import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
@@ -77,15 +69,19 @@ import {
createElevationHold, createElevationHold,
createProfileDrag, createProfileDrag,
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; } from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
import { import {
planCurveWarning, irregularGraphStations,
sampleAt, maxChainageOf,
snapToMasterStations, } from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { needsFullRedraw, 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 type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
import { noticeDroppedEdits } from "./design_save";
import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import "../../B06_Section/B06_Section_UI_Style.css"; import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
@@ -107,8 +103,8 @@ export interface DesignProfileContext {
view: DesignView; view: DesignView;
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */ /** 오른쪽 판 — 없으면 판 없이 종단 열만. */
side?: DesignSideFactory; side?: DesignSideFactory;
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */ /** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */
sideTool?: (board: DesignSideBoard) => HTMLElement; toolLead?: HTMLElement;
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */ /** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
onSelectStation: (stationId: string | null) => void; onSelectStation: (stationId: string | null) => void;
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */ /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
@@ -161,40 +157,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
if (drag.isSubDragging()) scheduleLightSync(); if (drag.isSubDragging()) scheduleLightSync();
else draw(); else draw();
}; };
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). // 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
* 유토곡선을 접어도 이 값은 남아야 한다. */ const { massHaul, tableOverlay } = createProfileOverlays({
const massBadge = createMassHaulBadge();
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
bodyWrap.append(massBadge.root);
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
const haulPrefetch = createHaulPlanPrefetch(
projectId, projectId,
() => routeId ?? undefined, routeId: () => routeId,
subPanelChanged, bodyWrap,
); body,
const massHaul = createRouteMassHaulPanel( onChange: subPanelChanged,
subPanelChanged, });
(summary) => {
const before = massHaulSummary;
massHaulSummary = summary;
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
},
haulPrefetch,
);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(subPanelChanged);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
tableOverlay.attachScrollSync(body);
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
body.addEventListener("contextmenu", (event) => event.preventDefault());
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
const content = document.createElement("div"); const content = document.createElement("div");
content.className = "b05-route-profile__content"; content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
@@ -249,6 +219,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지). // 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 }; let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null; let base: AlignmentBase | null = null;
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
let storedBase: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null; let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
@@ -261,9 +233,16 @@ export function createDesignProfile(ctx: DesignProfileContext) {
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로 /** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */ * 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
const structureIdAtStation = (stationId: string | null): string | null => const structureIdAtStation = (stationId: string | null): string | null =>
structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail); structureIdKeeping(
stationId,
selectedStructureId,
irregularStations,
structures,
detail,
structureTypes,
);
const stationIdAtStructure = (structureId: string | null): string | null => const stationIdAtStructure = (structureId: string | null): string | null =>
stationIdAtStructureOf(structureId, irregularStations, structures); stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes);
/** /**
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
@@ -288,7 +267,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
planCurve: planCurveWarning(alignment, detail), planCurve: planCurveWarning(alignment, detail),
minCoverViolations, minCoverViolations,
balanceBar, balanceBar,
tools: tools.render(), tools: withToolLead(tools.render()),
trailing, trailing,
alignment, alignment,
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
@@ -310,11 +289,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */ /** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw()); const elevationHold = createElevationHold(body, () => draw());
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
const trailing = document.createElement("div"); const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing"; trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : [])); trailing.append(profileZoom.bar);
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
const withToolLead = (wrap: HTMLElement): HTMLElement => {
if (ctx.toolLead) wrap.prepend(ctx.toolLead);
return wrap;
};
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */ /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
const { tools, history, handleToolPick, setListening } = createPanelTools({ const { tools, history, handleToolPick, setListening } = createPanelTools({
@@ -360,6 +342,33 @@ export function createDesignProfile(ctx: DesignProfileContext) {
history.record(); history.record();
} }
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
function listed(): AlignmentBase | null {
if (!storedBase || !detail) return storedBase;
const server = detail.longitudinal.stations;
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
const list = oneStationList(server, planted);
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
const gone = server
.filter((station) => station.kind === "irregular" && !station.grid)
.map((station) => station.chainage_m)
.filter((chainage) => !after.has(chainage.toFixed(3)));
const born = list
.map((station) => station.chainage_m)
.filter((chainage) => !before.has(chainage.toFixed(3)));
return {
...storedBase,
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
stations: list.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
})),
};
}
/** /**
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만 * 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
* 실제 렌더링하도록 모은다. * 실제 렌더링하도록 모은다.
@@ -372,7 +381,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
scheduleCrossPreview(); scheduleCrossPreview();
if (redrawPending) return; if (redrawPending) return;
redrawPending = true; redrawPending = true;
requestAnimationFrame(() => { frameOf(root, () => {
redrawPending = false; redrawPending = false;
draw(); draw();
}); });
@@ -418,6 +427,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
function draw(): void { function draw(): void {
renderProfile({ renderProfile({
onStaleDesigns: crossPreview.scheduleIfStale, // 그린 뒤 생긴 낡음도 다시 셈(317-1)
body, body,
callbacks, callbacks,
massHaul, massHaul,
@@ -453,7 +463,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
zoom: profileZoom.state, zoom: profileZoom.state,
holdElevationRange: elevationHold.hold, holdElevationRange: elevationHold.hold,
toolActive: () => tools.mode() !== "none", toolActive: () => tools.mode() !== "none",
selectedRuns: () => tools.selectedRuns(), tools,
stationIdAtStructure, stationIdAtStructure,
redraw: draw, redraw: draw,
setElevationWindowUpdater: (update) => { setElevationWindowUpdater: (update) => {
@@ -462,46 +472,27 @@ export function createDesignProfile(ctx: DesignProfileContext) {
}); });
} }
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ /** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */
const SCROLL_SETTLE_MS = 80; const reveal = createSelectionReveal(body);
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ const revealPick = (): void =>
let settledScrollLeft = 0; reveal(
let scrollSettleTimer = 0; (selectedStationId ?? selectedStructureId)
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 ? `${selectedStationId}|${selectedStructureId}`
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 : null,
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. );
body.addEventListener("scroll", () => { // 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
if (elevationHold.editing()) return; const unbindViewport = bindProfileViewport({
const fitted = updateElevationWindow?.() ?? null; body,
window.clearTimeout(scrollSettleTimer); editing: () => elevationHold.editing(),
if (!needsFullRedraw(fitted)) return; fitElevation: () => updateElevationWindow?.() ?? null,
scrollSettleTimer = window.setTimeout(() => { draw: () => draw(),
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; lastSize: () => ({ width: lastWidth, height: lastHeight }),
settledScrollLeft = body.scrollLeft; scheduleLightSync,
draw(); scheduleDraw: () => drag.scheduleDraw(),
}, SCROLL_SETTLE_MS);
}); });
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
attachWheelHorizontalScroll(body);
const resizeObserver = new ResizeObserver(() => {
if (
body.clientWidth <= 0 ||
body.clientHeight <= 0 ||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
)
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
scheduleLightSync();
drag.scheduleDraw();
});
resizeObserver.observe(body);
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw)); bindProfileCollapse(root, panelHandle, () => frameOf(root, draw));
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
function syncDrainageSpans(): void { function syncDrainageSpans(): void {
@@ -529,7 +520,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
savedEdits = stored?.edits ?? emptyEdits(); savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild()); store = createProfileEditStore(routeId, savedEdits, () => rebuild());
} }
base = stored ? toAlignmentBase(stored) : null; storedBase = stored ? toAlignmentBase(stored) : null;
base = listed();
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried); if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null; alignment = base ? buildAlignment(base, store.edits()) : null;
@@ -540,11 +532,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
nextDetail.balloon_offsets ?? undefined, nextDetail.balloon_offsets ?? undefined,
); );
draw(); draw();
requestAnimationFrame(draw); frameOf(root, draw);
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
//
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이 // 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기 // 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다 // 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
@@ -578,6 +569,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17). // 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
selectedStructureId = structureIdAtStation(stationId); selectedStructureId = structureIdAtStation(stationId);
draw(); draw();
revealPick();
}, },
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) { setEnforceMinCover(enforce: boolean) {
@@ -592,9 +584,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19 * 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */ * 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
setGradeLimit(maxGradePct: number) { setGradeLimit(maxGradePct: number) {
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return; if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return; if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } }; const policy = { ...base.policy, max_grade_pct: maxGradePct };
storedBase = { ...storedBase, policy };
base = { ...base, policy };
alignment = buildAlignment(base, store.edits()); alignment = buildAlignment(base, store.edits());
draw(); draw();
}, },
@@ -604,7 +598,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
}, },
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) { setIrregularStations(stations: IrregularStation[]) {
irregularStations = snapToMasterStations(alignment, stations); irregularStations = stations;
base = listed();
if (base) alignment = buildAlignment(base, store.edits());
draw(); draw();
}, },
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */ /** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
@@ -629,6 +625,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId; const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
selectedStationId = keep ? selectedStationId : own; selectedStationId = keep ? selectedStationId : own;
draw(); draw();
revealPick();
}, },
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
setStationDisplay(next: { station: number; cumulative: number }) { setStationDisplay(next: { station: number; cumulative: number }) {
@@ -640,20 +637,24 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다. * 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
*/ */
resetStationEdit(chainageM: number, moveTo?: number) { resetStationEdit(chainageM: number, moveTo?: number) {
const key = chainageKey(chainageM); // 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
const edits = store.edits(); const edits = store.edits();
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
store.resetStation(chainageM, moveTo); (entry) => Math.abs(Number(entry) - chainageM) < 0.006,
);
if (key !== undefined) store.resetStation(Number(key), moveTo);
}, },
isDirty: () => store.dirty(), isDirty: () => store.dirty(),
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> { async save(): Promise<void> {
if (!routeId || !store.dirty()) return; if (!routeId || !store.dirty()) return;
const saved = await saveProfileAlignment(projectId, routeId, store.edits()); const saved = await saveProfileAlignment(projectId, routeId, store.edits());
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined; const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) { if (next?.base_pvi?.length) {
base = toAlignmentBase(next); storedBase = toAlignmentBase(next);
alignment = next; base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next; if (detail) detail.longitudinal.profile_alignment = next;
} }
store.markSaved(); store.markSaved();
@@ -662,6 +663,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
clear() { clear() {
detail = null; detail = null;
base = null; base = null;
storedBase = null;
alignment = null; alignment = null;
selectedStationId = null; selectedStationId = null;
balanceBar.replaceChildren(); balanceBar.replaceChildren();
@@ -689,7 +691,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
setListening(false); setListening(false);
drag.dispose(); drag.dispose();
crossPreview.dispose(); crossPreview.dispose();
resizeObserver.disconnect(); unbindViewport();
}, },
}; };
} }
+363
View File
@@ -0,0 +1,363 @@
/* =============================================================================
* A00_Common/design/design_profile_free.ts
* 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1).
*
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
*
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
* (R = L / |대수차|). 양 끝 세로선의 라운드는 직선화처럼 그대로 둔다.
*
* 화면(`design_profile_free_view.ts`)은 클릭 · 마우스 · 키만 넘기고 여기 표시 자료를 그린다.
* ========================================================================== */
import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
chainageKey,
controlElevationAt,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import {
baseElevationAt,
defaultCurveLength,
} from "../../B05_Profile/B05_Profile_UI_Profile_Straighten";
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
const SAME_M = 1e-6;
/** 클릭 높이를 이 단위(m)로 끊는다. */
const ELEVATION_STEP_M = 0.01;
export interface FreeVertex {
chainage_m: number;
elevation_m: number;
}
/** range = 범위 고르는 중 · armed = 시작 세로선을 기다림 · drawing = 점 찍는 중. */
export type FreePhase = "range" | "armed" | "drawing";
/** 그래프 세로 창 — 그릴 때 기준(`data-yw-*`)과 지금 걸린 변환. */
export interface ChartWindow {
center: number;
pxPerM: number;
zero: number;
scale: number;
offset: number;
}
/** 세로 창 변환 글(`translate(0 a) scale(1 b)`)을 읽는다 — 없으면 그릴 때 그대로. */
export function parseWindowTransform(text: string | null): { scale: number; offset: number } {
const num = "([-+0-9.eE]+)";
const match = new RegExp(`translate\\(\\s*0[ ,]+${num}\\s*\\)\\s*scale\\(\\s*1[ ,]+${num}`).exec(
text ?? "",
);
if (!match) return { scale: 1, offset: 0 };
const offset = Number(match[1]);
const scale = Number(match[2]);
return Number.isFinite(offset) && Number.isFinite(scale) && scale > 0
? { scale, offset }
: { scale: 1, offset: 0 };
}
/** 그래프 y(px) → 표고(m). 과장 배율은 `pxPerM` 에 들어 있다. */
export function elevationAtChartY(view: ChartWindow, y: number): number {
const baked = (y - view.offset) / view.scale;
return view.center + (view.zero - baked) / view.pxPerM;
}
/** 표고(m) → 그래프 y(px). `elevationAtChartY` 의 역. */
export function chartYAtElevation(view: ChartWindow, elevationM: number): number {
return view.scale * (view.zero - (elevationM - view.center) * view.pxPerM) + view.offset;
}
/** 가장 가까운 측점 세로선. 범위를 주면 그 안(양 끝 포함)에서만 고른다. */
export function snapStation(
stations: number[],
chainageM: number,
range: [number, number] | null = null,
): number | null {
let best: number | null = null;
for (const station of stations) {
if (range && (station < range[0] - SAME_M || station > range[1] + SAME_M)) continue;
if (best === null || Math.abs(station - chainageM) < Math.abs(best - chainageM)) {
best = station;
}
}
return best;
}
/** 두 점 사이 기울기(%). */
export function gradePct(from: FreeVertex, to: FreeVertex): number {
const span = to.chainage_m - from.chainage_m;
return Math.abs(span) > 1e-12 ? ((to.elevation_m - from.elevation_m) / span) * 100 : 0;
}
/** 임도 기준 최대 기울기를 넘는가 — 판정은 선형 계산의 위반 판정(`violations`)과 같은 식. */
export function overGrade(pct: number, maxPct: number): boolean {
return Math.abs(pct) > maxPct + 1e-6;
}
/** 점들을 잇는 꺾은선 위 표고(chainage 오름차순 꺾은선). */
function elevationOnPath(path: FreeVertex[], chainageM: number): number {
for (let index = 1; index < path.length; index += 1) {
const left = path[index - 1];
const right = path[index];
if (chainageM > right.chainage_m + SAME_M) continue;
const span = right.chainage_m - left.chainage_m;
const ratio = span > 1e-12 ? (chainageM - left.chainage_m) / span : 0;
return left.elevation_m + (right.elevation_m - left.elevation_m) * ratio;
}
return path[path.length - 1].elevation_m;
}
/**
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
*/
export function buildFreeEdits(
base: AlignmentBase,
edits: AlignmentEdits,
vertices: FreeVertex[],
): AlignmentEdits {
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
if (path.length < 2) return edits;
const fromM = path[0].chainage_m;
const toM = path[path.length - 1].chainage_m;
if (toM - fromM <= SAME_M) return edits;
const inner = new Map<string, number>();
buildAlignment(base, edits).pvi.forEach((node) => {
if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) {
inner.set(chainageKey(node.chainage_m), node.chainage_m);
}
});
path
.slice(1, -1)
.forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m));
const offsets = { ...edits.station_offsets };
const radii = { ...edits.curve_radii };
inner.forEach((chainage, key) => {
const target = elevationOnPath(path, chainage);
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
delete radii[key];
});
for (let index = 1; index < path.length - 1; index += 1) {
const gradeIn = gradePct(path[index - 1], path[index]) / 100;
const gradeOut = gradePct(path[index], path[index + 1]) / 100;
const deltaGrade = Math.abs(gradeOut - gradeIn);
if (deltaGrade < 1e-9) continue;
radii[chainageKey(path[index].chainage_m)] = Number(
(defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6),
);
}
return { ...edits, station_offsets: offsets, curve_radii: radii };
}
export interface FreeSegment {
from: FreeVertex;
to: FreeVertex;
grade_pct: number;
over: boolean;
/** 마지막 점 → 마우스 자리(아직 안 찍음). */
preview: boolean;
}
/** 오버레이 표시 자료 — 화면은 이것만 그린다. */
export interface FreeMarks {
/** 고른 범위(강조 띠) — 둘 다 고르기 전엔 null. */
band: [number, number] | null;
/** 세로 강조선 chainage(범위 첫 측점 또는 범위 양 끝). */
edges: number[];
segments: FreeSegment[];
/** 찍힌 점(시작 점 포함). */
dots: FreeVertex[];
}
export interface FreeDrawDeps {
base: () => AlignmentBase | null;
alignment: () => ProfileAlignment | null;
edits: () => AlignmentEdits;
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
apply: (next: AlignmentEdits) => void;
/** 모드를 끈다(적용 뒤 · Esc). */
close: () => void;
/** 단계 · 안내가 바뀜. */
changed: () => void;
}
export interface FreeDraw {
phase: () => FreePhase;
reset: () => void;
/** 그래프 클릭 — 날 chainage · 표고와 그래프 측점선 목록. 먹었으면 true. */
click: (chainageM: number, elevationM: number, stations: number[]) => boolean;
/** 마우스 자리 — 미리 보기 대상. 그래프 밖이면 null. */
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
/** Enter · Escape · Backspace — 먹었으면 true. */
key: (key: string) => boolean;
marks: () => FreeMarks;
hint: () => string;
}
export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
let phase: FreePhase = "range";
let pending: number | null = null;
let range: [number, number] | null = null;
let anchor: number | null = null;
let points: FreeVertex[] = [];
let target: FreeVertex | null = null;
function reset(): void {
phase = "range";
pending = null;
range = null;
anchor = null;
points = [];
target = null;
}
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
function heightAt(chainageM: number): number | null {
const alignment = deps.alignment();
return alignment ? controlElevationAt(alignment, chainageM) : null;
}
const finishM = (): number | null =>
range && anchor !== null ? (anchor === range[0] ? range[1] : range[0]) : null;
const same = (a: number | null, b: number | null): boolean =>
a !== null && b !== null && Math.abs(a - b) < SAME_M;
/** 지나온 점(시작 포함) — 마지막 것이 다음 선의 출발점. */
function path(): FreeVertex[] {
if (anchor === null) return [];
const z = heightAt(anchor);
return z === null ? [] : [{ chainage_m: anchor, elevation_m: z }, ...points];
}
/** 클릭 · 마우스 자리 → 다음 점 후보. 끝 세로선이면 그 계획고, 뒤로 가면 null. */
function candidate(chainageM: number, elevationM: number, stations: number[]): FreeVertex | null {
if (phase !== "drawing" || !range) return null;
const station = snapStation(stations, chainageM, range);
const end = finishM();
if (station === null || end === null) return null;
if (same(station, end)) {
const z = heightAt(end);
return z === null ? null : { chainage_m: end, elevation_m: z };
}
const walked = path();
const last = walked[walked.length - 1];
if (!last || (station - last.chainage_m) * Math.sign(end - last.chainage_m) <= SAME_M) {
return null;
}
const z = Math.round(elevationM / ELEVATION_STEP_M) * ELEVATION_STEP_M;
return { chainage_m: station, elevation_m: Number(z.toFixed(3)) };
}
function finish(): void {
const base = deps.base();
const end = finishM();
const z = end === null ? null : heightAt(end);
if (!base || end === null || z === null) return;
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
const next = buildFreeEdits(base, deps.edits(), vertices);
reset();
deps.apply(next);
deps.close();
}
return {
phase: () => phase,
reset,
click(chainageM, elevationM, stations) {
if (phase === "range") {
const station = snapStation(stations, chainageM);
if (station === null) return true;
if (pending === null || same(pending, station)) pending = station;
else {
range = [Math.min(pending, station), Math.max(pending, station)];
pending = null;
phase = "armed";
}
} else if (phase === "armed" && range) {
const station = snapStation(stations, chainageM, range);
if (same(station, range[0]) || same(station, range[1])) {
anchor = station;
points = [];
phase = "drawing";
}
} else if (phase === "drawing") {
const next = candidate(chainageM, elevationM, stations);
if (!next) return true;
if (same(next.chainage_m, finishM())) {
finish();
return true;
}
points.push(next);
}
target = null;
deps.changed();
return true;
},
hover(pointer) {
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
},
key(key) {
if (key === "Escape") {
reset();
deps.close();
return true;
}
if (key === "Enter") {
if (phase !== "drawing") return false;
finish();
return true;
}
if (key === "Backspace") {
if (phase !== "drawing") return false;
if (points.length) points.pop();
else {
anchor = null;
phase = "armed";
}
target = null;
deps.changed();
return true;
}
return false;
},
marks() {
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
const dots = path();
const line = target ? [...dots, target] : dots;
const segments: FreeSegment[] = [];
for (let index = 1; index < line.length; index += 1) {
const grade = gradePct(line[index - 1], line[index]);
segments.push({
from: line[index - 1],
to: line[index],
grade_pct: grade,
over: overGrade(grade, maxPct),
preview: target !== null && index === line.length - 1,
});
}
return {
band: range,
edges: range ? [...range] : pending !== null ? [pending] : [],
segments,
dots,
};
},
hint() {
if (phase === "range") {
return pending === null
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
}
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
},
};
}
@@ -0,0 +1,217 @@
/* =============================================================================
* A00_Common/design/design_profile_free_view.ts
* 종단 [자유지정] 화면 — 그래프 클릭 · 마우스 · 오버레이(범위 강조 · 점선 · 기울기 글)(215-1).
*
* 계산 · 상태는 `design_profile_free.ts` 몫. 여기서는 마우스 자리를 (chainage, 표고)로 바꿔
* 넘기고 돌려받은 표시 자료를 그린다. 표고 변환은 종단 그래프 세로 창(`data-yw-*` + 지금 걸린
* 변환)을 그대로 되짚는다 — 스크롤로 창이 바뀌어도 같은 식이다.
*
* 그래프는 그릴 때마다 새로 만들어지므로 그릴 때마다 `mount` 를 다시 부른다.
* 자유지정 중엔 그래프 클릭 · 누르기를 여기서 먹는다(측점 고르기 · 구조물 끌기로 새지 않게).
* ========================================================================== */
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../../B06_Section/B06_Section_UI_Longitudinal";
import {
chartYAtElevation,
elevationAtChartY,
parseWindowTransform,
type ChartWindow,
type FreeDraw,
} from "./design_profile_free";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 종단 그래프 한 벌의 가로 매핑 — 렌더러가 그릴 때마다 넘긴다. */
export interface FreeChartGeom {
/** 그래프 래퍼(`b05-profile__chart`). */
chart: HTMLElement;
/** 가로 스크롤러 — 스크롤하면 세로 창이 바뀌어 다시 그린다. */
scroller: HTMLElement;
x: (chainageM: number) => number;
toChainage: (px: number) => number;
widthPx: number;
heightPx: number;
/** 그래프 세로 측점선 chainage(정규 + 구조물). */
stations: number[];
}
export interface FreeView {
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
mount: (geom: FreeChartGeom | null, active: boolean) => void;
redraw: () => void;
}
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
const element = document.createElementNS(SVG_NS, tag);
Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value)));
return element;
}
/** 지금 그래프의 세로 창 — 자동 창 SVG 가 없으면 null. */
function readWindow(chart: HTMLElement): { view: ChartWindow; top: number } | null {
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
if (!svg) return null;
const read = (name: string): number => Number(svg.getAttribute(name));
const layer = svg.querySelector(`.${Y_WINDOW_CLASS}`);
const view: ChartWindow = {
center: read(Y_WINDOW_ATTRS.center),
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
zero: read(Y_WINDOW_ATTRS.zero),
...parseWindowTransform(layer?.getAttribute("transform") ?? null),
};
if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) {
return null;
}
// 그래프 SVG 가 래퍼 위쪽에서 떨어져 있으면 그만큼 오버레이 y 를 맞춘다.
const top = svg.getBoundingClientRect().top - chart.getBoundingClientRect().top;
return { view, top };
}
export function createFreeView(free: FreeDraw): FreeView {
let geom: FreeChartGeom | null = null;
let active = false;
let overlay: SVGSVGElement | null = null;
let listeners: AbortController | null = null;
/** 마우스 자리 → (chainage, 표고). 그래프 밖이면 null. */
function pointerAt(event: MouseEvent) {
if (!geom) return null;
const win = readWindow(geom.chart);
if (!win) return null;
const rect = geom.chart.getBoundingClientRect();
const chainageM = geom.toChainage(event.clientX - rect.left);
if (!Number.isFinite(chainageM)) return null;
const elevationM = elevationAtChartY(win.view, event.clientY - rect.top - win.top);
return { chainageM, elevationM, stations: geom.stations };
}
function redraw(): void {
overlay?.remove();
overlay = null;
if (!geom || !active) return;
const win = readWindow(geom.chart);
if (!win) return;
const { x, widthPx, heightPx } = geom;
const y = (elevationM: number): number => chartYAtElevation(win.view, elevationM) + win.top;
const marks = free.marks();
const svg = svgEl("svg", {
class: "b05-profile-freemark",
width: widthPx,
height: heightPx,
viewBox: `0 0 ${widthPx} ${heightPx}`,
}) as SVGSVGElement;
if (marks.band) {
const [left, right] = marks.band.map(x);
svg.append(
svgEl("rect", {
class: "b05-profile-freemark__band",
x: left,
y: 0,
width: Math.max(0, right - left),
height: heightPx,
}),
);
}
marks.edges.forEach((chainage) =>
svg.append(
svgEl("line", {
class: "b05-profile-freemark__edge",
x1: x(chainage),
x2: x(chainage),
y1: 0,
y2: heightPx,
}),
),
);
marks.segments.forEach((segment) => {
const x1 = x(segment.from.chainage_m);
const x2 = x(segment.to.chainage_m);
const y1 = y(segment.from.elevation_m);
const y2 = y(segment.to.elevation_m);
const over = segment.over ? " is-over" : "";
svg.append(
svgEl("line", {
class: `b05-profile-freemark__line${over}${segment.preview ? " is-preview" : ""}`,
x1,
y1,
x2,
y2,
}),
);
const label = svgEl("text", {
class: `b05-profile-freemark__grade${over}`,
x: (x1 + x2) / 2,
y: (y1 + y2) / 2 - 6,
"text-anchor": "middle",
});
label.textContent = `${segment.grade_pct.toFixed(1)}%`;
svg.append(label);
});
marks.dots.forEach((dot) =>
svg.append(
svgEl("circle", {
class: "b05-profile-freemark__dot",
cx: x(dot.chainage_m),
cy: y(dot.elevation_m),
r: 3.5,
}),
),
);
overlay = svg;
geom.chart.append(svg);
}
function mount(next: FreeChartGeom | null, on: boolean): void {
listeners?.abort();
listeners = null;
if (next) geom = next;
active = on;
if (!on) free.reset();
if (!geom || !on || !geom.chart.isConnected) {
overlay?.remove();
overlay = null;
return;
}
listeners = new AbortController();
const { signal } = listeners;
const chart = geom.chart;
const swallow = (event: Event): void => {
event.stopPropagation();
event.stopImmediatePropagation();
};
// 누르기는 삼킴 — 구조물 측점선 끌기 · 계획고 버튼 길게 누르기가 시작되지 않게.
chart.addEventListener("pointerdown", swallow, { capture: true, signal });
chart.addEventListener(
"click",
(event) => {
swallow(event);
event.preventDefault();
const pointer = pointerAt(event);
if (pointer) free.click(pointer.chainageM, pointer.elevationM, pointer.stations);
free.hover(pointerAt(event));
redraw();
},
{ capture: true, signal },
);
chart.addEventListener(
"mousemove",
(event) => {
free.hover(pointerAt(event));
redraw();
},
{ signal },
);
chart.addEventListener(
"mouseleave",
() => {
free.hover(null);
redraw();
},
{ signal },
);
geom.scroller.addEventListener("scroll", redraw, { signal, passive: true });
redraw();
}
return { mount, redraw };
}
@@ -0,0 +1,69 @@
/* =============================================================================
* design_profile_overlays.ts
* 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌).
* 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
* ========================================================================== */
import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { readStateRaw, writeStateRaw } from "../b_page_state";
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
import {
createRouteMassHaulPanel,
type MassHaulSummaryValues,
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
export interface ProfileOverlaysDeps {
projectId: string;
routeId: () => number | null;
/** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */
bodyWrap: HTMLElement;
/** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */
body: HTMLElement;
/** 서브패널이 바뀜(펼침 · 범례 · 리사이저 · 유토 배분 도착). */
onChange: () => void;
}
export function createProfileOverlays(deps: ProfileOverlaysDeps) {
const { bodyWrap, body, onChange } = deps;
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
* 유토곡선을 접어도 이 값은 남아야 한다. */
const massBadge = createMassHaulBadge();
// 그래프 위 도구줄 · 측점 ▲ 줄(바닥 47px) 아래로 내려 겹치지 않게 한다(289).
massBadge.root.style.setProperty("--mass-haul-badge-top", "52px");
bodyWrap.append(massBadge.root);
// 그래프 안 끌어 옮김(309-2) — 자리는 화면 설정 한 칸(B05 · B06 같은 값)
attachMassHaulBadgeDrag(massBadge.root, body, {
read: () => readStateRaw("mass-haul-badge-pos"),
write: (value) => writeStateRaw("mass-haul-badge-pos", value),
});
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
const haulPrefetch = createHaulPlanPrefetch(
deps.projectId,
() => deps.routeId() ?? undefined,
onChange,
);
const massHaul = createRouteMassHaulPanel(
onChange,
(summary) => {
const before = massHaulSummary;
massHaulSummary = summary;
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
},
haulPrefetch,
);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(onChange);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
tableOverlay.attachScrollSync(body);
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
body.addEventListener("contextmenu", (event) => event.preventDefault());
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
return { massHaul, tableOverlay };
}
+109 -28
View File
@@ -1,6 +1,7 @@
/* ============================================================================= /* =============================================================================
* A00_Common/design/design_profile_popout.ts * A00_Common/design/design_profile_popout.ts
* 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35). * 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35).
* 유토곡선 · 배수유역 판은 따로 또 뗀다(계획서 325-3) — 같은 몸통(`attachPopout`), 창 이름만 다름.
* *
* 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다. * 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다.
* 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난 * 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난
@@ -13,30 +14,39 @@ import type { DesignProfile } from "./design_profile";
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ /** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"]; const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */ /** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */
export function attachProfilePopout( const viewOfNode = (node: Node): Window => node.ownerDocument?.defaultView ?? window;
profile: DesignProfile,
slot: HTMLElement, /** 조각 하나를 새 창으로 떼는 단추 — 창 이름은 조각마다 달라야 함(같으면 앞 창 body 를 덮어 조각이 사라짐). */
sideTool: HTMLElement | null, interface PopoutSpec {
): void { piece: HTMLElement;
const { root } = profile; button: HTMLButtonElement;
name: string;
features: string;
docTitle: string;
/** 조각을 제자리로 돌림. */
home: () => void;
/** 뗐다 · 붙였다(조각이 옮겨 간 뒤). */
onMove?: (popped: boolean) => void;
}
/** 조각을 새 창으로 떼고 붙이는 몸통 — 새 창은 단추가 놓인 창에서 엶(종단 새 창 안에서 다시 떼기). */
function attachPopout(profile: DesignProfile, spec: PopoutSpec): void {
const { piece, button } = spec;
let popup: Window | null = null; let popup: Window | null = null;
const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; button.textContent = "창 분할";
button.textContent = "새 창";
button.title = "종단 하단 패널을 브라우저 새 창으로 띄움";
button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open())); button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open()));
sideTool?.after(button);
const restore = (): void => { const restore = (): void => {
if (!root.classList.contains("is-popout")) return; if (!piece.classList.contains("is-popout")) return;
root.classList.remove("is-popout"); piece.classList.remove("is-popout");
button.textContent = "새 창"; button.textContent = "창 분할";
slot.append(root); spec.home();
popup = null; popup = null;
requestAnimationFrame(profile.redraw); spec.onMove?.(false);
viewOfNode(piece).requestAnimationFrame(profile.redraw);
}; };
const closePopup = (): void => { const closePopup = (): void => {
popup?.close(); popup?.close();
@@ -51,28 +61,31 @@ export function attachProfilePopout(
}; };
function open(): void { function open(): void {
const next = window.open("", "aislo-profile-popout", "popup,width=1400,height=720"); const opener = viewOfNode(button);
const next = opener.open("", spec.name, spec.features);
if (!next) { if (!next) {
showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error"); showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error");
return; return;
} }
popup = next; popup = next;
const source = opener.document;
const doc = next.document; const doc = next.document;
doc.title = "종단 — Aislo"; doc.title = spec.docTitle;
// 스타일 · 테마 — 메인 창 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. // 스타일 · 테마 — 단추가 놓인 창의 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다.
doc.head.replaceChildren( doc.head.replaceChildren(
...Array.from(document.querySelectorAll('link[rel="stylesheet"], style'), (node) => ...Array.from(source.querySelectorAll('link[rel="stylesheet"], style'), (node) =>
doc.importNode(node, true), doc.importNode(node, true),
), ),
); );
Array.from(document.documentElement.attributes).forEach((attr) => Array.from(source.documentElement.attributes).forEach((attr) =>
doc.documentElement.setAttribute(attr.name, attr.value), doc.documentElement.setAttribute(attr.name, attr.value),
); );
doc.body.className = document.body.className; doc.body.className = source.body.className;
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`; doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${opener.getComputedStyle(source.body).backgroundColor}`;
root.classList.add("is-popout"); piece.classList.add("is-popout");
button.textContent = "제자리로"; button.textContent = "창 결합";
doc.body.replaceChildren(root); doc.body.replaceChildren(piece);
spec.onMove?.(true);
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정). // 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
FORWARD_TYPES.forEach((type) => FORWARD_TYPES.forEach((type) =>
@@ -95,7 +108,75 @@ export function attachProfilePopout(
new (next as Window & typeof globalThis).ResizeObserver(() => { new (next as Window & typeof globalThis).ResizeObserver(() => {
next.cancelAnimationFrame(pending); next.cancelAnimationFrame(pending);
pending = next.requestAnimationFrame(profile.redraw); pending = next.requestAnimationFrame(profile.redraw);
}).observe(root); }).observe(piece);
next.addEventListener("pagehide", restore); next.addEventListener("pagehide", restore);
} }
} }
/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
export function attachProfilePopout(
profile: DesignProfile,
slot: HTMLElement,
button: HTMLButtonElement,
): void {
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합";
attachPopout(profile, {
piece: profile.root,
button,
name: "aislo-profile-popout",
features: "popup,width=1400,height=720",
docTitle: "종단 — Aislo",
home: () => slot.append(profile.root),
});
}
/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */
export function attachSubPanelPopouts(profile: DesignProfile): void {
const subButton = (title: string): HTMLButtonElement => {
const button = document.createElement("button");
button.className = "ui-btn ui-btn--ghost b05-subpanel__popout";
button.title = title;
return button;
};
const massOverlay = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-overlay");
const massHandle = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-handle");
if (massOverlay && massHandle) {
const button = subButton("유토곡선 창 분할");
massOverlay.append(button);
attachPopout(profile, {
piece: massOverlay,
button,
name: "aislo-masshaul-popout",
features: "popup,width=1400,height=420",
docTitle: "유토곡선 — Aislo",
// 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판).
home: () => massHandle.before(massOverlay),
onMove: (popped) => {
// 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀).
massHandle.hidden = popped;
massOverlay.style.height = "";
},
});
}
const drainage = profile.side;
const header = drainage?.root.querySelector<HTMLElement>(".b05-drainage__header");
if (drainage && header) {
const button = subButton("배수유역 창 분할");
header.prepend(button);
const content = drainage.root.parentElement;
attachPopout(profile, {
piece: drainage.root,
button,
name: "aislo-drainage-popout",
features: "popup,width=1000,height=720",
docTitle: "배수유역 — Aislo",
// 제자리 = 종단 content 끝(종단 본문 오른쪽).
home: () => content?.append(drainage.root),
// 지도 크기 관찰을 놓인 창 것으로(325-1).
onMove: () => drainage.rehome(),
});
}
}
@@ -0,0 +1,100 @@
/* =============================================================================
* design_profile_viewport.ts
* 종단 스크롤러의 가로 스크롤 · 휠 · 크기 바뀜 듣개(213-3 · 700줄 한계로 뗌).
* 다시 그리기 · 높이 동기화는 종단도가 넘긴 함수를 부른다.
* ========================================================================== */
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;
export interface ProfileViewportDeps {
body: HTMLElement;
/** ▲▼ 계획고 편집으로 Y 축을 잡고 있는 중. */
editing: () => boolean;
/** 세로 창 갱신기 — 그릴 때마다 렌더러가 새로 준다(없으면 null). */
fitElevation: () => ElevationWindowResult | null;
draw: () => void;
/** 마지막으로 그린 본문 크기 — 같으면 크기 바뀜을 무시한다. */
lastSize: () => { width: number; height: number };
scheduleLightSync: () => void;
scheduleDraw: () => void;
}
/** 듣개를 붙이고 떼는 함수를 돌려준다. */
export function bindProfileViewport(deps: ProfileViewportDeps): () => void {
const { body } = deps;
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
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();
timerView.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
timerView = viewOf(body);
scrollSettleTimer = timerView.setTimeout(() => {
if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = body.scrollLeft;
deps.draw();
}, SCROLL_SETTLE_MS);
});
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
attachWheelHorizontalScroll(body);
const resizeObserver = new ResizeObserver(() => {
const last = deps.lastSize();
if (
body.clientWidth <= 0 ||
body.clientHeight <= 0 ||
(Math.abs(body.clientWidth - last.width) < 1 && Math.abs(body.clientHeight - last.height) < 1)
)
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
deps.scheduleLightSync();
deps.scheduleDraw();
});
resizeObserver.observe(body);
return () => resizeObserver.disconnect();
}
/** 고른 것이 보이는 칸 가장자리에서 이만큼(폭 비율) 안쪽이면 그대로 둔다. */
const REVEAL_EDGE = 0.1;
/**
* 고른 측점선(없으면 고른 구조물 알약)이 종단도 보이는 칸 밖이면 가운데로 민다 — 다른 영역(횡단 카드 ·
* 3D · 배수유역도 · 좌측 목록)에서 고른 자리도 종단에서 바로 보이게. 칠한 결과(강조된 선 · 알약)를
* 보고 옮긴다 · 같은 고름을 다시 칠할 때는 안 밂(사용자가 옮겨 본 자리를 지킴) · 그래프 안에서
* 누른 것은 이미 보이는 자리라 안 움직임. `key` = 고름 열쇠(풀면 null).
*/
export function createSelectionReveal(body: HTMLElement): (key: string | null) => void {
let shown: string | null = null;
return (key) => {
if (key === null || key === shown) {
shown = key;
return;
}
const mark =
body.querySelector(".b06-chart__station--selected") ??
body.querySelector(".b05-structure__mark.is-selected");
if (!mark || body.clientWidth <= 0) return; // 아직 안 그려짐 — 다음 칠하기에서
shown = key;
const box = mark.getBoundingClientRect();
const view = body.getBoundingClientRect();
const at = box.left + box.width / 2 - view.left;
if (at >= view.width * REVEAL_EDGE && at <= view.width * (1 - REVEAL_EDGE)) return;
// 바로 옮김 — 고른 뒤 잇따르는 다시 그리기가 부드러운 이동을 끊음(다시 그리기는 지금 자리를 지킴)
body.scrollLeft += at - view.width / 2;
};
}
+26 -1
View File
@@ -57,6 +57,13 @@ export interface DesignSaveContext {
failed: (error: unknown, confirm: boolean) => void; failed: (error: unknown, confirm: boolean) => void;
} }
const saving = new Set<string>();
/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */
export function savingDesign(projectId: string | null): boolean {
return projectId !== null && saving.has(projectId);
}
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */ /** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
function reportOnly(label: string): (error: unknown) => void { function reportOnly(label: string): (error: unknown) => void {
return (error) => { return (error) => {
@@ -65,11 +72,26 @@ function reportOnly(label: string): (error: unknown) => void {
}; };
} }
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void {
const dropped = saved.dropped_edit_chainages ?? [];
if (!dropped.length) return;
const where = dropped
.slice(0, 5)
.map((value) => `${value.toFixed(2)}m`)
.join(" · ");
showToast(
`계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` +
(dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""),
"info",
);
}
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */ /** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> { async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
const draft = pendingAlignment(routeId); const draft = pendingAlignment(routeId);
if (!draft) return; if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft); noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
markAlignmentSaved(routeId); markAlignmentSaved(routeId);
} }
@@ -115,6 +137,7 @@ export async function saveDesign(
const routeId = ctx.routeId(); const routeId = ctx.routeId();
if (routeId === null) return false; if (routeId === null) return false;
showLoadingOverlay(); showLoadingOverlay();
saving.add(ctx.projectId);
try { try {
await ctx.prepare?.(); await ctx.prepare?.();
for (const step of DESIGN_SAVE_ORDER) { for (const step of DESIGN_SAVE_ORDER) {
@@ -135,12 +158,14 @@ export async function saveDesign(
await (label ? run().catch(reportOnly(label)) : run()); await (label ? run().catch(reportOnly(label)) : run());
} }
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
await ctx.done?.(confirm); await ctx.done?.(confirm);
return true; return true;
} catch (error) { } catch (error) {
ctx.failed(error, confirm); ctx.failed(error, confirm);
return false; return false;
} finally { } finally {
saving.delete(ctx.projectId);
hideLoadingOverlay(); hideLoadingOverlay();
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@
import { readState, writeState } from "../b_page_state"; import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */ /** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51; export const PIPE_MATCH_M = 0.51;
export interface DesignSelection { export interface DesignSelection {
+6 -5
View File
@@ -21,10 +21,10 @@ import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
import { createStructuresSection, type StructuresSection } from "../b_structures_section"; import { createStructuresSection, type StructuresSection } from "../b_structures_section";
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile"; import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout } from "./design_profile_popout"; import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
import { saveDesign, type DesignSaveStep } from "./design_save"; import { saveDesign, type DesignSaveStep } from "./design_save";
import { resetDesign } from "./design_reset"; import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock"; import type { DesignView } from "./design_dock";
@@ -150,12 +150,12 @@ function toAll<K extends keyof RouteProfilePanelCallbacks>(
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */ /** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
export function sharedProfile(shared: DesignPageShared): DesignProfile { export function sharedProfile(shared: DesignPageShared): DesignProfile {
if (shared.profile) return shared.profile; if (shared.profile) return shared.profile;
let sideTool: HTMLElement | null = null; const popoutButton = document.createElement("button");
const profile = createDesignProfile({ const profile = createDesignProfile({
projectId: shared.projectId, projectId: shared.projectId,
view: shared.view(), view: shared.view(),
side: drainageSide, side: drainageSide,
sideTool: (board) => (sideTool = drainageSideTool(board)), toolLead: popoutButton,
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId), onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
onResetAll: () => bound(shared).profile?.onResetAll?.(), onResetAll: () => bound(shared).profile?.onResetAll?.(),
callbacks: { callbacks: {
@@ -178,7 +178,8 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
}, },
}); });
shared.profileSlot.append(profile.root); shared.profileSlot.append(profile.root);
attachProfilePopout(profile, shared.profileSlot, sideTool); attachProfilePopout(profile, shared.profileSlot, popoutButton);
attachSubPanelPopouts(profile);
shared.profile = profile; shared.profile = profile;
return profile; return profile;
} }
@@ -16,6 +16,8 @@
* ========================================================================== */ * ========================================================================== */
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
import type { SpecForm } from "./design_structure_form";
import type { SpecContainer, SpecItem } from "./design_structure_spec";
/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */ /** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */
export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null { export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null {
@@ -23,6 +25,54 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null; return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
} }
/**
* 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3).
* · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로.
* · 새 시설(`arm(true)`) 빈 높이 = 유량이 오면 필요 수심(사용자가 넣은 것처럼 — [추가] · [수정]이 읽음).
* · 상자 끝 안내 — 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」(유량이 없으면 손 입력).
* `box` = 폭 · 높이 칸이 든 상자 id(세월교 `ford` · 물넘이 `slope`) · `flow` = 담당 유역 설계유량.
*/
export function overflowSync(
form: () => Pick<SpecForm, "state" | "setValue" | "refresh">,
box: string,
flow: () => number | null,
emit: () => void,
) {
let pending = false;
const values = () => form().state()[box]?.[0] ?? {};
const width = () => Number(values().width ?? "");
const need = (): number | null => fordHeightM(flow(), width());
return {
arm: (fresh: boolean): void => {
pending = fresh;
},
onChange: (c: SpecContainer, item: SpecItem): void => {
if (c.id === box && item.id === "height") pending = false;
const want = c.id === box ? need() : null;
const height = values().height ?? "";
const low = height === "" || !(Number(height) >= (want ?? 0));
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
form().setValue(box, "height", want.toFixed(2));
emit();
},
/** 유량이 왔거나 시설을 올림 — 새 시설 빈 높이를 채우고 안내를 다시 그림. */
fill: (): void => {
const want = pending && !values().height ? need() : null;
if (want === null) return form().refresh();
form().setValue(box, "height", want.toFixed(2));
pending = false;
},
/** 상자 끝 안내 글(폼 `fields` 훅). */
fields: (c: SpecContainer): HTMLElement[] => {
if (c.id !== box) return [];
const hint = document.createElement("p");
hint.className = "b05-structure__flow-hint";
hint.textContent = fordHeightHint(flow(), width());
return [hint];
},
};
}
/** 높이 빈 칸 안내. */ /** 높이 빈 칸 안내. */
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string { export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
const need = fordHeightM(designFlowM3s, widthM); const need = fordHeightM(designFlowM3s, widthM);
@@ -2,13 +2,16 @@
* design_structure_ford_pavement.ts * design_structure_ford_pavement.ts
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로. * 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
* *
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임). * 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 다른 구조물과
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` · * 같은 칸 `CHUTE_ITEMS` + 맨 아래 길이 · 286-3).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_*` ·
* 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`). * 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`).
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음. * 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
* import 없음 — 시험이 이 파일만 옮겨 돌림. * 길이(`ford_chute_{n}_length_m`) = 횡단 · 3D 띠를 사면 따라 그릴 길이 — 그림 전용(수량 · 내역 · 일위대가에 안 씀) ·
* 비면 원지반까지.
* ========================================================================== */ * ========================================================================== */
import { CHUTE_ITEMS } from "./design_structure_guard";
import type { StructureSpec } from "./design_structure_spec"; import type { StructureSpec } from "./design_structure_spec";
export const FORD_PAVEMENT_SPEC: StructureSpec = { export const FORD_PAVEMENT_SPEC: StructureSpec = {
@@ -32,20 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
prefix: "ford_chute", prefix: "ford_chute",
link: "gfordpavechute", link: "gfordpavechute",
max: 1, max: 1,
// 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2) // 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4)
items: [ items: CHUTE_ITEMS,
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
default: "도수로",
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
],
}, },
], ],
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) // 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
+9 -1
View File
@@ -22,6 +22,8 @@ import {
linkOf, linkOf,
readState, readState,
solveSplit, solveSplit,
specContainerHidden,
specItemHidden,
TEXT_MAX, TEXT_MAX,
writeOptions, writeOptions,
type SpecContainer, type SpecContainer,
@@ -82,6 +84,8 @@ export interface SpecForm {
groupNames?: Record<string, string>, groupNames?: Record<string, string>,
/** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */ /** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
defaults?: Record<string, string | number>, defaults?: Record<string, string | number>,
/** 숨긴 옵션 키(정의 「표시」 · 322-2) — 그 키만 쓰는 칸 · 상자는 안 그림(값은 그대로). */
hidden?: ReadonlySet<string>,
) => void; ) => void;
read: () => Record<string, string | number>; read: () => Record<string, string | number>;
state: () => SpecState; state: () => SpecState;
@@ -104,6 +108,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
let names: SpecNames = {}; let names: SpecNames = {};
let groupNames: Record<string, string> = {}; let groupNames: Record<string, string> = {};
let defaults: Record<string, string | number> = {}; let defaults: Record<string, string | number> = {};
let hidden: ReadonlySet<string> = new Set();
const recent = new Map<Instance, SplitRole[]>(); const recent = new Map<Instance, SplitRole[]>();
const slots = new Map<string, HTMLElement>(); const slots = new Map<string, HTMLElement>();
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = []; const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
@@ -235,6 +240,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
} }
const fields = c.items const fields = c.items
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view)) .filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
.filter((item) => !specItemHidden(item, hidden))
.map((item) => { .map((item) => {
const input = control(c, item, view, tags.get(item.id)); const input = control(c, item, view, tags.get(item.id));
const wrap = labeled(itemLabel(c, item, names), input); const wrap = labeled(itemLabel(c, item, names), input);
@@ -316,6 +322,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const fitted: SpecContainer[] = []; const fitted: SpecContainer[] = [];
for (const c of spec?.containers ?? []) { for (const c of spec?.containers ?? []) {
const list = (state[c.id] ??= c.required ? [{}] : []); const list = (state[c.id] ??= c.required ? [{}] : []);
if (specContainerHidden(c, hidden)) continue; // 숨긴 상자 — 값은 그대로(322-2)
if (fit(c, list)) fitted.push(c); if (fit(c, list)) fitted.push(c);
shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index))); shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index)));
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue; if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
@@ -341,9 +348,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return { return {
root, root,
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) { write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}, nextHidden) {
spec = next; spec = next;
defaults = nextDefaults; defaults = nextDefaults;
hidden = nextHidden ?? new Set();
names = nextNames; names = nextNames;
groupNames = nextGroups; groupNames = nextGroups;
state = readState(next, options); state = readState(next, options);
@@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [
}, },
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" }, { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
{ id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" }, { id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" },
// 길이(294-4 · `{접두}_{n}_length_m`) — 띠를 사면 따라 그릴 길이 · 그림 전용(수량 · 내역 · 일위대가에 안 씀)
{ id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 },
]; ];
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */ /** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
@@ -18,6 +18,7 @@ import { BOX_SPEC } from "./design_structure_box";
import { FORD_SPEC } from "./design_structure_ford"; import { FORD_SPEC } from "./design_structure_ford";
import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement"; import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement";
import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road"; import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road";
import { baseType } from "../../common_util/common_util_structure_catalog";
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
const BASIN = "집수정"; const BASIN = "집수정";
@@ -116,3 +117,7 @@ export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = {
pavement_concrete: PAVEMENT_SPEC, pavement_concrete: PAVEMENT_SPEC,
etc: ETC_SPEC, etc: ETC_SPEC,
}; };
/** 그 종류의 명세 — 복사한 종류는 본보기 명세(323-7 · `baseType`). 화면은 이것으로 고름(종류 키 직접 안 씀). */
export const specFor = (typeId: string): StructureSpec | undefined =>
STRUCTURE_SPECS[baseType(typeId)];
@@ -12,6 +12,7 @@
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts"; import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
export { pipeLengthText } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1)
import { PIPE_MATCH_M } from "./design_selection"; import { PIPE_MATCH_M } from "./design_selection";
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */ /** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
@@ -120,6 +120,23 @@ export const linkOf = (c: SpecContainer, n: number): string =>
const text = (value: unknown): string => const text = (value: unknown): string =>
value === undefined || value === null ? "" : String(value); value === undefined || value === null ? "" : String(value);
/** 항목이 쓰는 정의 옵션 키 — 필수 상자 저장 키 · 갈래 키(선택 상자 인스턴스 키는 옵션 밖 · 322-2 보임). */
export const specItemKeys = (item: SpecItem): string[] =>
[item.key, ...Object.values(item.by?.map ?? {}).map((b) => b.key)].filter(
(key): key is string => !!key,
);
/** 숨긴 항목인가 — 쓰는 키가 있고 모두 숨김(정의 「표시」 · 322-2). */
export const specItemHidden = (item: SpecItem, hidden: ReadonlySet<string>): boolean => {
const keys = specItemKeys(item);
return keys.length > 0 && keys.every((key) => hidden.has(key));
};
/** 숨긴 상자인가 — 키 있는 항목이 하나 이상이고 모두 숨김. */
export const specContainerHidden = (c: SpecContainer, hidden: ReadonlySet<string>): boolean =>
c.items.some((item) => specItemKeys(item).length > 0) &&
c.items.every((item) => !specItemKeys(item).length || specItemHidden(item, hidden));
/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */ /** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */
export function readState( export function readState(
spec: StructureSpec, spec: StructureSpec,
@@ -0,0 +1,77 @@
/* =============================================================================
* A00_Common/design/design_structure_status.ts
* 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션)
* → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) →
* 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴).
*
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
* ========================================================================== */
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
import type { PlannedStation } from "../../common_util/common_util_structure_station";
import { useDesignStore, type DesignStoreValues } from "./design_store";
/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */
export function structureStatus<P, S>(
saved: readonly [ReadonlyArray<P>, ReadonlyArray<S>],
drafts: readonly [ReadonlyArray<P> | null, ReadonlyArray<S> | null],
): { pipes: P[]; structures: S[] } {
return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] };
}
/**
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
* 「배관」 꼴(관 끌기 · 지우기 길).
*/
export function plannedIrregular(
planned: ReadonlyArray<PlannedStation>,
intervalM: number,
): IrregularStation[] {
const interval = intervalM > 0 ? intervalM : 20;
return planned.map((station) => {
const owner = station.owners[0];
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
const number = Math.floor(station.chainage_m / interval);
return {
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
station: number,
remainder: station.chainage_m - number * interval,
chainage_m: station.chainage_m,
structure: station.title,
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
};
});
}
/**
* 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
* 돌려주는 함수로 끊음.
*/
export function watchDrafts(projectId: string | null, onChange: () => void): () => void {
const shop = useDesignStore(projectId, null);
type Pipe = NonNullable<DesignStoreValues["pipes"]>[number];
const layout = (pipes: ReadonlyArray<Pipe> | null): string =>
JSON.stringify(
(pipes ?? []).map((p) => [
p.chainage_m,
p.facility ?? "pipe",
p.start_m ?? null,
p.end_m ?? null,
p.summary_item ?? null,
p.group_items ?? null,
]),
);
let seen = layout(shop.get("pipes"));
const offs = [
shop.subscribe("structures", onChange),
shop.subscribe("pipes", (pipes) => {
const key = layout(pipes);
if (key === seen) return;
seen = key;
onChange();
}),
];
return () => offs.forEach((off) => off());
}
+47 -49
View File
@@ -17,10 +17,8 @@ import { leaveForDashboard } from "../b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../b_page_state"; import { readByKey, stateKey, writeByKey } from "../b_page_state";
import { showToast } from "@ui/ui_template_elements"; import { showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations"; import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
import type { import { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
StructureInstance, import { pipeIdAt } from "./design_pipe_ids";
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { import {
fetchSectionContext, fetchSectionContext,
getSections, getSections,
@@ -29,6 +27,7 @@ import {
type StandardCrossSection, type StandardCrossSection,
} from "../../B06_Section/B06_Section_Api_Fetch"; } from "../../B06_Section/B06_Section_Api_Fetch";
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls"; import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import { import {
createCutSlopeStore, createCutSlopeStore,
createRockBoundaryStore, createRockBoundaryStore,
@@ -37,13 +36,15 @@ import {
} from "../../B06_Section/B06_Section_UI_Page_Persist"; } from "../../B06_Section/B06_Section_UI_Page_Persist";
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { paintMapPick } from "./design_pick_profile";
import { watchDrafts } from "./design_structure_status";
import { savingDesign } from "./design_save";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; 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 { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import { import {
applyPipeOptionsToCache, applyPipeOptionsToCache,
replayDraftPipeOptions,
type PipeOptionsContext, type PipeOptionsContext,
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options"; } from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
import { import {
@@ -131,8 +132,7 @@ export async function mountCrossView(
); );
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b). // 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
let structureMarksSink: let structureMarksSink: Parameters<typeof createB06StructuresPanel>[0]["onMarks"] | null = null;
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */ /** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
let pipeReloadTimer = 0; let pipeReloadTimer = 0;
let wallAdjustTimer = 0; let wallAdjustTimer = 0;
@@ -140,12 +140,11 @@ export async function mountCrossView(
projectId, projectId,
shared, shared,
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4). // 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
onMarks: (structures, types) => { onMarks: (structures, types, pipes) => {
structureMarksSink?.(structures, types); structureMarksSink?.(structures, types, pipes);
syncBermSpans(structures); syncBermSpans(structures);
}, },
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06). // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
onStructuresChanged: () => void refreshDetailForStructures(),
onPipesWritten: () => { onPipesWritten: () => {
window.clearTimeout(pipeReloadTimer); window.clearTimeout(pipeReloadTimer);
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200); pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
@@ -191,7 +190,7 @@ export async function mountCrossView(
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly); if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
}, },
releaseCard: () => releaseCross(), releaseCard: () => releaseCross(),
markStructure: (structureId) => sectionView.selectMark(structureId), markStructure: (structureId, span) => sectionView.selectMark(structureId, span),
applyPipeOptions: (chainageM, patch) => applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
}); });
@@ -220,6 +219,7 @@ export async function mountCrossView(
async function refreshDetailForStructures(): Promise<void> { async function refreshDetailForStructures(): Promise<void> {
if (!projectId || currentRouteId === null) return; if (!projectId || currentRouteId === null) return;
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
try { try {
const fresh = await loadSectionDetail(projectId, currentRouteId); const fresh = await loadSectionDetail(projectId, currentRouteId);
if (seq !== structuresRefreshSeq) return; if (seq !== structuresRefreshSeq) return;
@@ -233,6 +233,7 @@ export async function mountCrossView(
); );
} }
renderSectionDetail(); renderSectionDetail();
void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314)
} catch (error) { } catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : ""; const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error"); showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error");
@@ -301,6 +302,7 @@ export async function mountCrossView(
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120); wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
}, },
}); });
setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4)
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김 // 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
@@ -332,7 +334,7 @@ export async function mountCrossView(
shared, shared,
); );
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); structureMarksSink = sectionView.setStructureMarks;
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙. // 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로 // 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
@@ -341,15 +343,15 @@ export async function mountCrossView(
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM), addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM), removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId), addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
removeStructure: (structureId) => { removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId),
structuresPanel.removeStructureById(structureId);
},
movePipe: (fromChainageM, toChainageM) => movePipe: (fromChainageM, toChainageM) =>
structuresPanel.movePipeTo(fromChainageM, toChainageM), structuresPanel.movePipeTo(fromChainageM, toChainageM),
moveStructure: (structureId, toChainageM) => { moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM); structuresPanel.moveStructureTo(structureId, toChainageM);
}, },
selectStructure: (structureId) => structuresPanel.selectStructure(structureId), selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM), // 측점 없는 관(요구 178)
pipesChanged: () => (structuresPanel.adoptFordHeights(), void refreshDetailForStructures()),
}); });
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = { const pipeOptionsContext: PipeOptionsContext = {
@@ -387,19 +389,18 @@ export async function mountCrossView(
: null; : null;
if (!target) { if (!target) {
structuresPanel.showAtChainage(null); structuresPanel.showAtChainage(null);
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다. releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
return; return;
} }
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
const preferred = structuresPanel.preferred() !== null; const preferred = structuresPanel.preferred() !== null;
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); const opened = structuresPanel.showAtChainage(structureSelection.formChainageOf(target));
// 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1) // 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1)
const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null }; const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null };
if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" }); if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" });
// 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면 // 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
const pipeAt = structuresPanel.currentChainage(); const pipeAt = structuresPanel.currentChainage();
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); const mark = opened.structureId ?? (pipeAt === null ? null : pipeIdAt(pipeAt, projectId));
sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5) sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5)
structureSelection.syncInletStructure(); structureSelection.syncInletStructure();
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
@@ -474,16 +475,11 @@ export async function mountCrossView(
handoff, handoff,
sectionDetail, sectionDetail,
{ {
// 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15). // 넘겨받은 구조물 id 가 먼저(52-15) · 폼은 id 로 직접(53-1) · 측점 없는 구조물은 카드 · 스크롤 없음(307)
focusStation: (stationId) => { focusStation: (stationId) =>
structuresPanel.prefer(handoff?.structureId ?? null); structuresPanel.focusPicked(handoff?.structureId ?? null, () =>
sectionView.focusStation(stationId); sectionView.focusStation(stationId),
structuresPanel.prefer(null); ),
// 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1)
const sid = handoff?.structureId;
if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid))
structuresPanel.selectStructure(sid);
},
refreshCard: (chainageM) => sectionView.refreshCard(chainageM), refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
@@ -494,23 +490,21 @@ export async function mountCrossView(
if (applied) appliedPick = signature; if (applied) appliedPick = signature;
} }
/** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */ // 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
const crossPick = bindCrossPick(projectId, host, {
sections: () => sectionDetail?.cross_sections ?? [],
controls: stationControls,
releaseForm: () => structuresPanel.showAtChainage(null),
view: sectionView,
reapply: () => {
appliedPick = null;
restoreStructurePick();
},
paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection),
});
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
function releaseCross(): void { function releaseCross(): void {
const { revetOffset, ford, box } = stationControls; crossPick.release();
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));
appliedPick = null; appliedPick = null;
selectDesign(projectId, NO_SELECTION, { from: "b06" }); selectDesign(projectId, NO_SELECTION, { from: "b06" });
} }
@@ -543,6 +537,7 @@ export async function mountCrossView(
// 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로 // 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로
// 실려 나가던 자리(2026-09-09 실측). // 실려 나가던 자리(2026-09-09 실측).
reconcileDesigns: () => reconcileStaleDesigns({ force: true }), reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
redraw: () => (host.visible() ? refreshDetailForStructures() : Promise.resolve()),
patchSources: () => ({ patchSources: () => ({
rockOffsets: rockStore.offsets, rockOffsets: rockStore.offsets,
cutSlopeRatios: cutSlopeStore.ratios, cutSlopeRatios: cutSlopeStore.ratios,
@@ -568,8 +563,13 @@ export async function mountCrossView(
// 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38) // 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38)
if (!readStructurePick(projectId)) releaseCross(); if (!readStructurePick(projectId)) releaseCross();
void structuresPanel.load().then(restoreStructurePick); void structuresPanel.load().then(restoreStructurePick);
void refreshDetailForStructures().then(() => reconcileStaleDesigns()); void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314)
}); });
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
const reloadPanel = (): void => void structuresPanel.load().then(restoreStructurePick);
host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel()));
// 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음)
host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures()));
host.onDispose(() => { host.onDispose(() => {
window.clearTimeout(pipeReloadTimer); window.clearTimeout(pipeReloadTimer);
window.clearTimeout(wallAdjustTimer); window.clearTimeout(wallAdjustTimer);
@@ -669,8 +669,6 @@ export async function mountCrossView(
bindPipeOptions(shared, (chainageM, patch) => bindPipeOptions(shared, (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
); );
// 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a).
replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId));
renderSectionDetail(); renderSectionDetail();
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다 // 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만. // (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
+62 -47
View File
@@ -31,14 +31,9 @@ import {
restoreStructurePick, restoreStructurePick,
wireStructurePick, wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection";
coversAtStation,
NO_SELECTION,
nearestWithin,
readSelection,
selectDesign,
} from "./design_selection";
import type { DesignPageHost } from "./design_page"; import type { DesignPageHost } from "./design_page";
import { bindProfilePick } from "./design_pick_profile";
import { import {
bindDesignView, bindDesignView,
designReady, designReady,
@@ -46,7 +41,6 @@ import {
type DesignPageShared, type DesignPageShared,
} from "./design_shared"; } from "./design_shared";
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life"; import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures";
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures"; import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
import { import {
@@ -62,7 +56,11 @@ import {
type SectionDetailResponse, type SectionDetailResponse,
} from "../../B06_Section/B06_Section_Api_Fetch"; } from "../../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; import {
migrateLegacyStations,
onStaleStructureTypes,
structureAnchorM,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import { import {
loadCorridorIfFresh, loadCorridorIfFresh,
@@ -82,6 +80,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css";
import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { pipeIdAt } from "./design_pipe_ids";
import { import {
DEFAULT_ROAD_WIDTHS, DEFAULT_ROAD_WIDTHS,
fetchRoadWidths, fetchRoadWidths,
@@ -127,6 +126,7 @@ export async function mountProfileView(
bridge.setPipes(pipes); bridge.setPipes(pipes);
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem)); panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
profilePanel.setMinCoverPipes(pipes); profilePanel.setMinCoverPipes(pipes);
snapOld();
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
}; };
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) · /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
@@ -135,10 +135,8 @@ export async function mountProfileView(
const structurePick = !!readSelection(activeProjectId)?.structureId; const structurePick = !!readSelection(activeProjectId)?.structureId;
const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []); const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []);
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections); const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
if ("pipeChainageM" in target) { if ("pipeChainageM" in target)
profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`); return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId));
return;
}
profilePanel.setSelectedStation(target.stationId); profilePanel.setSelectedStation(target.stationId);
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId); if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
}; };
@@ -173,13 +171,15 @@ export async function mountProfileView(
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => { onStructureSelect: (structureId) => {
const chainage = bridge.pipeMarkChainage(structureId); const chainage = bridge.pipeMarkChainage(structureId);
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null); // 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
if (chainage !== null) { if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage); panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage); selectStationOfPipe(chainage);
@@ -220,7 +220,7 @@ export async function mountProfileView(
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */ * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
const uphillOverrides = loadUphillOverrides(activeProjectId); const uphillOverrides = loadUphillOverrides(activeProjectId);
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(), stations: () => bridge.irregularStations(),
isSyncing: () => selectionSyncing, isSyncing: () => selectionSyncing,
setSyncing: (value) => { setSyncing: (value) => {
@@ -244,9 +244,24 @@ export async function mountProfileView(
remember: (chainageM) => rememberPick(chainageM, null), remember: (chainageM) => rememberPick(chainageM, null),
}); });
/** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */
const pickFromMap = (chainageM: number | null): void => {
if (!selectionSyncing) rememberPick(chainageM, null);
selectStationOfPipe(chainageM);
};
let oldSnapped = false;
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
function snapOld(): void {
if (oldSnapped || restoring || !lastPipes) return;
oldSnapped = true;
panel.structures.snapOld();
}
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void { function restorePick(): void {
profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1)
if (panel.structures.hasSelection()) return; if (panel.structures.hasSelection()) return;
const pick = viewer.structurePick; const pick = viewer.structurePick;
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
@@ -318,45 +333,31 @@ export async function mountProfileView(
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>); forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
}, },
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
// 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3)
onPipeFacilitySelect: (chainage) => { onPipeFacilitySelect: (chainage) => {
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다. rememberPick(chainage, null);
if (selectionSyncing) return; if (!selectionSyncing) profilePick.paint();
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;
}
}, },
onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3)
onStructureSelect: (structure) => { onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 // 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
if (selectionSyncing) return; // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2)
selectionSyncing = true; if (!selectionSyncing) profilePick.paint();
try {
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
} finally {
selectionSyncing = false;
}
}, },
}, },
shared, 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). */ * 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
@@ -402,6 +403,17 @@ export async function mountProfileView(
}, },
isRestoring: () => restoring, isRestoring: () => restoring,
}); });
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
const profilePick = bindProfilePick(
activeProjectId,
{ profile: profilePanel, viewer, form: panel.structures },
host,
{
irregular: () => bridge.irregularStations(),
pipes: () => lastPipes ?? [],
sections: () => currentSectionDetail?.cross_sections ?? [],
},
);
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
@@ -538,6 +550,9 @@ export async function mountProfileView(
onReset: () => uphillOverrides.clear(), onReset: () => uphillOverrides.clear(),
}); });
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load());
host.onDispose(onStaleStructureTypes(reloadIfShown));
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
bindProfileViewLife(host, { bindProfileViewLife(host, {
projectId: activeProjectId, projectId: activeProjectId,
@@ -558,8 +573,7 @@ export async function mountProfileView(
); );
return; return;
} }
viewer.markers.selectStation(null); profilePick.paint();
profilePanel.setSelectedStation(null);
}, },
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => { onHide: () => {
@@ -655,6 +669,7 @@ export async function mountProfileView(
} finally { } finally {
progress.remove(); progress.remove();
restoring = false; restoring = false;
snapOld();
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
} }
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* device_context.css — 기기 대응 공통 규칙(PLAN_기기대응 2-6)
* 모든 규칙은 `<html>` 표시(data-touch · data-size) 안에서만 — 데스크탑 마우스 화면은 안 바뀜.
* 표시는 `device_context.ts` 가 적음.
* ========================================================================== */
/* 누를 자리 44px — 손가락(주 입력 coarse) 또는 터치 모드 켬 */
html[data-touch="on"]
:is(
.ui-btn,
.ui-workflow-overlay__toggle,
.ui-sidebar-row,
.ui-workflow-layout__step,
.ui-ctx-menu__item,
select,
input:not([type="checkbox"], [type="radio"], [type="range"], [type="hidden"], [type="color"])
) {
min-height: 44px;
}
html[data-touch="on"] .ui-btn {
min-width: 44px;
}
/* 체크 · 라디오 — 상자를 키우고 글까지 한 줄 전체가 누를 자리 */
html[data-touch="on"] :is(input[type="checkbox"], input[type="radio"]) {
width: 22px;
height: 22px;
}
html[data-touch="on"] .ui-checkbox {
min-height: 44px;
}
/* 접힌 좌측 패널 손잡이 — 손가락 폭 · 화면 왼쪽 끝 안 */
html[data-touch="on"]
.ui-workflow-overlay__panel--title.is-collapsed
> .ui-workflow-overlay__handle--side {
width: 32px;
right: -32px;
}
/* 팝업(2-4) — 폭 600 미만은 전체 화면 · 겹치면 나중 것(맨 위)만 보이고 × 로 앞 창.
공용 모달(.ui-modal) · 페이지 팝업은 공용 클래스 `.ui-popup` 만 붙여 맞춤(자리 · 크기 인라인 값도 덮음) */
html[data-size="compact"] :is(.ui-modal, .ui-popup) {
position: fixed !important;
inset: 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
max-width: none !important;
max-height: none !important;
margin: 0 !important;
border-radius: 0 !important;
transform: none !important;
overflow: auto;
}
html[data-size="compact"] .ui-confirm__panel {
min-width: 0;
max-width: calc(100vw - var(--spacing-32));
}
/* 공용 표(2-5) — 폭 840 미만은 고정 열이 없는 표도 첫 열을 붙임(머리줄은 늘 붙어 있음 · 가로는 밀기) */
html:is([data-size="compact"], [data-size="medium"])
.ui-data-table__table:not(:has(.is-frozen))
tr
> :first-child {
position: sticky;
left: 0;
z-index: 1;
}
html:is([data-size="compact"], [data-size="medium"])
.ui-data-table__table:not(:has(.is-frozen))
thead
tr
> :first-child {
z-index: 3;
}
/* 터치 모드에서만 보이는 단추(자판 없는 기기용 · 4-3) — 마우스 화면엔 없음 */
html:not([data-touch="on"]) .ui-touch-only {
display: none !important;
}
/* 위 줄 높이(6-1) — 화면 높이 계산이 모두 이 값을 씀 · 로그인 뒤 쪽(B · M)은 위 줄을 걷어 0(7-1)
☰ 폭(7-4) · 들여쓰기(7-2) = 왼쪽 위에 뜬 ☰(자리 8 + 폭) + 틈 8 · 그 자리에 걸치는 첫 줄이 이만큼 왼쪽을 비움 */
:root {
--app-header-h: 64px;
--app-burger-w: 36px;
--app-burger-inset: 0px;
}
html.is-bm-route {
--app-header-h: 0px;
--app-burger-inset: calc(var(--spacing-8) * 2 + var(--app-burger-w));
}
+123
View File
@@ -0,0 +1,123 @@
/* =============================================================================
* device_context.ts
* 기기 상태 한 곳(요구 193~195 · PLAN_기기대응 2-1) — `<html>` 에 지금 상태를 늘 적음.
*
* - data-size = compact(<600) · medium(<840) · expanded(<1200) · large ← 배치는 이것만 봄
* - data-pointer = 주 입력 coarse · fine(미디어 `pointer`)
* - data-input = 마지막 누름의 pointerType(mouse · touch · pen)
* - data-touch-mode = 사용자 스위치(auto · on · off · 이 기기 브라우저에만 저장)
* - data-touch = on · off — 누를 자리 크기를 키울지(auto 면 주 입력 coarse 일 때)
* - data-burger = on · off — 공개 쪽에서 ☰ 서랍이 메뉴를 맡는지(폭 900 이하 · 터치) · 로그인 뒤 쪽(B · M)은 늘 ☰(7-1)
* 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태).
* 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수.
* 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘.
* ========================================================================== */
import "./device_context.css";
export const SIZE_BREAKS = { medium: 600, expanded: 840, large: 1200 } as const;
/** 위 메뉴가 숨는 폭(app_shell `@media (max-width: 900px)` 과 짝) — 이하면 ☰ 서랍 */
export const BURGER_MAX = 900;
export type SizeClass = "compact" | "medium" | "expanded" | "large";
export type TouchMode = "auto" | "on" | "off";
export interface DeviceContext {
size: SizeClass;
pointer: "coarse" | "fine";
input: string;
touchMode: TouchMode;
touch: boolean;
burger: boolean;
}
const TOUCH_MODE_KEY = "frd_touch_mode";
export function sizeClassOf(width: number): SizeClass {
if (width < SIZE_BREAKS.medium) return "compact";
if (width < SIZE_BREAKS.expanded) return "medium";
if (width < SIZE_BREAKS.large) return "expanded";
return "large";
}
let modeInMemory: TouchMode | null = null; // 저장이 막힌 브라우저용
export function readTouchMode(): TouchMode {
if (modeInMemory) return modeInMemory;
try {
const v = localStorage.getItem(TOUCH_MODE_KEY);
return v === "on" || v === "off" ? v : "auto";
} catch {
return "auto";
}
}
const coarseQuery = window.matchMedia("(pointer: coarse)");
let current: DeviceContext | null = null;
let lastInput = "";
function read(): DeviceContext {
const pointer = coarseQuery.matches ? "coarse" : "fine";
const touchMode = readTouchMode();
const touch = touchMode === "on" || (touchMode === "auto" && pointer === "coarse");
return {
size: sizeClassOf(window.innerWidth),
pointer,
input: lastInput || (pointer === "coarse" ? "touch" : "mouse"),
touchMode,
touch,
burger: touch || window.innerWidth <= BURGER_MAX,
};
}
function apply(): void {
const next = read();
if (current && JSON.stringify(current) === JSON.stringify(next)) return;
const html = document.documentElement;
html.dataset.size = next.size;
html.dataset.pointer = next.pointer;
html.dataset.input = next.input;
html.dataset.touchMode = next.touchMode;
html.dataset.touch = next.touch ? "on" : "off";
html.dataset.burger = next.burger ? "on" : "off";
const first = !current;
current = next;
if (!first) window.dispatchEvent(new CustomEvent("app:context", { detail: next }));
}
export function deviceContext(): DeviceContext {
return current ?? read();
}
/** 터치 모드 스위치(☰ 서랍) — 이 기기 브라우저에만 저장 */
export function setTouchMode(mode: TouchMode): void {
modeInMemory = mode;
try {
if (mode === "auto") localStorage.removeItem(TOUCH_MODE_KEY);
else localStorage.setItem(TOUCH_MODE_KEY, mode);
} catch {
/* 저장 막힌 브라우저 — 이번 화면만 */
}
apply();
}
function init(): void {
const html = document.documentElement;
for (const [name, px] of Object.entries(SIZE_BREAKS))
html.style.setProperty(`--bp-${name}`, `${px}px`);
apply();
coarseQuery.addEventListener("change", apply);
window.addEventListener("resize", apply);
window.visualViewport?.addEventListener("resize", apply);
screen.orientation?.addEventListener("change", apply);
window.addEventListener(
"pointerdown",
(e) => {
if (e.pointerType === lastInput) return;
lastInput = e.pointerType;
apply();
},
{ capture: true, passive: true },
);
}
init();
+64
View File
@@ -0,0 +1,64 @@
/* =============================================================================
* input_prefs.ts
* 마우스 · 단축키 설정 읽기 층(PLAN 332) — ☰ [설정] 창이 [저장] 때 계정 pref 로 씀.
*
* - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름)
* - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import)
* - 휠 확대 방향 하나로 모든 화면 일체 — 기본 밂 = 휠을 앞으로(deltaY < 0) 굴리면 확대(오토캐드) · 당김 = 몸 쪽으로
* - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0)
* ========================================================================== */
export type WheelZoom = "pull" | "push";
export interface InputPrefs {
/** 휠 확대 방향 — pull = 당기면 확대 · push = 밀면 확대 */
wheelZoom: WheelZoom;
/** CAD 내 별칭 `{명령: [별칭]}`(대문자 · acad.pgp 식 — 명령 이름 > 내 별칭 > 기본 별칭) */
cadAliases: Record<string, string[]>;
}
/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */
export const INPUT_PREFS_KEY = "aislo:pref:input-prefs";
export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "push", cadAliases: {} };
const cleanAliases = (raw: unknown): Record<string, string[]> => {
const out: Record<string, string[]> = {};
if (!raw || typeof raw !== "object") return out;
for (const [id, list] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(list)) continue;
const aliases = list
.filter((a): a is string => typeof a === "string")
.map((a) => a.toUpperCase());
if (aliases.length) out[id.toUpperCase()] = aliases;
}
return out;
};
/** 지금 설정 — 부를 때마다 저장소에서 읽음 */
export function inputPrefs(): InputPrefs {
try {
const raw = globalThis.localStorage?.getItem(INPUT_PREFS_KEY);
if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
const value = JSON.parse(raw) as Partial<InputPrefs> | null;
return {
wheelZoom: value?.wheelZoom === "pull" ? "pull" : "push",
cadAliases: cleanAliases(value?.cadAliases),
};
} catch {
return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
}
}
/** 휠 한 번이 확대인가 — 설정 방향을 따름 */
export const wheelIn = (deltaY: number): boolean =>
inputPrefs().wheelZoom === "pull" ? deltaY > 0 : deltaY < 0;
/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄
* ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */
export const isTrackpadPinch = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">): boolean =>
e.ctrlKey && Math.abs(e.deltaY) < 50;
/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */
export const wheelEventIn = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">, pinch = false): boolean =>
pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY);
+49
View File
@@ -0,0 +1,49 @@
/* =============================================================================
* key_shortcut.ts — Ctrl(⌘)+글자 단축키 판정 공용(PLAN 214-2)
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞음 — 영문 글자·숫자면 그 글자 ·
* 아니면 자판 자리(`code`: KeyZ · Digit1)로 판정. Shift 의 대문자도 소문자로 맞춤.
* 쓰는 곳 — 페이지(B05 · M02 · 시트 키) + CAD(`B07_DesignDetail/openwebcad` 의 `input-shortcut.ts` 가 다시 내보냄).
* 시험 = `resources/tester/cad/ctrl-shortcut-hangul.test.ts`
* ========================================================================== */
type KeyLike = Pick<KeyboardEvent, "code"> & Partial<Pick<KeyboardEvent, "key">>;
/** 누른 글자·숫자(소문자) — 글자·숫자 자리가 아니면 빈 글 */
export function keyLetter(evt: KeyLike): string {
const key = evt.key ?? "";
if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase();
const m = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(evt.code ?? "");
return m ? (m[1] ?? m[2]).toLowerCase() : "";
}
/** Ctrl(Mac ⌘)과 함께 누른 `letter`(소문자 글자·숫자)인가 — Shift · Alt 는 보지 않음 */
export function isCtrlKey(
evt: KeyLike & Pick<KeyboardEvent, "ctrlKey" | "metaKey">,
letter: string,
): boolean {
return (evt.ctrlKey || evt.metaKey) && keyLetter(evt) === letter;
}
/** Ctrl 단축키 이름 */
export type CtrlShortcut = "undo" | "redo" | "save" | "selectAll";
/** 공용 Ctrl 단축키 — Z · Shift+Z · Y · S · A (Alt 함께는 아님 · Shift 는 Z 만) */
export function ctrlShortcut(
evt: KeyLike &
Pick<KeyboardEvent, "ctrlKey" | "altKey" | "shiftKey"> &
Partial<Pick<KeyboardEvent, "metaKey">>,
): CtrlShortcut | null {
if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null;
switch (keyLetter(evt)) {
case "z":
return evt.shiftKey ? "redo" : "undo";
case "y":
return evt.shiftKey ? null : "redo";
case "s":
return evt.shiftKey ? null : "save";
case "a":
return evt.shiftKey ? null : "selectAll";
default:
return null;
}
}
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* m_settings_nav.ts
* 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는
* 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`).
* ========================================================================== */
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { setDrawerSteps } from "./app_shell";
import { currentRoute, navigateTo, takeLeaveGuard } from "./router";
interface SettingUser {
role?: string;
is_master?: boolean;
company_id?: number | null;
}
/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */
export interface SettingLink {
key: string;
title: string;
desc: string;
route: RoutePath;
query?: Record<string, string>;
}
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const ICONS: Record<string, string> = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" };
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
const company: Record<string, string> =
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({
key: open,
title,
desc,
route: ROUTES.M02_MASTER_TEMPLATE,
query: { open, root, ...company },
});
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
return [
{
key: "m01",
title: L("B01_Dashboard_MasterElements"),
desc: L("B01_Dashboard_PermSourceRead"),
route: ROUTES.M01_MASTER_DATA,
},
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
];
}
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
const companyAdmin = user.role === "ADMIN" || user.is_master;
const root = companyAdmin ? "company" : "personal";
const desc = [
L("B01_Dashboard_PermMasterRead"),
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)];
}
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
export function mountSettingsDrawer(
user: SettingUser,
activeKey: string,
companyId: number | string | null,
): void {
const links = settingLinks(user, companyId);
const bar = createStepBar(
links.map((link) => link.title),
links.findIndex((link) => link.key === activeKey),
{
orientation: "vertical",
icons: links.map((link) => ICONS[link.key] ?? ""),
routes: links.map((link) => link.route),
homeButton: true,
onStepClick: (index) => void go(links[index]),
},
);
setDrawerSteps(bar);
}
async function go(link: SettingLink): Promise<void> {
const guard = link.route === currentRoute() ? takeLeaveGuard() : null;
if (guard && !(await guard())) return;
navigateTo(link.route, link.query);
}
+8 -1
View File
@@ -9,9 +9,12 @@
* ========================================================================== */ * ========================================================================== */
import "./random_uuid_polyfill"; import "./random_uuid_polyfill";
import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(기기대응 2-1)
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1)
import "@ui/ui_template_theme.css"; import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements"; import { injectBaseStyles } from "@ui/ui_template_elements";
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu"; import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe";
import { ROUTES, type RoutePath } from "@config/config_frontend"; import { ROUTES, type RoutePath } from "@config/config_frontend";
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
import { import {
@@ -19,6 +22,7 @@ import {
renderCurrentRoute, renderCurrentRoute,
ensureInitialHash, ensureInitialHash,
setLeaveGuard, setLeaveGuard,
draftRoute,
showWithinPage, showWithinPage,
takeLeaveGuard, takeLeaveGuard,
} from "./router"; } from "./router";
@@ -54,6 +58,8 @@ function bootstrap(): void {
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a) // B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute())); blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
// B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220)
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼 // 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
let shown = ""; let shown = "";
@@ -67,7 +73,8 @@ function bootstrap(): void {
shown = currentRoute(); shown = currentRoute();
shownHash = window.location.hash; shownHash = window.location.hash;
if (within) return; if (within) return;
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute()); const showFooter =
!FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless;
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신 const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더 void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
}; };
+29 -4
View File
@@ -74,6 +74,29 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate, (await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
}; };
/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음.
* 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */
export interface DraftRoute {
path: string;
load: () => Promise<PageRenderer>;
/** 로그인 · 회사 필요(B 단계 화면) — 로그인만이면 protected */
workflow?: boolean;
protected?: boolean;
/** 현재 프로젝트로 여는 화면(지워진 프로젝트 안내) */
project?: boolean;
footerless?: boolean;
}
const DRAFT_ROUTES = new Map(
Object.values(import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true }))
.map((m) => m.ROUTE)
.filter(Boolean)
.map((r) => [r.path, r] as const),
);
/** 초안 길 선언(없으면 undefined) */
export const draftRoute = (route: string): DraftRoute | undefined => DRAFT_ROUTES.get(route);
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */ /** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
let leaveGuard: (() => Promise<boolean>) | null = null; let leaveGuard: (() => Promise<boolean>) | null = null;
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => { export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
@@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
mounted?.dispose(); mounted?.dispose();
mounted = null; mounted = null;
if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) { const draft = draftRoute(route);
const guarded = PROTECTED_ROUTES.includes(route) || !!(draft?.protected || draft?.workflow);
if (guarded && !(await isAuthenticated())) {
navigateTo(ROUTES.A06_LOGIN); navigateTo(ROUTES.A06_LOGIN);
return; return;
} }
@@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
ROUTES.B11_LOADING, ROUTES.B11_LOADING,
]; ];
if (workflowRoutes.includes(route)) { if (workflowRoutes.includes(route) || draft?.workflow) {
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
try { try {
const user = await fetchSessionUser(); const user = await fetchSessionUser();
@@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
} }
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다. // 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다.
if (PROJECT_ROUTES.includes(route)) { if (PROJECT_ROUTES.includes(route) || draft?.project) {
const { dropGoneProject } = await import("./b_workflow_nav"); const { dropGoneProject } = await import("./b_workflow_nav");
if (await dropGoneProject()) { if (await dropGoneProject()) {
if (seq !== renderSeq) return; if (seq !== renderSeq) return;
@@ -208,7 +233,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
} }
} }
const loader = routeTable[route]; const loader = routeTable[route] ?? draft?.load;
if (!loader) { if (!loader) {
renderPlaceholder(outlet, route); renderPlaceholder(outlet, route);
return; return;
+18 -1
View File
@@ -16,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard"; import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands"; import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor, slotOf } from "./spreadsheet_editor"; import { createEditor, slotOf } from "./spreadsheet_editor";
import { mountTouchKeys } from "./spreadsheet_touch_keys";
import { toCalcValue } from "./spreadsheet_eval"; import { toCalcValue } from "./spreadsheet_eval";
import { attachExtras } from "./spreadsheet_extras"; import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph"; import { createCalcEngine } from "./spreadsheet_graph";
@@ -27,7 +28,7 @@ import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set"; import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu"; import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse"; import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; import { LAST_C, LAST_R, selectCell, selectRanges } from "./spreadsheet_selection";
import { createStatusBar } from "./spreadsheet_statusbar"; import { createStatusBar } from "./spreadsheet_statusbar";
import { mountTabs } from "./spreadsheet_tabs"; import { mountTabs } from "./spreadsheet_tabs";
import { mountToolbar } from "./spreadsheet_toolbar"; import { mountToolbar } from "./spreadsheet_toolbar";
@@ -89,6 +90,9 @@ export interface SpreadsheetHandle {
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) · /** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */ * `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
pasteTable(html: string, check?: PasteCheck): Promise<string | null>; pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
/** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) ·
* 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */
select(범위: string | string[], 시트?: string): boolean;
destroy(): void; destroy(): void;
} }
@@ -161,6 +165,7 @@ export function createSpreadsheet(
const extras = attachExtras(ctx, ed, toolbar?.root ?? null); const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts); others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys); const detachKeys = attachKeys(ctx, ed, extras.keys);
mountTouchKeys(ctx, ed); // 자판 없는 기기 단추 줄(기기대응 4-3 · 터치 모드에서만 보임)
const detachMouse = attachMouse(ctx, ed, extras.mouse); const detachMouse = attachMouse(ctx, ed, extras.mouse);
/** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */ /** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */
let lateTabs: PartHandle | null = null; let lateTabs: PartHandle | null = null;
@@ -348,6 +353,18 @@ export function createSpreadsheet(
return 이름; return 이름;
}, },
pasteTable: (html, check) => pasteTableAt(ctx, html, check), pasteTable: (html, check) => pasteTableAt(ctx, html, check),
select(range, sheetKey) {
const target = sheetKey
? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey)
: ctx.sheet();
const next = target && selectRanges(target, typeof range === "string" ? [range] : range);
if (!target || !next) return false;
showSheet(target.id);
if (ctx.selection.시트 !== target.id) return false; // 편집기 유효성에 막힘
select(next, true);
grid.reveal(next.활성.r, next.활성.c); // 범위가 판보다 크면 왼위가 보이게
return true;
},
destroy() { destroy() {
clearTimeout(toastTimer); clearTimeout(toastTimer);
detachKeys(); detachKeys();
@@ -8,7 +8,7 @@
* ========================================================================== */ * ========================================================================== */
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address"; 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 { createCalcEngine } from "./spreadsheet_graph";
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers"; import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
import { resolveName } from "./spreadsheet_names"; import { resolveName } from "./spreadsheet_names";
@@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) {
: { 글: "", 정렬: "left" as const }; : { 글: "", 정렬: "left" as const };
const key = `${sheet.id}!${toA1(r, c)}`; const key = `${sheet.id}!${toA1(r, c)}`;
값[key] = shown.글; 값[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 }; return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
}; };
const cells = rows.map((r) => const cells = rows.map((r) =>
@@ -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<HAlign, string> = { const HALIGN_CSS: Record<HAlign, string> = {
general: "left", general: "center",
left: "left", left: "left",
right: "right", right: "right",
center: "center", center: "center",
@@ -149,13 +149,23 @@ const HALIGN_CSS: Record<HAlign, string> = {
centerContinuous: "center", 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<VAlign, string> = { const VALIGN_CSS: Record<VAlign, string> = {
top: "flex-start", top: "flex-start",
center: "center", center: "center",
bottom: "flex-end", bottom: "flex-end",
}; };
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). /** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */ * `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle( export function applyCellStyle(
cell: HTMLElement, cell: HTMLElement,
@@ -178,7 +188,7 @@ export function applyCellStyle(
s.borderLeft = borderCss(borders.왼, scale); s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale); s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = 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"; s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
let text = cell.firstElementChild as HTMLElement | null; let text = cell.firstElementChild as HTMLElement | null;
@@ -187,7 +197,7 @@ export function applyCellStyle(
text.className = "aislo-cell__text"; text.className = "aislo-cell__text";
cell.replaceChildren(text); cell.replaceChildren(text);
} }
text.style.textAlign = HALIGN_CSS[hAlign]; text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)];
text.style.textAlignLast = hAlign === "distributed" ? "justify" : ""; text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
} }
@@ -14,6 +14,7 @@
import { showToast } from "@ui/ui_template_elements"; import { showToast } from "@ui/ui_template_elements";
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { clipWholeToUsed } from "./spreadsheet_grid_cells"; import { clipWholeToUsed } from "./spreadsheet_grid_cells";
import { formatValue } from "./spreadsheet_numfmt"; import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift"; import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
@@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string {
if (deco) parts.push(`text-decoration:${deco}`); if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`); if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`); if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") { // 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`); const h = shownHAlign(style.가로);
} parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`);
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`); const v = shownVAlign(style.세로);
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap"); if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) { if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => { (["위", "아래", "왼", "오른"] as const).forEach((side) => {
@@ -129,7 +129,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
attrs: { type: "button", title: TEXT.close }, attrs: { type: "button", title: TEXT.close },
}); });
const dialog = el("div", { const dialog = el("div", {
className: "ss-dialog", className: "ss-dialog ui-popup",
attrs: { hidden: "" }, attrs: { hidden: "" },
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body], children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
}); });
+9 -1
View File
@@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
ta.style.fontSize = s.크기 ? `${s.크기}pt` : ""; ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
ta.style.fontWeight = s.굵게 ? "bold" : ""; ta.style.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : ""; 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.width = "0";
ta.style.height = "0"; ta.style.height = "0";
@@ -303,6 +303,14 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
} }
function focus(): void { function focus(): void {
// 손가락 — 고칠 때만 화면 자판(기기대응 4-2) · 칸 고름만으로는 안 뜸
if (document.documentElement.dataset.touch === "on") {
const mode = st ? "text" : "none";
if (ta.inputMode !== mode) {
ta.inputMode = mode;
if (document.activeElement === ta) ta.blur();
}
}
if (document.activeElement !== ta) ta.focus({ preventScroll: true }); if (document.activeElement !== ta) ta.focus({ preventScroll: true });
} }
@@ -210,7 +210,7 @@ export function openEvalSteps(ctx: SpreadsheetContext): () => void {
onClick: () => go(i + 1 < steps.length ? i + 1 : 0), onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
}); });
const box = el("div", { const box = el("div", {
className: "ss-steps__box", className: "ss-steps__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") }, attrs: { role: "dialog", "aria-label": t("Title") },
}); });
const shade = el("div", { className: "ss-steps", children: [box] }); const shade = el("div", { className: "ss-steps", children: [box] });
@@ -23,7 +23,7 @@ import {
type NegStyle, type NegStyle,
} from "./spreadsheet_format_code"; } from "./spreadsheet_format_code";
import { dialogKeys } from "./spreadsheet_actions"; import { dialogKeys } from "./spreadsheet_actions";
import { isAutoInk } from "./spreadsheet_cellstyle"; import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt"; import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types"; import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["centerContinuous", "선택 영역 가운데"], ["centerContinuous", "선택 영역 가운데"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement; }) as HTMLSelectElement;
hSelect.value = draft.가로 ?? "general"; hSelect.value = shownHAlign(draft.가로);
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign)); hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
const vSelect = el("select", { const vSelect = el("select", {
@@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["bottom", "아래"], ["bottom", "아래"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement; }) as HTMLSelectElement;
vSelect.value = draft.세로 ?? "bottom"; vSelect.value = shownVAlign(draft.세로);
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign)); vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
@@ -263,3 +263,16 @@
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text { .ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
visibility: hidden; visibility: hidden;
} }
/* 손가락(기기대응 4-2) — 채우기 손잡이를 키움 · 격자는 한 손가락 밀기만 브라우저에(두 손가락 = 격자 확대) */
html[data-touch="on"] .aislo-grid__fill-handle {
width: 14px;
height: 14px;
margin: -4px 0 0 -4px;
}
@media (any-pointer: coarse) {
.aislo-grid {
touch-action: pan-x pan-y;
}
}
+9 -2
View File
@@ -13,6 +13,7 @@
import "./spreadsheet_grid.css"; import "./spreadsheet_grid.css";
import { colIndex, toA1 } from "./spreadsheet_address"; import { colIndex, toA1 } from "./spreadsheet_address";
import { wheelZoomsIn } from "../touch_bridge";
import { import {
buildColGeometry, buildColGeometry,
buildOutlineRuns, buildOutlineRuns,
@@ -271,7 +272,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
if (!ev.ctrlKey) return; if (!ev.ctrlKey) return;
ev.preventDefault(); ev.preventDefault();
const before = zoom; const before = zoom;
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015))); const step = (wheelZoomsIn(ev) ? 1 : -1) * Math.abs(ev.deltaY) * 0.0015; // ☰ [설정] 방향(332)
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(step)));
if (next === before) return; if (next === before) return;
zoom = next; zoom = next;
colGeo = scaledGeometry(baseColGeo, zoom); colGeo = scaledGeometry(baseColGeo, zoom);
@@ -553,7 +555,12 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null; const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
if (handleAt) { if (handleAt) {
const screen = screenBoxOf(handleAt.r, handleAt.c); const screen = screenBoxOf(handleAt.r, handleAt.c);
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) { // 손가락이면 손잡이 잡는 자리를 넓힘(기기대응 4-2)
const grip = document.documentElement.dataset.touch === "on" ? 20 : 5;
if (
Math.abs(x - (screen.x + screen.w)) <= grip &&
Math.abs(y - (screen.y + screen.h)) <= grip
) {
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c }; return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
} }
} }
@@ -13,6 +13,7 @@ import {
isAutoInk, isAutoInk,
resolveBoxBorders, resolveBoxBorders,
setCellText, setCellText,
shownHAlign,
type ResolvedBorders, type ResolvedBorders,
} from "./spreadsheet_cellstyle"; } from "./spreadsheet_cellstyle";
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers"; import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
@@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
} }
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = { const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left", general: "center",
left: "left", left: "left",
right: "right", right: "right",
center: "center", center: "center",
@@ -218,7 +219,7 @@ function cellDisplay(
const scalar = scalarAt(ctx, sheet, r, c); const scalar = scalarAt(ctx, sheet, r, c);
const formatted = formatValue(scalar, code); const formatted = formatValue(scalar, code);
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar; 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 title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
const isNumeric = const isNumeric =
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
+4 -2
View File
@@ -10,6 +10,7 @@
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음. * 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
* ========================================================================== */ * ========================================================================== */
import { keyLetter } from "../key_shortcut";
import { cycleAbs } from "./spreadsheet_editor"; import { cycleAbs } from "./spreadsheet_editor";
import { fillCells } from "./spreadsheet_fill"; import { fillCells } from "./spreadsheet_fill";
import { import {
@@ -127,7 +128,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl })); go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
return true; return true;
} }
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; const k = keyLetter(e) || (e.key.length === 1 ? e.key.toLowerCase() : e.key);
if (ctrl && !e.altKey) { if (ctrl && !e.altKey) {
switch (k) { switch (k) {
case "z": case "z":
@@ -233,7 +234,8 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
} }
function onKey(e: KeyboardEvent): void { function onKey(e: KeyboardEvent): void {
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫 // 한글 조합 중 — 브라우저 몫 (Ctrl+글자 단축키는 조합 중에도 판정 · 한글 자판 대응 214-2)
if ((e.isComposing || e.keyCode === 229) && !((e.ctrlKey || e.metaKey) && keyLetter(e))) return;
const inBar = e.target === ed.formula; const inBar = e.target === ed.formula;
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e); const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
if (done) { if (done) {
@@ -255,11 +255,61 @@ export function attachMouse(
}; };
const onLeave = (): void => hooks.hover?.(null); const onLeave = (): void => hooks.hover?.(null);
// 손가락(기기대응 4-2) — 톡 = 칸 고름(브라우저 호환 마우스 그대로) · 고른 칸 · 행열 머리 · 채우기 손잡이에서
// 끌기 = 범위 · 채우기(마우스 끌기와 같은 길) · 안 고른 칸에서 끌기 = 화면 밀기 ·
// 두 번 톡 = 고침(손 동작 안에서 바로 열어 화면 자판이 뜸)
let touchDrag = false;
let lastTap = 0;
const asMouse = (t: Touch, type: string): MouseEvent =>
new MouseEvent(type, { clientX: t.clientX, clientY: t.clientY, button: 0, bubbles: true });
const onTouchStart = (e: TouchEvent): void => {
if (e.touches.length !== 1 || e.target === ed.ta) return;
const t = e.touches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const grab =
hit &&
(hit.kind === "fillHandle" ||
hit.kind === "rowHead" ||
hit.kind === "colHead" ||
(hit.kind === "cell" && inSelection(hit.r, hit.c)));
if (!grab) return;
e.preventDefault(); // 화면 밀기 대신 범위 · 호환 마우스 이벤트도 안 옴
touchDrag = true;
onDown(asMouse(t, "mousedown"));
};
const onTouchMove = (e: TouchEvent): void => {
if (!touchDrag) return;
e.preventDefault();
root.dispatchEvent(asMouse(e.touches[0], "mousemove")); // 올라가 window 끌기 듣개에 닿음
};
const onTouchEnd = (e: TouchEvent): void => {
if (touchDrag) {
touchDrag = false;
root.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); // M02 칸 고르기도 받음
}
if (e.touches.length || e.changedTouches.length !== 1) return;
const t = e.changedTouches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const now = Date.now();
if (now - lastTap < 350 && hit?.kind === "cell" && !ed.editing() && !ctx.readOnly) {
lastTap = 0;
ed.begin();
return;
}
lastTap = now;
};
root.addEventListener("mousedown", onDown); root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl); root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover); root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave); root.addEventListener("mouseleave", onLeave);
root.addEventListener("touchstart", onTouchStart, { passive: false });
root.addEventListener("touchmove", onTouchMove, { passive: false });
root.addEventListener("touchend", onTouchEnd);
return () => { return () => {
root.removeEventListener("touchstart", onTouchStart);
root.removeEventListener("touchmove", onTouchMove);
root.removeEventListener("touchend", onTouchEnd);
root.removeEventListener("mousemove", onHover); root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave); root.removeEventListener("mouseleave", onLeave);
stopDrag?.(); stopDrag?.();
@@ -91,6 +91,20 @@ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress):
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } }; return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
} }
/** A1 범위 글 여럿 → 고름(329-2 손잡이 `select`) — 병합만큼 늘림 · 활성 = 첫 범위 왼위 · 틀린 글 · 빈 목록 = null */
export function selectRanges(sheet: Sheet, texts: string[]): Selection | null {
const list = merges(sheet);
const 범위: CellRange[] = [];
for (const t of texts) {
const g = parseRange(t);
if (!g) return null;
범위.push(expand(g, list));
}
if (!범위.length) return null;
const at = { r: 범위[0].r0, c: 범위[0].c0 };
return { 시트: sheet.id, 범위, 활성: at, 기준: { ...at } };
}
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */ /** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection { export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
const r0 = Math.min(a, b); const r0 = Math.min(a, b);
@@ -87,7 +87,7 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void {
const caseBox = el("input", { attrs: { type: "checkbox" } }); const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" }); const list = el("div", { className: "ss-sort__keys" });
const box = el("div", { const box = el("div", {
className: "ss-sort__box", className: "ss-sort__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") }, attrs: { role: "dialog", "aria-label": t("Title") },
}); });
const shade = el("div", { className: "ss-sort", children: [box] }); const shade = el("div", { className: "ss-sort", children: [box] });
@@ -15,6 +15,7 @@ export function setSpreadsheetLanguage(index: 0 | 1): void {
const TEXT = { const TEXT = {
Undo: ["되돌리기", "Undo"], Undo: ["되돌리기", "Undo"],
CellMenu: ["칸 메뉴(우클릭과 같음)", "Cell menu (same as right-click)"],
Redo: ["다시", "Redo"], Redo: ["다시", "Redo"],
FontName: ["글꼴", "Font"], FontName: ["글꼴", "Font"],
FontSize: ["크기", "Size"], FontSize: ["크기", "Size"],
@@ -291,3 +291,23 @@
justify-content: flex-end; justify-content: flex-end;
gap: var(--spacing-8); gap: var(--spacing-8);
} }
/* ⋯ 칸 메뉴(기기대응 3-5) — 터치 모드에서만 */
.ss-toolbar .ss-toolbar__more {
display: none;
}
html[data-touch="on"] .ss-toolbar .ss-toolbar__more {
display: inline-flex;
}
/* 자판 없는 기기 단추 줄(기기대응 4-3 · spreadsheet_touch_keys.ts) — 터치 모드에서만 */
.ss-touch-keys {
display: none;
}
html[data-touch="on"] .ss-touch-keys {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
+28 -3
View File
@@ -13,6 +13,7 @@
import { createButton, el } from "@ui/ui_template_elements"; import { createButton, el } from "@ui/ui_template_elements";
import { confirmMerge } from "./spreadsheet_actions"; import { confirmMerge } from "./spreadsheet_actions";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { cellToText, textToCell } from "./spreadsheet_editor"; import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text"; import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types"; import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
@@ -147,6 +148,30 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
undoBtn.title = st("Undo"); undoBtn.title = st("Undo");
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() }); const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
redoBtn.title = st("Redo"); redoBtn.title = st("Redo");
// 손가락 — 우클릭 대신 활성 칸 메뉴를 여는 ⋯(기기대응 3-5 · 터치 모드에서만 보임)
const moreBtn = createButton({
label: "⋯",
variant: "ghost",
onClick: () => {
const { r, c } = ctx.selection.활성;
const { box } = ctx.grid.editorSlot(r, c);
const at = ctx.grid.root.getBoundingClientRect();
const x = at.left + box.x + Math.min(box.w / 2, 24);
const y = at.top + box.y + box.h / 2;
const target = document.elementFromPoint(x, y) ?? ctx.grid.root;
target.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
button: 2,
}),
);
},
});
moreBtn.classList.add("ss-toolbar__more");
moreBtn.title = st("CellMenu");
// ── 글꼴 · 크기 ──────────────────────────────────────────────────────── // ── 글꼴 · 크기 ────────────────────────────────────────────────────────
const fontInput = el("input", { const fontInput = el("input", {
@@ -500,7 +525,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
}); });
root.append( root.append(
group(undoBtn, redoBtn), group(undoBtn, redoBtn, moreBtn),
group(fontInput, sizeInput), group(fontInput, sizeInput),
group(boldBtn, italicBtn, underlineBtn, strikeBtn), group(boldBtn, italicBtn, underlineBtn, strikeBtn),
group(textColor, textColorClear, fillColor, fillColorClear), group(textColor, textColorClear, fillColor, fillColorClear),
@@ -528,8 +553,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
strikeBtn.classList.toggle("is-on", !!style.취소선); strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000"; textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff"; fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general"; hAlignSelect.value = shownHAlign(style.가로);
vAlignSelect.value = style.세로 ?? "bottom"; vAlignSelect.value = shownVAlign(style.세로);
wrapBtn.classList.toggle("is-on", !!style.줄바꿈); wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? ""; numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0]; const range = ctx.selection.범위[0];
@@ -0,0 +1,43 @@
/* =============================================================================
* spreadsheet_touch_keys.ts
* 자판 없는 기기용 단추 줄(기기대응 4-3) — 단축키로만 되던 일을 같은 키 길(`attachKeys`)로 보냄.
* 입력줄(`ed.bar`) 끝에 붙고 터치 모드에서만 보임(spreadsheet_toolbar.css). 마우스 화면은 그대로.
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import type { Editor } from "./spreadsheet_editor";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const KEYS: [label: string, title: string, init: KeyboardEventInit, edit: boolean][] = [
["🔍", "찾기 · 바꾸기(Ctrl+F)", { key: "f", code: "KeyF", ctrlKey: true }, false],
["⇉", "고른 칸 모두 같은 값(Ctrl+Enter)", { key: "Enter", ctrlKey: true }, true],
["↵", "칸 안 줄바꿈(Alt+Enter)", { key: "Enter", altKey: true }, true],
["✕", "고침 취소(Esc)", { key: "Escape" }, true],
["📅", "오늘 날짜(Ctrl+;)", { key: ";", code: "Semicolon", ctrlKey: true }, true],
["$", "참조 $ 돌리기(F4)", { key: "F4" }, true],
];
export function mountTouchKeys(ctx: SpreadsheetContext, ed: Editor): void {
const row = document.createElement("div");
row.className = "ss-touch-keys";
for (const [label, title, init, edit] of KEYS) {
if (edit && ctx.readOnly) continue;
const b = createButton({
label,
variant: "ghost",
onClick: () => {
// 고치는 중이면 그 칸(입력줄 · 칸 편집기) — 아니면 칸 편집기로
const target = document.activeElement === ed.formula ? ed.formula : ed.ta;
target.dispatchEvent(
new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true }),
);
},
});
b.title = title;
b.setAttribute("aria-label", title);
// 누를 때 초점을 뺏지 않음 — 고치던 칸이 그대로(손가락은 호환 mousedown 이 옴)
b.addEventListener("mousedown", (e) => e.preventDefault());
row.append(b);
}
ed.bar.append(row);
}
+233
View File
@@ -0,0 +1,233 @@
/* =============================================================================
* touch_bridge.ts
* 손가락 → 마우스 번역 한 곳(요구 193~195 · PLAN_기기대응 3-1) — 페이지 코드는 마우스 그대로.
*
* - 길게 누름(500ms · 10px 안) → 그 자리에 `contextmenu` · 뒤따르는 click 막음
* (안드로이드처럼 브라우저가 먼저 내면 그것 하나만)
* - 두 번 톡 → 브라우저가 `dblclick` 을 안 낼 때만 대신
* - 두 손가락 벌림 → 가운데 자리에 Ctrl+휠(deltaY = −100·ln(배율) · 배율 10% 마다 한 번 · 트랙패드 핀치와 같은 꼴)
* 받는 면이 touch-action 으로 브라우저 확대를 막은 곳에서만 끝까지 옴
* - `[data-touch-own]` 요소(3D · 공용 SVG 확대)는 건너뜀 — 자체 손가락 처리 · 입력 칸 길게 누름도 브라우저 몫
* 마우스 · 펜 이벤트는 건드리지 않음.
* ========================================================================== */
import { wheelEventIn } from "./input_prefs";
const LONG_MS = 500;
const SLOP_PX = 10;
const DOUBLE_MS = 300;
const DOUBLE_PX = 25;
/** 브라우저 dblclick 을 기다리는 틈 */
const NATIVE_WAIT_MS = 350;
/** 길게 누름 뒤 따라오는 click · 원래 contextmenu 를 막는 틈 */
const AFTER_LONG_MS = 800;
const PINCH_STEP = Math.log(1.1);
const synthetic = new WeakSet<Event>();
/** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */
export const isPinchWheel = (e: Event): boolean => synthetic.has(e);
let contactPx = 0;
/** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */
export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px);
/** 휠 화면(B04 · B05 지도 · M02 그림 · 시트)의 확대 판정 — ☰ [설정] 방향(332) · 손가락 벌림은 늘 벌리면 확대 */
export const wheelZoomsIn = (e: WheelEvent): boolean => wheelEventIn(e, isPinchWheel(e));
const ownsTouch = (t: EventTarget | null): boolean =>
t instanceof Element && !!t.closest("[data-touch-own]");
const isEditable = (t: EventTarget | null): boolean =>
t instanceof Element &&
!!t.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
interface Pt {
x: number;
y: number;
}
const touches = new Map<number, Pt>();
let press: (Pt & { id: number; target: Element; timer: ReturnType<typeof setTimeout> }) | null =
null;
let longAt = 0;
let nativeMenuAt = 0;
let nativeDblAt = 0;
let lastTap: (Pt & { t: number }) | null = null;
let pinch: { dist: number; acc: number } | null = null;
const at = (p: Pt, fallback: Element): Element => document.elementFromPoint(p.x, p.y) ?? fallback;
function fire(type: "contextmenu" | "dblclick", target: Element, p: Pt): void {
const ev = new MouseEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
view: window,
clientX: p.x,
clientY: p.y,
screenX: p.x,
screenY: p.y,
button: type === "contextmenu" ? 2 : 0,
detail: type === "dblclick" ? 2 : 1,
});
synthetic.add(ev);
target.dispatchEvent(ev);
}
function fireWheel(target: Element, p: Pt, deltaY: number): void {
const ev = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
composed: true,
view: window,
clientX: p.x,
clientY: p.y,
ctrlKey: true,
deltaY,
deltaMode: 0,
});
synthetic.add(ev);
target.dispatchEvent(ev);
}
function cancelPress(): void {
if (press) clearTimeout(press.timer);
press = null;
}
const pair = (): { mid: Pt; dist: number } => {
const [a, b] = [...touches.values()];
return {
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
dist: Math.hypot(a.x - b.x, a.y - b.y),
};
};
/** 그림 면(SVG · 캔버스)을 처음 만지면 한 번 — 「길게 누르면 메뉴」(3-5 · 이 탭에서 한 번) */
const HINT_KEY = "frd_touch_hint_shown";
function hintOnce(target: Element): void {
if (!target.closest("svg, canvas")) return;
try {
if (sessionStorage.getItem(HINT_KEY)) return;
sessionStorage.setItem(HINT_KEY, "1");
} catch {
return;
}
void Promise.all([import("@ui/ui_template_elements"), import("@ui/ui_template_locale")]).then(
([ui, loc]) => ui.showToast(loc.t("App_Touch_LongPressHint"), "info", 4000),
);
}
function onDown(e: PointerEvent): void {
contactPx = e.pointerType === "touch" ? Math.max(24, e.width / 2, e.height / 2) : 0;
if (e.pointerType !== "touch") return;
const p = { x: e.clientX, y: e.clientY };
touches.set(e.pointerId, p);
const target = e.target as Element;
hintOnce(target);
if (touches.size === 1) {
if (ownsTouch(target) || isEditable(target)) return;
press = {
...p,
id: e.pointerId,
target,
timer: setTimeout(() => {
const was = press;
press = null;
if (!was || Date.now() - nativeMenuAt < LONG_MS) return;
longAt = Date.now();
fire("contextmenu", at(was, was.target), was);
}, LONG_MS),
};
return;
}
cancelPress();
lastTap = null;
pinch = touches.size === 2 && !ownsTouch(target) ? { dist: pair().dist, acc: 0 } : null;
}
function onMove(e: PointerEvent): void {
if (e.pointerType !== "touch" || !touches.has(e.pointerId)) return;
const p = { x: e.clientX, y: e.clientY };
touches.set(e.pointerId, p);
if (press && press.id === e.pointerId && Math.hypot(p.x - press.x, p.y - press.y) > SLOP_PX) {
cancelPress();
lastTap = null;
}
if (!pinch || touches.size !== 2) return;
const { mid, dist } = pair();
if (pinch.dist > 0 && dist > 0) pinch.acc += Math.log(dist / pinch.dist);
pinch.dist = dist;
if (Math.abs(pinch.acc) < PINCH_STEP) return;
fireWheel(at(mid, e.target as Element), mid, -100 * pinch.acc);
pinch.acc = 0;
}
function onUp(e: PointerEvent): void {
if (e.pointerType !== "touch") return;
touches.delete(e.pointerId);
if (touches.size < 2) pinch = null;
if (!press || press.id !== e.pointerId) return;
const tap = press;
cancelPress();
const now = Date.now();
if (
lastTap &&
now - lastTap.t < DOUBLE_MS &&
Math.hypot(tap.x - lastTap.x, tap.y - lastTap.y) < DOUBLE_PX
) {
lastTap = null;
setTimeout(() => {
if (nativeDblAt < now) fire("dblclick", at(tap, tap.target), tap);
}, NATIVE_WAIT_MS);
return;
}
lastTap = { x: tap.x, y: tap.y, t: now };
}
function onCancel(e: PointerEvent): void {
if (e.pointerType !== "touch") return;
touches.delete(e.pointerId);
cancelPress();
pinch = null;
}
function onNativeMenu(e: Event): void {
if (synthetic.has(e)) return;
if (Date.now() - longAt < AFTER_LONG_MS) {
// 우리가 이미 냄 — 브라우저 것은 하나 더라 버림
e.preventDefault();
e.stopImmediatePropagation();
} else if (press) {
nativeMenuAt = Date.now(); // 브라우저가 먼저 냄 — 우리 것은 안 냄
cancelPress();
}
}
function onClick(e: Event): void {
if (synthetic.has(e) || Date.now() - longAt >= AFTER_LONG_MS) return;
longAt = 0;
e.preventDefault();
e.stopImmediatePropagation();
}
function listen(): void {
const opts = { capture: true, passive: true } as const;
window.addEventListener("pointerdown", onDown, opts);
window.addEventListener("pointermove", onMove, opts);
window.addEventListener("pointerup", onUp, opts);
window.addEventListener("pointercancel", onCancel, opts);
window.addEventListener("contextmenu", onNativeMenu, true);
window.addEventListener("click", onClick, true);
window.addEventListener(
"dblclick",
(e) => {
if (!synthetic.has(e)) nativeDblAt = Date.now();
},
true,
);
}
// 브라우저에서만(노드 시험이 이 모듈을 거쳐 불러도 됨)
if (typeof window !== "undefined" && typeof window.addEventListener === "function") listen();
+27
View File
@@ -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]]: async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size) 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)
@@ -19,6 +19,8 @@ export interface AddressFieldHandle {
root: HTMLElement; root: HTMLElement;
/** 저장할 한 줄 주소. 비면 null. */ /** 저장할 한 줄 주소. 비면 null. */
value: () => string | null; value: () => string | null;
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
point: () => { lat: number; lon: number } | null;
} }
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */ /** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
@@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
label: "지도 확인", label: "지도 확인",
variant: "ghost", variant: "ghost",
onClick: async function onB01_Address_Map_Click() { 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; 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); root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
let manual = false; let manual = false;
let picked: { lat: number; lon: number } | null = null;
function pick(item: AddressCandidate): void { function pick(item: AddressCandidate): void {
base.input.value = candidateLine(item); base.input.value = candidateLine(item);
@@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
zip.hidden = !item.zipcode; zip.hidden = !item.zipcode;
list.hidden = true; list.hidden = true;
list.innerHTML = ""; list.innerHTML = "";
picked = { lat: item.lat, lon: item.lon };
map.showPoint(item.lat, item.lon); map.showPoint(item.lat, item.lon);
detail.input.focus(); 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 { return {
root, root,
@@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim(); const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
return merged || null; return merged || null;
}, },
point: () => picked,
}; };
} }
@@ -58,11 +58,17 @@ export function buildKnowledgeChat(): HTMLElement {
input.rows = 2; input.rows = 2;
input.required = true; input.required = true;
input.maxLength = 500; input.maxLength = 500;
input.placeholder = "메시지를 입력하세요"; input.placeholder = "메시지를 입력하세요 (Shift+Enter 줄바꿈)";
input.setAttribute("aria-label", "질문"); input.setAttribute("aria-label", "질문");
const submit = document.createElement("button"); const submit = document.createElement("button");
submit.type = "submit"; submit.type = "submit";
submit.textContent = "보내기"; submit.textContent = "보내기";
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit.click();
}
});
form.append(input, submit); form.append(input, submit);
panel.append(header, messages, form); panel.append(header, messages, form);
wrap.append(toggle, panel); wrap.append(toggle, panel);
+6 -4
View File
@@ -32,7 +32,8 @@ function tileIndex(
export function buildAddressMap(): { export function buildAddressMap(): {
root: HTMLElement; root: HTMLElement;
show: (address: string) => Promise<void>; /** 찾은 좌표를 돌려줌 — 못 찾으면 null. */
show: (address: string) => Promise<{ lat: number; lon: number } | null>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */ /** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void; showPoint: (lat: number, lon: number) => void;
} { } {
@@ -110,17 +111,18 @@ export function buildAddressMap(): {
note.textContent = message; note.textContent = message;
} }
const show = async (address: string): Promise<void> => { const show = async (address: string): Promise<{ lat: number; lon: number } | null> => {
if (!address.trim()) { if (!address.trim()) {
fail("주소를 먼저 입력하십시오."); fail("주소를 먼저 입력하십시오.");
return; return null;
} }
const point = await geocodeAddress(address).catch(() => null); const point = await geocodeAddress(address).catch(() => null);
if (!point) { if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오."); fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return; return null;
} }
draw(point.lat, point.lon); draw(point.lat, point.lon);
return { lat: point.lat, lon: point.lon };
}; };
return { root, show, showPoint: draw }; return { root, show, showPoint: draw };
+43 -32
View File
@@ -9,7 +9,10 @@ import {
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks"; import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { navigateTo } from "../A00_Common/router"; import { createStepBar } from "@ui/ui_template_workflow_layout";
import { setDrawerSteps } from "../A00_Common/app_shell";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { currentRoute, navigateTo } from "../A00_Common/router";
import { import {
fetchAllCompanies, fetchAllCompanies,
fetchAllUsers, fetchAllUsers,
@@ -162,40 +165,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement {
return card; return card;
} }
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ /** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "b01-dashboard__setting-grid"; wrap.className = "b01-dashboard__setting-grid";
// 시스템 관리자는 고른 회사를 해시에 실음(29-1) wrap.append(
const company: Record<string, string> = ...settingLinks(user, companyId).map((link) =>
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)),
const m02 = (title: string, open: string, root: string, desc: string): HTMLElement => ),
settingCard(title, desc, () => );
navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }),
);
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
wrap.append(
settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () =>
navigateTo(ROUTES.M01_MASTER_DATA),
),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
);
return wrap;
}
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
const companyAdmin = user.role === "ADMIN" || user.is_master;
const root = companyAdmin ? "company" : "personal";
const desc = [
L("B01_Dashboard_PermMasterRead"),
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc));
return wrap; return wrap;
} }
@@ -231,6 +209,38 @@ function buildSystemSettingsSection(state: DashboardState): HTMLElement {
return card; return card;
} }
/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */
const SECTION_ICONS: [Parameters<typeof L>[0], string][] = [
["B01_Dashboard_Resources", "📊"],
["B01_Dashboard_SystemSettings", "⚙️"],
["B01_Dashboard_Projects", "🗂️"],
["B01_Temp_Section", "📦"],
["B01_Dashboard_Users", "👥"],
["B01_Dashboard_Members", "👥"],
["B01_Dashboard_JoinRequests", "📨"],
["B01_Dashboard_Companies", "🏢"],
["B01_Dashboard_Company", "🏢"],
["B01_Dashboard_Profile", "👤"],
["B01_Account_Section_Security", "🔒"],
["B01_Dashboard_AuditLogs", "📜"],
];
/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */
function mountSectionShortcuts(grid: HTMLElement): void {
// 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음
if (currentRoute() !== ROUTES.B01_ACCOUNT) return;
const sections = [...grid.children] as HTMLElement[];
const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? "");
const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? "");
const bar = createStepBar(titles, -1, {
orientation: "vertical",
icons,
routes: titles,
onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }),
});
setDrawerSteps(bar);
}
function buildPage(state: DashboardState): HTMLElement { function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div"); const page = document.createElement("div");
@@ -311,6 +321,7 @@ function buildPage(state: DashboardState): HTMLElement {
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
} }
page.append(grid); page.append(grid);
mountSectionShortcuts(grid);
page.append(buildKnowledgeChat()); page.append(buildKnowledgeChat());
const layout = createGeneralLayout({ const layout = createGeneralLayout({
pageClass: "b01-dashboard", pageClass: "b01-dashboard",
+18 -11
View File
@@ -536,8 +536,8 @@
.b01-knowledge-chat__toggle, .b01-knowledge-chat__toggle,
.b01-knowledge-chat__form button { .b01-knowledge-chat__form button {
border: 0; border: 0;
background: var(--color-royal-amethyst, #6843a7); background: var(--color-primary);
color: #fff; color: var(--color-primary-text);
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
} }
@@ -560,12 +560,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: min(380px, calc(100vw - 32px)); width: min(380px, calc(100vw - 32px));
height: min(680px, calc(100vh - 104px)); height: min(680px, calc(100dvh - 104px));
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 12px; border-radius: 12px;
background: var(--color-surface, #fff); background: var(--color-surface, #fff);
box-shadow: 0 8px 28px #0003; box-shadow: 0 8px 28px #0003;
color: var(--color-text-primary, #222); color: var(--color-text-body);
overflow: hidden; overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
@@ -588,8 +588,8 @@
height: 34px; height: 34px;
place-items: center; place-items: center;
border-radius: 50%; border-radius: 50%;
background: var(--color-royal-amethyst, #6843a7); background: var(--color-primary);
color: #fff; color: var(--color-primary-text);
} }
.b01-knowledge-chat__avatar svg { .b01-knowledge-chat__avatar svg {
@@ -631,14 +631,15 @@
.b01-knowledge-chat__user { .b01-knowledge-chat__user {
align-self: flex-end; align-self: flex-end;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
background: var(--color-royal-amethyst, #6843a7); background: var(--color-primary);
color: #fff; color: var(--color-primary-text);
} }
.b01-knowledge-chat__reply { .b01-knowledge-chat__reply {
align-self: flex-start; align-self: flex-start;
border: 1px solid var(--color-border);
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
background: var(--color-background, #f4f4f4); background: var(--color-surface-raised);
} }
.b01-knowledge-chat__reply details { .b01-knowledge-chat__reply details {
@@ -722,7 +723,7 @@
.b01-knowledge-source-modal__content code { .b01-knowledge-source-modal__content code {
padding: 0.1em 0.35em; padding: 0.1em 0.35em;
border-radius: 4px; border-radius: 4px;
background: var(--color-background, #f4f4f4); background: var(--color-surface);
} }
.b01-knowledge-source-modal__content .ui-markdown-table { .b01-knowledge-source-modal__content .ui-markdown-table {
@@ -746,7 +747,7 @@
} }
.b01-knowledge-source-modal__content th { .b01-knowledge-source-modal__content th {
background: var(--color-background, #f4f4f4); background: var(--color-surface);
} }
.b01-knowledge-chat__form { .b01-knowledge-chat__form {
@@ -765,11 +766,17 @@
padding: 8px; padding: 8px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit; font: inherit;
resize: vertical; resize: vertical;
overflow-y: auto; overflow-y: auto;
} }
.b01-knowledge-chat__form textarea::placeholder {
color: var(--color-text-muted);
}
.b01-knowledge-chat__form button { .b01-knowledge-chat__form button {
padding: 9px 14px; padding: 9px 14px;
border-radius: 8px; border-radius: 8px;
@@ -350,6 +350,18 @@ async def run_auto_design_chain(
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
try:
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults
_schedule_background_task(
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
)
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
return { return {
"route_id": route_id, "route_id": route_id,
"length_m": float(solve_result.total_length_m or 0.0), "length_m": float(solve_result.total_length_m or 0.0),
+1 -1
View File
@@ -1,5 +1,5 @@
.b03-file { .b03-file {
min-height: calc(100vh - var(--app-header-height, 0px)); min-height: calc(100dvh - var(--app-header-height, 0px));
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
var(--color-canvas, #ffffff) 0%, var(--color-canvas, #ffffff) 0%,
+1 -1
View File
@@ -74,7 +74,7 @@ export function createTempPicker(
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop"; backdrop.className = "b03-file__modal-backdrop";
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.className = "b03-file__modal b03-file__temp-modal"; modal.className = "b03-file__modal b03-file__temp-modal ui-popup";
modal.setAttribute("role", "dialog"); modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true"); modal.setAttribute("aria-modal", "true");
+3 -10
View File
@@ -471,17 +471,10 @@ export async function fetchRoadInflow(
export type PipeSource = "stream" | "spacing" | "user"; export type PipeSource = "stream" | "spacing" | "user";
/** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에 /** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에
* 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */ * 따라 택일한다 — 관 지점 정본 종류 = 구조물 표 정본(`catalogRow().owner` · 323-7) · 이름 = 표 이름 ·
* 형상마다 손코딩 폼이 있어 종류 더하기(본보기)에서 뺌. 교량은 임도용이 아니다. */
export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment"; export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment";
export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [
["pipe", "배수관"],
["box_culvert", "BOX암거"],
["ford_pavement", "물넘이포장"],
["ford_bridge", "세월교"],
["revetment", "기슭막이"],
];
export interface DetailPipePoint { export interface DetailPipePoint {
chainage_m: number; chainage_m: number;
lonlat: [number, number]; lonlat: [number, number];
@@ -585,7 +578,7 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). /** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
* *
* `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */ * 빈 목록 = 관 0개(312) · 기본 관 + 자동 보충으로 되돌리기는 `resetDetailPipePoints`(DELETE)만. */
export async function computeDetailBasins( export async function computeDetailBasins(
projectId: string, projectId: string,
points: DetailPipeInput[], points: DetailPipeInput[],
+13 -9
View File
@@ -62,16 +62,16 @@ def _build(
) -> tuple[DrainageDetail | None, list[PipePoint]]: ) -> tuple[DrainageDetail | None, list[PipePoint]]:
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다. """세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
`points`가 없으면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다. `points`가 None 이면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의 있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다. 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
""" """
directory = drainage_dir(stored_path) directory = drainage_dir(stored_path)
chainages = [point.chainage_m for point in points] if points else None chainages = [point.chainage_m for point in points] if points is not None else None
detail = build_detail(directory, context.vertices, chainages) detail = build_detail(directory, context.vertices, chainages)
if detail is None: if detail is None:
return None, [] return None, []
if points: if points is not None:
_retag(detail.pipes, points) _retag(detail.pipes, points)
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes] rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관 # 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
@@ -357,26 +357,30 @@ def _basin_features(
async def _resolve( async def _resolve(
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse: ) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse:
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.""" """요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
"""
context, reason = await load_drainage_context(project_id) context, reason = await load_drainage_context(project_id)
if context is None: if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason}) return JSONResponse(status_code=404, content={"status": "error", "message": reason})
try: try:
requested = parse_pipe_points((payload or {}).get("points")) raw = (payload or {}).get("points")
requested = None if raw is None else parse_pipe_points(raw)
except ValueError as error: # 집계 항목(summary_item) 모양이 틀림 except ValueError as error: # 집계 항목(summary_item) 모양이 틀림
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
signature = route_signature(context.vertices) signature = route_signature(context.vertices)
stored = ( stored = (
load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None
) )
points = requested or stored or None points = requested if requested is not None else stored
result = await asyncio.to_thread(_build, context.stored_path, context, points) result = await asyncio.to_thread(_build, context.stored_path, context, points)
detail, resolved = result detail, resolved = result
if detail is None: if detail is None:
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS}) return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
return context, detail, resolved, bool(stored) and not requested return context, detail, resolved, requested is None and stored is not None
@router.get("/{project_id}/drainage/pipe-points", response_model=None) @router.get("/{project_id}/drainage/pipe-points", response_model=None)
@@ -395,8 +399,8 @@ async def post_detail_basins(
) -> dict[str, Any] | JSONResponse: ) -> dict[str, Any] | JSONResponse:
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). """화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
`points`를 비우고 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 — `points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
""" """
resolved = await _resolve(project_id, payload, use_stored=False) resolved = await _resolve(project_id, payload, use_stored=False)
if isinstance(resolved, JSONResponse): if isinstance(resolved, JSONResponse):
+2 -1
View File
@@ -40,6 +40,7 @@ import {
resampleRoute, resampleRoute,
type RoutePoint, type RoutePoint,
} from "./B04_PreProcess_UI_RouteSamples"; } from "./B04_PreProcess_UI_RouteSamples";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
function L(key: keyof typeof ui_locales): string { function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
@@ -296,7 +297,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
function selectBasinOfPipe(pipeIndex: number): void { function selectBasinOfPipe(pipeIndex: number): void {
const pipe = pipes[pipeIndex]; const pipe = pipes[pipeIndex];
if (!pipe) return; 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; selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null;
say(summary()); say(summary());
} }
+80 -21
View File
@@ -1,6 +1,7 @@
import * as THREE from "three"; import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch"; import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch";
import { wheelEventIn } from "../A00_Common/input_prefs";
export const SURFACE_CAMERA_FOV = 50; export const SURFACE_CAMERA_FOV = 50;
const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9; const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9;
@@ -225,10 +226,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
if (options.blocked?.() || !controls.enabled) return; if (options.blocked?.() || !controls.enabled) return;
if (event.button === 1 && panPointerId === null) { if (event.button === 1 && panPointerId === null) {
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다. // 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
pickPivot(event); startGrabPan(event);
panAnchor.copy(pivot);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
panPointerId = event.pointerId; panPointerId = event.pointerId;
element.setPointerCapture?.(event.pointerId); element.setPointerCapture?.(event.pointerId);
return; return;
@@ -249,13 +247,17 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
} }
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
* 휠을 위로 올리면 멀어진다(사용자 지시). */ * 방향 = ☰ [설정](332 · 기본 당기면 가까워짐 · 위로 올리면 멀어짐). */
function onWheel(event: WheelEvent): void { function onWheel(event: WheelEvent): void {
const camera = getCamera();
if (!controls.enabled || options.blocked?.()) return; if (!controls.enabled || options.blocked?.()) return;
event.preventDefault(); event.preventDefault();
pickPivot(event); zoomAround(event, wheelEventIn(event) ? ZOOM_STEP : 1 / ZOOM_STEP);
const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP; }
/** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
function zoomAround(at: { clientX: number; clientY: number }, factor: number): void {
const camera = getCamera();
pickPivot(at);
const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor); const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor);
const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor); const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다. // 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
@@ -276,19 +278,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
function onPointerMove(event: PointerEvent): void { function onPointerMove(event: PointerEvent): void {
const camera = getCamera(); const camera = getCamera();
if (panPointerId === event.pointerId) { if (panPointerId === event.pointerId) {
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다. grabPanTo(event);
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
pointer.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
const delta = panAnchor.clone().sub(panPoint);
camera.position.add(delta);
controls.target.add(delta);
controls.update();
return; return;
} }
if (pointerId !== event.pointerId) return; if (pointerId !== event.pointerId) return;
@@ -351,6 +341,67 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
syncPivotMarker(); syncPivotMarker();
} }
/** 그랩 팬 — 잡은 점이 커서(손가락 가운데) 아래에 계속 오도록 카메라·target을 평행 이동한다. */
function grabPanTo(at: { clientX: number; clientY: number }): void {
const camera = getCamera();
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
pointer.set(
((at.clientX - rect.left) / rect.width) * 2 - 1,
-((at.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
const delta = panAnchor.clone().sub(panPoint);
camera.position.add(delta);
controls.target.add(delta);
controls.update();
}
function startGrabPan(at: { clientX: number; clientY: number }): void {
pickPivot(at);
panAnchor.copy(pivot);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
}
// 손가락(기기대응 3-1) — 하나 = 회전(왼쪽 단추와 같음) · 둘 = 벌려 확대 · 같이 끌어 이동.
// 3D 는 자체 처리 — 손가락 번역(`touch_bridge`)은 이 요소를 건너뜀.
element.dataset.touchOwn = "3d";
element.style.touchAction = "none";
const touches = new Map<number, { clientX: number; clientY: number }>();
let pinchDist = 0;
const touchMid = (): { clientX: number; clientY: number; dist: number } => {
const [a, b] = [...touches.values()];
return {
clientX: (a.clientX + b.clientX) / 2,
clientY: (a.clientY + b.clientY) / 2,
dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
};
};
function onTouchDown(event: PointerEvent): void {
if (event.pointerType !== "touch" || options.blocked?.() || !controls.enabled) return;
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
if (touches.size !== 2) return;
stop(); // 한 손가락 회전을 멈추고 두 손가락으로
const m = touchMid();
pinchDist = m.dist;
startGrabPan(m);
}
function onTouchMove(event: PointerEvent): void {
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
if (touches.size !== 2) return;
const m = touchMid();
grabPanTo(m);
if (pinchDist > 0 && m.dist > 0) zoomAround(m, pinchDist / m.dist);
pinchDist = m.dist;
startGrabPan(m); // 확대 뒤 다시 잡음 — 다음 이동이 새 배율 기준
}
function onTouchUp(event: PointerEvent): void {
touches.delete(event.pointerId);
}
function stop(): void { function stop(): void {
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) { if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
element.releasePointerCapture(pointerId); element.releasePointerCapture(pointerId);
@@ -381,6 +432,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.addEventListener("pointercancel", onPointerEnd); element.addEventListener("pointercancel", onPointerEnd);
element.addEventListener("pointerleave", onPointerLeave); element.addEventListener("pointerleave", onPointerLeave);
element.addEventListener("wheel", onWheel, { passive: false }); element.addEventListener("wheel", onWheel, { passive: false });
element.addEventListener("pointerdown", onTouchDown);
element.addEventListener("pointermove", onTouchMove);
element.addEventListener("pointerup", onTouchUp);
element.addEventListener("pointercancel", onTouchUp);
return () => { return () => {
element.removeEventListener("pointerdown", onPointerDown); element.removeEventListener("pointerdown", onPointerDown);
@@ -389,6 +444,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.removeEventListener("pointercancel", onPointerEnd); element.removeEventListener("pointercancel", onPointerEnd);
element.removeEventListener("pointerleave", onPointerLeave); element.removeEventListener("pointerleave", onPointerLeave);
element.removeEventListener("wheel", onWheel); element.removeEventListener("wheel", onWheel);
element.removeEventListener("pointerdown", onTouchDown);
element.removeEventListener("pointermove", onTouchMove);
element.removeEventListener("pointerup", onTouchUp);
element.removeEventListener("pointercancel", onTouchUp);
if (pivotMarker) { if (pivotMarker) {
pivotMarker.removeFromParent(); pivotMarker.removeFromParent();
pivotMarker.material.map?.dispose(); pivotMarker.material.map?.dispose();
@@ -211,6 +211,8 @@ export interface StationTickOptions {
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고, /** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */ * 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
onLabel?: (box: StationLabelBox) => void; onLabel?: (box: StationLabelBox) => void;
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */
chainages?: ReadonlyArray<number>;
} }
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */ /** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
@@ -253,7 +255,10 @@ export function drawStationTicks(
// 이미 그린 라벨과 겹치는 자리는 비켜 놓는다. // 이미 그린 라벨과 겹치는 자리는 비켜 놓는다.
const drawn: Array<{ x: number; y: number; half: number }> = []; const drawn: Array<{ x: number; y: number; half: number }> = [];
let cursor = 1; let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += interval) { const marks: number[] = [];
if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total));
else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage);
for (const chainage of marks) {
const stationNo = Math.round(chainage / interval); const stationNo = Math.round(chainage / interval);
if (stationNo % step !== 0) continue; if (stationNo % step !== 0) continue;
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
@@ -1,4 +1,5 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { createProgressCircle } from "@ui/ui_template_progress"; import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { import {
@@ -546,7 +547,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
wheelWidth, wheelWidth,
8, 8,
); );
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87))); scale = Math.min(maxScale, Math.max(0.5, scale * (wheelZoomsIn(event) ? 1.15 : 0.87)));
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정. // 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
// screen = center + (base - center)·scale + offset 이므로, // screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale). // 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
+1 -1
View File
@@ -119,7 +119,7 @@
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
max-width: none; max-width: none;
min-height: calc(100vh - var(--spacing-64)); min-height: calc(100dvh - var(--app-header-h));
} }
.b04-surface__workspace > .b04-surface__status { .b04-surface__workspace > .b04-surface__status {
+2
View File
@@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse {
route_id: number; route_id: number;
profile_alignment: unknown; profile_alignment: unknown;
grade_summary: RouteGradeSummary | null; grade_summary: RouteGradeSummary | null;
/** 측점 · 관 · 구조물 자리가 없어 서버가 버린 계획고 편집 자리(m · 213-2). */
dropped_edit_chainages?: number[];
} }
/** /**
+4
View File
@@ -100,6 +100,8 @@ export interface RoutePlanResponse {
violation_count: number; violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */ /** 사용자가 고친 계획노선이 저장돼 있으면 true. */
edited: boolean; edited: boolean;
/** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */
station_snap?: boolean;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null }; current_initial?: { logic: string; weights: Record<string, number> | null };
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */ /** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
@@ -220,6 +222,8 @@ export interface CrossPreviewRequest {
station_interval_m: number; station_interval_m: number;
/** 횡단 반폭(m) — 없으면 서버 기본값. */ /** 횡단 반폭(m) — 없으면 서버 기본값. */
cross_half_width_m?: number; cross_half_width_m?: number;
/** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */
station_snap?: boolean;
} }
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ /** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
+78 -10
View File
@@ -11,9 +11,11 @@
* 하려는 것이라, 목록은 반드시 서버에서 받아 온다. * 하려는 것이라, 목록은 반드시 서버에서 받아 온다.
* ========================================================================== */ * ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { noteSnapshotFailed } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { noteSnapshotFailed } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import { useCatalogDefinition } from "../common_util/common_util_structure_catalog";
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { useDesignStore } from "../A00_Common/design/design_store";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { import {
type DefinedRule, type DefinedRule,
@@ -77,7 +79,6 @@ export interface StructureType {
placement: StructurePlacement; placement: StructurePlacement;
options: StructureOptionField[]; options: StructureOptionField[];
style: { color?: string; abbr?: string }; style: { color?: string; abbr?: string };
drawing_views: string[];
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */ /** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
managed_by: string | null; managed_by: string | null;
/** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름. /** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름.
@@ -140,6 +141,8 @@ export interface StructureListResponse {
project_id: string; project_id: string;
revision: number; revision: number;
structures: StructureInstance[]; structures: StructureInstance[];
/** 정본을 적을 때의 구조물 정의 판(322-2) — 없으면 옛 파일 · 아직 안 적음. */
definition_version?: number | null;
} }
export interface StructureSaveResponse { export interface StructureSaveResponse {
@@ -192,8 +195,66 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
} }
} }
/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */ /** 타입 레지스트리 캐시 — 정의 판(`typesVersion`)이 바뀌면(M02 저장) 버리고 다시 받음(322-0). */
let typesCache: Promise<StructureType[]> | null = null; let typesCache: Promise<StructureType[]> | null = null;
let typesVersion: number | null = null;
/** 정의 판이 캐시 판과 다르면 종류 캐시를 버림 — 버렸으면 true(B05 · B06 보기를 열 때 · 탭이 다시 보일 때). */
export async function dropStaleStructureTypes(): Promise<boolean> {
if (!typesCache || typesVersion === null) return false;
const now = await requestJson<{ 판: number }>("/m02/structure-definition/version", {
method: "GET",
})
.then((payload) => payload.판)
.catch(() => null);
if (now === null || now === typesVersion) return false;
typesCache = null;
typesVersion = null;
return true;
}
const noticed = new Set<string>();
const staleSaved = new Set<string>(); // 정본이 옛 정의 판으로 적힌 프로젝트(323-9)
/** 정본이 옛 정의 판으로 적혔나 — 그러면 고침이 없어도 [저장]이 정본을 다시 써 지금 판 도장(323-9). */
export function definitionStale(projectId: string): boolean {
return staleSaved.has(projectId);
}
/** 프로젝트 정본이 옛 정의 판으로 적혔으면 알림 한 줄(322-2 · 탭 수명 동안 프로젝트 · 판마다 한 번). */
export function staleDefinitionNotice(
projectId: string,
saved: number | null | undefined,
): string | null {
if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion) {
staleSaved.delete(projectId);
return null;
}
staleSaved.add(projectId);
const key = `${projectId}:${saved}:${typesVersion}`;
if (noticed.has(key)) return null;
noticed.add(key);
return `구조물 정의가 판 ${typesVersion} 으로 바뀜 — 저장하면 반영`;
}
const staleListeners = new Set<() => void>();
function checkStaleOnShow(): void {
if (document.visibilityState !== "visible") return;
void dropStaleStructureTypes().then((stale) => {
if (stale) staleListeners.forEach((reload) => reload());
});
}
/** 탭이 다시 보일 때 정의 판이 바뀌었으면 `reload`(종류 · 패널 다시 읽음) — 떼는 함수를 돌려줌. */
export function onStaleStructureTypes(reload: () => void): () => void {
if (!staleListeners.size) document.addEventListener("visibilitychange", checkStaleOnShow);
staleListeners.add(reload);
return () => {
staleListeners.delete(reload);
if (!staleListeners.size) document.removeEventListener("visibilitychange", checkStaleOnShow);
};
}
/** /**
* 구조물 타입 목록. * 구조물 타입 목록.
@@ -212,7 +273,12 @@ export function fetchStructureTypes(includeDisabled = false): Promise<StructureT
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", { typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
method: "GET", method: "GET",
}) })
.then(async (payload) => withDefinition(payload.types, await definition)) .then(async (payload) => {
const defined = await definition;
typesVersion = defined?.판 ?? null;
useCatalogDefinition({ types: payload.types, ...defined }); // 구조물 표(322-1 · 반영 · 부품)
return withDefinition(payload.types, defined);
})
.catch((error) => { .catch((error) => {
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
throw error; throw error;
@@ -239,6 +305,7 @@ export async function saveStructures(
{ method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) }, { method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) },
); );
noteSnapshotFailed(saved.snapshot_failed); noteSnapshotFailed(saved.snapshot_failed);
staleSaved.delete(projectId); // 서버가 지금 판으로 적음
return saved; return saved;
} }
@@ -267,7 +334,7 @@ export function pipesToStructureMarks(
}>, }>,
): StructureInstance[] { ): StructureInstance[] {
return pipes.map((pipe) => ({ return pipes.map((pipe) => ({
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, structure_id: pipeIdAt(pipe.chainage_m), // 관 세션 id(323-5)
type_id: pipe.facility, type_id: pipe.facility,
placement: "point", placement: "point",
chainage_m: pipe.chainage_m, chainage_m: pipe.chainage_m,
@@ -304,21 +371,22 @@ export function defaultOptions(type: StructureType): Record<string, string | num
/** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */ /** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
export function readPendingStructures(projectId: string): StructureInstance[] | null { export function readPendingStructures(projectId: string): StructureInstance[] | null {
return readState<StructureInstance[]>("structures", projectId); return useDesignStore(projectId, null).get("structures");
} }
/** 설계 가게로 씀 — 관 초안과 같은 바뀜 신호(구독 · 323-6). 칸은 그대로(`structures`). */
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void { export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
writeState("structures", next, projectId); useDesignStore(projectId, null).set("structures", next);
} }
/** /**
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는 * 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다. * 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다 — 정본이 옛 정의 판이면 그대로 다시 씀(323-9).
*/ */
export async function flushPendingStructures(projectId: string): Promise<void> { export async function flushPendingStructures(projectId: string): Promise<void> {
const pending = readPendingStructures(projectId); const pending = readPendingStructures(projectId);
if (!pending) return; if (!pending && !definitionStale(projectId)) return;
const stored = await fetchStructures(projectId); const stored = await fetchStructures(projectId);
await saveStructures(projectId, stored.revision, pending); await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만
writePendingStructures(projectId, null); writePendingStructures(projectId, null);
} }
@@ -214,6 +214,30 @@ def snap_to_stations(
) )
def drop_unplaced_edits(
edits: dict[str, dict[str, float]], places: np.ndarray
) -> tuple[dict[str, dict[str, float]], list[float]]:
"""측점 · 관 · 구조물 자리(`places`) 0.006m 안에 없는 편집 키를 버린다(213-2).
옮긴 관의 옛 자리 · 지운 시설 자리에 남은 키는 측점선 없는 꼭짓점(유령)이 된다 — 세션 초안이
옛 키를 다시 보내도 [저장]에서 걸러진다. (남은 편집, 버린 자리 누가거리).
"""
kept: dict[str, dict[str, float]] = {}
dropped: set[float] = set()
for name, values in edits.items():
kept[name] = {}
for key, value in values.items():
try:
chainage = float(key)
except (TypeError, ValueError):
continue
if places.size and float(np.min(np.abs(places - chainage))) < SAME_STATION_SNAP_M:
kept[name][key] = value
else:
dropped.add(round(chainage, _KEY_DECIMALS))
return kept, sorted(dropped)
def resolve_pvi( def resolve_pvi(
base_s: np.ndarray, base_s: np.ndarray,
base_z: np.ndarray, base_z: np.ndarray,
+20 -6
View File
@@ -3,8 +3,9 @@
예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 ·
공사비 어림 최소 · 비중. 공사비 어림 최소 · 비중.
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 계산 자리 = 서버 단독(③). 이 모듈은 파일을 쓰지 않는다 — 결과는 라우터가 입력 지문 키로
받은 노드로 기존 `POST /route/replan`([확인])을 부른다. `B05_Profile/route_recommend/` 에 남긴다(`..._Store` · PLAN 327). 적용은 화면이 받은 노드로
기존 `POST /route/replan`([확인])을 부른다.
갈래(`mode`): 갈래(`mode`):
· follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선. · follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선.
@@ -182,6 +183,20 @@ def write_initial_choice(
return choice return choice
def resolve_inputs(
max_grade: float,
stream_offset_m: float | None,
grade_smooth_m: float | None,
target_grade: float | None,
) -> tuple[float, float, float]:
"""(계류 이격, 기울기 잴 지반 폭, 영선 목표 기울기) — 비운 값은 기본값 · 저장 키와 같은 풀이."""
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만)
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
return stream_offset, grade_smooth, goal_grade
def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]: def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]:
"""갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값.""" """갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값."""
if mode != "weighted": if mode != "weighted":
@@ -240,10 +255,9 @@ def generate_initial_route(
if len(expected) < 2: if len(expected) < 2:
raise ValueError("예상노선 정점이 2개 미만입니다.") raise ValueError("예상노선 정점이 2개 미만입니다.")
used = resolve_weights(mode, weights) used = resolve_weights(mode, weights)
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) stream_offset, grade_smooth, goal_grade = resolve_inputs(
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) max_grade, stream_offset_m, grade_smooth_m, target_grade
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) )
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
def measure( def measure(
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
@@ -37,12 +37,14 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_LENGTH_M, COST_LENGTH_M,
) )
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CROSSING_BAND, CROSSING_BAND,
OBJECTIVES, OBJECTIVES,
NoRoute, NoRoute,
RouteGraph, RouteGraph,
) )
from common_util.common_util_route_geometry import densify_route
from common_util.common_util_route_polyline import build_planned_polyline from common_util.common_util_route_polyline import build_planned_polyline
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0} ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
@@ -70,6 +72,8 @@ MIN_RANGE_SHARE = 0.05
SIMILAR_M = 10.0 SIMILAR_M = 10.0
# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기 # 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기
CONSTRAINT_SHARE = 1.0 CONSTRAINT_SHARE = 1.0
# 탐색 경로를 예정노선처럼 폴리라인화하기 전 점 간격(m) — R 맞춤이 꺾임점 사이 점으로 잼
TRACE_DENSIFY_M = 2.0
Measure = Callable[..., dict[str, Any]] Measure = Callable[..., dict[str, Any]]
Route = tuple[Any, dict[str, Any]] Route = tuple[Any, dict[str, Any]]
@@ -103,14 +107,28 @@ def _trace(
traced: dict[tuple, Route], traced: dict[tuple, Route],
**kw: Any, **kw: Any,
) -> Route: ) -> Route:
# 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선. # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판
# 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 ·
# 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
key = (tuple(nodes), tuple(sorted(kw.items()))) key = (tuple(nodes), tuple(sorted(kw.items())))
if key not in traced: if key not in traced:
outline = build_planned_polyline( floor = build_planned_polyline(
nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes)
) )
traced[key] = (outline, measure(outline, **kw)) least = (floor, measure(floor, **kw))
path = densify_route(nodes, TRACE_DENSIFY_M)
fitted = build_planned_polyline(path, min_radius_m=min_radius_m)
larger = (widen(fitted, path, min_radius_m), widen(floor, path, min_radius_m), fitted)
traced[key] = next(
(
route
for route in ((outline, measure(outline, **kw)) for outline in larger)
if route[1]["over_limit"]["total"] <= least[1]["over_limit"]["total"]
),
least,
)
return traced[key] return traced[key]
@@ -1,8 +1,9 @@
"""노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A). """노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A).
후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다. 후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다.
편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을 편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 작업본 파일 · initial_snapshot · 확정 종단을
바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. 바꾸지 않는다(추천 결과째 저장본에 남는 것은 라우터 몫 · PLAN 327). 확정 체인의 종단 규칙
(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다.
계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다. 계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다.
@@ -0,0 +1,110 @@
"""추천 노선 큰 R — 2단(PLAN 328-2 · 요구 224-4 「R 은 클수록 좋음」).
`B05_Profile_Engine_RouteInitial_Pool._trace` 가 부름. 예상노선 추적(전처리 초기값)은 안 거침.
1단(`_trace`)이 예정노선 규칙(단순화 · 같은 방향 묶음 · R 맞춤)으로 짠 선을 받아, 곡선마다 R 을
[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M`
인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로.
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
"""
from __future__ import annotations
import math
import numpy as np
from shapely.geometry import LineString, Point
from common_util.common_util_route_polyline import (
PlannedPolyline,
_inner_angle_deg,
build_planned_polyline,
)
from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M
Xy = tuple[float, float]
def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]:
"""곡선 묶음을 교각점 하나로 편 노드 · 곡선 켬 · R — 화면 `flattenServerPlan` 과 같은 꼴."""
starts = {curve.node_first: curve for curve in outline.curves}
inside = {i for c in outline.curves for i in range(c.node_first + 1, c.node_last + 1)}
points: list[Xy] = []
flags: list[bool] = []
radii: list[float | None] = []
for index, node in enumerate(outline.nodes):
if index in inside:
continue
curve = starts.get(index)
points.append(curve.apex if curve else (node.x, node.y))
flags.append(curve is not None)
radii.append(round(curve.radius_m, 2) if curve else None)
return points, flags, radii
def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray:
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
def _gap(samples: np.ndarray, before: Xy, apex: Xy, after: Xy, radii: np.ndarray) -> np.ndarray:
"""R 마다 점들이 「직선 · 원호 · 직선」(앞뒤 직선은 절반까지)에서 가장 먼 거리(m)."""
a = np.asarray(apex, dtype=float)
p = samples - a # (S, 2) 교각점 기준
units = [(np.asarray(end, dtype=float) - a) for end in (before, after)]
halves = [float(np.hypot(*u)) / 2 for u in units]
units = [u / (2 * h) for u, h in zip(units, halves)]
inner = math.radians(_inner_angle_deg(before, apex, after))
tangent = radii[:, None] * math.tan((math.pi - inner) / 2) # (M, 1)
legs = []
for unit, half in zip(units, halves):
along = p @ unit
side = np.abs(_cross(unit, p))
legs.append(np.hypot(side, np.maximum(0.0, np.maximum(tangent - along, along - half))))
bisector = (units[0] + units[1]) / np.hypot(*(units[0] + units[1]))
center = (radii / math.sin(inner / 2))[:, None, None] * bisector # (M, 1, 2)
starts = [tangent[..., None] * unit - center for unit in units] # 중심 → 접선점
q = p[None] - center # (M, S, 2) 중심 → 점
turn = _cross(starts[0], starts[1])
within = (_cross(starts[0], q) * turn >= 0) & (_cross(q, starts[1]) * turn >= 0)
arc = np.where(within, np.abs(np.hypot(q[..., 0], q[..., 1]) - radii[:, None]), np.inf)
return np.minimum(np.minimum(legs[0], legs[1]), arc).max(axis=1)
def widen(
outline: PlannedPolyline,
path: list[Xy],
min_radius_m: float,
max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M,
) -> PlannedPolyline:
"""곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로."""
if not outline.curves:
return outline
points, flags, radii = _flat(outline)
line = LineString(path)
coords = np.asarray(path, dtype=float)
along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))])
for index in range(1, len(points) - 1):
if not flags[index]:
continue
before, apex, after = points[index - 1], points[index], points[index + 1]
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
if half_tan <= 1e-9:
continue
room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan
candidates = np.arange(math.floor(room), float(radii[index]), -1.0) # 큰 것부터
# 이 곡선이 쓰는 자리 = 앞뒤 직선 가운데점 사이 — 그 사이 탐색 경로 점만 잼
ends = [
line.project(Point((apex[0] + end[0]) / 2, (apex[1] + end[1]) / 2))
for end in (before, after)
]
samples = coords[(along >= min(ends)) & (along <= max(ends))]
if not candidates.size or not len(samples):
continue
ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m]
if ok.size:
radii[index] = float(ok[0])
return build_planned_polyline(
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
)
@@ -0,0 +1,128 @@
"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3).
같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면
셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음).
층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리
`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라
[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다.
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때
다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임).
"""
from __future__ import annotations
import asyncio
import hashlib
import json
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, AsyncIterator
from B05_Profile.B05_Profile_Engine_RouteInitial import (
DEFAULT_WEIGHTS,
Terrain,
_pool_key,
resolve_inputs,
resolve_weights,
)
from common_util.common_util_json import atomic_write_json
ALGO_VERSION = 2
STORE_DIR = "B05_Profile/route_recommend"
# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐
DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow")
# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음)
_LOCKS: dict[str, asyncio.Lock] = {}
def store_dir(project_root: Path) -> Path:
return Path(project_root) / STORE_DIR
def request_fingerprint(
expected: list[tuple[float, float]],
terrain: Terrain,
streams: list[list[tuple[float, float]]],
*,
mode: str,
weights: dict[str, float] | None,
max_grade: float,
min_radius_m: float,
stream_offset_m: float | None,
grade_smooth_m: float | None,
target_grade: float | None,
with_profile: bool,
design_speed: int,
) -> tuple[str, str]:
"""(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키."""
pool = _pool_key(
expected,
terrain,
streams,
max_grade,
min_radius_m,
*resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade),
).hex()
used = resolve_weights(mode, weights)
tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION)
key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest()
return key, pool
def is_default(
mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any
) -> bool:
"""기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음."""
empty = not any(value is not None for value in (criteria or {}).values())
plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS
return empty and plain and terrain is None
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
"""저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None."""
try:
data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict) or data.get("version") != ALGO_VERSION:
return None
return data
def write_stored(
project_root: Path,
key: str,
pool: str,
request: dict[str, Any],
result: dict[str, Any],
default: bool,
) -> bool:
"""새 키만 적는다 — 이미 있으면 그대로 두고 False(덮어쓰기 없음)."""
path = store_dir(project_root) / f"{key}.json"
if path.exists():
return False
atomic_write_json(
path,
{
"version": ALGO_VERSION,
"key": key,
"pool": pool,
"default": default,
"created_at": datetime.now().isoformat(timespec="seconds"),
"request": request,
"result": result,
},
)
return True
@asynccontextmanager
async def pool_lock(pool: str) -> AsyncIterator[None]:
# ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로
async with _LOCKS.setdefault(pool, asyncio.Lock()):
yield
+81 -78
View File
@@ -25,13 +25,9 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import (
) )
from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import ( from B05_Profile.B05_Profile_Structures_Schema import (
is_station_planting_type,
structure_type_map, structure_type_map,
) )
from common_util.common_util_drainage_pipes import ( from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
PIPE_FACILITY_REVET,
PipePoint, PipePoint,
load_pipe_points_file, load_pipe_points_file,
parse_pipe_points, parse_pipe_points,
@@ -44,14 +40,14 @@ from common_util.common_util_route_geometry import (
planned_route_initial_path, planned_route_initial_path,
planned_route_working_path, planned_route_working_path,
) )
from common_util.common_util_structure_catalog import card_title, catalog
from common_util.common_util_structure_station import plan_stations
from common_util.common_util_surface_sampler import build_surface_sampler from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_system import ( from config.config_system import (
DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_DIRNAME,
DRAINAGE_EDITS_DIRNAME, DRAINAGE_EDITS_DIRNAME,
DRAINAGE_PIPE_POINTS_FILENAME, DRAINAGE_PIPE_POINTS_FILENAME,
FOREST_ROAD_PROFILE_CRITERIA, FOREST_ROAD_PROFILE_CRITERIA,
PIPE_DEFAULT_DIAMETER_MM,
PIPE_DEFAULT_TYPE,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -83,21 +79,31 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고, 곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다. 받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
""" """
curves = _read_design_curve_table(project_root).get("curves")
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
def load_station_snap(project_root: Path) -> bool:
"""[측점 스냅] 켬 — 곡선표 `"station_snap"`(요구 224-2 · [확인] 때 적힘). 칸 없으면 끔."""
return _read_design_curve_table(project_root).get("station_snap") is True
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
"""설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict."""
route_path = planned_route_working_path(project_root) route_path = planned_route_working_path(project_root)
if not route_path.is_file(): if not route_path.is_file():
route_path = planned_route_initial_path(project_root) route_path = planned_route_initial_path(project_root)
target = route_path.with_name(f"{route_path.stem}_curves.json") target = route_path.with_name(f"{route_path.stem}_curves.json")
if not target.is_file(): if not target.is_file():
return [] return {}
try: try:
data = json.loads(target.read_text(encoding="utf-8")) data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target) logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
return [] return {}
curves = data.get("curves") if isinstance(data, dict) else None return data if isinstance(data, dict) else {}
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ──────────── # ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
@@ -208,16 +214,10 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None: def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음.""" """관 지점 측점 라벨 = 표 카드 이름(322-1 · 배관 = 「관종 D관경」 · 빈칸 = 정의 기본값)."""
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: if pipe.facility not in types:
return None return None
if pipe.facility == PIPE_FACILITY_PIPE: return card_title(pipe.facility, pipe.options)
values = pipe.options or {}
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
return f"{kind} D{diameter}"
definition = types.get(pipe.facility)
return definition.name if definition is not None else None
def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None: def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None:
@@ -239,7 +239,7 @@ def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any
except (OSError, ValueError, AttributeError): except (OSError, ValueError, AttributeError):
return return
types = structure_type_map() types = structure_type_map()
pipe_names = {definition.name for definition in types.values() if definition.managed_by} pipe_names = {row["name"] for row in catalog().values() if row["owner"] == "pipe_points"}
for section in cross_sections: for section in cross_sections:
label = section.get("structure") label = section.get("structure")
if not isinstance(label, str) or not ( if not isinstance(label, str) or not (
@@ -267,51 +267,22 @@ def resolve_extra_stations(
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다 (2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다. — B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다 물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1).
(2026-08-28 사용자 확정).
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를 측점 · 이름 = 측점 계획 한 벌(322-3 · `plan_stations` · TS `planStations` 짝) — 표 횡단도
되짚기 때문이다(B05_Profile_Structures_Migration). (anchor = 기준 한 곳 · span3 = 시작 · 기준 · 끝) · 카드 이름 · 같은 m(0.005) 는 하나(이름 = 관이
먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다.
거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`.
""" """
types = structure_type_map()
derived: list[tuple[float, str]] = []
for pipe in pipes:
label = pipe_station_label(pipe, types)
if label is None:
continue
# 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 —
# 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은
# 기준 한 곳(관이 노선을 가로지르는 점형).
if pipe.facility == PIPE_FACILITY_REVET:
marks = (pipe.start_m, pipe.chainage_m, pipe.end_m)
else:
marks = (pipe.chainage_m,)
for mark in marks:
if mark is not None:
derived.append((float(mark), label))
# 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은
# structures.json에 저장되지 않아 중복되지 않는다.
# · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳.
# · 구간형(D군 기슭막이): **시작·기준·종료** — 길이가 길수록 횡단도가 여러 장
# 나와야 한다(2026-08-28 사용자). 격자와 같은 정수 미터면 generate_sections가
# 격자로 스냅해 파일이 겹치지 않는다.
try: try:
for structure in load_structures(str(project_root))[1]: structures = load_structures(str(project_root))[1]
definition = types.get(structure.type_id)
if definition is None or definition.managed_by:
continue
if not is_station_planting_type(definition):
continue
if definition.placement == "interval":
marks = (structure.start_m, structure.chainage_m, structure.end_m)
else:
marks = (structure.anchor_m(),)
for mark in marks:
if mark is not None:
derived.append((float(mark), definition.name))
except Exception: except Exception:
logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)") logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)")
return tuple(sorted(derived)) structures = []
return tuple(
(float(station["chainage_m"]), str(station["title"]))
for station in plan_stations(pipes, structures)
)
def _append_design_profiles( def _append_design_profiles(
@@ -474,6 +445,7 @@ def run_section_generation(
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라 # 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
# 종전의 측점별 실측 반경으로 물러선다. # 종전의 측점별 실측 반경으로 물러선다.
design_curves=_load_design_curves(project_root), design_curves=_load_design_curves(project_root),
station_snap=load_station_snap(project_root),
) )
stage_root = project_root / _STAGE_SUBDIR stage_root = project_root / _STAGE_SUBDIR
@@ -549,7 +521,7 @@ def run_section_generation(
} }
def generate_irregular_sections( def sample_structure_sections(
project_root: Path, project_root: Path,
route_data_path: str, route_data_path: str,
filter_key: str, filter_key: str,
@@ -559,16 +531,15 @@ def generate_irregular_sections(
extra_stations: tuple[tuple[float, str], ...], extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None, options: SectionGenerationOptions | None = None,
crs: str | None = None, crs: str | None = None,
) -> list[dict[str, Any]]: ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다. """구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋 격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각 (`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로 (`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
""" """
if not extra_stations: if not extra_stations:
return [] return [], []
base = options or SectionGenerationOptions() base = options or SectionGenerationOptions()
merged_options = SectionGenerationOptions( merged_options = SectionGenerationOptions(
station_interval_m=base.station_interval_m, station_interval_m=base.station_interval_m,
@@ -588,19 +559,51 @@ def generate_irregular_sections(
crs=crs, crs=crs,
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다. # 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root), design_curves=_load_design_curves(project_root),
# 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남.
station_snap=load_station_snap(project_root),
)
def structural(entry: dict[str, Any]) -> bool:
return entry.get("kind") == "irregular" or bool(entry.get("structure"))
return (
[station for station in result["longitudinal"]["stations"] if structural(station)],
[cross for cross in result["cross_sections"] if structural(cross)],
)
def generate_irregular_sections(
project_root: Path,
route_data_path: str,
filter_key: str,
method: str,
smooth: bool,
*,
extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
"""
irregular_stations, crosses = sample_structure_sections(
project_root,
route_data_path,
filter_key,
method,
smooth,
extra_stations=extra_stations,
options=options,
crs=crs,
) )
irregular_stations = [
station
for station in result["longitudinal"]["stations"]
if station.get("kind") == "irregular"
]
if not irregular_stations: if not irregular_stations:
return [] return []
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections" cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
cross_dir.mkdir(parents=True, exist_ok=True) cross_dir.mkdir(parents=True, exist_ok=True)
for cross_section in result["cross_sections"]: for cross_section in crosses:
if cross_section.get("kind") != "irregular":
continue
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"])) cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
atomic_write_json(cross_file, cross_section) atomic_write_json(cross_file, cross_section)
return irregular_stations return irregular_stations
@@ -6,6 +6,9 @@
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` ⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`. (`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔
`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험
`resources/tester/test_224_2_station_snap.py`.
""" """
import math import math
@@ -275,6 +278,35 @@ def _design_curve_spans(
return sorted(spans) return sorted(spans)
#: 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」 — 관 · 구조물 겹침 판정과 같은 값(m).
CURVE_STATION_EDGE_M = 0.005
def snap_curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
) -> np.ndarray:
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC
(floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로.
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리).
"""
values = [float(value) for value in stations]
for span in spans:
start, end = float(span[0]), float(span[1])
middle = (start + end) / 2.0
inside = [
index
for index, value in enumerate(values)
if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M
]
if not inside:
continue
nearest = min(inside, key=lambda index: abs(values[index] - middle))
values[nearest] = float(math.floor(middle + 0.5))
return np.unique(np.asarray(values, dtype=np.float64))
def _design_curve_widenings( def _design_curve_widenings(
station_chainage: np.ndarray, station_chainage: np.ndarray,
spans: list[tuple[float, float, float, str]], spans: list[tuple[float, float, float, str]],
@@ -319,8 +351,12 @@ def generate_sections(
source_snapshot: dict[str, Any] | None = None, source_snapshot: dict[str, Any] | None = None,
crs: str | None = None, crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None, design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.""" """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2).
"""
options = options or SectionGenerationOptions() options = options or SectionGenerationOptions()
options.validate() options.validate()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
@@ -331,6 +367,9 @@ def generate_sections(
long_z, long_valid = sampler.sample_xy(long_xy) long_z, long_valid = sampler.sample_xy(long_xy)
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint) station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if station_snap:
station_chainage = snap_curve_stations(station_chainage, spans)
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다. # 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
regular_rounded = {round(float(value), 6) for value in station_chainage} regular_rounded = {round(float(value), 6) for value in station_chainage}
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단 # 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
@@ -372,7 +411,6 @@ def generate_sections(
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로 # 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다). # 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다. # 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if spans: if spans:
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings( plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
station_chainage, spans station_chainage, spans
@@ -415,8 +453,10 @@ def generate_sections(
kind = "bp" kind = "bp"
elif abs(float(value) - total) <= 1e-6: elif abs(float(value) - total) <= 1e-6:
kind = "ep" kind = "ep"
elif rounded in extras and rounded not in regular_rounded: elif rounded in extras:
# 격자와 겹치지 않는 비정규 측점만 irregular. 규칙 격자와 겹치면 규칙 측점 우선. # 구조물 측점이 위(272 · 요구 167) — 격자와 같은 m 여도 구조물 측점(irregular) 하나 ·
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
kind = "irregular" kind = "irregular"
else: else:
kind = "regular" kind = "regular"
@@ -466,9 +506,10 @@ def generate_sections(
"uphill_side": uphill_side, "uphill_side": uphill_side,
"frame": frame, "frame": frame,
} }
# 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지).
if rounded in extras: if rounded in extras:
station["structure"] = extras[rounded] station["structure"] = extras[rounded]
if rounded in regular_rounded:
station["grid"] = True # 숨은 일반 측점 자리(272)
stations.append(station) stations.append(station)
samples = [] samples = []
+39
View File
@@ -241,6 +241,45 @@ async def update_longitudinal_grade_summary(
) )
async def sync_cross_section_rows(
connection: aiomysql.Connection, route_id: int, chainages: list[float]
) -> int:
"""종단 정본 측점에 횡단 행을 맞춘다 — 정본에 없는 측점의 행(옮긴 · 지운 구조물 측점의 옛 자리).
0.5 m 안에 행 없는 새 측점이 있으면 그리로 옮긴다(옛 소수 → 정수 m · 사용자 설계 값 그대로 ·
면적은 저장 뒤 서버 재계산) · 나머지는 지운다 — 남기면 수량(B08 토적)이 옛 자리 측점을 그대로
셈(272). 같은 측점 = 0.01 m 안(`_rowless_station_chainages` 와 같은 폭). 측점 목록이 비면
아무것도 안 건드린다. 옮기거나 지운 행 수.
"""
if not chainages:
return 0
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT id, chainage_m FROM cross_sections WHERE route_id = %s", (route_id,)
)
rows = [(int(row[0]), float(row[1])) for row in await cursor.fetchall()]
orphans = [
(row_id, at) for row_id, at in rows if all(abs(at - c) >= 0.01 for c in chainages)
]
free = [c for c in chainages if all(abs(c - at) >= 0.01 for _, at in rows)]
pairs = sorted(
(abs(c - at), row_id, c) for row_id, at in orphans for c in free if abs(c - at) <= 0.5
)
moved: dict[int, float] = {}
for _gap, row_id, chainage in pairs:
if row_id not in moved and chainage not in moved.values():
moved[row_id] = chainage
for row_id, chainage in moved.items():
await cursor.execute(
"UPDATE cross_sections SET chainage_m = %s WHERE id = %s", (chainage, row_id)
)
dropped = [row_id for row_id, _ in orphans if row_id not in moved]
if dropped:
marks = ", ".join(["%s"] * len(dropped))
await cursor.execute(f"DELETE FROM cross_sections WHERE id IN ({marks})", dropped)
return len(orphans)
async def get_surface_crs_epsg( async def get_surface_crs_epsg(
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
) -> int | None: ) -> int | None:
+43 -6
View File
@@ -8,6 +8,7 @@ from typing import Any
from uuid import UUID from uuid import UUID
import aiomysql import aiomysql
import numpy as np
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -15,6 +16,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
from B05_Profile.B05_Profile_Debug import log_b05_debug from B05_Profile.B05_Profile_Debug import log_b05_debug
from B05_Profile.B05_Profile_Engine import run_route_design from B05_Profile.B05_Profile_Engine import run_route_design
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
@@ -384,13 +386,30 @@ async def update_contour_interval(
) )
def _structure_marks(project_root: Path, route_data_path: str) -> list[float]:
"""관 · 구조물 자리(m) — 관 지점 전부 + 구조물 측점 자리(시작 · 기준 · 종료)."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
resolve_extra_stations,
)
pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path))
extras = resolve_extra_stations(project_root, pipes)
return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras]
def _apply_alignment_edits( def _apply_alignment_edits(
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any] project_root: Path,
) -> tuple[dict[str, Any], dict[str, Any]]: longitudinal_file_path: str,
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다. edits: dict[str, Any],
marks: list[float] | None = None,
) -> tuple[dict[str, Any], dict[str, Any], list[float]]:
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약, 버린 자리)를 반환한다.
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에 화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. 남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. `marks`(관 · 구조물 자리)를 주면
측점 · 관 · 구조물 어디에도 없는 편집 키를 버린다(213-2 — 저장 차례가 관 · 구조물 뒤라 최신).
""" """
root = project_root.resolve() root = project_root.resolve()
path = (root / longitudinal_file_path).resolve() path = (root / longitudinal_file_path).resolve()
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
if not path.is_file(): if not path.is_file():
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.") raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
longitudinal = json.loads(path.read_text(encoding="utf-8")) longitudinal = json.loads(path.read_text(encoding="utf-8"))
dropped: list[float] = []
if marks is not None:
stations = [float(s.get("chainage_m", 0.0)) for s in longitudinal.get("stations") or []]
edits, dropped = drop_unplaced_edits(
{
"station_offsets": dict(edits.get("station_offsets") or {}),
"curve_radii": dict(edits.get("curve_radii") or {}),
},
np.array(stations + marks, dtype=np.float64),
)
alignment, profile = rebuild_alignment_profile(longitudinal, edits) alignment, profile = rebuild_alignment_profile(longitudinal, edits)
longitudinal["profile_alignment"] = alignment longitudinal["profile_alignment"] = alignment
profiles = longitudinal.get("design_profiles") profiles = longitudinal.get("design_profiles")
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
else: else:
longitudinal["design_profiles"] = [profile] longitudinal["design_profiles"] = [profile]
atomic_write_json(path, longitudinal) atomic_write_json(path, longitudinal)
return alignment, {"id": profile["id"], **profile["summary"]} return alignment, {"id": profile["id"], **profile["summary"]}, dropped
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse) @router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
@@ -426,11 +455,18 @@ async def save_profile_alignment(
) )
stored_path = await get_project_storage_relative_path(connection, project_id) stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path)) project_root = Path(resolve_stored_project_path(stored_path))
alignment, grade_summary = await asyncio.to_thread( route = await get_latest_route(connection, project_id)
marks = None
if route and route["id"] == request.route_id and route.get("route_data_path"):
marks = await asyncio.to_thread(
_structure_marks, project_root, str(route["route_data_path"])
)
alignment, grade_summary, dropped = await asyncio.to_thread(
_apply_alignment_edits, _apply_alignment_edits,
project_root, project_root,
str(longitudinal["longitudinal_file_path"]), str(longitudinal["longitudinal_file_path"]),
request.edits(), request.edits(),
marks,
) )
await connection.begin() await connection.begin()
try: try:
@@ -446,6 +482,7 @@ async def save_profile_alignment(
route_id=request.route_id, route_id=request.route_id,
profile_alignment=alignment, profile_alignment=alignment,
grade_summary=grade_summary, grade_summary=grade_summary,
dropped_edit_chainages=dropped,
) )
except FileNotFoundError as exc: except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
+118 -31
View File
@@ -6,6 +6,7 @@
import asyncio import asyncio
import json import json
import math
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -15,9 +16,19 @@ import aiomysql
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path 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_Alignment import chainage_key
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile 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,
find_cross_file,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions 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_Repository import (
get_surface_crs_epsg,
sync_cross_section_rows,
update_longitudinal_grade_summary,
)
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
from B06_Section.B06_Section_Repository import ( from B06_Section.B06_Section_Repository import (
get_latest_section_options, get_latest_section_options,
@@ -96,6 +107,7 @@ async def sync_uphill_overrides_into_designs(
compute_cross_design, compute_cross_design,
curve_widening_args, curve_widening_args,
stored_shoulder_extend, stored_shoulder_extend,
stored_tilt,
) )
from B06_Section.B06_Section_Repository import get_cross_section_designs from B06_Section.B06_Section_Repository import get_cross_section_designs
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
@@ -158,12 +170,13 @@ async def sync_uphill_overrides_into_designs(
paved=bool(design.get("paved", False)), paved=bool(design.get("paved", False)),
standard=stored_standard, standard=stored_standard,
rock_boundary_offset_m=design.get("rock_boundary_offset_m"), rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", True)), two_stage_slope=bool(design.get("two_stage_slope", False)),
ditch_enabled=design.get("ditch_enabled"), ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"), ditch_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops), surface_drop_m=ford_drop_at(float(chainage), ford_drops),
**curve_widening_args(cross_record), **curve_widening_args(cross_record),
**stored_shoulder_extend(design), # 노견 확장(139-6) **stored_shoulder_extend(design), # 노견 확장(139-6)
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
) )
next_design["status"] = design.get("status", "provisional") next_design["status"] = design.get("status", "provisional")
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
@@ -188,12 +201,16 @@ async def sync_uphill_overrides_into_designs(
_SAME_STATION_M = 0.006 _SAME_STATION_M = 0.006
def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[str, Any] | None: def _follow_moved_stations(
data: dict[str, Any], before: list[float], previous: list[float] | None = None
) -> dict[str, Any] | None:
"""사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다. """사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다.
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에 남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다. 편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`. 자리(272)만 옮기고 나머지는 지운다.
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
아니면 `None`.
""" """
stored = data.get("profile_alignment") stored = data.get("profile_alignment")
if not isinstance(stored, dict) or not stored.get("base_pvi"): if not isinstance(stored, dict) or not stored.get("base_pvi"):
@@ -213,6 +230,11 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
), ),
None, None,
) )
# 옛 소수 측점 → 정수 m 맞춤(272 · 요구 167) — 맞춘 자리가 새 측점이면 꼭짓점 · 편집도
# 그리로(지우면 계획선이 그 자리에서 바뀜 · 화면 `followMovedNodes` 와 짝)
snapped = chainage_key(math.floor(old + 0.5))
if near is None and snapped in after_keys and snapped not in before_keys:
near = float(snapped)
moves[chainage_key(old)] = None if near is None else chainage_key(near) moves[chainage_key(old)] = None if near is None else chainage_key(near)
def moved(value: float) -> tuple[bool, str | None]: def moved(value: float) -> tuple[bool, str | None]:
@@ -249,7 +271,15 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
changed = True changed = True
if new_key is not None: if new_key is not None:
base.append({**node, "chainage_m": float(new_key)}) base.append({**node, "chainage_m": float(new_key)})
if not changed: # 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
# 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌
rows = stored.get("stations")
listed = (
[round(float(row.get("chainage_m", 0.0)), 6) for row in rows]
if isinstance(rows, list)
else previous
)
if not changed and listed in (None, [round(value, 6) for value in after]):
return None return None
stored["base_pvi"] = base stored["base_pvi"] = base
alignment, profile = rebuild_alignment_profile(data, next_edits) alignment, profile = rebuild_alignment_profile(data, next_edits)
@@ -265,12 +295,17 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
def _merge_irregular_into_longitudinal( def _merge_irregular_into_longitudinal(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]] project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬). """종단 정본 stations 의 구조물 측점을 **지금 목록 전체로 다시 맞춘다**(272 · 요구 167).
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과 `irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은 `grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` — 구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그
바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다. 격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다.
B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다
(`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations`
— 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다.
TS 짝 = `common_util_structure_station.ts` `oneStationList`(화면 측점 한 벌) · 거울 시험 =
`resources/tester/test_272_1_station_list.py`.
""" """
root = project_root.resolve() root = project_root.resolve()
path = (root / longitudinal_file_path).resolve() path = (root / longitudinal_file_path).resolve()
@@ -285,15 +320,53 @@ def _merge_irregular_into_longitudinal(
for station in stations for station in stations
if station.get("kind") == "irregular" if station.get("kind") == "irregular"
] ]
regular = [station for station in stations if station.get("kind") != "irregular"] fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
merged = regular + list(irregular_stations) kept: dict[float, dict[str, Any]] = {}
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0))) left: list[dict[str, Any]] = []
data["stations"] = merged for station in stations:
summary = _follow_moved_stations(data, before) if station.get("kind") == "irregular" and not station.get("grid"):
continue # 옛 구조물 측점 — 새 목록이 갈음
key = round(float(station.get("chainage_m", 0.0)), 6)
if key not in fresh and ("structure" in station or station.get("grid")):
# 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함)
station = {k: v for k, v in station.items() if k not in ("grid", "structure")}
if station.get("kind") == "irregular":
station["kind"] = "regular"
left.append(station)
kept[key] = station
kept.update(fresh)
data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0)))
previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations]
summary = _follow_moved_stations(data, before, previous)
atomic_write_json(path, data) atomic_write_json(path, data)
_relabel_cross_files(path.parent.parent / "cross_sections", left)
return summary return summary
async def drop_rows_off_stations(
connection: aiomysql.Connection, route_id: int, project_root: Path, longitudinal_path: str
) -> None:
"""측점을 다시 맞춘 뒤 — 종단 정본에 없는 측점의 횡단 행을 새 측점으로 옮기거나 지운다(272)."""
path = (project_root.resolve() / longitudinal_path).resolve()
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
await sync_cross_section_rows(
connection, route_id, [float(station["chainage_m"]) for station in stations]
)
def _relabel_cross_files(cross_dir: Path, stations: list[dict[str, Any]]) -> None:
"""일반 측점으로 돌아온 자리의 횡단 파일 글(종류 · 구조물)도 맞춘다 — 카드 제목(272)."""
for station in stations:
cross_path = find_cross_file(cross_dir, float(station.get("chainage_m", 0.0)))
if not cross_path.is_file():
continue
cross = json.loads(cross_path.read_text(encoding="utf-8"))
cross.pop("structure", None)
cross.pop("grid", None)
cross["kind"] = station.get("kind")
atomic_write_json(cross_path, cross)
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해. #: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다. #: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
SAMPLING_SNAPSHOT_NAME = "sampling.json" SAMPLING_SNAPSHOT_NAME = "sampling.json"
@@ -350,24 +423,38 @@ async def _append_irregular_cross_sections(
save_sampling_snapshot(project_root, request) save_sampling_snapshot(project_root, request)
stored_options = await get_latest_section_options(connection, project_id) stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id) crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread( route_data_path = str(route["route_data_path"])
generate_irregular_sections,
project_root, def _generate() -> list[dict[str, Any]]:
str(route["route_data_path"]), # 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은
request.filter_key, # 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1).
request.method, polyline = _load_route_polyline(project_root, route_data_path)
request.smooth, extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
extra_stations=request.extra_stations(), return generate_irregular_sections(
options=_section_options_from_stored(stored_options), project_root,
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, route_data_path,
) request.filter_key,
if not irregular_stations: request.method,
return 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,
)
# 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(272)
irregular_stations = await asyncio.to_thread(_generate)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
if longitudinal: if longitudinal:
await asyncio.to_thread( summary = await asyncio.to_thread(
_merge_irregular_into_longitudinal, _merge_irregular_into_longitudinal,
project_root, project_root,
str(longitudinal["longitudinal_file_path"]), str(longitudinal["longitudinal_file_path"]),
irregular_stations, irregular_stations,
) )
if summary is not None: # 계획선을 다시 셈 — DB 등급 요약도 같이(B06 측점 만들기와 같은 길)
await update_longitudinal_grade_summary(
connection, route_id=route["id"], grade_summary=summary
)
await drop_rows_off_stations(
connection, route["id"], project_root, str(longitudinal["longitudinal_file_path"])
)
+33 -83
View File
@@ -26,7 +26,7 @@
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀) POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀)
""" """
import asyncio import asyncio
@@ -41,10 +41,17 @@ from uuid import UUID
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import AliasChoices, BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
RouteInitialCriteria,
RouteInitialRequest,
RouteInitialWeights,
RouteReplanRequest,
RouteVertexInput,
)
from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_initial_snapshot import design_route_csv_path
from common_util.common_util_route_geometry import ( from common_util.common_util_route_geometry import (
densify_route, densify_route,
@@ -69,74 +76,6 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."} _PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}
class RouteVertexInput(BaseModel):
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
"""
x: float
y: float
curve: bool = True
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
radius_m: float | None = None
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
class RouteInitialWeights(BaseModel):
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
expected: float = Field(50.0, ge=0, le=100)
earthwork: float = Field(25.0, ge=0, le=100)
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
target_grade_pct: float | None = Field(None, ge=0, le=100)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
mode: Literal[
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
class RouteReplanRequest(BaseModel):
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
vertices: list[RouteVertexInput] = Field(default_factory=list)
initial: RouteInitialRequest | None = None
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
balanced_profile: bool = False
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
terrain: Literal["las", "sheet"] | None = None
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None: async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
"""(프로젝트 실경로, 저장소 상대경로). 없으면 None.""" """(프로젝트 실경로, 저장소 상대경로). 없으면 None."""
pool = get_db_pool() pool = get_db_pool()
@@ -305,9 +244,12 @@ def _write_planned_polyline(
simplify: bool = True, simplify: bool = True,
curve_flags: list[bool] | None = None, curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None, radii: list[float | None] | None = None,
station_snap: bool | None = None,
) -> dict: ) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔).
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
@@ -321,13 +263,13 @@ def _write_planned_polyline(
) )
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
_curves_path(path).write_text( table: dict[str, Any] = {
json.dumps( "min_radius_m": round(radius_m, 3),
{"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]}, "curves": [c.as_dict() for c in result.curves],
ensure_ascii=False, }
), if station_snap is not None:
encoding="utf-8", table["station_snap"] = bool(station_snap)
) _curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return { return {
"nodes": len(result.nodes), "nodes": len(result.nodes),
"curves": result.curve_count, "curves": result.curve_count,
@@ -572,6 +514,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"curve_count": len(saved_curves) if saved_curves else outline.curve_count, "curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count, "violation_count": outline.violation_count,
"edited": bool(working), "edited": bool(working),
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
"current_initial": await asyncio.to_thread(read_initial_choice, project_root), "current_initial": await asyncio.to_thread(read_initial_choice, project_root),
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m), "initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
} }
@@ -604,11 +547,17 @@ async def replan_route(
radii = [vertex.radius_m for vertex in request.vertices] radii = [vertex.radius_m for vertex in request.vertices]
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면 # 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측). # 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
# 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음.
working_path = planned_route_working_path(project_root) working_path = planned_route_working_path(project_root)
previous = working_path.read_bytes() if working_path.is_file() else None keep = (working_path, _curves_path(working_path), _nodes_path(working_path))
previous = {path: path.read_bytes() if path.is_file() else None for path in keep}
summary = await asyncio.to_thread( summary = await asyncio.to_thread(
functools.partial( functools.partial(
_write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii _write_planned_polyline,
simplify=False,
curve_flags=curve_flags,
radii=radii,
station_snap=request.station_snap,
), ),
working_path, working_path,
nodes, nodes,
@@ -633,10 +582,11 @@ async def replan_route(
) )
if "error" in result: if "error" in result:
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
if previous is None: for path, saved in previous.items():
working_path.unlink(missing_ok=True) if saved is None:
else: path.unlink(missing_ok=True)
working_path.write_bytes(previous) else:
path.write_bytes(saved)
logger.warning( logger.warning(
"계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s", "계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s",
project_id, project_id,
+90 -24
View File
@@ -30,14 +30,35 @@ async def initial_route(
) -> dict[str, Any] | JSONResponse: ) -> dict[str, Any] | JSONResponse:
"""초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장). """초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장).
**파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]). 작업본은 쓰지 않는다 — 추천 저장본(`..._Store` · PLAN 327)만. 적용은 받은 `nodes` 로
`POST /route/replan`([확인]).
"""
return await compute_initial(project_id, request)
async def compute_initial(
project_id: UUID, request: replan.RouteInitialRequest
) -> dict[str, Any] | JSONResponse:
"""같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로.
응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음).
""" """
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
from B05_Profile.B05_Profile_Engine_RouteInitial import ( from B05_Profile.B05_Profile_Engine_RouteInitial import (
generate_initial_route, generate_initial_route,
load_terrain_and_streams, load_terrain_and_streams,
) )
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
cover_shortfall,
project_pipes,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
is_default,
pool_lock,
read_stored,
request_fingerprint,
write_stored,
)
from common_util.common_util_surface_confirmation import ( from common_util.common_util_surface_confirmation import (
analysis_terrain_params, analysis_terrain_params,
get_surface_confirmation_params, get_surface_confirmation_params,
@@ -81,37 +102,62 @@ async def initial_route(
async with pool.acquire() as connection: async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id)) selection = await get_surface_confirmation_params(connection, str(project_id))
selection = analysis_terrain_params(selection, request.terrain) selection = analysis_terrain_params(selection, request.terrain)
inputs = {
"mode": request.mode,
"weights": weights,
"max_grade": max_grade,
"min_radius_m": radius_m,
"stream_offset_m": stream_offset_m,
"grade_smooth_m": grade_smooth_m,
"target_grade": None if target_pct is None else target_pct / 100.0,
"design_speed": design_speed or 20,
"with_profile": request.with_profile,
}
started = time.perf_counter() started = time.perf_counter()
try: try:
terrain, streams = await asyncio.to_thread( terrain, streams = await asyncio.to_thread(
load_terrain_and_streams, project_root, selection load_terrain_and_streams, project_root, selection
) )
pipes = ( route = [(x, y) for x, y in expected]
await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None key, pool_key = await asyncio.to_thread(
) functools.partial(request_fingerprint, route, terrain, streams, **inputs)
result = await asyncio.to_thread(
functools.partial(
generate_initial_route,
[(x, y) for x, y in expected],
terrain,
streams,
mode=request.mode,
weights=weights,
max_grade=max_grade,
min_radius_m=radius_m,
stream_offset_m=stream_offset_m,
grade_smooth_m=grade_smooth_m,
target_grade=None if target_pct is None else target_pct / 100.0,
design_speed=design_speed or 20,
pipes=pipes,
with_profile=request.with_profile,
)
) )
async with pool_lock(pool_key):
stored = await asyncio.to_thread(read_stored, project_root, key)
if stored is not None:
result = stored["result"]
else:
# 관 지점은 키 밖 — 덮개 수 없이 셈해 남기고 아래에서 지금 관으로 센다.
result = await asyncio.to_thread(
functools.partial(
generate_initial_route, route, terrain, streams, pipes=None, **inputs
)
)
await asyncio.to_thread(
write_stored,
project_root,
key,
pool_key,
request.model_dump(),
result,
is_default(
request.mode,
weights,
criteria.model_dump() if criteria else None,
request.terrain,
),
)
profile = result["metrics"].get("profile")
if request.with_profile and profile and "error" not in profile:
pipes = await asyncio.to_thread(project_pipes, project_root)
if pipes:
profile.update(cover_shortfall(profile, result["planned"], pipes))
except (FileNotFoundError, ValueError) as error: except (FileNotFoundError, ValueError) as error:
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)}) return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
logger.info( logger.info(
"초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", "초기 계획노선 %s(%s): project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm",
request.mode, request.mode,
"저장본" if stored is not None else "셈",
project_id, project_id,
time.perf_counter() - started, time.perf_counter() - started,
result["metrics"]["length_m"], result["metrics"]["length_m"],
@@ -122,10 +168,28 @@ async def initial_route(
"status": "success", "status": "success",
"project_id": str(project_id), "project_id": str(project_id),
**result, **result,
"stored": stored is not None,
"current_initial": current, "current_initial": current,
} }
async def precompute_defaults(project_id: UUID) -> None:
"""업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만."""
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
started = time.perf_counter()
for mode in DEFAULT_MODES:
# 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`)
request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow")
outcome = await compute_initial(project_id, request)
if isinstance(outcome, JSONResponse):
logger.warning(
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
)
return
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int: def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다.""" """종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
import json import json
@@ -173,7 +237,9 @@ async def apply_profile_target(
root = Path(resolve_stored_project_path(stored_path)) root = Path(resolve_stored_project_path(stored_path))
relative = str(row["longitudinal_file_path"]) relative = str(row["longitudinal_file_path"])
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target) count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
_alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {}) _alignment, summary, _dropped = await asyncio.to_thread(
_apply_alignment_edits, root, relative, {}
)
async with pool.acquire() as connection: async with pool.acquire() as connection:
await connection.begin() await connection.begin()
await update_longitudinal_grade_summary( await update_longitudinal_grade_summary(
+10 -1
View File
@@ -133,6 +133,8 @@ class CrossPreviewRequest(BaseModel):
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
cross_half_width_m: float | None = Field(None, gt=0) cross_half_width_m: float | None = Field(None, gt=0)
terrain: AnalysisTerrain | None = None terrain: AnalysisTerrain | None = None
#: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로.
station_snap: bool = False
def _cross_preview( def _cross_preview(
@@ -177,7 +179,14 @@ def _cross_preview(
else defaults.station_interval_m, else defaults.station_interval_m,
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
) )
result = generate_sections(built.vertices, sampler, options) # 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙.
result = generate_sections(
built.vertices,
sampler,
options,
design_curves=[curve.as_dict() for curve in built.curves],
station_snap=request.station_snap,
)
sections = result["cross_sections"] sections = result["cross_sections"]
if not sections: if not sections:
return None return None
+80 -8
View File
@@ -2,7 +2,7 @@
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod
from config.config_system import ( from config.config_system import (
@@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel):
route_id: int route_id: int
profile_alignment: dict[str, Any] profile_alignment: dict[str, Any]
grade_summary: dict[str, Any] | None = None grade_summary: dict[str, Any] | None = None
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
dropped_edit_chainages: list[float] = Field(default_factory=list)
class RouteSolveResponse(BaseModel): class RouteSolveResponse(BaseModel):
@@ -266,7 +268,8 @@ class RouteConfirmRequest(BaseModel):
"""경로 확정 요청. """경로 확정 요청.
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값 확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다. (solve 때 쓰던 필터/방법/모델)을 함께 받는다. 셋이 다 오면 구조물 측점을 서버 정본 목록
전체로 다시 맞춘다(빈 목록이어도 · 272).
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다. 모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
""" """
@@ -284,12 +287,8 @@ class RouteConfirmRequest(BaseModel):
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations) return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
def can_regenerate(self) -> bool: def can_regenerate(self) -> bool:
return bool( # 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272)
self.irregular_stations return bool(self.filter_key and self.method and self.surface_model_id is not None)
and self.filter_key
and self.method
and self.surface_model_id is not None
)
class RouteConfirmResponse(BaseModel): class RouteConfirmResponse(BaseModel):
@@ -310,3 +309,76 @@ class RouteLatestResponse(BaseModel):
route_points: list[dict[str, Any]] = Field(default_factory=list) route_points: list[dict[str, Any]] = Field(default_factory=list)
surface_params: dict[str, Any] surface_params: dict[str, Any]
route_params: dict[str, Any] | None = None route_params: dict[str, Any] | None = None
# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ─────────────────────────────
class RouteVertexInput(BaseModel):
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
"""
x: float
y: float
curve: bool = True
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
radius_m: float | None = None
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
class RouteInitialWeights(BaseModel):
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
expected: float = Field(50.0, ge=0, le=100)
earthwork: float = Field(25.0, ge=0, le=100)
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
target_grade_pct: float | None = Field(None, ge=0, le=100)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
mode: Literal[
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
class RouteReplanRequest(BaseModel):
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
vertices: list[RouteVertexInput] = Field(default_factory=list)
initial: RouteInitialRequest | None = None
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
balanced_profile: bool = False
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
terrain: Literal["las", "sheet"] | None = None
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
station_snap: bool = False
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
+14 -17
View File
@@ -16,15 +16,16 @@
""" """
import re import re
from collections.abc import Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Iterable from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import ( from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance, StructureInstance,
station_planting_labels,
structure_type_map, structure_type_map,
) )
from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint
from common_util.common_util_structure_station import SAME_STATION_M
PIPE_STRUCTURE_NAME = "배관" PIPE_STRUCTURE_NAME = "배관"
# 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `managed_by`). # 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `managed_by`).
@@ -54,28 +55,22 @@ _PIPE_LABEL = re.compile(r"^(배관|.*관)\s+D\d+", re.UNICODE)
_ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE) _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE)
def _managed_elsewhere_labels() -> set[str]: def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bool:
"""서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 → """정본(관 지점 · 구조물)이 이미 관리하는 측점인가 — 재이관하면 정본 이중화 → 「기타」 유령.
"기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels).
관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서 자리로 가름(323-7 · 지금 관 · 구조물이 심는 측점 계획 자리 `planted`) — 옛 이름 되짚기
투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관). (지난 판 이름 전부 · 322-0)는 걷음 · 이름을 바꿔도 자리는 그대로라 옛 이름 측점도 알아봄.
지금 판 종류 이름 · 관 라벨 꼴(관종 D관경)은 자리를 못 받은 부름(옛 확정분만)을 위해 그대로.
""" """
owned = {
item.name for item in structure_type_map().values() if item.managed_by == PIPE_POINTS_OWNER
}
return station_planting_labels() | owned
def _is_managed_elsewhere(entry: dict[str, Any]) -> bool:
"""다른 정본(pipe_points.json·구조물 A군)이 관리하는 측점인가."""
label = str(entry.get("structure", "")).strip() label = str(entry.get("structure", "")).strip()
at = float(entry.get("chainage_m", 0.0))
return ( return (
entry.get("origin") == "pipe" entry.get("origin") == "pipe"
or entry.get("structureType") == PIPE_STRUCTURE_NAME or entry.get("structureType") == PIPE_STRUCTURE_NAME
or label == PIPE_STRUCTURE_NAME or label == PIPE_STRUCTURE_NAME
or label in _managed_elsewhere_labels() or label in {item.name for item in structure_type_map().values()}
or bool(_PIPE_LABEL.match(label)) or bool(_PIPE_LABEL.match(label))
or any(abs(at - p) < SAME_STATION_M for p in planted)
) )
@@ -107,7 +102,9 @@ def _escape_width_of(entry: dict[str, Any]) -> float | None:
return None return None
def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPlan: def migrate_irregular_stations(
entries: Iterable[dict[str, Any]], planted: Sequence[float] = ()
) -> MigrationPlan:
"""기존 비정규 측점 목록을 정본별로 나눠 옮긴다(같은 입력이면 같은 결과). """기존 비정규 측점 목록을 정본별로 나눠 옮긴다(같은 입력이면 같은 결과).
타입이 다른 정본 소관(`managed_by`)이면 구조물 목록이 아니라 그 정본으로 간다 — 타입이 다른 정본 소관(`managed_by`)이면 구조물 목록이 아니라 그 정본으로 간다 —
@@ -120,7 +117,7 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPl
seen: set[tuple[str, float]] = set() seen: set[tuple[str, float]] = set()
for entry in entries: for entry in entries:
if _is_managed_elsewhere(entry): if _is_managed_elsewhere(entry, planted):
continue continue
chainage = float(entry.get("chainage_m", 0.0)) chainage = float(entry.get("chainage_m", 0.0))
@@ -69,6 +69,17 @@ def load_structures(project_root: str) -> tuple[int, list[StructureInstance]]:
return revision, structures return revision, structures
def load_saved_definition_version(project_root: str) -> int | None:
"""정본을 적을 때의 구조물 정의 판(322-2 판 알림) — 파일이 없거나 옛 파일이면 None."""
path = structures_file_path(project_root)
try:
with open(path, encoding="utf-8") as handle:
value = json.load(handle).get("definition_version")
except (OSError, ValueError, TypeError, AttributeError):
return None
return value if isinstance(value, int) and not isinstance(value, bool) else None
def load_migrated_legacy(project_root: str) -> set[str]: def load_migrated_legacy(project_root: str) -> set[str]:
"""이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합. """이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합.

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