576 Commits
Author SHA1 Message Date
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 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
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 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 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 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
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 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 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 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
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
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
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
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 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
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 536dacefac Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 16:11:58 +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 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
850 changed files with 78641 additions and 5146 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000
# Public URL for email links (프론트엔드 주소)
APP_PUBLIC_BASE_URL=http://localhost:5173
KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08
KAKAO_JS_KEY=98ac6edef14ffbca300d90e540644b68
# kakao app ID 1603330
# Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B
+2
View File
@@ -71,6 +71,8 @@ config/formula_node/
config/spreadsheet_node/
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
config/b07_extent_node/
# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1).
config/b07_note_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
+267 -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 { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
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;
@@ -65,38 +66,12 @@ function buildHeader(): HTMLElement {
const header = document.createElement("header");
header.className = "app-header";
// 로고 (클릭 시 홈)
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 brand = buildBrand();
// 네비게이션 링크
const nav = document.createElement("nav");
nav.className = "app-nav";
const navItems: [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],
];
for (const [labelKey, route] of navItems) {
const link = createButton({
label: L(labelKey),
variant: "pill",
onClick: () => navigateTo(route as never),
});
nav.append(link);
}
nav.append(...buildNavButtons());
// 우측 액션 (언어/테마/로그인)
const actions = document.createElement("div");
@@ -120,10 +95,157 @@ function buildHeader(): HTMLElement {
actions.append(authSlot);
void fillAuthActions(authSlot);
header.append(brand, nav, actions);
header.append(brand, nav, actions, ...buildDrawer(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;
export function setDrawerSteps(node: HTMLElement | null): void {
drawerSteps = node?.parentElement ? { 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 (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"))
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,
}),
);
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> {
try {
@@ -188,6 +310,7 @@ function buildFooter(): HTMLElement {
* -------------------------------------------------------------------------- */
export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement {
appRoot.innerHTML = "";
drawerSteps = null; // 새 쪽이 진행 판을 세우면 다시 맡김
const header = buildHeader();
const outlet = document.createElement("main");
outlet.className = "app-outlet";
@@ -259,10 +382,10 @@ const SHELL_CSS = `
gap: var(--spacing-8);
}
.app-outlet {
min-height: calc(100vh - 64px - 56px);
min-height: calc(100dvh - var(--app-header-h) - 56px);
}
.app-outlet--without-footer {
min-height: calc(100vh - 64px);
min-height: calc(100dvh - var(--app-header-h));
}
.app-footer {
height: 56px;
@@ -285,7 +408,120 @@ const SHELL_CSS = `
}
@media (max-width: 900px) {
.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 {
+1 -1
View File
@@ -174,7 +174,7 @@ const SCAFFOLD_CSS = `
font-size: var(--text-body-sm);
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 {
display: flex;
+35 -16
View File
@@ -57,8 +57,6 @@ export const STATE_REGISTRY = {
legacy: () => "b05-route-drainage-collapsed",
},
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
"drainage-visible": { bucket: "pref", scope: "global" },
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
"m02-combo-panel-collapsed": { 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" },
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
"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-cols": { bucket: "pref", scope: "global" },
/** B09 행 누름(303-1) `popup · link` */
"b09-link-mode": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
@@ -101,6 +101,10 @@ export const STATE_REGISTRY = {
},
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
"cross-columns": { bucket: "pref", scope: "global" },
/** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */
"mass-haul-badge-pos": { bucket: "pref", scope: "global" },
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
"b05-3d-projection": { bucket: "pref", scope: "global" },
"section-panel-height": {
bucket: "pref",
scope: "global",
@@ -110,6 +114,8 @@ export const STATE_REGISTRY = {
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
"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) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
"detail-table": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
@@ -127,10 +133,14 @@ export const STATE_REGISTRY = {
scope: "project",
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}`. */
selection: { bucket: "draft", scope: "project" },
/** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */
"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}` },
/** 배수관 이동(측점 옮김) 예약. */
@@ -185,6 +195,8 @@ export const STATE_REGISTRY = {
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
"b08-options": { bucket: "draft", scope: "project" },
/** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */
"b08-milestone": { bucket: "draft", scope: "project" },
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
"b09-estimation": { bucket: "draft", scope: "project" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
@@ -477,29 +489,36 @@ export function clearState(
routeId?: number | string | null,
): void {
const entry = entryOf(name);
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다.
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다(`routeStateKeys`).
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
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 {
/* 무시 — 저장소가 막힌 브라우저 */
}
routeStateKeys(name, projectId).forEach((key) => writeRaw(key, null));
return;
}
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[] {
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 {
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
}
}
+56 -10
View File
@@ -61,12 +61,18 @@ export interface SvgZoomPanOptions {
maxScale?: number;
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
initial?: ZoomPanState;
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
homeScale?: number;
/** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */
home?: ZoomPanState;
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
onChange?: (state: ZoomPanState) => void;
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean;
/** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */
panStart?: (event: PointerEvent) => boolean;
}
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
@@ -83,9 +89,17 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
height: Math.max(heightPx - pad.top - pad.bottom, 1),
};
const maxScale = options.maxScale ?? 8;
let scale = initial?.scale ?? 1;
let tx = initial?.tx ?? 0;
let ty = initial?.ty ?? 0;
const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
const home: ZoomPanState = options.home ?? {
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 => {
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty });
@@ -122,7 +136,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
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;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
@@ -146,8 +160,10 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
if (event.button !== 1) return;
// 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1)
moved = false;
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
event.preventDefault();
panning = true;
moved = false;
@@ -178,7 +194,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/* 이미 해제됨 */
}
};
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
if (scale !== 1 || tx !== 0 || ty !== 0) {
clampPan();
applyTransform();
}
@@ -203,11 +219,41 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
{ 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 reset = (): void => {
scale = 1;
tx = 0;
ty = 0;
({ scale, tx, ty } = home);
applyTransform();
};
// 더블클릭 원복.
+15 -8
View File
@@ -10,6 +10,7 @@ import {
fetchSurfaceStatus,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { enterProject } from "./b_project_switch";
import { navigateTo } from "./router";
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];
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
navigateTo(route);
return;
}
void routeAfterPreloadCheck(projectId, route);
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
void enterProject(
projectId,
async (id) => (await fetchWorkflowState(id)).project_name || id,
).then(() => {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
navigateTo(route);
return;
}
void routeAfterPreloadCheck(projectId, route);
});
}
/**
+1 -1
View File
@@ -50,7 +50,7 @@
display: none;
}
.b05-route-profile__trailing > .b05-route-profile__popout {
.b05-route-profile__tools > .b05-route-profile__popout {
padding: var(--btn-bar-padding);
font-size: var(--btn-bar-font-size);
}
+2
View File
@@ -29,6 +29,7 @@ import {
import { currentRoute, navigateTo, type MountedPage } from "../router";
import type { DesignView } from "./design_dock";
import type { DesignPageShared } from "./design_shared";
import { usePipeIds } from "./design_pipe_ids";
import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
@@ -105,6 +106,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
page.className = "design-page";
root.replaceChildren(page);
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
const left = document.createElement("div");
+3 -2
View File
@@ -82,15 +82,16 @@ export function createSectionFrame(
heading.textContent = title;
if (view === "profile") {
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추) — 두 보기 모두.
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 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";
const body = document.createElement("div");
body.className = "b05-route__panel-body";
root.append(heading, body);
return { root, body };
}
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
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";
root.append(heading);
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 type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import type {
StructureInstance,
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
import {
hasLegacyAlignment,
readAlignment,
@@ -45,7 +42,6 @@ import type {
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
chainageKey,
emptyEdits,
toAlignmentBase,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
@@ -56,11 +52,7 @@ import {
structureIdKeeping,
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
createRouteMassHaulPanel,
type MassHaulSummaryValues,
type RouteMassHaulContext,
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
@@ -77,15 +69,19 @@ import {
createElevationHold,
createProfileDrag,
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
import {
planCurveWarning,
sampleAt,
snapToMasterStations,
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
irregularGraphStations,
maxChainageOf,
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { frameOf } from "@util/common_util_view_window";
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
import { noticeDroppedEdits } from "./design_save";
import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
@@ -107,8 +103,8 @@ export interface DesignProfileContext {
view: DesignView;
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
side?: DesignSideFactory;
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */
sideTool?: (board: DesignSideBoard) => HTMLElement;
/** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */
toolLead?: HTMLElement;
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
onSelectStation: (stationId: string | null) => void;
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
@@ -161,40 +157,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
if (drag.isSubDragging()) scheduleLightSync();
else draw();
};
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
* 유토곡선을 접어도 이 값은 남아야 한다. */
const massBadge = createMassHaulBadge();
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
bodyWrap.append(massBadge.root);
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
const haulPrefetch = createHaulPlanPrefetch(
// 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
const { massHaul, tableOverlay } = createProfileOverlays({
projectId,
() => routeId ?? undefined,
subPanelChanged,
);
const massHaul = createRouteMassHaulPanel(
subPanelChanged,
(summary) => {
const before = massHaulSummary;
massHaulSummary = summary;
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
},
haulPrefetch,
);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(subPanelChanged);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
tableOverlay.attachScrollSync(body);
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
body.addEventListener("contextmenu", (event) => event.preventDefault());
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
routeId: () => routeId,
bodyWrap,
body,
onChange: subPanelChanged,
});
const content = document.createElement("div");
content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
@@ -249,6 +219,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null;
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
let storedBase: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
@@ -261,9 +233,16 @@ export function createDesignProfile(ctx: DesignProfileContext) {
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
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 =>
stationIdAtStructureOf(structureId, irregularStations, structures);
stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes);
/**
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
@@ -288,7 +267,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
planCurve: planCurveWarning(alignment, detail),
minCoverViolations,
balanceBar,
tools: tools.render(),
tools: withToolLead(tools.render()),
trailing,
alignment,
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
@@ -310,11 +289,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw());
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : []));
trailing.append(profileZoom.bar);
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
const withToolLead = (wrap: HTMLElement): HTMLElement => {
if (ctx.toolLead) wrap.prepend(ctx.toolLead);
return wrap;
};
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
const { tools, history, handleToolPick, setListening } = createPanelTools({
@@ -360,6 +342,33 @@ export function createDesignProfile(ctx: DesignProfileContext) {
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회)로 연속 호출되므로 한 프레임에 한 번만
* 실제 렌더링하도록 모은다.
@@ -372,7 +381,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
scheduleCrossPreview();
if (redrawPending) return;
redrawPending = true;
requestAnimationFrame(() => {
frameOf(root, () => {
redrawPending = false;
draw();
});
@@ -418,6 +427,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
function draw(): void {
renderProfile({
onStaleDesigns: crossPreview.scheduleIfStale, // 그린 뒤 생긴 낡음도 다시 셈(317-1)
body,
callbacks,
massHaul,
@@ -453,7 +463,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
zoom: profileZoom.state,
holdElevationRange: elevationHold.hold,
toolActive: () => tools.mode() !== "none",
selectedRuns: () => tools.selectedRuns(),
tools,
stationIdAtStructure,
redraw: draw,
setElevationWindowUpdater: (update) => {
@@ -462,46 +472,27 @@ export function createDesignProfile(ctx: DesignProfileContext) {
});
}
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
let settledScrollLeft = 0;
let scrollSettleTimer = 0;
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
body.addEventListener("scroll", () => {
if (elevationHold.editing()) return;
const fitted = updateElevationWindow?.() ?? null;
window.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
scrollSettleTimer = window.setTimeout(() => {
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = body.scrollLeft;
draw();
}, SCROLL_SETTLE_MS);
/** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */
const reveal = createSelectionReveal(body);
const revealPick = (): void =>
reveal(
(selectedStationId ?? selectedStructureId)
? `${selectedStationId}|${selectedStructureId}`
: null,
);
// 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
const unbindViewport = bindProfileViewport({
body,
editing: () => elevationHold.editing(),
fitElevation: () => updateElevationWindow?.() ?? null,
draw: () => draw(),
lastSize: () => ({ width: lastWidth, height: lastHeight }),
scheduleLightSync,
scheduleDraw: () => drag.scheduleDraw(),
});
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
attachWheelHorizontalScroll(body);
const resizeObserver = new ResizeObserver(() => {
if (
body.clientWidth <= 0 ||
body.clientHeight <= 0 ||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
)
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
scheduleLightSync();
drag.scheduleDraw();
});
resizeObserver.observe(body);
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw));
bindProfileCollapse(root, panelHandle, () => frameOf(root, draw));
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
function syncDrainageSpans(): void {
@@ -529,7 +520,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
}
base = stored ? toAlignmentBase(stored) : null;
storedBase = stored ? toAlignmentBase(stored) : null;
base = listed();
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null;
@@ -540,11 +532,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
nextDetail.balloon_offsets ?? undefined,
);
draw();
requestAnimationFrame(draw);
frameOf(root, draw);
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
//
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
@@ -578,6 +569,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
selectedStructureId = structureIdAtStation(stationId);
draw();
revealPick();
},
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
@@ -592,9 +584,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
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;
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());
draw();
},
@@ -604,7 +598,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
},
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) {
irregularStations = snapToMasterStations(alignment, stations);
irregularStations = stations;
base = listed();
if (base) alignment = buildAlignment(base, store.edits());
draw();
},
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
@@ -629,6 +625,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
selectedStationId = keep ? selectedStationId : own;
draw();
revealPick();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
setStationDisplay(next: { station: number; cumulative: number }) {
@@ -640,20 +637,24 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
*/
resetStationEdit(chainageM: number, moveTo?: number) {
const key = chainageKey(chainageM);
// 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
const edits = store.edits();
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
store.resetStation(chainageM, moveTo);
const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
(entry) => Math.abs(Number(entry) - chainageM) < 0.006,
);
if (key !== undefined) store.resetStation(Number(key), moveTo);
},
isDirty: () => store.dirty(),
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> {
if (!routeId || !store.dirty()) return;
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
base = toAlignmentBase(next);
alignment = next;
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
@@ -662,6 +663,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
clear() {
detail = null;
base = null;
storedBase = null;
alignment = null;
selectedStationId = null;
balanceBar.replaceChildren();
@@ -689,7 +691,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
setListening(false);
drag.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 };
}
+6 -8
View File
@@ -13,27 +13,25 @@ import type { DesignProfile } from "./design_profile";
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */
/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
export function attachProfilePopout(
profile: DesignProfile,
slot: HTMLElement,
sideTool: HTMLElement | null,
button: HTMLButtonElement,
): void {
const { root } = profile;
let popup: Window | null = null;
const button = document.createElement("button");
button.type = "button";
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
button.textContent = "새 창";
button.title = "종단 하단 패널을 브라우저 새 창으로 띄움";
button.textContent = "창 분할";
button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합";
button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open()));
sideTool?.after(button);
const restore = (): void => {
if (!root.classList.contains("is-popout")) return;
root.classList.remove("is-popout");
button.textContent = "새 창";
button.textContent = "창 분할";
slot.append(root);
popup = null;
requestAnimationFrame(profile.redraw);
@@ -71,7 +69,7 @@ export function attachProfilePopout(
doc.body.className = document.body.className;
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`;
root.classList.add("is-popout");
button.textContent = "제자리로";
button.textContent = "창 결합";
doc.body.replaceChildren(root);
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
@@ -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;
}
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 {
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> {
const draft = pendingAlignment(routeId);
if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft);
noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
markAlignmentSaved(routeId);
}
@@ -115,6 +137,7 @@ export async function saveDesign(
const routeId = ctx.routeId();
if (routeId === null) return false;
showLoadingOverlay();
saving.add(ctx.projectId);
try {
await ctx.prepare?.();
for (const step of DESIGN_SAVE_ORDER) {
@@ -135,12 +158,14 @@ export async function saveDesign(
await (label ? run().catch(reportOnly(label)) : run());
}
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
await ctx.done?.(confirm);
return true;
} catch (error) {
ctx.failed(error, confirm);
return false;
} finally {
saving.delete(ctx.projectId);
hideLoadingOverlay();
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
export interface DesignSelection {
+4 -4
View File
@@ -21,7 +21,7 @@ import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout } from "./design_profile_popout";
@@ -150,12 +150,12 @@ function toAll<K extends keyof RouteProfilePanelCallbacks>(
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
export function sharedProfile(shared: DesignPageShared): DesignProfile {
if (shared.profile) return shared.profile;
let sideTool: HTMLElement | null = null;
const popoutButton = document.createElement("button");
const profile = createDesignProfile({
projectId: shared.projectId,
view: shared.view(),
side: drainageSide,
sideTool: (board) => (sideTool = drainageSideTool(board)),
toolLead: popoutButton,
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
onResetAll: () => bound(shared).profile?.onResetAll?.(),
callbacks: {
@@ -178,7 +178,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
},
});
shared.profileSlot.append(profile.root);
attachProfilePopout(profile, shared.profileSlot, sideTool);
attachProfilePopout(profile, shared.profileSlot, popoutButton);
shared.profile = profile;
return profile;
}
@@ -16,6 +16,8 @@
* ========================================================================== */
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. */
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;
}
/**
* 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(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 {
const need = fordHeightM(designFlowM3s, widthM);
@@ -2,13 +2,16 @@
* design_structure_ford_pavement.ts
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
*
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` ·
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 다른 구조물과
* 같은 칸 `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_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* 길이(`ford_chute_{n}_length_m`) = 횡단 · 3D 띠를 사면 따라 그릴 길이 — 그림 전용(수량 · 내역 · 일위대가에 안 씀) ·
* 비면 원지반까지.
* ========================================================================== */
import { CHUTE_ITEMS } from "./design_structure_guard";
import type { StructureSpec } from "./design_structure_spec";
export const FORD_PAVEMENT_SPEC: StructureSpec = {
@@ -32,20 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
prefix: "ford_chute",
link: "gfordpavechute",
max: 1,
// 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2)
items: [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
default: "도수로",
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
],
// 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4)
items: CHUTE_ITEMS,
},
],
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
+9 -1
View File
@@ -22,6 +22,8 @@ import {
linkOf,
readState,
solveSplit,
specContainerHidden,
specItemHidden,
TEXT_MAX,
writeOptions,
type SpecContainer,
@@ -82,6 +84,8 @@ export interface SpecForm {
groupNames?: Record<string, string>,
/** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
defaults?: Record<string, string | number>,
/** 숨긴 옵션 키(정의 「표시」 · 322-2) — 그 키만 쓰는 칸 · 상자는 안 그림(값은 그대로). */
hidden?: ReadonlySet<string>,
) => void;
read: () => Record<string, string | number>;
state: () => SpecState;
@@ -104,6 +108,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
let names: SpecNames = {};
let groupNames: Record<string, string> = {};
let defaults: Record<string, string | number> = {};
let hidden: ReadonlySet<string> = new Set();
const recent = new Map<Instance, SplitRole[]>();
const slots = new Map<string, HTMLElement>();
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
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
.filter((item) => !specItemHidden(item, hidden))
.map((item) => {
const input = control(c, item, view, tags.get(item.id));
const wrap = labeled(itemLabel(c, item, names), input);
@@ -316,6 +322,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const fitted: SpecContainer[] = [];
for (const c of spec?.containers ?? []) {
const list = (state[c.id] ??= c.required ? [{}] : []);
if (specContainerHidden(c, hidden)) continue; // 숨긴 상자 — 값은 그대로(322-2)
if (fit(c, list)) fitted.push(c);
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;
@@ -341,9 +348,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return {
root,
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) {
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}, nextHidden) {
spec = next;
defaults = nextDefaults;
hidden = nextHidden ?? new Set();
names = nextNames;
groupNames = nextGroups;
state = readState(next, options);
@@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
{ 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) — 옛 둘 이상은 보이되 [추가]만 숨김. */
@@ -18,6 +18,7 @@ import { BOX_SPEC } from "./design_structure_box";
import { FORD_SPEC } from "./design_structure_ford";
import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement";
import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road";
import { baseType } from "../../common_util/common_util_structure_catalog";
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
const BASIN = "집수정";
@@ -116,3 +117,7 @@ export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = {
pavement_concrete: PAVEMENT_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 { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
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";
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
@@ -120,6 +120,23 @@ export const linkOf = (c: SpecContainer, n: number): string =>
const text = (value: unknown): string =>
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 차례대로. */
export function readState(
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 { showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
import type {
StructureInstance,
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeIdAt } from "./design_pipe_ids";
import {
fetchSectionContext,
getSections,
@@ -29,6 +27,7 @@ import {
type StandardCrossSection,
} from "../../B06_Section/B06_Section_Api_Fetch";
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import {
createCutSlopeStore,
createRockBoundaryStore,
@@ -37,13 +36,15 @@ import {
} from "../../B06_Section/B06_Section_UI_Page_Persist";
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { 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 { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
replayDraftPipeOptions,
type PipeOptionsContext,
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
import {
@@ -131,8 +132,7 @@ export async function mountCrossView(
);
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
let structureMarksSink:
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
let structureMarksSink: Parameters<typeof createB06StructuresPanel>[0]["onMarks"] | null = null;
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
let pipeReloadTimer = 0;
let wallAdjustTimer = 0;
@@ -140,12 +140,11 @@ export async function mountCrossView(
projectId,
shared,
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
onMarks: (structures, types) => {
structureMarksSink?.(structures, types);
onMarks: (structures, types, pipes) => {
structureMarksSink?.(structures, types, pipes);
syncBermSpans(structures);
},
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
onStructuresChanged: () => void refreshDetailForStructures(),
onPipesWritten: () => {
window.clearTimeout(pipeReloadTimer);
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
@@ -191,7 +190,7 @@ export async function mountCrossView(
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
},
releaseCard: () => releaseCross(),
markStructure: (structureId) => sectionView.selectMark(structureId),
markStructure: (structureId, span) => sectionView.selectMark(structureId, span),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
});
@@ -220,6 +219,7 @@ export async function mountCrossView(
async function refreshDetailForStructures(): Promise<void> {
if (!projectId || currentRouteId === null) return;
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
try {
const fresh = await loadSectionDetail(projectId, currentRouteId);
if (seq !== structuresRefreshSeq) return;
@@ -233,6 +233,7 @@ export async function mountCrossView(
);
}
renderSectionDetail();
void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314)
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error");
@@ -301,6 +302,7 @@ export async function mountCrossView(
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
},
});
setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4)
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
@@ -332,7 +334,7 @@ export async function mountCrossView(
shared,
);
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
structureMarksSink = sectionView.setStructureMarks;
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
@@ -341,15 +343,15 @@ export async function mountCrossView(
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
removeStructure: (structureId) => {
structuresPanel.removeStructureById(structureId);
},
removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId),
movePipe: (fromChainageM, toChainageM) =>
structuresPanel.movePipeTo(fromChainageM, toChainageM),
moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM);
},
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM), // 측점 없는 관(요구 178)
pipesChanged: () => (structuresPanel.adoptFordHeights(), void refreshDetailForStructures()),
});
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
@@ -387,19 +389,18 @@ export async function mountCrossView(
: null;
if (!target) {
structuresPanel.showAtChainage(null);
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
return;
}
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
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)
const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null };
if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" });
// 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
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)
structureSelection.syncInletStructure();
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
@@ -474,16 +475,11 @@ export async function mountCrossView(
handoff,
sectionDetail,
{
// 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15).
focusStation: (stationId) => {
structuresPanel.prefer(handoff?.structureId ?? null);
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);
},
// 넘겨받은 구조물 id 가 먼저(52-15) · 폼은 id 로 직접(53-1) · 측점 없는 구조물은 카드 · 스크롤 없음(307)
focusStation: (stationId) =>
structuresPanel.focusPicked(handoff?.structureId ?? null, () =>
sectionView.focusStation(stationId),
),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
@@ -494,23 +490,21 @@ export async function mountCrossView(
if (applied) appliedPick = signature;
}
/** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */
// 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
const crossPick = bindCrossPick(projectId, host, {
sections: () => sectionDetail?.cross_sections ?? [],
controls: stationControls,
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 {
const { revetOffset, ford, box } = stationControls;
const lit = (sectionDetail?.cross_sections ?? []).filter(
(s) =>
revetOffset.highlightFor(s) ||
ford.selectedFor(s.chainage_m) ||
box.selectedFor(s.chainage_m),
);
for (const s of lit) {
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
}
structuresPanel.releasePipe();
sectionView.focusStation(null);
sectionView.refreshCards(lit.map((s) => s.chainage_m));
crossPick.release();
appliedPick = null;
selectDesign(projectId, NO_SELECTION, { from: "b06" });
}
@@ -543,6 +537,7 @@ export async function mountCrossView(
// 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로
// 실려 나가던 자리(2026-09-09 실측).
reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
redraw: () => (host.visible() ? refreshDetailForStructures() : Promise.resolve()),
patchSources: () => ({
rockOffsets: rockStore.offsets,
cutSlopeRatios: cutSlopeStore.ratios,
@@ -568,8 +563,13 @@ export async function mountCrossView(
// 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38)
if (!readStructurePick(projectId)) releaseCross();
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(() => {
window.clearTimeout(pipeReloadTimer);
window.clearTimeout(wallAdjustTimer);
@@ -669,8 +669,6 @@ export async function mountCrossView(
bindPipeOptions(shared, (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
);
// 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a).
replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId));
renderSectionDetail();
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
+62 -47
View File
@@ -31,14 +31,9 @@ import {
restoreStructurePick,
wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
coversAtStation,
NO_SELECTION,
nearestWithin,
readSelection,
selectDesign,
} from "./design_selection";
import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection";
import type { DesignPageHost } from "./design_page";
import { bindProfilePick } from "./design_pick_profile";
import {
bindDesignView,
designReady,
@@ -46,7 +41,6 @@ import {
type DesignPageShared,
} from "./design_shared";
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures";
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
import {
@@ -62,7 +56,11 @@ import {
type SectionDetailResponse,
} from "../../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
import {
migrateLegacyStations,
onStaleStructureTypes,
structureAnchorM,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import {
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_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { pipeIdAt } from "./design_pipe_ids";
import {
DEFAULT_ROAD_WIDTHS,
fetchRoadWidths,
@@ -127,6 +126,7 @@ export async function mountProfileView(
bridge.setPipes(pipes);
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
profilePanel.setMinCoverPipes(pipes);
snapOld();
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
};
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
@@ -135,10 +135,8 @@ export async function mountProfileView(
const structurePick = !!readSelection(activeProjectId)?.structureId;
const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []);
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
if ("pipeChainageM" in target) {
profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`);
return;
}
if ("pipeChainageM" in target)
return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId));
profilePanel.setSelectedStation(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"),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
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) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
@@ -220,7 +220,7 @@ export async function mountProfileView(
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
const uphillOverrides = loadUphillOverrides(activeProjectId);
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(),
isSyncing: () => selectionSyncing,
setSyncing: (value) => {
@@ -244,9 +244,24 @@ export async function mountProfileView(
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). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void {
profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1)
if (panel.structures.hasSelection()) return;
const pick = viewer.structurePick;
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
@@ -318,45 +333,31 @@ export async function mountProfileView(
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
},
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
// 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3)
onPipeFacilitySelect: (chainage) => {
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다.
if (selectionSyncing) return;
selectionSyncing = true;
try {
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
const station =
chainage === null
? undefined
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
selectGraphAt(id, chainage);
syncBasinHighlight(id);
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
profilePanel.drainage.selectPipeAtChainage(chainage);
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
} finally {
selectionSyncing = false;
}
rememberPick(chainage, null);
if (!selectionSyncing) profilePick.paint();
},
onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3)
onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
if (selectionSyncing) return;
selectionSyncing = true;
try {
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
} finally {
selectionSyncing = false;
}
// 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2)
if (!selectionSyncing) profilePick.paint();
},
},
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). */
@@ -402,6 +403,17 @@ export async function mountProfileView(
},
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).
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
@@ -538,6 +550,9 @@ export async function mountProfileView(
onReset: () => uphillOverrides.clear(),
});
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load());
host.onDispose(onStaleStructureTypes(reloadIfShown));
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
bindProfileViewLife(host, {
projectId: activeProjectId,
@@ -558,8 +573,7 @@ export async function mountProfileView(
);
return;
}
viewer.markers.selectStation(null);
profilePanel.setSelectedStation(null);
profilePick.paint();
},
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => {
@@ -655,6 +669,7 @@ export async function mountProfileView(
} finally {
progress.remove();
restoring = false;
snapOld();
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();
+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;
}
}
+8 -1
View File
@@ -9,9 +9,12 @@
* ========================================================================== */
import "./random_uuid_polyfill";
import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(기기대응 2-1)
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1)
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
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 { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
import {
@@ -19,6 +22,7 @@ import {
renderCurrentRoute,
ensureInitialHash,
setLeaveGuard,
draftRoute,
showWithinPage,
takeLeaveGuard,
} from "./router";
@@ -54,6 +58,8 @@ function bootstrap(): void {
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
// B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220)
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
let shown = "";
@@ -67,7 +73,8 @@ function bootstrap(): void {
shown = currentRoute();
shownHash = window.location.hash;
if (within) return;
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
const showFooter =
!FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless;
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
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,
};
/** 초안 길(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 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
let leaveGuard: (() => Promise<boolean>) | null = null;
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
@@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
mounted?.dispose();
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);
return;
}
@@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
ROUTES.B11_LOADING,
];
if (workflowRoutes.includes(route)) {
if (workflowRoutes.includes(route) || draft?.workflow) {
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
try {
const user = await fetchSessionUser();
@@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
}
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다.
if (PROJECT_ROUTES.includes(route)) {
if (PROJECT_ROUTES.includes(route) || draft?.project) {
const { dropGoneProject } = await import("./b_workflow_nav");
if (await dropGoneProject()) {
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) {
renderPlaceholder(outlet, route);
return;
+2
View File
@@ -16,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor, slotOf } from "./spreadsheet_editor";
import { mountTouchKeys } from "./spreadsheet_touch_keys";
import { toCalcValue } from "./spreadsheet_eval";
import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph";
@@ -161,6 +162,7 @@ export function createSpreadsheet(
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys);
mountTouchKeys(ctx, ed); // 자판 없는 기기 단추 줄(기기대응 4-3 · 터치 모드에서만 보임)
const detachMouse = attachMouse(ctx, ed, extras.mouse);
/** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */
let lateTabs: PartHandle | null = null;
@@ -8,7 +8,7 @@
* ========================================================================== */
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
import { cellStyleAt } from "./spreadsheet_cellstyle";
import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle";
import { createCalcEngine } from "./spreadsheet_graph";
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
import { resolveName } from "./spreadsheet_names";
@@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) {
: { 글: "", 정렬: "left" as const };
const key = `${sheet.id}!${toA1(r, c)}`;
값[key] = shown.글;
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1)
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
};
const cells = rows.map((r) =>
@@ -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> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -149,13 +149,23 @@ const HALIGN_CSS: Record<HAlign, string> = {
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> = {
top: "flex-start",
center: "center",
bottom: "flex-end",
};
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle(
cell: HTMLElement,
@@ -178,7 +188,7 @@ export function applyCellStyle(
s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
let text = cell.firstElementChild as HTMLElement | null;
@@ -187,7 +197,7 @@ export function applyCellStyle(
text.className = "aislo-cell__text";
cell.replaceChildren(text);
}
text.style.textAlign = HALIGN_CSS[hAlign];
text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)];
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
}
@@ -14,6 +14,7 @@
import { showToast } from "@ui/ui_template_elements";
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { clipWholeToUsed } from "./spreadsheet_grid_cells";
import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
@@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string {
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") {
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
}
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
// 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양
const h = shownHAlign(style.가로);
parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`);
const v = shownVAlign(style.세로);
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
@@ -129,7 +129,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
attrs: { type: "button", title: TEXT.close },
});
const dialog = el("div", {
className: "ss-dialog",
className: "ss-dialog ui-popup",
attrs: { hidden: "" },
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.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : "";
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
ta.style.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center";
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
ta.style.width = "0";
ta.style.height = "0";
@@ -303,6 +303,14 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
}
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 });
}
@@ -210,7 +210,7 @@ export function openEvalSteps(ctx: SpreadsheetContext): () => void {
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
});
const box = el("div", {
className: "ss-steps__box",
className: "ss-steps__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-steps", children: [box] });
@@ -23,7 +23,7 @@ import {
type NegStyle,
} from "./spreadsheet_format_code";
import { dialogKeys } from "./spreadsheet_actions";
import { isAutoInk } from "./spreadsheet_cellstyle";
import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["centerContinuous", "선택 영역 가운데"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
hSelect.value = draft.가로 ?? "general";
hSelect.value = shownHAlign(draft.가로);
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
const vSelect = el("select", {
@@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["bottom", "아래"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
vSelect.value = draft.세로 ?? "bottom";
vSelect.value = shownVAlign(draft.세로);
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
@@ -263,3 +263,16 @@
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
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;
}
}
+6 -1
View File
@@ -553,7 +553,12 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
if (handleAt) {
const screen = screenBoxOf(handleAt.r, handleAt.c);
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
// 손가락이면 손잡이 잡는 자리를 넓힘(기기대응 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 };
}
}
@@ -13,6 +13,7 @@ import {
isAutoInk,
resolveBoxBorders,
setCellText,
shownHAlign,
type ResolvedBorders,
} from "./spreadsheet_cellstyle";
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
@@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
}
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -218,7 +219,7 @@ function cellDisplay(
const scalar = scalarAt(ctx, sheet, r, c);
const formatted = formatValue(scalar, code);
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
const hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1)
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
const isNumeric =
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
+4 -2
View File
@@ -10,6 +10,7 @@
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
* ========================================================================== */
import { keyLetter } from "../key_shortcut";
import { cycleAbs } from "./spreadsheet_editor";
import { fillCells } from "./spreadsheet_fill";
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 }));
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) {
switch (k) {
case "z":
@@ -233,7 +234,8 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
}
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 done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
if (done) {
@@ -255,11 +255,61 @@ export function attachMouse(
};
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("dblclick", onDbl);
root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave);
root.addEventListener("touchstart", onTouchStart, { passive: false });
root.addEventListener("touchmove", onTouchMove, { passive: false });
root.addEventListener("touchend", onTouchEnd);
return () => {
root.removeEventListener("touchstart", onTouchStart);
root.removeEventListener("touchmove", onTouchMove);
root.removeEventListener("touchend", onTouchEnd);
root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave);
stopDrag?.();
@@ -87,7 +87,7 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void {
const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" });
const box = el("div", {
className: "ss-sort__box",
className: "ss-sort__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-sort", children: [box] });
@@ -15,6 +15,7 @@ export function setSpreadsheetLanguage(index: 0 | 1): void {
const TEXT = {
Undo: ["되돌리기", "Undo"],
CellMenu: ["칸 메뉴(우클릭과 같음)", "Cell menu (same as right-click)"],
Redo: ["다시", "Redo"],
FontName: ["글꼴", "Font"],
FontSize: ["크기", "Size"],
@@ -291,3 +291,23 @@
justify-content: flex-end;
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 { confirmMerge } from "./spreadsheet_actions";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
@@ -147,6 +148,30 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
undoBtn.title = st("Undo");
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.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", {
@@ -500,7 +525,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
});
root.append(
group(undoBtn, redoBtn),
group(undoBtn, redoBtn, moreBtn),
group(fontInput, sizeInput),
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
group(textColor, textColorClear, fillColor, fillColorClear),
@@ -528,8 +553,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general";
vAlignSelect.value = style.세로 ?? "bottom";
hAlignSelect.value = shownHAlign(style.가로);
vAlignSelect.value = shownVAlign(style.세로);
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0];
@@ -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);
}
+232
View File
@@ -0,0 +1,232 @@
/* =============================================================================
* 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 확대)는 건너뜀 — 자체 손가락 처리 · 입력 칸 길게 누름도 브라우저 몫
* 마우스 · 펜 이벤트는 건드리지 않음.
* ========================================================================== */
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 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */
export const wheelZoomsIn = (e: WheelEvent): boolean =>
isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0;
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]]:
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;
/** 저장할 한 줄 주소. 비면 null. */
value: () => string | null;
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
point: () => { lat: number; lon: number } | null;
}
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
@@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Address_Map_Click() {
await map.show(base.input.value.trim());
picked = await map.show(base.input.value.trim());
},
});
mapBtn.hidden = true;
@@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
let manual = false;
let picked: { lat: number; lon: number } | null = null;
function pick(item: AddressCandidate): void {
base.input.value = candidateLine(item);
@@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
zip.hidden = !item.zipcode;
list.hidden = true;
list.innerHTML = "";
picked = { lat: item.lat, lon: item.lon };
map.showPoint(item.lat, item.lon);
detail.input.focus();
}
@@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
});
// 이미 있는 주소는 열자마자 지도로 보여 준다.
if (initial && initial.trim()) void map.show(initial.trim());
if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p));
return {
root,
@@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
return merged || null;
},
point: () => picked,
};
}
@@ -58,11 +58,17 @@ export function buildKnowledgeChat(): HTMLElement {
input.rows = 2;
input.required = true;
input.maxLength = 500;
input.placeholder = "메시지를 입력하세요";
input.placeholder = "메시지를 입력하세요 (Shift+Enter 줄바꿈)";
input.setAttribute("aria-label", "질문");
const submit = document.createElement("button");
submit.type = "submit";
submit.textContent = "보내기";
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit.click();
}
});
form.append(input, submit);
panel.append(header, messages, form);
wrap.append(toggle, panel);
+6 -4
View File
@@ -32,7 +32,8 @@ function tileIndex(
export function buildAddressMap(): {
root: HTMLElement;
show: (address: string) => Promise<void>;
/** 찾은 좌표를 돌려줌 — 못 찾으면 null. */
show: (address: string) => Promise<{ lat: number; lon: number } | null>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void;
} {
@@ -110,17 +111,18 @@ export function buildAddressMap(): {
note.textContent = message;
}
const show = async (address: string): Promise<void> => {
const show = async (address: string): Promise<{ lat: number; lon: number } | null> => {
if (!address.trim()) {
fail("주소를 먼저 입력하십시오.");
return;
return null;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return;
return null;
}
draw(point.lat, point.lon);
return { lat: point.lat, lon: point.lon };
};
return { root, show, showPoint: draw };
+18 -11
View File
@@ -536,8 +536,8 @@
.b01-knowledge-chat__toggle,
.b01-knowledge-chat__form button {
border: 0;
background: var(--color-royal-amethyst, #6843a7);
color: #fff;
background: var(--color-primary);
color: var(--color-primary-text);
font: inherit;
cursor: pointer;
}
@@ -560,12 +560,12 @@
display: flex;
flex-direction: column;
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-radius: 12px;
background: var(--color-surface, #fff);
box-shadow: 0 8px 28px #0003;
color: var(--color-text-primary, #222);
color: var(--color-text-body);
overflow: hidden;
overscroll-behavior: contain;
}
@@ -588,8 +588,8 @@
height: 34px;
place-items: center;
border-radius: 50%;
background: var(--color-royal-amethyst, #6843a7);
color: #fff;
background: var(--color-primary);
color: var(--color-primary-text);
}
.b01-knowledge-chat__avatar svg {
@@ -631,14 +631,15 @@
.b01-knowledge-chat__user {
align-self: flex-end;
border-bottom-right-radius: 4px;
background: var(--color-royal-amethyst, #6843a7);
color: #fff;
background: var(--color-primary);
color: var(--color-primary-text);
}
.b01-knowledge-chat__reply {
align-self: flex-start;
border: 1px solid var(--color-border);
border-bottom-left-radius: 4px;
background: var(--color-background, #f4f4f4);
background: var(--color-surface-raised);
}
.b01-knowledge-chat__reply details {
@@ -722,7 +723,7 @@
.b01-knowledge-source-modal__content code {
padding: 0.1em 0.35em;
border-radius: 4px;
background: var(--color-background, #f4f4f4);
background: var(--color-surface);
}
.b01-knowledge-source-modal__content .ui-markdown-table {
@@ -746,7 +747,7 @@
}
.b01-knowledge-source-modal__content th {
background: var(--color-background, #f4f4f4);
background: var(--color-surface);
}
.b01-knowledge-chat__form {
@@ -765,11 +766,17 @@
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
resize: vertical;
overflow-y: auto;
}
.b01-knowledge-chat__form textarea::placeholder {
color: var(--color-text-muted);
}
.b01-knowledge-chat__form button {
padding: 9px 14px;
border-radius: 8px;
+1 -1
View File
@@ -1,5 +1,5 @@
.b03-file {
min-height: calc(100vh - var(--app-header-height, 0px));
min-height: calc(100dvh - var(--app-header-height, 0px));
background: linear-gradient(
180deg,
var(--color-canvas, #ffffff) 0%,
+1 -1
View File
@@ -74,7 +74,7 @@ export function createTempPicker(
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
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("aria-modal", "true");
+3 -10
View File
@@ -471,17 +471,10 @@ export async function fetchRoadInflow(
export type PipeSource = "stream" | "spacing" | "user";
/** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에
* 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */
* 따라 택일한다 — 관 지점 정본 종류 = 구조물 표 정본(`catalogRow().owner` · 323-7) · 이름 = 표 이름 ·
* 형상마다 손코딩 폼이 있어 종류 더하기(본보기)에서 뺌. 교량은 임도용이 아니다. */
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 {
chainage_m: 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(
projectId: string,
points: DetailPipeInput[],
+13 -9
View File
@@ -62,16 +62,16 @@ def _build(
) -> tuple[DrainageDetail | None, list[PipePoint]]:
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
`points`가 없으면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
`points`가 None 이면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
"""
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)
if detail is None:
return None, []
if points:
if points is not None:
_retag(detail.pipes, points)
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(
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse:
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다."""
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
"""
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
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) 모양이 틀림
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
signature = route_signature(context.vertices)
stored = (
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)
detail, resolved = result
if detail is None:
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)
@@ -395,8 +399,8 @@ async def post_detail_basins(
) -> dict[str, Any] | JSONResponse:
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
`points`를 비우고 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다.
`points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
"""
resolved = await _resolve(project_id, payload, use_stored=False)
if isinstance(resolved, JSONResponse):
+2 -1
View File
@@ -40,6 +40,7 @@ import {
resampleRoute,
type RoutePoint,
} from "./B04_PreProcess_UI_RouteSamples";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -296,7 +297,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
function selectBasinOfPipe(pipeIndex: number): void {
const pipe = pipes[pipeIndex];
if (!pipe) return;
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51);
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < PIPE_MATCH_M);
selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null;
say(summary());
}
+78 -20
View File
@@ -225,10 +225,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
if (options.blocked?.() || !controls.enabled) return;
if (event.button === 1 && panPointerId === null) {
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
pickPivot(event);
panAnchor.copy(pivot);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
startGrabPan(event);
panPointerId = event.pointerId;
element.setPointerCapture?.(event.pointerId);
return;
@@ -251,11 +248,15 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
* 휠을 위로 올리면 멀어진다(사용자 지시). */
function onWheel(event: WheelEvent): void {
const camera = getCamera();
if (!controls.enabled || options.blocked?.()) return;
event.preventDefault();
pickPivot(event);
const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
zoomAround(event, 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 targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
@@ -276,19 +277,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
function onPointerMove(event: PointerEvent): void {
const camera = getCamera();
if (panPointerId === event.pointerId) {
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다.
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();
grabPanTo(event);
return;
}
if (pointerId !== event.pointerId) return;
@@ -351,6 +340,67 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
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 {
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
element.releasePointerCapture(pointerId);
@@ -381,6 +431,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.addEventListener("pointercancel", onPointerEnd);
element.addEventListener("pointerleave", onPointerLeave);
element.addEventListener("wheel", onWheel, { passive: false });
element.addEventListener("pointerdown", onTouchDown);
element.addEventListener("pointermove", onTouchMove);
element.addEventListener("pointerup", onTouchUp);
element.addEventListener("pointercancel", onTouchUp);
return () => {
element.removeEventListener("pointerdown", onPointerDown);
@@ -389,6 +443,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.removeEventListener("pointercancel", onPointerEnd);
element.removeEventListener("pointerleave", onPointerLeave);
element.removeEventListener("wheel", onWheel);
element.removeEventListener("pointerdown", onTouchDown);
element.removeEventListener("pointermove", onTouchMove);
element.removeEventListener("pointerup", onTouchUp);
element.removeEventListener("pointercancel", onTouchUp);
if (pivotMarker) {
pivotMarker.removeFromParent();
pivotMarker.material.map?.dispose();
@@ -1,4 +1,5 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
@@ -546,7 +547,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
wheelWidth,
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 보정.
// screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
+1 -1
View File
@@ -119,7 +119,7 @@
flex-direction: column;
width: 100%;
max-width: none;
min-height: calc(100vh - var(--spacing-64));
min-height: calc(100dvh - var(--app-header-h));
}
.b04-surface__workspace > .b04-surface__status {
+2
View File
@@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse {
route_id: number;
profile_alignment: unknown;
grade_summary: RouteGradeSummary | null;
/** 측점 · 관 · 구조물 자리가 없어 서버가 버린 계획고 편집 자리(m · 213-2). */
dropped_edit_chainages?: number[];
}
/**
+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 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 {
type DefinedRule,
@@ -77,7 +79,6 @@ export interface StructureType {
placement: StructurePlacement;
options: StructureOptionField[];
style: { color?: string; abbr?: string };
drawing_views: string[];
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
managed_by: string | null;
/** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름.
@@ -140,6 +141,8 @@ export interface StructureListResponse {
project_id: string;
revision: number;
structures: StructureInstance[];
/** 정본을 적을 때의 구조물 정의 판(322-2) — 없으면 옛 파일 · 아직 안 적음. */
definition_version?: number | null;
}
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 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", {
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) => {
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
throw error;
@@ -239,6 +305,7 @@ export async function saveStructures(
{ method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) },
);
noteSnapshotFailed(saved.snapshot_failed);
staleSaved.delete(projectId); // 서버가 지금 판으로 적음
return saved;
}
@@ -267,7 +334,7 @@ export function pipesToStructureMarks(
}>,
): StructureInstance[] {
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,
placement: "point",
chainage_m: pipe.chainage_m,
@@ -304,21 +371,22 @@ export function defaultOptions(type: StructureType): Record<string, string | num
/** 미저장 조작분(② 설계 초안). 없으면 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 {
writeState("structures", next, projectId);
useDesignStore(projectId, null).set("structures", next);
}
/**
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다.
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다 — 정본이 옛 정의 판이면 그대로 다시 씀(323-9).
*/
export async function flushPendingStructures(projectId: string): Promise<void> {
const pending = readPendingStructures(projectId);
if (!pending) return;
if (!pending && !definitionStale(projectId)) return;
const stored = await fetchStructures(projectId);
await saveStructures(projectId, stored.revision, pending);
await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만
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(
base_s: np.ndarray,
base_z: np.ndarray,
+62 -72
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_Schema import (
is_station_planting_type,
structure_type_map,
)
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
PIPE_FACILITY_REVET,
PipePoint,
load_pipe_points_file,
parse_pipe_points,
@@ -44,14 +40,14 @@ from common_util.common_util_route_geometry import (
planned_route_initial_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 config.config_system import (
DRAINAGE_CACHE_DIRNAME,
DRAINAGE_EDITS_DIRNAME,
DRAINAGE_PIPE_POINTS_FILENAME,
FOREST_ROAD_PROFILE_CRITERIA,
PIPE_DEFAULT_DIAMETER_MM,
PIPE_DEFAULT_TYPE,
)
logger = logging.getLogger(__name__)
@@ -208,16 +204,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:
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음."""
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
"""관 지점 측점 라벨 = 표 카드 이름(322-1 · 배관 = 「관종 D관경」 · 빈칸 = 정의 기본값)."""
if pipe.facility not in types:
return None
if pipe.facility == PIPE_FACILITY_PIPE:
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
return card_title(pipe.facility, pipe.options)
def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None:
@@ -239,7 +229,7 @@ def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any
except (OSError, ValueError, AttributeError):
return
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:
label = section.get("structure")
if not isinstance(label, str) or not (
@@ -267,51 +257,22 @@ def resolve_extra_stations(
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다
(2026-08-28 사용자 확정).
물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1).
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
되짚기 때문이다(B05_Profile_Structures_Migration).
측점 · 이름 = 측점 계획 한 벌(322-3 · `plan_stations` · TS `planStations` 짝) — 표 횡단도
(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:
for structure in 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))
structures = load_structures(str(project_root))[1]
except Exception:
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(
@@ -549,7 +510,7 @@ def run_section_generation(
}
def generate_irregular_sections(
def sample_structure_sections(
project_root: Path,
route_data_path: str,
filter_key: str,
@@ -559,16 +520,15 @@ def generate_irregular_sections(
extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다.
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
(`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
(`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
"""
if not extra_stations:
return []
return [], []
base = options or SectionGenerationOptions()
merged_options = SectionGenerationOptions(
station_interval_m=base.station_interval_m,
@@ -589,18 +549,48 @@ def generate_irregular_sections(
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
)
irregular_stations = [
station
for station in result["longitudinal"]["stations"]
if station.get("kind") == "irregular"
]
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,
)
if not irregular_stations:
return []
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
cross_dir.mkdir(parents=True, exist_ok=True)
for cross_section in result["cross_sections"]:
if cross_section.get("kind") != "irregular":
continue
for cross_section in crosses:
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
atomic_write_json(cross_file, cross_section)
return irregular_stations
@@ -415,8 +415,10 @@ def generate_sections(
kind = "bp"
elif abs(float(value) - total) <= 1e-6:
kind = "ep"
elif rounded in extras and rounded not in regular_rounded:
# 격자와 겹치지 않는 비정규 측점만 irregular. 규칙 격자와 겹치면 규칙 측점 우선.
elif rounded in extras:
# 구조물 측점이 위(272 · 요구 167) — 격자와 같은 m 여도 구조물 측점(irregular) 하나 ·
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
kind = "irregular"
else:
kind = "regular"
@@ -466,9 +468,10 @@ def generate_sections(
"uphill_side": uphill_side,
"frame": frame,
}
# 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지).
if rounded in extras:
station["structure"] = extras[rounded]
if rounded in regular_rounded:
station["grid"] = True # 숨은 일반 측점 자리(272)
stations.append(station)
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(
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
) -> int | None:
+43 -6
View File
@@ -8,6 +8,7 @@ from typing import Any
from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter
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_Engine import run_route_design
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_Sections import run_section_generation
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(
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any]]:
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다.
project_root: Path,
longitudinal_file_path: str,
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()
path = (root / longitudinal_file_path).resolve()
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
if not path.is_file():
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
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)
longitudinal["profile_alignment"] = alignment
profiles = longitudinal.get("design_profiles")
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
else:
longitudinal["design_profiles"] = [profile]
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)
@@ -426,11 +455,18 @@ async def save_profile_alignment(
)
stored_path = await get_project_storage_relative_path(connection, project_id)
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,
project_root,
str(longitudinal["longitudinal_file_path"]),
request.edits(),
marks,
)
await connection.begin()
try:
@@ -446,6 +482,7 @@ async def save_profile_alignment(
route_id=request.route_id,
profile_alignment=alignment,
grade_summary=grade_summary,
dropped_edit_chainages=dropped,
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
+118 -31
View File
@@ -6,6 +6,7 @@
import asyncio
import json
import math
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -15,9 +16,19 @@ import aiomysql
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import generate_irregular_sections
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
find_cross_file,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
from B05_Profile.B05_Profile_Repository import (
get_surface_crs_epsg,
sync_cross_section_rows,
update_longitudinal_grade_summary,
)
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
@@ -96,6 +107,7 @@ async def sync_uphill_overrides_into_designs(
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
stored_tilt,
)
from B06_Section.B06_Section_Repository import get_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)),
standard=stored_standard,
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_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
**curve_widening_args(cross_record),
**stored_shoulder_extend(design), # 노견 확장(139-6)
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
)
next_design["status"] = design.get("status", "provisional")
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
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) · 편집을 새 측점으로 옮기거나 지운다.
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다.
바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`.
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
자리(272)만 옮기고 나머지는 지운다.
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
아니면 `None`.
"""
stored = data.get("profile_alignment")
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,
)
# 옛 소수 측점 → 정수 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)
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
if new_key is not None:
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
stored["base_pvi"] = base
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(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> dict[str, Any] | None:
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
"""종단 정본 stations 의 구조물 측점을 **지금 목록 전체로 다시 맞춘다**(272 · 요구 167).
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은
손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` —
바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다.
`irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
`grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
구조물이 떠난 격자 자리(`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()
path = (root / longitudinal_file_path).resolve()
@@ -285,15 +320,53 @@ def _merge_irregular_into_longitudinal(
for station in stations
if station.get("kind") == "irregular"
]
regular = [station for station in stations if station.get("kind") != "irregular"]
merged = regular + list(irregular_stations)
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
data["stations"] = merged
summary = _follow_moved_stations(data, before)
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
kept: dict[float, dict[str, Any]] = {}
left: list[dict[str, Any]] = []
for station in stations:
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)
_relabel_cross_files(path.parent.parent / "cross_sections", left)
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"
@@ -350,24 +423,38 @@ async def _append_irregular_cross_sections(
save_sampling_snapshot(project_root, request)
stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread(
generate_irregular_sections,
project_root,
str(route["route_data_path"]),
request.filter_key,
request.method,
request.smooth,
extra_stations=request.extra_stations(),
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
if not irregular_stations:
return
route_data_path = str(route["route_data_path"])
def _generate() -> list[dict[str, Any]]:
# 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은
# 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1).
polyline = _load_route_polyline(project_root, route_data_path)
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
return generate_irregular_sections(
project_root,
route_data_path,
request.filter_key,
request.method,
request.smooth,
extra_stations=extras,
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
# 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(272)
irregular_stations = await asyncio.to_thread(_generate)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
if longitudinal:
await asyncio.to_thread(
summary = await asyncio.to_thread(
_merge_irregular_into_longitudinal,
project_root,
str(longitudinal["longitudinal_file_path"]),
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"])
)
@@ -173,7 +173,9 @@ async def apply_profile_target(
root = Path(resolve_stored_project_path(stored_path))
relative = str(row["longitudinal_file_path"])
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:
await connection.begin()
await update_longitudinal_grade_summary(
+6 -7
View File
@@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel):
route_id: int
profile_alignment: dict[str, Any]
grade_summary: dict[str, Any] | None = None
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
dropped_edit_chainages: list[float] = Field(default_factory=list)
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)
def can_regenerate(self) -> bool:
return bool(
self.irregular_stations
and self.filter_key
and self.method
and self.surface_model_id is not None
)
# 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272)
return bool(self.filter_key and self.method and self.surface_model_id is not None)
class RouteConfirmResponse(BaseModel):
+14 -17
View File
@@ -16,15 +16,16 @@
"""
import re
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
station_planting_labels,
structure_type_map,
)
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 = "배관"
# 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `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)
def _managed_elsewhere_labels() -> set[str]:
"""서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 →
"기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels).
def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bool:
"""정본(관 지점 · 구조물)이 이미 관리하는 측점인가 — 재이관하면 정본 이중화 → 「기타」 유령.
관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서
투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관).
자리로 가름(323-7 · 지금 관 · 구조물이 심는 측점 계획 자리 `planted`) — 옛 이름 되짚기
(지난 판 이름 전부 · 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()
at = float(entry.get("chainage_m", 0.0))
return (
entry.get("origin") == "pipe"
or entry.get("structureType") == 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 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
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`)이면 구조물 목록이 아니라 그 정본으로 간다 —
@@ -120,7 +117,7 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPl
seen: set[tuple[str, float]] = set()
for entry in entries:
if _is_managed_elsewhere(entry):
if _is_managed_elsewhere(entry, planted):
continue
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
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]:
"""이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합.
+14 -2
View File
@@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stati
from B05_Profile.B05_Profile_Structures_Repository import (
StructureRevisionConflict,
load_migrated_legacy,
load_saved_definition_version,
load_structures,
requires_downstream_invalidation,
save_structures,
@@ -42,6 +43,7 @@ from common_util.common_util_drainage_pipes import (
read_pipe_points_file,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_station import plan_stations
from config.config_db import get_db_pool
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
@@ -159,7 +161,10 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
revision, structures = load_structures(root)
return StructureListResponse(
project_id=str(project_id), revision=revision, structures=structures
project_id=str(project_id),
revision=revision,
structures=structures,
definition_version=load_saved_definition_version(root),
)
except LookupError:
# 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다.
@@ -189,7 +194,14 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
revision, existing = load_structures(root)
migrated_before = load_migrated_legacy(root)
plan = migrate_irregular_stations(payload.stations)
# 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음)
planted = [
float(station["chainage_m"])
for station in plan_stations(
read_pipe_points_file(pipe_points_path_in(Path(root))), existing
)
]
plan = migrate_irregular_stations(payload.stations, planted)
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
# 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다
# (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본).
+21 -18
View File
@@ -88,8 +88,6 @@ class StructureType(BaseModel):
placement: Placement
options: list[StructureOptionField] = Field(default_factory=list)
style: dict[str, str] = Field(default_factory=dict)
# B06 도면 연계용 메타데이터 — 저장만 하고 이번 단계 완료 조건에는 넣지 않는다.
drawing_views: list[str] = Field(default_factory=list)
# 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다.
managed_by: str | None = None
# 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다
@@ -116,10 +114,19 @@ def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]:
return json.load(handle)
#: 정의에 남은 옛 칸 — 아무도 안 읽어 걷음(323-7 · 마스터는 M02 [저장] 때 빠짐 · 읽을 때 버림).
RETIRED_TYPE_KEYS = ("drawing_views",)
def without_retired(item: dict[str, Any]) -> dict[str, Any]:
"""종류 한 줄에서 옛 칸을 뺀 사본."""
return {k: v for k, v in item.items() if k not in RETIRED_TYPE_KEYS}
@lru_cache(maxsize=4)
def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]:
payload = _read_definition(path, mtime, size)
return tuple(StructureType.model_validate(item) for item in payload["types"])
return tuple(StructureType.model_validate(without_retired(item)) for item in payload["types"])
def load_definition() -> dict[str, Any]:
@@ -141,25 +148,19 @@ def structure_type_map() -> dict[str, StructureType]:
def is_station_planting_type(item: StructureType) -> bool:
"""서버가 종단 정본에 **측점으로 심는** 타입인가 (단일 규칙, 2026-08-28).
"""서버가 종단 정본에 **측점으로 심는** 타입인가 — 구조물 표 횡단도 부품이 있음(322-1).
- A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다.
- D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면
그만큼 여러 장이 나온다(사용자 확정).
- C군 구간형(사면안정 — 옹벽·흙막이·소단 등): D군 구간형과 같이 시작·기준·종료.
안 심으면 측점 격자 사이에 놓인 벽이 횡단도에 안 얹힌다.
측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단
(`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다.
anchor(기준 한 장 · 횡단배수 · 관 지점 시설) · span3(시작 · 기준 · 끝 · 벽 · 소단 · 기슭막이
— 길이가 길면 그만큼 여러 장). 옛 군 글자 규칙(A군 · C · D군 구간형)은 표 처음값으로 옮김.
"""
if item.group == "A":
return True
return item.group in ("C", "D") and item.placement == "interval"
from common_util.common_util_structure_catalog import catalog_row
row = catalog_row(item.type_id)
return row is not None and row["station"] != "none"
def station_planting_labels() -> set[str]:
"""서버가 심는 측점 라벨 집합 — 구조물로 되옮기면 정본이 이중화된다."""
return {item.name for item in load_structure_types() if is_station_planting_type(item)}
#: 지난 판 정의(`판_N.json`) 폴더 — 정본 옆(M02 `M02_SO_Definition.HISTORY` 와 같은 이름)
HISTORY_DIRNAME = "구조물정의_이력"
def registry_schema_version() -> int:
@@ -290,3 +291,5 @@ class StructureListResponse(BaseModel):
project_id: str
revision: int
structures: list[StructureInstance] = Field(default_factory=list)
# 정본을 적을 때의 정의 판(322-2) — 지금 판과 다르면 화면이 「저장하면 반영」 한 줄 알림
definition_version: int | None = None
+5 -2
View File
@@ -20,6 +20,7 @@ import {
writeCachedBytes,
} from "../A00_Common/b_asset_cache";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import {
@@ -247,7 +248,7 @@ function withSections(
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거).
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */
export function refreshCorridor(
export async function refreshCorridor(
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
projectId: string,
routeId: number | undefined,
@@ -256,7 +257,9 @@ export function refreshCorridor(
designSamples?: ProfileSamples,
rebuild = true,
): Promise<void> {
if (!routeId) return Promise.resolve();
if (!routeId) return;
// [3D 업데이트] — 낡은 횡단 설계(계획고 · 구조물 자리 값)를 먼저 다시 셈한 뒤 그 설계로 지음(317-1)
if (rebuild) await refreshStaleDesigns({ projectId, routeId, detail }).catch(() => []);
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
.then((build) => viewer.setCorridor(withSections(build, detail)))
.catch(() => viewer.setCorridor(null));
@@ -181,7 +181,8 @@ function fnv1a(text: string): string {
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
// 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이
// 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다.
const BUILD_VERSION = 93;
// 94 = 물넘이포장 노면을 파지 않음(287-4) — 설계 노면이 콘크리트 윗면 · 저장본을 만료한다.
const BUILD_VERSION = 94;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+6 -1
View File
@@ -382,7 +382,12 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
(build.structures ?? []).forEach((structure, index) => {
// 신원표 — 3D 클릭이 이 값으로 부재를 가려낸다(2026-09-04). 이름 규칙은 그대로 둔다
// (다른 코드가 이름으로 찾는다). 배관은 겉·속 두 겹이라 자식까지 같은 표를 단다.
const tag = { chainageM: structure.chainage_m, kind: structure.kind, key: structure.key };
const tag = {
chainageM: structure.chainage_m,
kind: structure.kind,
key: structure.key,
structureId: structure.structure_id, // 323-5 — 벽은 id 로 고름
};
if (structure.kind === "pipe") {
const mesh = structurePipeMesh(structure, origin);
if (mesh) {
+2 -17
View File
@@ -9,7 +9,6 @@
* (2026-08-23 사용자 지시)의 핵심.
* ========================================================================== */
import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
@@ -227,22 +226,8 @@ export function classifyStation(section: CrossSection): StationPieces | null {
if (!design || !line || line.length < 2) return null;
const roadL = design.road_edges.left.offset_m;
const roadR = design.road_edges.right.offset_m;
// 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려
// 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라
// 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다.
const ford = section.ford_pavement;
const carved =
ford && ford.depth_m
? line.map((point) =>
point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9
? {
...point,
elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m),
}
: point,
)
: line;
const sorted = [...carved].sort((a, b) => a.offset_m - b.offset_m);
// 물넘이포장 — 설계 노면이 곧 콘크리트 윗면(287-4 · 서버가 지대 낮은 쪽 한 경사로 냄) · 파지 않음
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
@@ -20,7 +20,7 @@
* 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다.
* ========================================================================== */
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash";
import { structureHashParts, wallSweepAt } from "./B05_Profile_UI_Corridor_Structures_Hash";
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import {
@@ -54,6 +54,7 @@ import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Reve
import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard";
import { chuteBands, fordPavementChute } from "./B05_Profile_UI_Corridor_Structures_Chute";
import { isChuteSegment } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
import { builds, culvertBody, ownsBody } from "./B05_Profile_UI_Corridor_Structures_Pick";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -85,6 +86,8 @@ export interface CorridorStructure {
* 신원표다(2026-09-04). 세월교·BOX암거 부재는 아직 비워 둔다.
*/
key?: string;
/** 구조물 id(323-5) — C군 벽(id 로 고름 · 덮는 측점 어디서 골라도 · 한 번 스윕). 관 부재는 자리로 고름. */
structure_id?: string;
/** 단면 폴리곤 [offset, elevation][] — 스윕형(벽·집수정). pipe는 없음. */
polygon?: Array<[number, number]>;
/**
@@ -276,17 +279,31 @@ export function buildCorridorStructures(
): CorridorStructure[] {
const solids: CorridorStructure[] = [];
for (const section of crossSections) {
const layout = culvertLayoutOf(section);
// 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) · 같은 시설 카드 둘이면 한 번(323-9)
const own = (key: "culvert" | "ford" | "box"): boolean => ownsBody(section, crossSections, key);
const layout = own("culvert") && culvertBody(section) ? culvertLayoutOf(section) : null;
// 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1)
const fordLayout = withoutGuardedFill(fordLayoutOf(section), layout);
const fordLayout =
own("ford") && builds("ford_bridge", "ford")
? withoutGuardedFill(fordLayoutOf(section), layout)
: null;
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
const revetLayout = ownWallsDrawAt(section, culvertOwnerFor(section, crossSections))
? computeRevetmentLayout(section, section.design?.revet_adjust?.own)
// C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것)
const wallSweep = wallSweepAt(section, crossSections);
const wallId = section.revetment?.structure_id;
const revetLayout =
wallSweep &&
ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) &&
builds(section.revetment?.type_id, "wall")
? computeRevetmentLayout(section, section.design?.revet_adjust?.own)
: null;
const paveChute = builds("ford_pavement", "chute")
? fordPavementChute(section, crossSections)
: null;
const paveChute = fordPavementChute(section, crossSections);
if (!layout && !fordLayout && !section.box && !revetLayout && !paveChute) continue;
const box = own("box") && builds("box_culvert", "box") ? section.box : undefined;
if (!layout && !fordLayout && !box && !revetLayout && !paveChute) continue;
const chainage = section.chainage_m;
const stationFrame: StructureFrame = {
cx: section.center_x,
@@ -355,18 +372,12 @@ export function buildCorridorStructures(
});
};
if (revetLayout) {
if (revetLayout && wallSweep) {
// 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다.
for (const tier of revetLayout.tiers) {
pushSwept(
"revet",
tier.polygon,
revetLayout.span.beforeM,
revetLayout.span.afterM,
null,
"own",
);
}
const from = solids.length;
for (const tier of revetLayout.tiers)
pushSwept("revet", tier.polygon, wallSweep.beforeM, wallSweep.afterM, null, "own");
for (const solid of solids.slice(from)) solid.structure_id = wallId ?? undefined;
}
/**
@@ -447,7 +458,7 @@ export function buildCorridorStructures(
}
};
if (section.box) {
if (box) {
solids.push(...boxSolids(section, stationFrame, boxAdjustOf(section)));
}
paveChute?.bands.forEach((band) =>
@@ -487,6 +498,8 @@ export function buildCorridorStructures(
chuteBands(
fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments),
section.ford?.chute,
undefined,
fordLayout.ground,
).forEach((band) => pushSwept("chute", band, half, half, null, "chute"));
// 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면**
// (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은
@@ -595,7 +608,12 @@ export function buildCorridorStructures(
);
}
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3).
chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) =>
chuteBands(
layout.outletFill.segments,
section.culvert?.chute,
isChuteSegment,
layout.ground,
).forEach((band) =>
pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"),
);
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
@@ -182,6 +182,8 @@ export function boxSolids(
const chutes: CorridorStructure[] = chuteBands(
layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments),
box.chute,
undefined,
layout.ground,
).map((band) => ({
chainage_m: chainage,
kind: "chute",
@@ -10,7 +10,11 @@
import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { chuteBandOutline } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
import {
chuteBandOutline,
chuteRun,
groundLine,
} from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
/** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */
@@ -25,10 +29,11 @@ export function chuteBands(
segments: ReadonlyArray<FillSegment>,
chute: ChuteSpec | null | undefined,
keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut",
/** 원지반 — 길이가 사면보다 길면 이어 그림(294-4 · 횡단 띠와 같은 `chuteRun`). */
ground?: readonly OffsetPoint[],
): OffsetPoint[][] {
if (!chute) return [];
return segments.flatMap((segment) => {
if (!keep(segment)) return [];
return chuteRun(segments.filter(keep), chute.length_m, ground).flatMap((segment) => {
const band = chuteBandOutline(segment.points, chute.thickness_m);
return band ? [band.outline] : [];
});
@@ -51,6 +56,12 @@ export function fordPavementChute(
);
if (owner !== section) return null;
}
const bands = chuteBands([{ points: outletFillLine(section.design) }], pave.chute);
const ground = groundLine(section.samples ?? []);
const bands = chuteBands(
[{ points: outletFillLine(section.design, ground) }],
pave.chute,
undefined,
ground,
);
return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null;
}
@@ -1,138 +1,79 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Structures_Hash.ts
* 저장 코리도 만료 판정용 **구조물 해시 요약**.
* 저장 코리도 만료 판정용 **구조물 지문** · C군 벽 3D 한 번(323-4).
*
* `B05_Profile_UI_Corridor_Structures` 에서 떼어냈다(700줄 제한, 2026-09-02).
* 값이 바뀌면 저장본이 만료되도록 **구조물에 영향을 주는 값만** 이어 붙인다 —
* 빠뜨린 값은 곧 "고쳐도 3D가 안 바뀌는" 버그가 되므로 항목 추가에 주의할 것.
* 원래 파일이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
* 지문 = 그 측점 그림 자료(형상 얹기 한 벌 `SHAPE_KEYS` · 카드 · 상세 창 · B07 · 3D 가 같은 목록) + 형상을
* 바꾸는 조작값 통째를 키 차례 고정 글로 — 손으로 고른 칸 목록(빠뜨린 칸 = 「고쳐도 3D 가 안 바뀜」)을 걷음.
* 원래 파일(`B05_Profile_UI_Corridor_Structures`)이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
* ========================================================================== */
import type {
ChuteSpec,
CrossSection,
CulvertSideSpec,
} from "../B06_Section/B06_Section_Api_Fetch";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { SHAPE_KEYS } from "../common_util/common_util_structure_shape_keys";
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
return chute ? ["ch", chute.structure, chute.thickness_m] : [];
}
/** 형상을 바꾸는 조작값(정본 `design` 칸) — 조정창 · 다단 · 연동 · 유입 구조. */
const ADJUST_KEYS = [
"revet_adjust",
"box_adjust",
"ford_adjust",
"basin_adjust",
"extra_wall_counts",
"extra_spans",
"revet_follow_grade",
"revet_link_detached",
"inlet_structure",
] as const;
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
export function structureHashParts(section: CrossSection): Array<string | number> {
// 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 —
// 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28).
const extras: Array<string | number> = [];
const fordPave = section.ford_pavement;
if (fordPave) {
extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? "");
extras.push(...chuteParts(fordPave.chute));
}
const ownRevet = section.revetment;
if (ownRevet) {
extras.push(
"rv",
ownRevet.start_m,
ownRevet.end_m,
ownRevet.anchor_m,
ownRevet.height_m ?? "",
ownRevet.side ?? "",
ownRevet.form ?? "",
ownRevet.tiers ?? "",
ownRevet.lift_m ?? "",
ownRevet.shift_m ?? "",
// 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다.
JSON.stringify(section.design?.revet_adjust?.own ?? ""),
);
}
const box = section.box;
// 세월교 · 박스에 흙막이(`culvert`)가 함께 서면 그 값도 이어 붙임(73-4) — 일찍 돌려주지 않음.
if (box) {
extras.push(
"bx",
box.inner_width_m,
box.inner_height_m,
box.span_m,
box.cover_m,
box.wing_in.slab_extend_m,
box.wing_in.height_m ?? "",
box.wing_out.slab_extend_m,
box.wing_out.height_m ?? "",
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
...(["left", "right"] as const).map((side) => {
const value = section.design?.box_adjust?.[side];
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
}),
...chuteParts(box.chute),
);
}
const ford = section.ford;
if (ford) {
const adjust = section.design?.ford_adjust;
const wall = (role: "inlet" | "outlet"): string => {
const value = adjust?.[role];
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
};
extras.push(`dr:${adjust?.dropM ?? ""}`);
extras.push(
"fd",
ford.diameter_m,
ford.pipe_count,
ford.span_m,
ford.wing_in.slab_extend_m,
ford.wing_out.slab_extend_m,
wall("inlet"),
wall("outlet"),
...chuteParts(ford.chute),
);
}
const culvert = section.culvert;
if (!culvert) return extras;
const side = (spec: CulvertSideSpec): string =>
[
spec.structure,
spec.revet_form ?? "",
spec.revet_height_m ?? "",
spec.revet_length_m ?? "",
spec.revet_before_m ?? "",
spec.revet_after_m ?? "",
spec.basin_length_m ?? "",
spec.basin_before_m ?? "",
spec.basin_after_m ?? "",
spec.basin_height_m ?? "",
].join(",");
const basin = section.design?.basin_adjust;
const revet = section.design?.revet_adjust;
const counts = section.design?.extra_wall_counts;
/** 키 차례를 고정한 JSON — 같은 값이면 같은 글. */
const stable = (value: unknown): string =>
JSON.stringify(value, (_key, item: unknown) =>
item && typeof item === "object" && !Array.isArray(item)
? Object.fromEntries(
Object.keys(item)
.sort()
.map((key) => [key, (item as Record<string, unknown>)[key]]),
)
: item,
);
/** 해시 입력 — 구조물이 없는 측점은 빈 목록(저장본 지문 그대로). */
export function structureHashParts(section: CrossSection): string[] {
const record = section as unknown as Record<string, unknown>;
const shapes = SHAPE_KEYS.filter((key) => record[key] != null);
if (!shapes.length) return [];
const design = (section.design ?? {}) as unknown as Record<string, unknown>;
return [
...extras,
"cv",
culvert.diameter_m,
culvert.pipe_kind ?? "",
side(culvert.inlet),
side(culvert.outlet),
section.design?.inlet_structure ?? "",
basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "",
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
revet
? Object.keys(revet)
.sort()
.map((role) => {
const value = revet[role];
return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`;
})
.join("|")
: "",
counts ? `${counts.outlet},${counts.basin}` : "",
section.design?.revet_follow_grade === false ? "flatZ" : "",
// 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼
// 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30).
Object.entries(section.design?.extra_spans ?? {})
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
.join("|"),
section.design?.revet_link_detached === true ? "detached" : "",
...chuteParts(culvert.chute),
stable({
...Object.fromEntries(shapes.map((key) => [key, record[key]])),
adjust: Object.fromEntries(ADJUST_KEYS.map((key) => [key, design[key] ?? null])),
}),
];
}
/**
* C군 벽 3D 한 번 — 그 벽을 얹은 측점 중 기준 측점과 가장 가까운 것(목록 앞쪽 먼저)만 스윕 ·
* 스윕 = 시작 ~ 끝(그 측점 기준 전 · 후). 다른 측점은 null(덮는 측점마다 통째로 겹치던 것).
*/
export function wallSweepAt(
section: CrossSection,
sections: readonly CrossSection[],
): { beforeM: number; afterM: number } | null {
const wall = section.revetment;
if (!wall) return null;
const same = (other: CrossSection): boolean =>
!!other.revetment &&
(wall.structure_id
? other.revetment.structure_id === wall.structure_id
: other.revetment.start_m === wall.start_m && other.revetment.end_m === wall.end_m);
const gap = (other: CrossSection): number => Math.abs(other.chainage_m - wall.anchor_m);
const owner = sections
.filter(same)
.reduce<CrossSection | null>(
(best, other) => (!best || gap(other) < gap(best) ? other : best),
null,
);
if (owner !== section) return null;
return {
beforeM: Math.max(section.chainage_m - wall.start_m, 0),
afterM: Math.max(wall.end_m - section.chainage_m, 0),
};
}
@@ -0,0 +1,38 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Structures_Pick.ts
* 3D 몸체 고르기(322-1) — 그림 자료 열쇠(`culvert` · `box` · `ford` · `ford_pavement` · `revetment`)의 주인
* 종류가 구조물 표 3D 부품으로 그 몸체를 세우는가. 지금 처음값 = 지금 결과 그대로(표가 끈 것만 빠짐).
* 쓰는 곳 = `B05_Profile_UI_Corridor_Structures.buildCorridorStructures`.
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { catalogRow } from "../common_util/common_util_structure_catalog";
/** 그림 자료의 주인 종류가 이 3D 몸체를 세우는가 — 표에 없는 종류면 세움. */
export const builds = (typeId: string | null | undefined, body: string): boolean =>
(catalogRow(typeId ?? "")?.model3d ?? body) === body;
/**
* 이 카드가 시설(관 · 세월교 · BOX) 몸체를 세우는가 — 같은 시설(얹을 때 적은 시설 누가거리)을 그리는 카드 중
* 시설 자리에 가장 가까운 하나만(노선 끝 EP 카드 0.01 m 차 · 323-9 · `fordPavementChute` 와 같은 규칙).
*/
export function ownsBody(
section: CrossSection,
sections: readonly CrossSection[],
key: "culvert" | "ford" | "box",
): boolean {
const at = section[key]?.chainage_m;
if (typeof at !== "number") return true; // 시설 자리 없음 = 옛 자료 그대로
const gap = (item: CrossSection): number => Math.abs(item.chainage_m - at);
const same = sections.filter((item) => item[key]?.chainage_m === at);
const owner = same.reduce((best, item) => (gap(item) < gap(best) ? item : best), same[0]);
return owner === section;
}
/** `culvert` 자리 주인 = 박스 · 세월교 흙막이(body) · 독립 기슭막이(관 숨김) · 배관. */
export function culvertBody(section: CrossSection): boolean {
const spec = section.culvert;
if (!spec) return false;
if (spec.body) return builds(spec.body === "box" ? "box_culvert" : "ford_bridge", spec.body);
return spec.hidden_pipe ? builds("revetment", "wall") : builds("pipe", "pipe");
}
+57 -53
View File
@@ -50,14 +50,14 @@ import {
outletTiersRow,
revetChoiceRow,
} from "./B05_Profile_UI_Drainage_Revet_Choice";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import {
createSpecForm,
type SpecForm,
type SpecLinks,
} from "../A00_Common/design/design_structure_form";
import type { SpecContainer, SpecItem } from "../A00_Common/design/design_structure_spec";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import { catalogRow } from "../common_util/common_util_structure_catalog";
import {
BOX_SPEC,
bodyOf,
@@ -65,11 +65,12 @@ import {
boxOptionsIn,
} from "../A00_Common/design/design_structure_box";
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import { overflowSync } from "../A00_Common/design/design_structure_ford_calc";
import {
pipeDrawn,
pipeExtraCount,
pipeLengthM,
pipeLengthText,
} from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
@@ -339,7 +340,7 @@ export function createFacilityOptionsForm(
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length));
return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length);
},
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
@@ -371,38 +372,21 @@ export function createFacilityOptionsForm(
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) —
// 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4)
let fordFlow: number | null = null;
let heightPending = false;
const overflow = (form: () => SpecForm, box: string) => {
const values = () => form().state()[box]?.[0] ?? {};
const need = (): number | null => fordHeightM(fordFlow, Number(values().width ?? ""));
return {
onChange: (c: SpecContainer, item: SpecItem) => {
if (c.id === box && item.id === "height") heightPending = 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: () => {
const want = heightPending && !values().height ? need() : null;
if (want === null) return;
form().setValue(box, "height", want.toFixed(2));
heightPending = false;
},
};
};
const fordFlowBox = overflow(() => fordForm, "ford");
const fordFlowBox = overflowSync(
() => fordForm,
"ford",
() => fordFlow,
emit,
);
const guardFields = guardChoiceFields(
() => chainage,
() => fordForm.refresh(),
() => fordForm.recount(),
);
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
const fordForm: SpecForm = createSpecForm(specLinks, {
onChange: fordFlowBox.onChange,
fields: guardChoiceFields(
() => chainage,
() => fordForm.refresh(),
() => fordForm.recount(),
),
fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)],
count: guardSoilCount(
() => chainage,
() => fordForm.state().outlet?.[0]?.structure,
@@ -484,8 +468,16 @@ export function createFacilityOptionsForm(
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 ·
// 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
const paveFlowBox = overflow(() => paveForm, "slope");
const paveForm: SpecForm = createSpecForm(specLinks, { onChange: paveFlowBox.onChange });
const paveFlowBox = overflowSync(
() => paveForm,
"slope",
() => fordFlow,
emit,
);
const paveForm: SpecForm = createSpecForm(specLinks, {
onChange: paveFlowBox.onChange,
fields: paveFlowBox.fields,
});
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
@@ -505,13 +497,16 @@ export function createFacilityOptionsForm(
);
let current: PipeFacility | null = null;
/** 서브폼 = 구조물 표 형상(323-7 · 종류 키 대신 · 관 지점 시설은 형상마다 손코딩 폼 하나). */
const shapeOf = (facility: PipeFacility | null) =>
(facility && catalogRow(facility)?.shape) || null;
function syncVisibility(): void {
root.hidden = current === null;
if (current === null) return;
const isPipe = current === "pipe";
const isBox = current === "box_culvert";
const isBridge = current === "ford_bridge";
const isPipe = shapeOf(current) === "pipe_set";
const isBox = shapeOf(current) === "box";
const isBridge = shapeOf(current) === "ford";
pipeForm.root.hidden = !isPipe;
boxForm.root.hidden = !isBox;
fordForm.root.hidden = !isBridge;
@@ -521,8 +516,8 @@ export function createFacilityOptionsForm(
wingOutFields.root.hidden = true;
(spec ? spec.slot("inlet") : wingInHome).append(wingInFields.slot);
(spec ? spec.slot("outlet") : wingOutHome).append(wingOutFields.slot);
paveForm.root.hidden = current !== "ford_pavement";
const isRevet = current === "revetment";
paveForm.root.hidden = shapeOf(current) !== "ford_pavement";
const isRevet = shapeOf(current) === "hidden_revet";
revetTopRow.hidden = !isRevet;
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
const side = revetSide.value || "양쪽";
@@ -589,10 +584,10 @@ export function createFacilityOptionsForm(
},
setChainage(chainageM) {
chainage = chainageM;
if (current === "pipe") pipeForm.refresh();
if (shapeOf(current) === "pipe_set") pipeForm.refresh();
},
recount() {
if (current === "pipe") pipeForm.recount();
if (shapeOf(current) === "pipe_set") pipeForm.recount();
},
onInletStructureChange(handler) {
inletStructureHandler = handler;
@@ -602,7 +597,7 @@ export function createFacilityOptionsForm(
},
setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) {
current = facility;
heightPending = fresh;
[fordFlowBox, paveFlowBox].forEach((box) => box.arm(fresh));
hiddenOptions.remember(options);
fordFlow = designFlow ?? null;
if (facility === null) {
@@ -616,6 +611,7 @@ export function createFacilityOptionsForm(
const names = definition?.spec_names ?? {};
// 필수 상자 빈 고르기 칸이 받는 정의 기본값(54-43) — 옛 키는 열 때와 같은 길로 바꿈
const defaults = optionDefaults(definition?.options ?? []);
const hidden = hiddenOptionKeys(definition?.options ?? []); // 정의 「표시」 숨김(322-2)
if (facility === "pipe") {
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
const { soil_add: _old, ...pipeNames } = names;
@@ -625,11 +621,18 @@ export function createFacilityOptionsForm(
!["revet", "I", "L", "U"].includes(String(kind ?? ""))
? { structure: kind }
: null;
pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults);
pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults, hidden);
}
if (facility === "box_culvert") {
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups, boxOptionsIn(defaults));
boxForm.write(
BOX_SPEC,
boxOptionsIn(options),
names,
groups,
boxOptionsIn(defaults),
hidden,
);
for (const [id, value] of Object.entries(bodyOf(options)))
boxForm.setValue("body", id, value);
}
@@ -640,9 +643,10 @@ export function createFacilityOptionsForm(
names,
groups,
withLegacyGuards(defaults),
hidden,
);
if (facility === "ford_pavement")
paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults);
paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults, hidden);
if (facility === "ford_bridge") fordFlowBox.fill();
if (facility === "ford_pavement") paveFlowBox.fill();
wingInFields.write(options);
@@ -658,28 +662,28 @@ export function createFacilityOptionsForm(
},
setDesignFlow(designFlow) {
fordFlow = designFlow ?? null;
if (current === "ford_bridge") fordFlowBox.fill();
if (current === "ford_pavement") paveFlowBox.fill();
if (shapeOf(current) === "ford") fordFlowBox.fill();
if (shapeOf(current) === "ford_pavement") paveFlowBox.fill();
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
if (current === "pipe") {
if (shapeOf(current) === "pipe_set") {
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
// 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13)
if (inletIsAuto()) {
if (inletAuto!.structure === undefined) delete options.inlet_structure;
else options.inlet_structure = inletAuto!.structure;
}
} else if (current === "box_culvert") {
} else if (shapeOf(current) === "box") {
// 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2)
Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0]));
} else if (current === "ford_pavement") {
} else if (shapeOf(current) === "ford_pavement") {
Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3)
} else if (current === "ford_bridge") {
} else if (shapeOf(current) === "ford") {
Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4)
} else if (current === "revetment") {
} else if (shapeOf(current) === "hidden_revet") {
if (revetSide.value) options.side = revetSide.value;
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
@@ -12,18 +12,29 @@ import {
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
const FORD_KINDS: Record<string, number> = {
ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M,
ford_pavement: FORD_PAVEMENT_DEFAULT_WIDTH_M,
};
/** 프로젝트마다 높이를 기다리는 측점 — 배수유역도 판 · B06 [추가] 가 같은 표를 씀(298-1). */
const waits = new Map<string, Set<number>>();
export function fordWaitsOf(projectId: string | null): Set<number> {
const key = projectId ?? "";
if (!waits.has(key)) waits.set(key, new Set());
return waits.get(key) as Set<number>;
}
/** 새로 놓으면 높이를 기다리는 종류 — 높이 칸이 빈 세월교 · 물넘이포장. */
export function waitsFordHeight(
facility: string | undefined,
options?: Record<string, string | number>,
): boolean {
return !!facility && facility in FORD_KINDS && options?.ford_height_m === undefined;
// 빈 글자도 빈칸 — 채우는 쪽(`fillFordHeights`)과 같은 규칙(314)
const height = options?.ford_height_m;
return !!facility && facility in FORD_KINDS && (height === undefined || height === "");
}
/** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌.
@@ -39,7 +50,7 @@ export function fillFordHeights(
): boolean {
let filled = false;
for (const wait of [...pending]) {
const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < 0.51);
const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < PIPE_MATCH_M);
if (!point?.facility || !(point.facility in FORD_KINDS)) {
pending.delete(wait); // 관이 사라졌거나 종류가 바뀜
continue;
@@ -48,7 +59,9 @@ export function fillFordHeights(
pending.delete(wait); // 사용자가 넣은 값 — 안 덮음
continue;
}
const flow = basins.find((entry) => Math.abs(entry.chainage_m - point.chainage_m) < 0.51);
const flow = basins.find(
(entry) => Math.abs(entry.chainage_m - point.chainage_m) < PIPE_MATCH_M,
);
const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility];
const need = fordHeightM(flow?.design_flow_m3s ?? null, width);
if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸
@@ -8,6 +8,7 @@
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
* ========================================================================== */
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import {
computeMaxScale,
lonLatToScreen,
@@ -17,6 +18,7 @@ import {
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import { clampWheelScale } from "./B05_Profile_UI_Drainage_ZoomRange";
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import type { createMapContextMenu } from "@ui/ui_template_context_menu";
@@ -33,6 +35,8 @@ export interface DrainageInteractParams {
setScale: (value: number) => void;
/** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */
getMeta: () => VWorldMeta | null;
/** 줌아웃 한계 배율 — 노선 기준(계획서 281). */
getMinScale: () => number;
getOffset: () => { x: number; y: number };
setOffset: (x: number, y: number) => void;
scheduleDraw: () => void;
@@ -62,7 +66,13 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
// 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시).
const view = currentView();
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 하한은 노선 기준(계획서 281) — 도엽 전체까지 빠지지 않는다.
const scale = clampWheelScale(
prevScale * (wheelZoomsIn(event) ? 1.15 : 0.87),
prevScale,
params.getMinScale(),
maxScale,
);
params.setScale(scale);
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale;
+47 -24
View File
@@ -1,5 +1,6 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { viewOf } from "../common_util/common_util_view_window";
import {
computeDetailBasins,
fetchDetailPipePoints,
@@ -29,20 +30,24 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_pipe_ids";
import { snapStructureStation } from "../common_util/common_util_structure_station";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
import { fillFordHeights, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange";
import {
mountDrainageToggles,
DRAINAGE_LAYERS,
fetchDrainageLayers,
createProgressReporter,
analyzeFailed,
fitCanvasToViewport,
fitViewToRoute,
observeViewportSize,
@@ -172,10 +177,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
let offsetX = 0;
let offsetY = 0;
let frameHandle = 0;
/** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */
let frameView: Window = window;
let loadSequence = 0;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
/** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */
const fordWaits = new Set<number>();
/** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */
const fordWaits = () => fordWaitsOf(projectId);
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
@@ -284,24 +291,25 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
function syncPipeSelection(): void {
function syncPipeSelection(notify = true): void {
const index = pipeEditor.selected();
deleteButton.disabled = index === null;
const picked = index === null ? null : pipeEditor.pipes()[index];
selectBasin(basinIndexOfPipe(basins, picked ?? null));
selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
// Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply)
// 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게.
const pickedChainage = picked ? picked.chainage_m : null;
if (pickedChainage !== lastNotifiedPipeChainage) {
lastNotifiedPipeChainage = pickedChainage;
callbacks.onPipeSelected?.(pickedChainage);
if (notify) callbacks.onPipeSelected?.(pickedChainage);
}
}
function scheduleDraw(): void {
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => {
frameView = viewOf(root);
frameHandle = frameView.requestAnimationFrame(() => {
frameHandle = 0;
draw();
});
@@ -351,7 +359,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
})),
);
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음).
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits);
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits());
facilityStore.replaceFromResponse(response.pipe_points);
if (fordFilled) rememberPipes();
// 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직
@@ -384,8 +392,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
} catch (error) {
if (seq !== requestSeq) return;
status.hidden = false;
status.textContent =
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));
} finally {
if (seq === requestSeq) {
analyzeButton.disabled = false;
@@ -414,7 +421,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const loadSaved = (): Promise<void> =>
run(() => {
const pending = readPendingPipes(projectId as string);
return pending?.length
return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
? computeDetailBasins(projectId as string, pending)
: fetchDetailPipePoints(projectId as string);
});
@@ -455,6 +462,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
offsetY = fitted.offsetY;
}
function zoomOutLimit(): number {
const view = currentView();
return computeZoomOutLimit(meta, routePoints, basins, view.width, view.height);
}
async function loadLayers(): Promise<void> {
if (!projectId) return;
const activeProjectId = projectId;
@@ -511,6 +523,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
scale = value;
},
getMeta: () => meta,
getMinScale: zoomOutLimit,
getOffset: () => ({ x: offsetX, y: offsetY }),
setOffset: (x, y) => {
offsetX = x;
@@ -534,8 +547,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
offsetY = next.offsetY;
},
redraw: () => {
// 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다.
if (fitPending) fitToRoute();
// 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. 늘려서 줌아웃 한계 밖이면 노선 맞춤(281-2).
if (fitPending || scale < zoomOutLimit() * 0.999) fitToRoute();
scheduleDraw();
},
});
@@ -567,15 +580,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
movePipe(fromChainage, toChainage) {
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51);
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < PIPE_MATCH_M);
if (index < 0) return;
pipeEditor.moveTo(index, toChainage);
const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게)
facilityStore.move(pipeEditor.pipes()[index].chainage_m, to);
followMovedPipe(projectId, pipeEditor.pipes()[index].chainage_m, to); // 고름 유지(322-3)
pipeEditor.moveTo(index, to);
},
selectBasinByChainage(chainageM) {
const picked =
chainageM === null
? null
: (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < 0.51) ?? null);
: (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < PIPE_MATCH_M) ?? null);
// 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단).
selectBasin(picked ? picked.index : null, false);
},
@@ -601,23 +617,28 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
scheduleDraw();
},
addPipe(chainageM, attributes) {
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
facilityStore.set(chainageM, attributes ?? null);
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM);
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM);
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
// 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리
if (Math.abs(toChainageM - fromChainageM) > 0.005)
toChainageM = snapStructureStation(toChainageM);
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM);
if (fordWaits().delete(fromChainageM)) fordWaits().add(toChainageM);
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51);
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M);
followMovedPipe(projectId, fromChainageM, toChainageM); // 고름 유지(322-3)
if (index >= 0) pipeEditor.moveTo(index, toChainageM);
else void analyze();
} else {
@@ -630,21 +651,23 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
removePipe(chainageM) {
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51);
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M);
if (index < 0) return;
pipeEditor.select(index);
pipeEditor.deleteSelected();
},
selectPipeAtChainage(chainageM) {
selectPipeAtChainage(chainageM, notify = true) {
const index =
chainageM === null
? null
: pipeEditor.pipes().findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51);
: pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M);
const next = index === -1 ? null : index;
if (pipeEditor.selected() === next) return;
// select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다.
pipeEditor.select(next);
syncPipeSelection();
syncPipeSelection(notify);
scheduleDraw();
},
async savePipes() {
@@ -667,7 +690,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
dispose() {
loadSequence += 1;
if (frameHandle) {
window.cancelAnimationFrame(frameHandle);
frameView.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver.disconnect();
@@ -45,7 +45,8 @@ export interface DrainagePanel {
selectBasinByChainage: (chainageM: number | null) => void;
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
selectPipeAtChainage: (chainageM: number | null) => void;
/** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(밖으로 안 알림 · 재계산 뒤 다시 얹음 · 233-3). */
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
markStation: (chainageM: number | null) => void;
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
+13 -4
View File
@@ -9,6 +9,7 @@
import { stateKey } from "../A00_Common/b_page_state";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import { showToast } from "@ui/ui_template_elements";
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
@@ -21,6 +22,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U
import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -153,7 +155,7 @@ export function renderBasinRows(
.replace("{relief}", basin.relief_m.toFixed(1))
.replace("{flow}", String(Math.round(basin.flow_length_m)))
.replace("{pipe}", pipe);
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1));
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(0)); // 정수 m(167)
// 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게.
if (basin.tc_minutes != null && basin.design_flow_m3s != null) {
row.title +=
@@ -352,7 +354,7 @@ export function reconcilePipes(
return null;
}
return next.map((chainage) => {
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51);
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < PIPE_MATCH_M);
return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" };
});
}
@@ -514,9 +516,16 @@ export function createProgressReporter(progress: {
};
}
/** 세부유역 요청 실패 — 지도 칸 문구를 돌려주고 토스트 한 줄(뒤에서 다시 나눔이 조용히 안 끝나게 · 298-1). */
export function analyzeFailed(error: unknown, fallback: string): string {
const text = error instanceof Error ? error.message : fallback;
showToast(text === fallback ? text : `${fallback} ${text}`, "error");
return text;
}
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
export function pipeMarkerColor(basins: ReadonlyArray<DetailBasin>, chainageM: number): string {
const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51);
const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < PIPE_MATCH_M);
if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb");
return basinColor(basin.index).replace(/0\.45\)$/, "1)");
}
@@ -527,7 +536,7 @@ export function basinIndexOfPipe(
pipe: { chainage_m: number } | null,
): number | null {
if (!pipe) return null;
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51);
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < PIPE_MATCH_M);
return basin ? basin.index : null;
}

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