529 Commits
Author SHA1 Message Date
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 8212da9c19 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 15:38:11 +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
707 changed files with 59347 additions and 4003 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000
# Public URL for email links (프론트엔드 주소) # Public URL for email links (프론트엔드 주소)
APP_PUBLIC_BASE_URL=http://localhost:5173 APP_PUBLIC_BASE_URL=http://localhost:5173
KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08
KAKAO_JS_KEY=98ac6edef14ffbca300d90e540644b68
# kakao app ID 1603330
# Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f # Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA # 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B # 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B
+2
View File
@@ -71,6 +71,8 @@ config/formula_node/
config/spreadsheet_node/ config/spreadsheet_node/
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물. # 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
config/b07_extent_node/ config/b07_extent_node/
# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1).
config/b07_note_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
+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 { createButton } from "@ui/ui_template_elements";
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend"; import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
import { navigateTo, isAuthenticated } from "./router"; import { navigateTo, isAuthenticated } from "./router";
import { readTouchMode, setTouchMode, type TouchMode } from "./device_context";
const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href; const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href;
@@ -65,38 +66,12 @@ function buildHeader(): HTMLElement {
const header = document.createElement("header"); const header = document.createElement("header");
header.className = "app-header"; header.className = "app-header";
// 로고 (클릭 시 홈) const brand = buildBrand();
const brand = document.createElement("button");
brand.className = "app-brand";
brand.type = "button";
const brandIcon = document.createElement("img");
brandIcon.className = "app-brand__icon";
brandIcon.src = BRAND_ICON_URL;
brandIcon.alt = "";
const brandName = document.createElement("span");
brandName.className = "app-brand__name";
brandName.textContent = L("App_BrandName");
brand.append(brandIcon, brandName);
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
// 네비게이션 링크 // 네비게이션 링크
const nav = document.createElement("nav"); const nav = document.createElement("nav");
nav.className = "app-nav"; nav.className = "app-nav";
const navItems: [keyof typeof ui_locales, string][] = [ nav.append(...buildNavButtons());
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
["App_Nav_Support", ROUTES.A08_SUPPORT],
];
for (const [labelKey, route] of navItems) {
const link = createButton({
label: L(labelKey),
variant: "pill",
onClick: () => navigateTo(route as never),
});
nav.append(link);
}
// 우측 액션 (언어/테마/로그인) // 우측 액션 (언어/테마/로그인)
const actions = document.createElement("div"); const actions = document.createElement("div");
@@ -120,10 +95,157 @@ function buildHeader(): HTMLElement {
actions.append(authSlot); actions.append(authSlot);
void fillAuthActions(authSlot); void fillAuthActions(authSlot);
header.append(brand, nav, actions); header.append(brand, nav, actions, ...buildDrawer(header));
return header; return header;
} }
/** 로고 (클릭 시 홈) — 위 줄 · ☰ 서랍 첫 묶음이 같이 씀 */
function buildBrand(): HTMLButtonElement {
const brand = document.createElement("button");
brand.className = "app-brand";
brand.type = "button";
const brandIcon = document.createElement("img");
brandIcon.className = "app-brand__icon";
brandIcon.src = BRAND_ICON_URL;
brandIcon.alt = "";
const brandName = document.createElement("span");
brandName.className = "app-brand__name";
brandName.textContent = L("App_BrandName");
brand.append(brandIcon, brandName);
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
return brand;
}
/* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠.
* 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */
let drawerSteps: { node: HTMLElement; home: HTMLElement } | null = null;
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> { async function fillAuthActions(slot: HTMLElement): Promise<void> {
try { try {
@@ -188,6 +310,7 @@ function buildFooter(): HTMLElement {
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */
export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement { export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement {
appRoot.innerHTML = ""; appRoot.innerHTML = "";
drawerSteps = null; // 새 쪽이 진행 판을 세우면 다시 맡김
const header = buildHeader(); const header = buildHeader();
const outlet = document.createElement("main"); const outlet = document.createElement("main");
outlet.className = "app-outlet"; outlet.className = "app-outlet";
@@ -259,10 +382,10 @@ const SHELL_CSS = `
gap: var(--spacing-8); gap: var(--spacing-8);
} }
.app-outlet { .app-outlet {
min-height: calc(100vh - 64px - 56px); min-height: calc(100dvh - var(--app-header-h) - 56px);
} }
.app-outlet--without-footer { .app-outlet--without-footer {
min-height: calc(100vh - 64px); min-height: calc(100dvh - var(--app-header-h));
} }
.app-footer { .app-footer {
height: 56px; height: 56px;
@@ -285,7 +408,120 @@ const SHELL_CSS = `
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.app-nav { display: none; } .app-nav { display: none; }
.app-actions { margin-left: auto; }
} }
/* ☰ 서랍 — 공개 쪽은 폭 900 이하 · 터치 모드에서만 · 로그인 뒤 쪽(B · M)은 늘 */
.app-burger,
.app-drawer,
.app-drawer__scrim {
display: none;
}
.app-burger {
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
font-size: 20px;
cursor: pointer;
}
html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; }
.app-header.is-drawer-open { z-index: var(--z-overlay); }
.app-header.is-drawer-open .app-drawer__scrim {
display: block;
position: fixed;
inset: var(--app-header-h) 0 0 0;
background: rgba(38, 17, 74, 0.35);
}
.app-header.is-drawer-open .app-drawer {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
position: fixed;
top: var(--app-header-h);
right: 0;
bottom: 0;
width: min(320px, 100vw);
padding: var(--spacing-16);
overflow-y: auto;
background: var(--color-surface-raised);
border-left: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
}
.app-drawer__nav {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; }
.app-drawer__row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.app-drawer__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.app-drawer__group + .app-drawer__group {
padding-top: var(--spacing-16);
border-top: 1px solid var(--color-border);
}
.app-drawer__steps:empty { display: none; }
.app-drawer__touch {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.app-drawer__touch [aria-checked="true"] {
background: var(--color-mist-violet);
color: var(--color-accent);
}
/* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */
html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); }
html[data-size="compact"] .app-actions { display: none; }
html[data-size="compact"] .app-burger { margin-left: auto; }
/* 로그인 뒤 쪽(B · M) — 폭 · 입력과 상관없이 위 줄을 걷고 ☰ 만 화면 왼쪽 위 모서리에 띄움(7-1)
본문이 화면 높이를 다 씀 · 서랍은 왼쪽에서 열림 · 위 줄 항목은 모두 서랍에 있음 */
html.is-bm-route .app-header {
height: 0;
padding: 0;
border: 0;
z-index: calc(var(--z-dropdown) + 1);
}
html.is-bm-route .app-header > :is(.app-brand, .app-nav, .app-actions) {
display: none;
}
/* ☰ 폭 36(7-4) · 높이는 손가락 44 · 마우스 36 */
html.is-bm-route .app-burger {
min-width: var(--app-burger-w);
min-height: var(--app-burger-w);
position: fixed;
top: var(--spacing-8);
left: var(--spacing-8);
z-index: 1;
margin: 0;
padding: 0;
box-shadow: var(--shadow-lg);
}
html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; }
html.is-bm-route .app-header.is-drawer-open .app-drawer {
left: 0;
right: auto;
border-left: 0;
border-right: 1px solid var(--color-border);
}
html.is-bm-route .app-drawer__brand {
min-height: 44px;
padding-left: calc(var(--app-burger-inset) - var(--spacing-16));
}
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; }
`; `;
export function injectShellStyles(): void { export function injectShellStyles(): void {
+1 -1
View File
@@ -174,7 +174,7 @@ const SCAFFOLD_CSS = `
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.b-scaffold-wf { height: calc(100vh - 64px - 56px); } .b-scaffold-wf { height: calc(100dvh - var(--app-header-h) - 56px); }
.b-pending { .b-pending {
display: flex; display: flex;
+33 -16
View File
@@ -57,8 +57,6 @@ export const STATE_REGISTRY = {
legacy: () => "b05-route-drainage-collapsed", legacy: () => "b05-route-drainage-collapsed",
}, },
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
"drainage-visible": { bucket: "pref", scope: "global" },
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */ /** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"m02-combo-panel-width": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" },
@@ -66,9 +64,11 @@ export const STATE_REGISTRY = {
"m02-card-pills": { bucket: "pref", scope: "global" }, "m02-card-pills": { bucket: "pref", scope: "global" },
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
"m01-logic-panel": { bucket: "pref", scope: "global" }, "m01-logic-panel": { bucket: "pref", scope: "global" },
/** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */ /** B09 묶음 탭(171-3) 보기 `cards · table`(304-1 셋 탭 한 값) · 가로 열 수 `{탭: 1~4}` */
"b09-group-view": { bucket: "pref", scope: "global" }, "b09-group-view": { bucket: "pref", scope: "global" },
"b09-group-cols": { bucket: "pref", scope: "global" }, "b09-group-cols": { bucket: "pref", scope: "global" },
/** B09 행 누름(303-1) `popup · link` */
"b09-link-mode": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" }, "routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
@@ -101,6 +101,10 @@ export const STATE_REGISTRY = {
}, },
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */ /** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
"cross-columns": { bucket: "pref", scope: "global" }, "cross-columns": { bucket: "pref", scope: "global" },
/** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */
"mass-haul-badge-pos": { bucket: "pref", scope: "global" },
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
"b05-3d-projection": { bucket: "pref", scope: "global" },
"section-panel-height": { "section-panel-height": {
bucket: "pref", bucket: "pref",
scope: "global", scope: "global",
@@ -110,6 +114,8 @@ export const STATE_REGISTRY = {
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
"structure-notes": { bucket: "draft", scope: "route" }, "structure-notes": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */
"structure-note-cads": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
"detail-table": { bucket: "draft", scope: "route" }, "detail-table": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
@@ -131,6 +137,8 @@ export const STATE_REGISTRY = {
selection: { bucket: "draft", scope: "project" }, selection: { bucket: "draft", scope: "project" },
/** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */ /** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */
"structure-type-cache": { bucket: "draft", scope: "project" }, "structure-type-cache": { bucket: "draft", scope: "project" },
/** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */
kindreset: { bucket: "draft", scope: "project" },
/** 배수관·암거 조정창에서 예약한 옵션 값. */ /** 배수관·암거 조정창에서 예약한 옵션 값. */
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
/** 배수관 이동(측점 옮김) 예약. */ /** 배수관 이동(측점 옮김) 예약. */
@@ -185,6 +193,8 @@ export const STATE_REGISTRY = {
"b08-hydro": { bucket: "draft", scope: "project", version: 3 }, "b08-hydro": { bucket: "draft", scope: "project", version: 3 },
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
"b08-options": { bucket: "draft", scope: "project" }, "b08-options": { bucket: "draft", scope: "project" },
/** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */
"b08-milestone": { bucket: "draft", scope: "project" },
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
"b09-estimation": { bucket: "draft", scope: "project" }, "b09-estimation": { bucket: "draft", scope: "project" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
@@ -477,29 +487,36 @@ export function clearState(
routeId?: number | string | null, routeId?: number | string | null,
): void { ): void {
const entry = entryOf(name); const entry = entryOf(name);
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다. // 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다(`routeStateKeys`).
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`), // 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169 // 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 — // 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다. // 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) { if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; routeStateKeys(name, projectId).forEach((key) => writeRaw(key, null));
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
try {
const doomed: string[] = [];
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) doomed.push(key);
}
doomed.forEach((key) => writeRaw(key, null));
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
}
return; return;
} }
writeStateRaw(name, null, projectId, routeId); writeStateRaw(name, null, projectId, routeId);
} }
/** 노선 범위 이름의 그 프로젝트 키 전부(모든 노선 · 세션에 있는 것만). */
export function routeStateKeys(name: StateName, projectId: string): string[] {
const entry = entryOf(name);
if (entry?.scope !== "route") return [];
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
const keys: string[] = [];
try {
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
}
return keys;
}
/** 등록표에서 한 통에 속한 이름만 고른다. */ /** 등록표에서 한 통에 속한 이름만 고른다. */
export function namesInBucket(bucket: StateBucket): StateName[] { export function namesInBucket(bucket: StateBucket): StateName[] {
return (Object.keys(STATE_REGISTRY) as StateName[]).filter( return (Object.keys(STATE_REGISTRY) as StateName[]).filter(
+122
View File
@@ -0,0 +1,122 @@
/* =============================================================================
* A00_Common/b_project_switch.ts
* 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1).
*
* 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과
* 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage).
* 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다.
* 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식).
* ========================================================================== */
import { showConfirmDialog } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "./b_asset_cache";
import { STATE_REGISTRY, type StateEntry } from "./b_page_state";
/** 이 탭이 마지막으로 들어온 프로젝트. */
const ENTERED_KEY = "aislo:entered-project";
const MARK_P = "\u0001";
const MARK_R = "\u0002";
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
const out: Array<{ prefix: string; draft: boolean }> = [];
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
if (entry.bucket === "pref" || entry.scope === "global") return;
const draft = entry.bucket === "draft";
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
const legacy = entry.legacy?.(MARK_P, MARK_R);
const at = legacy?.indexOf(MARK_P) ?? -1;
if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft });
});
return out;
}
const filled = (value: string | null): boolean =>
value !== null && value !== "" && value !== "{}" && value !== "[]";
export interface ProjectPurgePlan {
/** 저장 안 한 고침이 남은 다른 프로젝트. */
draftProjects: string[];
/** 지울 키 — `keep` 프로젝트의 초안은 뺀다. */
doomed: (keep: ReadonlySet<string>) => string[];
}
/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */
export function planProjectPurge(
session: Record<string, string>,
projectId: string,
): ProjectPurgePlan {
const prefixes = projectPrefixes();
const owners = new Map<string, { project: string; draft: boolean }>();
Object.keys(session).forEach((key) => {
const hit = prefixes.find((item) => key.startsWith(item.prefix));
if (!hit) return;
const project = key.slice(hit.prefix.length).split(":")[0];
if (project) owners.set(key, { project, draft: hit.draft });
});
const others = new Set(
[...owners.values()].map((owner) => owner.project).filter((id) => id !== projectId),
);
const draftProjects = [
...new Set(
[...owners.entries()]
.filter(([key, owner]) => owner.draft && others.has(owner.project) && filled(session[key]))
.map(([, owner]) => owner.project),
),
];
return {
draftProjects,
// 등록표 밖 칸도 키 마디에 다른 프로젝트 번호가 있으면 그 프로젝트 몫으로 본다.
doomed: (keep) =>
Object.keys(session).filter((key) => {
const owner = owners.get(key);
if (owner) return others.has(owner.project) && !(owner.draft && keep.has(owner.project));
return key.split(":").some((part) => others.has(part) && !keep.has(part));
}),
};
}
function sessionEntries(): Record<string, string> {
const out: Record<string, string> = {};
try {
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
const value = key === null ? null : window.sessionStorage.getItem(key);
if (key !== null && value !== null) out[key] = value;
}
} catch {
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
}
return out;
}
/**
* 프로젝트에 들어감 — 들어온 표식이 다르면 그 밖 프로젝트 캐시를 지운다. 실패해도 이동은 막지 않는다.
* `nameOf` 는 확인창에 적을 프로젝트 이름.
*/
export async function enterProject(
projectId: string,
nameOf: (projectId: string) => Promise<string>,
): Promise<void> {
try {
if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return;
const plan = planProjectPurge(sessionEntries(), projectId);
const keep = new Set<string>();
for (const other of plan.draftProjects) {
const name = await nameOf(other).catch(() => other);
const drop = await showConfirmDialog(
`「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`,
"지우고 열기",
"남기기",
);
if (!drop) keep.add(other);
}
plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key));
window.sessionStorage.setItem(ENTERED_KEY, projectId);
await purgeOtherProjects(projectId);
} catch {
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
}
}
+56 -10
View File
@@ -61,12 +61,18 @@ export interface SvgZoomPanOptions {
maxScale?: number; maxScale?: number;
/** 이전 상태 — 다시 그려도 배율이 살아남게. */ /** 이전 상태 — 다시 그려도 배율이 살아남게. */
initial?: ZoomPanState; initial?: ZoomPanState;
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
homeScale?: number;
/** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */
home?: ZoomPanState;
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
onChange?: (state: ZoomPanState) => void; onChange?: (state: ZoomPanState) => void;
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds; content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */ /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean; wheelZoom?: boolean;
/** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */
panStart?: (event: PointerEvent) => boolean;
} }
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */ /** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
@@ -83,9 +89,17 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
height: Math.max(heightPx - pad.top - pad.bottom, 1), height: Math.max(heightPx - pad.top - pad.bottom, 1),
}; };
const maxScale = options.maxScale ?? 8; const maxScale = options.maxScale ?? 8;
let scale = initial?.scale ?? 1; const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
let tx = initial?.tx ?? 0; const home: ZoomPanState = options.home ?? {
let ty = initial?.ty ?? 0; scale: homeScale,
tx: (plot.x + plot.width / 2) * (1 - homeScale),
ty: (plot.y + plot.height / 2) * (1 - homeScale),
};
/** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */
const minScale = Math.min(1, home.scale);
let scale = initial?.scale ?? home.scale;
let tx = initial?.tx ?? home.tx;
let ty = initial?.ty ?? home.ty;
const applyTransform = (): void => { const applyTransform = (): void => {
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty }); onChange?.({ scale, tx, ty });
@@ -122,7 +136,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */ /** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
const zoomAt = (factor: number, cx: number, cy: number): void => { const zoomAt = (factor: number, cx: number, cy: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor)); const next = Math.min(maxScale, Math.max(minScale, scale * factor));
tx = cx - ((cx - tx) / scale) * next; tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next; ty = cy - ((cy - ty) / scale) * next;
scale = next; scale = next;
@@ -146,8 +160,10 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
if (event.button === 1) event.preventDefault(); if (event.button === 1) event.preventDefault();
}); });
svg.addEventListener("pointerdown", (event) => { svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다. // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1)
if (event.button !== 1) return; moved = false;
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
event.preventDefault(); event.preventDefault();
panning = true; panning = true;
moved = false; moved = false;
@@ -178,7 +194,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/* 이미 해제됨 */ /* 이미 해제됨 */
} }
}; };
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) { if (scale !== 1 || tx !== 0 || ty !== 0) {
clampPan(); clampPan();
applyTransform(); applyTransform();
} }
@@ -203,11 +219,41 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
{ passive: false }, { passive: false },
); );
} }
// 손가락 둘 = 벌려 확대 · 같이 끌어 이동(기기대응 3-2) — 한 손가락은 그 화면의 고르기 · 목록 밀기 몫.
// 휠 줌 화면은 그림 면이 손가락을 다 받음 · 아니면(B06 카드 목록) 한 손가락 밀기는 브라우저에 둠.
svg.setAttribute?.("data-touch-own", "pinch"); // ?. = 노드 시험의 흉내 SVG
svg.style?.setProperty("touch-action", options.wheelZoom ? "none" : "pan-x pan-y");
const touches = new Map<number, { x: number; y: number }>();
const touchPair = (): { x: number; y: number; dist: number } => {
const [a, b] = [...touches.values()];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) };
};
svg.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch")
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
});
svg.addEventListener("pointermove", (event) => {
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
const before = touches.size === 2 ? touchPair() : null;
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (!before) return;
const after = touchPair();
const rect = svg.getBoundingClientRect();
const w = Math.max(rect.width, 1);
const h = Math.max(rect.height, 1);
tx += ((after.x - before.x) / w) * widthPx;
ty += ((after.y - before.y) / h) * heightPx;
moved = true;
const cx = ((after.x - rect.left) / w) * widthPx;
const cy = ((after.y - rect.top) / h) * heightPx;
zoomAt(before.dist > 0 && after.dist > 0 ? after.dist / before.dist : 1, cx, cy);
});
const liftTouch = (event: PointerEvent): void => void touches.delete(event.pointerId);
svg.addEventListener("pointerup", liftTouch);
svg.addEventListener("pointercancel", liftTouch);
const currentScale = (): number => scale; const currentScale = (): number => scale;
const reset = (): void => { const reset = (): void => {
scale = 1; ({ scale, tx, ty } = home);
tx = 0;
ty = 0;
applyTransform(); applyTransform();
}; };
// 더블클릭 원복. // 더블클릭 원복.
+7
View File
@@ -10,6 +10,7 @@ import {
fetchSurfaceStatus, fetchSurfaceStatus,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { enterProject } from "./b_project_switch";
import { navigateTo } from "./router"; import { navigateTo } from "./router";
export interface WorkflowState { export interface WorkflowState {
@@ -93,6 +94,11 @@ export async function dropGoneProject(): Promise<boolean> {
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE]; const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
export function goToWorkflowStage(projectId: string, route: RoutePath): void { export function goToWorkflowStage(projectId: string, route: RoutePath): void {
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
void enterProject(
projectId,
async (id) => (await fetchWorkflowState(id)).project_name || id,
).then(() => {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache(); clearWorkflowStateCache();
@@ -101,6 +107,7 @@ export function goToWorkflowStage(projectId: string, route: RoutePath): void {
return; return;
} }
void routeAfterPreloadCheck(projectId, route); void routeAfterPreloadCheck(projectId, route);
});
} }
/** /**
+1 -1
View File
@@ -50,7 +50,7 @@
display: none; display: none;
} }
.b05-route-profile__trailing > .b05-route-profile__popout { .b05-route-profile__tools > .b05-route-profile__popout {
padding: var(--btn-bar-padding); padding: var(--btn-bar-padding);
font-size: var(--btn-bar-font-size); font-size: var(--btn-bar-font-size);
} }
+3 -2
View File
@@ -82,15 +82,16 @@ export function createSectionFrame(
heading.textContent = title; heading.textContent = title;
if (view === "profile") { if (view === "profile") {
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추) — 두 보기 모두.
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫. // 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
heading.className = "ui-collapsible__title"; heading.className = "ui-collapsible__title";
const body = document.createElement("div"); const body = document.createElement("div");
body.className = "b05-route__panel-body"; body.className = "b05-route__panel-body";
root.append(heading, body); root.append(heading, body);
return { root, body }; return { root, body };
} }
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
heading.className = "b06-profile__group-legend ui-collapsible__title"; heading.className = "b06-profile__group-legend ui-collapsible__title";
root.append(heading); root.append(heading);
return { root, body: root }; return { root, body: root };
+31
View File
@@ -0,0 +1,31 @@
/* =============================================================================
* A00_Common/design/design_pick_escape.ts
* 풀기 한 길 — Esc = 고름 한 칸 비움 (233-3 · 조사 AF (나)).
*
* 값을 비우면 구독한 화면이 모두 따라 풂 — 종단(측점선 · 알약) · 3D(띠 · 핀 · 부재) · 배수유역도 ·
* 좌측 폼 · 횡단 카드 · 범위 테두리 · 포장층 · 형상. 입력 칸 · 창(모달) · 메뉴 · 종단 도구가 먼저
* 받은 Esc(입력 취소 · 창 닫기 · 도구 끝)는 그쪽 몫이라 건드리지 않음.
* ========================================================================== */
import { NO_SELECTION, selectDesign } from "./design_selection";
/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */
export const ESCAPE_FROM = "esc";
const OWN_ESCAPE =
"input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop";
/** 보기마다 건다 — 보이는 보기 하나만 받음 · 보기를 떠나면 뗌. */
export function bindEscapeRelease(
projectId: string | null,
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
): void {
const onKey = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || event.defaultPrevented || !host.visible()) return;
const target = event.target as HTMLElement | null;
if (target?.closest?.(OWN_ESCAPE) || document.querySelector(".ui-modal__backdrop")) return;
selectDesign(projectId, NO_SELECTION, { from: ESCAPE_FROM });
};
window.addEventListener("keydown", onKey);
host.onDispose(() => window.removeEventListener("keydown", onKey));
}
+153
View File
@@ -0,0 +1,153 @@
/* =============================================================================
* A00_Common/design/design_pick_profile.ts
* 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) ·
* 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤).
*
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
* 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는
* 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다.
* ========================================================================== */
import {
nearestWithin,
readSelection,
subscribeSelection,
type DesignSelection,
} from "./design_selection";
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
/** 종단도가 켤 것 — 측점선(알약은 그 측점 짝을 종단도가 맞춤) 또는 알약(구조물 · 관 `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: `pipe-${target.pipeChainageM.toFixed(2)}` }
: target;
}
export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void {
if ("structureId" in pick) target.setSelectedStructure(pick.structureId);
else target.setSelectedStation(pick.stationId);
}
/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(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 } | null) => void;
};
}
/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) ·
* 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */
export function viewerPickOf(
selection: (DesignSelection & { partKey?: string }) | null,
source: ProfilePickSource,
): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } {
if (!selection || selection.chainageM === null) return { stationId: null, member: null };
const at = selection.chainageM;
const pick = profilePickOf(selection, source);
const own = selection.structureId
? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m)
: undefined;
const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null;
return { stationId, member: { chainageM: at, kind: "", key: selection.partKey } };
}
export function paintViewerPick(
target: ViewerPickTarget,
pick: ReturnType<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 };
}
+108 -106
View File
@@ -15,13 +15,10 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drain
import { readState, writeState } from "../b_page_state"; import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import type { import type {
StructureInstance, StructureInstance,
StructureType, StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures"; } from "../../B05_Profile/B05_Profile_Api_Structures";
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
import { import {
hasLegacyAlignment, hasLegacyAlignment,
readAlignment, readAlignment,
@@ -45,7 +42,6 @@ import type {
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { import {
buildAlignment, buildAlignment,
chainageKey,
emptyEdits, emptyEdits,
toAlignmentBase, toAlignmentBase,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
@@ -56,11 +52,7 @@ import {
structureIdKeeping, structureIdKeeping,
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; } from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import { import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
createRouteMassHaulPanel,
type MassHaulSummaryValues,
type RouteMassHaulContext,
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance"; import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights"; import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render"; import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
@@ -77,15 +69,19 @@ import {
createElevationHold, createElevationHold,
createProfileDrag, createProfileDrag,
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; } from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
import { import {
planCurveWarning, irregularGraphStations,
sampleAt, maxChainageOf,
snapToMasterStations, } from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { frameOf } from "@util/common_util_view_window";
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
import { noticeDroppedEdits } from "./design_save";
import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import "../../B06_Section/B06_Section_UI_Style.css"; import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
@@ -107,8 +103,8 @@ export interface DesignProfileContext {
view: DesignView; view: DesignView;
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */ /** 오른쪽 판 — 없으면 판 없이 종단 열만. */
side?: DesignSideFactory; side?: DesignSideFactory;
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */ /** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */
sideTool?: (board: DesignSideBoard) => HTMLElement; toolLead?: HTMLElement;
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */ /** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
onSelectStation: (stationId: string | null) => void; onSelectStation: (stationId: string | null) => void;
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */ /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
@@ -161,40 +157,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
if (drag.isSubDragging()) scheduleLightSync(); if (drag.isSubDragging()) scheduleLightSync();
else draw(); else draw();
}; };
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). // 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
* 유토곡선을 접어도 이 값은 남아야 한다. */ const { massHaul, tableOverlay } = createProfileOverlays({
const massBadge = createMassHaulBadge();
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
bodyWrap.append(massBadge.root);
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
const haulPrefetch = createHaulPlanPrefetch(
projectId, projectId,
() => routeId ?? undefined, routeId: () => routeId,
subPanelChanged, bodyWrap,
); body,
const massHaul = createRouteMassHaulPanel( onChange: subPanelChanged,
subPanelChanged, });
(summary) => {
const before = massHaulSummary;
massHaulSummary = summary;
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
},
haulPrefetch,
);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(subPanelChanged);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
tableOverlay.attachScrollSync(body);
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
body.addEventListener("contextmenu", (event) => event.preventDefault());
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
const content = document.createElement("div"); const content = document.createElement("div");
content.className = "b05-route-profile__content"; content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
@@ -249,6 +219,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지). // 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 }; let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null; let base: AlignmentBase | null = null;
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
let storedBase: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null; let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
@@ -261,9 +233,16 @@ export function createDesignProfile(ctx: DesignProfileContext) {
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로 /** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */ * 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
const structureIdAtStation = (stationId: string | null): string | null => const structureIdAtStation = (stationId: string | null): string | null =>
structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail); structureIdKeeping(
stationId,
selectedStructureId,
irregularStations,
structures,
detail,
structureTypes,
);
const stationIdAtStructure = (structureId: string | null): string | null => const stationIdAtStructure = (structureId: string | null): string | null =>
stationIdAtStructureOf(structureId, irregularStations, structures); stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes);
/** /**
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
@@ -288,7 +267,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
planCurve: planCurveWarning(alignment, detail), planCurve: planCurveWarning(alignment, detail),
minCoverViolations, minCoverViolations,
balanceBar, balanceBar,
tools: tools.render(), tools: withToolLead(tools.render()),
trailing, trailing,
alignment, alignment,
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
@@ -310,11 +289,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */ /** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw()); const elevationHold = createElevationHold(body, () => draw());
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
const trailing = document.createElement("div"); const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing"; trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : [])); trailing.append(profileZoom.bar);
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
const withToolLead = (wrap: HTMLElement): HTMLElement => {
if (ctx.toolLead) wrap.prepend(ctx.toolLead);
return wrap;
};
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */ /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
const { tools, history, handleToolPick, setListening } = createPanelTools({ const { tools, history, handleToolPick, setListening } = createPanelTools({
@@ -360,6 +342,33 @@ export function createDesignProfile(ctx: DesignProfileContext) {
history.record(); history.record();
} }
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
function listed(): AlignmentBase | null {
if (!storedBase || !detail) return storedBase;
const server = detail.longitudinal.stations;
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
const list = oneStationList(server, planted);
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
const gone = server
.filter((station) => station.kind === "irregular" && !station.grid)
.map((station) => station.chainage_m)
.filter((chainage) => !after.has(chainage.toFixed(3)));
const born = list
.map((station) => station.chainage_m)
.filter((chainage) => !before.has(chainage.toFixed(3)));
return {
...storedBase,
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
stations: list.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
})),
};
}
/** /**
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만 * 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
* 실제 렌더링하도록 모은다. * 실제 렌더링하도록 모은다.
@@ -372,7 +381,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
scheduleCrossPreview(); scheduleCrossPreview();
if (redrawPending) return; if (redrawPending) return;
redrawPending = true; redrawPending = true;
requestAnimationFrame(() => { frameOf(root, () => {
redrawPending = false; redrawPending = false;
draw(); draw();
}); });
@@ -418,6 +427,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
function draw(): void { function draw(): void {
renderProfile({ renderProfile({
onStaleDesigns: crossPreview.scheduleIfStale, // 그린 뒤 생긴 낡음도 다시 셈(317-1)
body, body,
callbacks, callbacks,
massHaul, massHaul,
@@ -462,46 +472,27 @@ export function createDesignProfile(ctx: DesignProfileContext) {
}); });
} }
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ /** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */
const SCROLL_SETTLE_MS = 80; const reveal = createSelectionReveal(body);
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ const revealPick = (): void =>
let settledScrollLeft = 0; reveal(
let scrollSettleTimer = 0; (selectedStationId ?? selectedStructureId)
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 ? `${selectedStationId}|${selectedStructureId}`
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 : null,
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. );
body.addEventListener("scroll", () => { // 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
if (elevationHold.editing()) return; const unbindViewport = bindProfileViewport({
const fitted = updateElevationWindow?.() ?? null; body,
window.clearTimeout(scrollSettleTimer); editing: () => elevationHold.editing(),
if (!needsFullRedraw(fitted)) return; fitElevation: () => updateElevationWindow?.() ?? null,
scrollSettleTimer = window.setTimeout(() => { draw: () => draw(),
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; lastSize: () => ({ width: lastWidth, height: lastHeight }),
settledScrollLeft = body.scrollLeft; scheduleLightSync,
draw(); scheduleDraw: () => drag.scheduleDraw(),
}, SCROLL_SETTLE_MS);
}); });
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
attachWheelHorizontalScroll(body);
const resizeObserver = new ResizeObserver(() => {
if (
body.clientWidth <= 0 ||
body.clientHeight <= 0 ||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
)
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
scheduleLightSync();
drag.scheduleDraw();
});
resizeObserver.observe(body);
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw)); bindProfileCollapse(root, panelHandle, () => frameOf(root, draw));
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
function syncDrainageSpans(): void { function syncDrainageSpans(): void {
@@ -529,7 +520,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
savedEdits = stored?.edits ?? emptyEdits(); savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild()); store = createProfileEditStore(routeId, savedEdits, () => rebuild());
} }
base = stored ? toAlignmentBase(stored) : null; storedBase = stored ? toAlignmentBase(stored) : null;
base = listed();
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried); if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null; alignment = base ? buildAlignment(base, store.edits()) : null;
@@ -540,11 +532,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
nextDetail.balloon_offsets ?? undefined, nextDetail.balloon_offsets ?? undefined,
); );
draw(); draw();
requestAnimationFrame(draw); frameOf(root, draw);
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
//
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이 // 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기 // 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다 // 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
@@ -578,6 +569,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17). // 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
selectedStructureId = structureIdAtStation(stationId); selectedStructureId = structureIdAtStation(stationId);
draw(); draw();
revealPick();
}, },
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */ /** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) { setEnforceMinCover(enforce: boolean) {
@@ -592,9 +584,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19 * 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */ * 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
setGradeLimit(maxGradePct: number) { setGradeLimit(maxGradePct: number) {
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return; if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return; if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } }; const policy = { ...base.policy, max_grade_pct: maxGradePct };
storedBase = { ...storedBase, policy };
base = { ...base, policy };
alignment = buildAlignment(base, store.edits()); alignment = buildAlignment(base, store.edits());
draw(); draw();
}, },
@@ -604,7 +598,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
}, },
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) { setIrregularStations(stations: IrregularStation[]) {
irregularStations = snapToMasterStations(alignment, stations); irregularStations = stations;
base = listed();
if (base) alignment = buildAlignment(base, store.edits());
draw(); draw();
}, },
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */ /** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
@@ -629,6 +625,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId; const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
selectedStationId = keep ? selectedStationId : own; selectedStationId = keep ? selectedStationId : own;
draw(); draw();
revealPick();
}, },
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
setStationDisplay(next: { station: number; cumulative: number }) { setStationDisplay(next: { station: number; cumulative: number }) {
@@ -640,20 +637,24 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다. * 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
*/ */
resetStationEdit(chainageM: number, moveTo?: number) { resetStationEdit(chainageM: number, moveTo?: number) {
const key = chainageKey(chainageM); // 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
const edits = store.edits(); const edits = store.edits();
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
store.resetStation(chainageM, moveTo); (entry) => Math.abs(Number(entry) - chainageM) < 0.006,
);
if (key !== undefined) store.resetStation(Number(key), moveTo);
}, },
isDirty: () => store.dirty(), isDirty: () => store.dirty(),
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> { async save(): Promise<void> {
if (!routeId || !store.dirty()) return; if (!routeId || !store.dirty()) return;
const saved = await saveProfileAlignment(projectId, routeId, store.edits()); const saved = await saveProfileAlignment(projectId, routeId, store.edits());
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined; const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) { if (next?.base_pvi?.length) {
base = toAlignmentBase(next); storedBase = toAlignmentBase(next);
alignment = next; base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next; if (detail) detail.longitudinal.profile_alignment = next;
} }
store.markSaved(); store.markSaved();
@@ -662,6 +663,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
clear() { clear() {
detail = null; detail = null;
base = null; base = null;
storedBase = null;
alignment = null; alignment = null;
selectedStationId = null; selectedStationId = null;
balanceBar.replaceChildren(); balanceBar.replaceChildren();
@@ -689,7 +691,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
setListening(false); setListening(false);
drag.dispose(); drag.dispose();
crossPreview.dispose(); crossPreview.dispose();
resizeObserver.disconnect(); unbindViewport();
}, },
}; };
} }
@@ -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"]; const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */ /** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
export function attachProfilePopout( export function attachProfilePopout(
profile: DesignProfile, profile: DesignProfile,
slot: HTMLElement, slot: HTMLElement,
sideTool: HTMLElement | null, button: HTMLButtonElement,
): void { ): void {
const { root } = profile; const { root } = profile;
let popup: Window | null = null; let popup: Window | null = null;
const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
button.textContent = "새 창"; button.textContent = "창 분할";
button.title = "종단 하단 패널을 브라우저 새 창으로 띄움"; button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합";
button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open())); button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open()));
sideTool?.after(button);
const restore = (): void => { const restore = (): void => {
if (!root.classList.contains("is-popout")) return; if (!root.classList.contains("is-popout")) return;
root.classList.remove("is-popout"); root.classList.remove("is-popout");
button.textContent = "새 창"; button.textContent = "창 분할";
slot.append(root); slot.append(root);
popup = null; popup = null;
requestAnimationFrame(profile.redraw); requestAnimationFrame(profile.redraw);
@@ -71,7 +69,7 @@ export function attachProfilePopout(
doc.body.className = document.body.className; doc.body.className = document.body.className;
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`; doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`;
root.classList.add("is-popout"); root.classList.add("is-popout");
button.textContent = "제자리로"; button.textContent = "창 결합";
doc.body.replaceChildren(root); 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;
};
}
+16 -1
View File
@@ -65,11 +65,26 @@ function reportOnly(label: string): (error: unknown) => void {
}; };
} }
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void {
const dropped = saved.dropped_edit_chainages ?? [];
if (!dropped.length) return;
const where = dropped
.slice(0, 5)
.map((value) => `${value.toFixed(2)}m`)
.join(" · ");
showToast(
`계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` +
(dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""),
"info",
);
}
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */ /** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> { async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
const draft = pendingAlignment(routeId); const draft = pendingAlignment(routeId);
if (!draft) return; if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft); noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
markAlignmentSaved(routeId); markAlignmentSaved(routeId);
} }
+1 -1
View File
@@ -13,7 +13,7 @@
import { readState, writeState } from "../b_page_state"; import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */ /** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51; export const PIPE_MATCH_M = 0.51;
export interface DesignSelection { export interface DesignSelection {
+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 { createStructuresSection, type StructuresSection } from "../b_structures_section";
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile"; import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout } from "./design_profile_popout"; import { attachProfilePopout } from "./design_profile_popout";
@@ -150,12 +150,12 @@ function toAll<K extends keyof RouteProfilePanelCallbacks>(
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */ /** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
export function sharedProfile(shared: DesignPageShared): DesignProfile { export function sharedProfile(shared: DesignPageShared): DesignProfile {
if (shared.profile) return shared.profile; if (shared.profile) return shared.profile;
let sideTool: HTMLElement | null = null; const popoutButton = document.createElement("button");
const profile = createDesignProfile({ const profile = createDesignProfile({
projectId: shared.projectId, projectId: shared.projectId,
view: shared.view(), view: shared.view(),
side: drainageSide, side: drainageSide,
sideTool: (board) => (sideTool = drainageSideTool(board)), toolLead: popoutButton,
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId), onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
onResetAll: () => bound(shared).profile?.onResetAll?.(), onResetAll: () => bound(shared).profile?.onResetAll?.(),
callbacks: { callbacks: {
@@ -178,7 +178,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
}, },
}); });
shared.profileSlot.append(profile.root); shared.profileSlot.append(profile.root);
attachProfilePopout(profile, shared.profileSlot, sideTool); attachProfilePopout(profile, shared.profileSlot, popoutButton);
shared.profile = profile; shared.profile = profile;
return profile; return profile;
} }
@@ -16,6 +16,8 @@
* ========================================================================== */ * ========================================================================== */
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
import type { SpecForm } from "./design_structure_form";
import type { SpecContainer, SpecItem } from "./design_structure_spec";
/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */ /** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */
export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null { export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null {
@@ -23,6 +25,54 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null; return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
} }
/**
* 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3).
* · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로.
* · 새 시설(`arm(true)`) 빈 높이 = 유량이 오면 필요 수심(사용자가 넣은 것처럼 — [추가] · [수정]이 읽음).
* · 상자 끝 안내 — 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」(유량이 없으면 손 입력).
* `box` = 폭 · 높이 칸이 든 상자 id(세월교 `ford` · 물넘이 `slope`) · `flow` = 담당 유역 설계유량.
*/
export function overflowSync(
form: () => Pick<SpecForm, "state" | "setValue" | "refresh">,
box: string,
flow: () => number | null,
emit: () => void,
) {
let pending = false;
const values = () => form().state()[box]?.[0] ?? {};
const width = () => Number(values().width ?? "");
const need = (): number | null => fordHeightM(flow(), width());
return {
arm: (fresh: boolean): void => {
pending = fresh;
},
onChange: (c: SpecContainer, item: SpecItem): void => {
if (c.id === box && item.id === "height") pending = false;
const want = c.id === box ? need() : null;
const height = values().height ?? "";
const low = height === "" || !(Number(height) >= (want ?? 0));
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
form().setValue(box, "height", want.toFixed(2));
emit();
},
/** 유량이 왔거나 시설을 올림 — 새 시설 빈 높이를 채우고 안내를 다시 그림. */
fill: (): void => {
const want = pending && !values().height ? need() : null;
if (want === null) return form().refresh();
form().setValue(box, "height", want.toFixed(2));
pending = false;
},
/** 상자 끝 안내 글(폼 `fields` 훅). */
fields: (c: SpecContainer): HTMLElement[] => {
if (c.id !== box) return [];
const hint = document.createElement("p");
hint.className = "b05-structure__flow-hint";
hint.textContent = fordHeightHint(flow(), width());
return [hint];
},
};
}
/** 높이 빈 칸 안내. */ /** 높이 빈 칸 안내. */
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string { export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
const need = fordHeightM(designFlowM3s, widthM); const need = fordHeightM(designFlowM3s, widthM);
@@ -2,13 +2,16 @@
* design_structure_ford_pavement.ts * design_structure_ford_pavement.ts
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로. * 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
* *
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임). * 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 다른 구조물과
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` · * 같은 칸 `CHUTE_ITEMS` + 맨 아래 길이 · 286-3).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_*` ·
* 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`). * 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`).
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음. * 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
* import 없음 — 시험이 이 파일만 옮겨 돌림. * 길이(`ford_chute_{n}_length_m`) = 횡단 · 3D 띠를 사면 따라 그릴 길이 — 그림 전용(수량 · 내역 · 일위대가에 안 씀) ·
* 비면 원지반까지.
* ========================================================================== */ * ========================================================================== */
import { CHUTE_ITEMS } from "./design_structure_guard";
import type { StructureSpec } from "./design_structure_spec"; import type { StructureSpec } from "./design_structure_spec";
export const FORD_PAVEMENT_SPEC: StructureSpec = { export const FORD_PAVEMENT_SPEC: StructureSpec = {
@@ -32,20 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
prefix: "ford_chute", prefix: "ford_chute",
link: "gfordpavechute", link: "gfordpavechute",
max: 1, max: 1,
// 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2) // 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4)
items: [ items: CHUTE_ITEMS,
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
default: "도수로",
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
],
}, },
], ],
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) // 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
@@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [
}, },
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" }, { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
{ id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" }, { id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" },
// 길이(294-4 · `{접두}_{n}_length_m`) — 띠를 사면 따라 그릴 길이 · 그림 전용(수량 · 내역 · 일위대가에 안 씀)
{ id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 },
]; ];
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */ /** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
@@ -12,6 +12,7 @@
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts"; import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
export { pipeLengthText } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1)
import { PIPE_MATCH_M } from "./design_selection"; import { PIPE_MATCH_M } from "./design_selection";
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */ /** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
+31 -34
View File
@@ -29,6 +29,7 @@ import {
type StandardCrossSection, type StandardCrossSection,
} from "../../B06_Section/B06_Section_Api_Fetch"; } from "../../B06_Section/B06_Section_Api_Fetch";
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls"; import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import { import {
createCutSlopeStore, createCutSlopeStore,
createRockBoundaryStore, createRockBoundaryStore,
@@ -37,8 +38,10 @@ import {
} from "../../B06_Section/B06_Section_UI_Page_Persist"; } from "../../B06_Section/B06_Section_UI_Page_Persist";
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { paintMapPick } from "./design_pick_profile";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import { import {
@@ -191,7 +194,7 @@ export async function mountCrossView(
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly); if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
}, },
releaseCard: () => releaseCross(), releaseCard: () => releaseCross(),
markStructure: (structureId) => sectionView.selectMark(structureId), markStructure: (structureId, span) => sectionView.selectMark(structureId, span),
applyPipeOptions: (chainageM, patch) => applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
}); });
@@ -233,6 +236,7 @@ export async function mountCrossView(
); );
} }
renderSectionDetail(); renderSectionDetail();
void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314)
} catch (error) { } catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : ""; const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error"); showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error");
@@ -301,6 +305,7 @@ export async function mountCrossView(
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120); wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
}, },
}); });
setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4)
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김 // 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
@@ -341,15 +346,15 @@ export async function mountCrossView(
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM), addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM), removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId), addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
removeStructure: (structureId) => { removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId),
structuresPanel.removeStructureById(structureId);
},
movePipe: (fromChainageM, toChainageM) => movePipe: (fromChainageM, toChainageM) =>
structuresPanel.movePipeTo(fromChainageM, toChainageM), structuresPanel.movePipeTo(fromChainageM, toChainageM),
moveStructure: (structureId, toChainageM) => { moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM); structuresPanel.moveStructureTo(structureId, toChainageM);
}, },
selectStructure: (structureId) => structuresPanel.selectStructure(structureId), selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM), // 측점 없는 관(요구 178)
pipesChanged: () => (structuresPanel.adoptFordHeights(), void refreshDetailForStructures()),
}); });
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = { const pipeOptionsContext: PipeOptionsContext = {
@@ -387,12 +392,11 @@ export async function mountCrossView(
: null; : null;
if (!target) { if (!target) {
structuresPanel.showAtChainage(null); structuresPanel.showAtChainage(null);
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다. releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
return; return;
} }
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
const preferred = structuresPanel.preferred() !== null; const preferred = structuresPanel.preferred() !== null;
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); const opened = structuresPanel.showAtChainage(structureSelection.formChainageOf(target));
// 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1) // 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1)
const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null }; const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null };
if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" }); if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" });
@@ -474,16 +478,11 @@ export async function mountCrossView(
handoff, handoff,
sectionDetail, sectionDetail,
{ {
// 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15). // 넘겨받은 구조물 id 가 먼저(52-15) · 폼은 id 로 직접(53-1) · 측점 없는 구조물은 카드 · 스크롤 없음(307)
focusStation: (stationId) => { focusStation: (stationId) =>
structuresPanel.prefer(handoff?.structureId ?? null); structuresPanel.focusPicked(handoff?.structureId ?? null, () =>
sectionView.focusStation(stationId); sectionView.focusStation(stationId),
structuresPanel.prefer(null); ),
// 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1)
const sid = handoff?.structureId;
if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid))
structuresPanel.selectStructure(sid);
},
refreshCard: (chainageM) => sectionView.refreshCard(chainageM), refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
@@ -494,23 +493,21 @@ export async function mountCrossView(
if (applied) appliedPick = signature; if (applied) appliedPick = signature;
} }
/** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */ // 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
const crossPick = bindCrossPick(projectId, host, {
sections: () => sectionDetail?.cross_sections ?? [],
controls: stationControls,
releaseForm: () => structuresPanel.showAtChainage(null),
view: sectionView,
reapply: () => {
appliedPick = null;
restoreStructurePick();
},
paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection),
});
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
function releaseCross(): void { function releaseCross(): void {
const { revetOffset, ford, box } = stationControls; crossPick.release();
const lit = (sectionDetail?.cross_sections ?? []).filter(
(s) =>
revetOffset.highlightFor(s) ||
ford.selectedFor(s.chainage_m) ||
box.selectedFor(s.chainage_m),
);
for (const s of lit) {
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
}
structuresPanel.releasePipe();
sectionView.focusStation(null);
sectionView.refreshCards(lit.map((s) => s.chainage_m));
appliedPick = null; appliedPick = null;
selectDesign(projectId, NO_SELECTION, { from: "b06" }); selectDesign(projectId, NO_SELECTION, { from: "b06" });
} }
@@ -568,7 +565,7 @@ export async function mountCrossView(
// 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38) // 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38)
if (!readStructurePick(projectId)) releaseCross(); if (!readStructurePick(projectId)) releaseCross();
void structuresPanel.load().then(restoreStructurePick); void structuresPanel.load().then(restoreStructurePick);
void refreshDetailForStructures().then(() => reconcileStaleDesigns()); void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314)
}); });
host.onDispose(() => { host.onDispose(() => {
window.clearTimeout(pipeReloadTimer); window.clearTimeout(pipeReloadTimer);
+51 -41
View File
@@ -31,14 +31,9 @@ import {
restoreStructurePick, restoreStructurePick,
wireStructurePick, wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection";
coversAtStation,
NO_SELECTION,
nearestWithin,
readSelection,
selectDesign,
} from "./design_selection";
import type { DesignPageHost } from "./design_page"; import type { DesignPageHost } from "./design_page";
import { bindProfilePick } from "./design_pick_profile";
import { import {
bindDesignView, bindDesignView,
designReady, designReady,
@@ -127,6 +122,7 @@ export async function mountProfileView(
bridge.setPipes(pipes); bridge.setPipes(pipes);
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem)); panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
profilePanel.setMinCoverPipes(pipes); profilePanel.setMinCoverPipes(pipes);
snapOld();
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
}; };
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) · /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
@@ -173,13 +169,15 @@ export async function mountProfileView(
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => { onStructureSelect: (structureId) => {
const chainage = bridge.pipeMarkChainage(structureId); const chainage = bridge.pipeMarkChainage(structureId);
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null); // 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
if (chainage !== null) { if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage); panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage); selectStationOfPipe(chainage);
@@ -220,7 +218,7 @@ export async function mountProfileView(
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */ * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
const uphillOverrides = loadUphillOverrides(activeProjectId); const uphillOverrides = loadUphillOverrides(activeProjectId);
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(), stations: () => bridge.irregularStations(),
isSyncing: () => selectionSyncing, isSyncing: () => selectionSyncing,
setSyncing: (value) => { setSyncing: (value) => {
@@ -244,9 +242,24 @@ export async function mountProfileView(
remember: (chainageM) => rememberPick(chainageM, null), remember: (chainageM) => rememberPick(chainageM, null),
}); });
/** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */
const pickFromMap = (chainageM: number | null): void => {
if (!selectionSyncing) rememberPick(chainageM, null);
selectStationOfPipe(chainageM);
};
let oldSnapped = false;
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
function snapOld(): void {
if (oldSnapped || restoring || !lastPipes) return;
oldSnapped = true;
panel.structures.snapOld();
}
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void { function restorePick(): void {
profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1)
if (panel.structures.hasSelection()) return; if (panel.structures.hasSelection()) return;
const pick = viewer.structurePick; const pick = viewer.structurePick;
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
@@ -318,45 +331,31 @@ export async function mountProfileView(
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>); forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
}, },
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
// 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3)
onPipeFacilitySelect: (chainage) => { onPipeFacilitySelect: (chainage) => {
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다. rememberPick(chainage, null);
if (selectionSyncing) return; if (!selectionSyncing) profilePick.paint();
selectionSyncing = true;
try {
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
const station =
chainage === null
? undefined
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
selectGraphAt(id, chainage);
syncBasinHighlight(id);
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
profilePanel.drainage.selectPipeAtChainage(chainage);
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
} finally {
selectionSyncing = false;
}
}, },
onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3)
onStructureSelect: (structure) => { onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 // 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
if (selectionSyncing) return; // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2)
selectionSyncing = true; if (!selectionSyncing) profilePick.paint();
try {
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
} finally {
selectionSyncing = false;
}
}, },
}, },
shared, shared,
); );
wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe); // 3D 부재 고름도 측점 고름과 같은 거름 — 노면공 id 를 고름 한 칸에 적지 않음(231-1)
wireStructurePick(
viewer.structurePick,
activeProjectId,
panel.structures,
selectStationOfPipe,
(entry) => coversAtStation(bridge.typeGroup(entry.type_id)),
);
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의 /** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */ * 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
@@ -402,6 +401,17 @@ export async function mountProfileView(
}, },
isRestoring: () => restoring, isRestoring: () => restoring,
}); });
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
const profilePick = bindProfilePick(
activeProjectId,
{ profile: profilePanel, viewer, form: panel.structures },
host,
{
irregular: () => bridge.irregularStations(),
pipes: () => lastPipes ?? [],
sections: () => currentSectionDetail?.cross_sections ?? [],
},
);
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
@@ -558,8 +568,7 @@ export async function mountProfileView(
); );
return; return;
} }
viewer.markers.selectStation(null); profilePick.paint();
profilePanel.setSelectedStation(null);
}, },
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => { onHide: () => {
@@ -655,6 +664,7 @@ export async function mountProfileView(
} finally { } finally {
progress.remove(); progress.remove();
restoring = false; restoring = false;
snapOld();
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
} }
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* device_context.css — 기기 대응 공통 규칙(PLAN_기기대응 2-6)
* 모든 규칙은 `<html>` 표시(data-touch · data-size) 안에서만 — 데스크탑 마우스 화면은 안 바뀜.
* 표시는 `device_context.ts` 가 적음.
* ========================================================================== */
/* 누를 자리 44px — 손가락(주 입력 coarse) 또는 터치 모드 켬 */
html[data-touch="on"]
:is(
.ui-btn,
.ui-workflow-overlay__toggle,
.ui-sidebar-row,
.ui-workflow-layout__step,
.ui-ctx-menu__item,
select,
input:not([type="checkbox"], [type="radio"], [type="range"], [type="hidden"], [type="color"])
) {
min-height: 44px;
}
html[data-touch="on"] .ui-btn {
min-width: 44px;
}
/* 체크 · 라디오 — 상자를 키우고 글까지 한 줄 전체가 누를 자리 */
html[data-touch="on"] :is(input[type="checkbox"], input[type="radio"]) {
width: 22px;
height: 22px;
}
html[data-touch="on"] .ui-checkbox {
min-height: 44px;
}
/* 접힌 좌측 패널 손잡이 — 손가락 폭 · 화면 왼쪽 끝 안 */
html[data-touch="on"]
.ui-workflow-overlay__panel--title.is-collapsed
> .ui-workflow-overlay__handle--side {
width: 32px;
right: -32px;
}
/* 팝업(2-4) — 폭 600 미만은 전체 화면 · 겹치면 나중 것(맨 위)만 보이고 × 로 앞 창.
공용 모달(.ui-modal) · 페이지 팝업은 공용 클래스 `.ui-popup` 만 붙여 맞춤(자리 · 크기 인라인 값도 덮음) */
html[data-size="compact"] :is(.ui-modal, .ui-popup) {
position: fixed !important;
inset: 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
max-width: none !important;
max-height: none !important;
margin: 0 !important;
border-radius: 0 !important;
transform: none !important;
overflow: auto;
}
html[data-size="compact"] .ui-confirm__panel {
min-width: 0;
max-width: calc(100vw - var(--spacing-32));
}
/* 공용 표(2-5) — 폭 840 미만은 고정 열이 없는 표도 첫 열을 붙임(머리줄은 늘 붙어 있음 · 가로는 밀기) */
html:is([data-size="compact"], [data-size="medium"])
.ui-data-table__table:not(:has(.is-frozen))
tr
> :first-child {
position: sticky;
left: 0;
z-index: 1;
}
html:is([data-size="compact"], [data-size="medium"])
.ui-data-table__table:not(:has(.is-frozen))
thead
tr
> :first-child {
z-index: 3;
}
/* 터치 모드에서만 보이는 단추(자판 없는 기기용 · 4-3) — 마우스 화면엔 없음 */
html:not([data-touch="on"]) .ui-touch-only {
display: none !important;
}
/* 위 줄 높이(6-1) — 화면 높이 계산이 모두 이 값을 씀 · 로그인 뒤 쪽(B · M)은 위 줄을 걷어 0(7-1)
☰ 폭(7-4) · 들여쓰기(7-2) = 왼쪽 위에 뜬 ☰(자리 8 + 폭) + 틈 8 · 그 자리에 걸치는 첫 줄이 이만큼 왼쪽을 비움 */
:root {
--app-header-h: 64px;
--app-burger-w: 36px;
--app-burger-inset: 0px;
}
html.is-bm-route {
--app-header-h: 0px;
--app-burger-inset: calc(var(--spacing-8) * 2 + var(--app-burger-w));
}
+123
View File
@@ -0,0 +1,123 @@
/* =============================================================================
* device_context.ts
* 기기 상태 한 곳(요구 193~195 · PLAN_기기대응 2-1) — `<html>` 에 지금 상태를 늘 적음.
*
* - data-size = compact(<600) · medium(<840) · expanded(<1200) · large ← 배치는 이것만 봄
* - data-pointer = 주 입력 coarse · fine(미디어 `pointer`)
* - data-input = 마지막 누름의 pointerType(mouse · touch · pen)
* - data-touch-mode = 사용자 스위치(auto · on · off · 이 기기 브라우저에만 저장)
* - data-touch = on · off — 누를 자리 크기를 키울지(auto 면 주 입력 coarse 일 때)
* - data-burger = on · off — 공개 쪽에서 ☰ 서랍이 메뉴를 맡는지(폭 900 이하 · 터치) · 로그인 뒤 쪽(B · M)은 늘 ☰(7-1)
* 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태).
* 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수.
* 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘.
* ========================================================================== */
import "./device_context.css";
export const SIZE_BREAKS = { medium: 600, expanded: 840, large: 1200 } as const;
/** 위 메뉴가 숨는 폭(app_shell `@media (max-width: 900px)` 과 짝) — 이하면 ☰ 서랍 */
export const BURGER_MAX = 900;
export type SizeClass = "compact" | "medium" | "expanded" | "large";
export type TouchMode = "auto" | "on" | "off";
export interface DeviceContext {
size: SizeClass;
pointer: "coarse" | "fine";
input: string;
touchMode: TouchMode;
touch: boolean;
burger: boolean;
}
const TOUCH_MODE_KEY = "frd_touch_mode";
export function sizeClassOf(width: number): SizeClass {
if (width < SIZE_BREAKS.medium) return "compact";
if (width < SIZE_BREAKS.expanded) return "medium";
if (width < SIZE_BREAKS.large) return "expanded";
return "large";
}
let modeInMemory: TouchMode | null = null; // 저장이 막힌 브라우저용
export function readTouchMode(): TouchMode {
if (modeInMemory) return modeInMemory;
try {
const v = localStorage.getItem(TOUCH_MODE_KEY);
return v === "on" || v === "off" ? v : "auto";
} catch {
return "auto";
}
}
const coarseQuery = window.matchMedia("(pointer: coarse)");
let current: DeviceContext | null = null;
let lastInput = "";
function read(): DeviceContext {
const pointer = coarseQuery.matches ? "coarse" : "fine";
const touchMode = readTouchMode();
const touch = touchMode === "on" || (touchMode === "auto" && pointer === "coarse");
return {
size: sizeClassOf(window.innerWidth),
pointer,
input: lastInput || (pointer === "coarse" ? "touch" : "mouse"),
touchMode,
touch,
burger: touch || window.innerWidth <= BURGER_MAX,
};
}
function apply(): void {
const next = read();
if (current && JSON.stringify(current) === JSON.stringify(next)) return;
const html = document.documentElement;
html.dataset.size = next.size;
html.dataset.pointer = next.pointer;
html.dataset.input = next.input;
html.dataset.touchMode = next.touchMode;
html.dataset.touch = next.touch ? "on" : "off";
html.dataset.burger = next.burger ? "on" : "off";
const first = !current;
current = next;
if (!first) window.dispatchEvent(new CustomEvent("app:context", { detail: next }));
}
export function deviceContext(): DeviceContext {
return current ?? read();
}
/** 터치 모드 스위치(☰ 서랍) — 이 기기 브라우저에만 저장 */
export function setTouchMode(mode: TouchMode): void {
modeInMemory = mode;
try {
if (mode === "auto") localStorage.removeItem(TOUCH_MODE_KEY);
else localStorage.setItem(TOUCH_MODE_KEY, mode);
} catch {
/* 저장 막힌 브라우저 — 이번 화면만 */
}
apply();
}
function init(): void {
const html = document.documentElement;
for (const [name, px] of Object.entries(SIZE_BREAKS))
html.style.setProperty(`--bp-${name}`, `${px}px`);
apply();
coarseQuery.addEventListener("change", apply);
window.addEventListener("resize", apply);
window.visualViewport?.addEventListener("resize", apply);
screen.orientation?.addEventListener("change", apply);
window.addEventListener(
"pointerdown",
(e) => {
if (e.pointerType === lastInput) return;
lastInput = e.pointerType;
apply();
},
{ capture: true, passive: true },
);
}
init();
+8 -1
View File
@@ -9,9 +9,12 @@
* ========================================================================== */ * ========================================================================== */
import "./random_uuid_polyfill"; import "./random_uuid_polyfill";
import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(기기대응 2-1)
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1)
import "@ui/ui_template_theme.css"; import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements"; import { injectBaseStyles } from "@ui/ui_template_elements";
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu"; import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe";
import { ROUTES, type RoutePath } from "@config/config_frontend"; import { ROUTES, type RoutePath } from "@config/config_frontend";
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
import { import {
@@ -19,6 +22,7 @@ import {
renderCurrentRoute, renderCurrentRoute,
ensureInitialHash, ensureInitialHash,
setLeaveGuard, setLeaveGuard,
draftRoute,
showWithinPage, showWithinPage,
takeLeaveGuard, takeLeaveGuard,
} from "./router"; } from "./router";
@@ -54,6 +58,8 @@ function bootstrap(): void {
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a) // B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute())); blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
// B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220)
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼 // 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
let shown = ""; let shown = "";
@@ -67,7 +73,8 @@ function bootstrap(): void {
shown = currentRoute(); shown = currentRoute();
shownHash = window.location.hash; shownHash = window.location.hash;
if (within) return; if (within) return;
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute()); const showFooter =
!FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless;
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신 const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더 void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
}; };
+29 -4
View File
@@ -74,6 +74,29 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate, (await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
}; };
/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음.
* 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */
export interface DraftRoute {
path: string;
load: () => Promise<PageRenderer>;
/** 로그인 · 회사 필요(B 단계 화면) — 로그인만이면 protected */
workflow?: boolean;
protected?: boolean;
/** 현재 프로젝트로 여는 화면(지워진 프로젝트 안내) */
project?: boolean;
footerless?: boolean;
}
const DRAFT_ROUTES = new Map(
Object.values(import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true }))
.map((m) => m.ROUTE)
.filter(Boolean)
.map((r) => [r.path, r] as const),
);
/** 초안 길 선언(없으면 undefined) */
export const draftRoute = (route: string): DraftRoute | undefined => DRAFT_ROUTES.get(route);
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */ /** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
let leaveGuard: (() => Promise<boolean>) | null = null; let leaveGuard: (() => Promise<boolean>) | null = null;
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => { export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
@@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
mounted?.dispose(); mounted?.dispose();
mounted = null; mounted = null;
if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) { const draft = draftRoute(route);
const guarded = PROTECTED_ROUTES.includes(route) || !!(draft?.protected || draft?.workflow);
if (guarded && !(await isAuthenticated())) {
navigateTo(ROUTES.A06_LOGIN); navigateTo(ROUTES.A06_LOGIN);
return; return;
} }
@@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
ROUTES.B11_LOADING, ROUTES.B11_LOADING,
]; ];
if (workflowRoutes.includes(route)) { if (workflowRoutes.includes(route) || draft?.workflow) {
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
try { try {
const user = await fetchSessionUser(); const user = await fetchSessionUser();
@@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
} }
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다. // 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다.
if (PROJECT_ROUTES.includes(route)) { if (PROJECT_ROUTES.includes(route) || draft?.project) {
const { dropGoneProject } = await import("./b_workflow_nav"); const { dropGoneProject } = await import("./b_workflow_nav");
if (await dropGoneProject()) { if (await dropGoneProject()) {
if (seq !== renderSeq) return; if (seq !== renderSeq) return;
@@ -208,7 +233,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
} }
} }
const loader = routeTable[route]; const loader = routeTable[route] ?? draft?.load;
if (!loader) { if (!loader) {
renderPlaceholder(outlet, route); renderPlaceholder(outlet, route);
return; return;
+2
View File
@@ -16,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard"; import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands"; import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor, slotOf } from "./spreadsheet_editor"; import { createEditor, slotOf } from "./spreadsheet_editor";
import { mountTouchKeys } from "./spreadsheet_touch_keys";
import { toCalcValue } from "./spreadsheet_eval"; import { toCalcValue } from "./spreadsheet_eval";
import { attachExtras } from "./spreadsheet_extras"; import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph"; import { createCalcEngine } from "./spreadsheet_graph";
@@ -161,6 +162,7 @@ export function createSpreadsheet(
const extras = attachExtras(ctx, ed, toolbar?.root ?? null); const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts); others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys); const detachKeys = attachKeys(ctx, ed, extras.keys);
mountTouchKeys(ctx, ed); // 자판 없는 기기 단추 줄(기기대응 4-3 · 터치 모드에서만 보임)
const detachMouse = attachMouse(ctx, ed, extras.mouse); const detachMouse = attachMouse(ctx, ed, extras.mouse);
/** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */ /** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */
let lateTabs: PartHandle | null = null; let lateTabs: PartHandle | null = null;
@@ -8,7 +8,7 @@
* ========================================================================== */ * ========================================================================== */
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address"; import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
import { cellStyleAt } from "./spreadsheet_cellstyle"; import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle";
import { createCalcEngine } from "./spreadsheet_graph"; import { createCalcEngine } from "./spreadsheet_graph";
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers"; import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
import { resolveName } from "./spreadsheet_names"; import { resolveName } from "./spreadsheet_names";
@@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) {
: { 글: "", 정렬: "left" as const }; : { 글: "", 정렬: "left" as const };
const key = `${sheet.id}!${toA1(r, c)}`; const key = `${sheet.id}!${toA1(r, c)}`;
값[key] = shown.글; 값[key] = shown.글;
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]); const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1)
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key }; return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
}; };
const cells = rows.map((r) => const cells = rows.map((r) =>
@@ -139,9 +139,9 @@ function borderCss(side: BorderSide | undefined, scale: number): string {
} }
} }
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */ /** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). 안 준 칸(일반) = 가운데(237-1). */
const HALIGN_CSS: Record<HAlign, string> = { const HALIGN_CSS: Record<HAlign, string> = {
general: "left", general: "center",
left: "left", left: "left",
right: "right", right: "right",
center: "center", center: "center",
@@ -149,13 +149,23 @@ const HALIGN_CSS: Record<HAlign, string> = {
centerContinuous: "center", centerContinuous: "center",
}; };
/** 칸에 맞춤을 안 준(없음 · 일반) 보이는 가로 맞춤 = 가운데 — 화면 · 도구줄 · 대화상자 · 내보내기 공통(237-1). */
export function shownHAlign(h: HAlign | undefined): HAlign {
return !h || h === "general" ? "center" : h;
}
/** 칸에 맞춤을 안 준 보이는 세로 맞춤 = 가운데(237-1). */
export function shownVAlign(v: VAlign | undefined): VAlign {
return v ?? "center";
}
const VALIGN_CSS: Record<VAlign, string> = { const VALIGN_CSS: Record<VAlign, string> = {
top: "flex-start", top: "flex-start",
center: "center", center: "center",
bottom: "flex-end", bottom: "flex-end",
}; };
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). /** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */ * `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle( export function applyCellStyle(
cell: HTMLElement, cell: HTMLElement,
@@ -178,7 +188,7 @@ export function applyCellStyle(
s.borderLeft = borderCss(borders.왼, scale); s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale); s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale); s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"]; s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap"; s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
let text = cell.firstElementChild as HTMLElement | null; let text = cell.firstElementChild as HTMLElement | null;
@@ -187,7 +197,7 @@ export function applyCellStyle(
text.className = "aislo-cell__text"; text.className = "aislo-cell__text";
cell.replaceChildren(text); cell.replaceChildren(text);
} }
text.style.textAlign = HALIGN_CSS[hAlign]; text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)];
text.style.textAlignLast = hAlign === "distributed" ? "justify" : ""; text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
} }
@@ -14,6 +14,7 @@
import { showToast } from "@ui/ui_template_elements"; import { showToast } from "@ui/ui_template_elements";
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address"; import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { clipWholeToUsed } from "./spreadsheet_grid_cells"; import { clipWholeToUsed } from "./spreadsheet_grid_cells";
import { formatValue } from "./spreadsheet_numfmt"; import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift"; import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
@@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string {
if (deco) parts.push(`text-decoration:${deco}`); if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`); if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`); if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") { // 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`); const h = shownHAlign(style.가로);
} parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`);
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`); const v = shownVAlign(style.세로);
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap"); if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) { if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => { (["위", "아래", "왼", "오른"] as const).forEach((side) => {
@@ -129,7 +129,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
attrs: { type: "button", title: TEXT.close }, attrs: { type: "button", title: TEXT.close },
}); });
const dialog = el("div", { const dialog = el("div", {
className: "ss-dialog", className: "ss-dialog ui-popup",
attrs: { hidden: "" }, attrs: { hidden: "" },
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body], children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
}); });
+9 -1
View File
@@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
ta.style.fontSize = s.크기 ? `${s.크기}pt` : ""; ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
ta.style.fontWeight = s.굵게 ? "bold" : ""; ta.style.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : ""; ta.style.fontStyle = s.기울임 ? "italic" : "";
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : ""; ta.style.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center";
// 글이 길면 오른쪽 · 아래로 늘림(엑셀) // 글이 길면 오른쪽 · 아래로 늘림(엑셀)
ta.style.width = "0"; ta.style.width = "0";
ta.style.height = "0"; ta.style.height = "0";
@@ -303,6 +303,14 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
} }
function focus(): void { function focus(): void {
// 손가락 — 고칠 때만 화면 자판(기기대응 4-2) · 칸 고름만으로는 안 뜸
if (document.documentElement.dataset.touch === "on") {
const mode = st ? "text" : "none";
if (ta.inputMode !== mode) {
ta.inputMode = mode;
if (document.activeElement === ta) ta.blur();
}
}
if (document.activeElement !== ta) ta.focus({ preventScroll: true }); if (document.activeElement !== ta) ta.focus({ preventScroll: true });
} }
@@ -210,7 +210,7 @@ export function openEvalSteps(ctx: SpreadsheetContext): () => void {
onClick: () => go(i + 1 < steps.length ? i + 1 : 0), onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
}); });
const box = el("div", { const box = el("div", {
className: "ss-steps__box", className: "ss-steps__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") }, attrs: { role: "dialog", "aria-label": t("Title") },
}); });
const shade = el("div", { className: "ss-steps", children: [box] }); const shade = el("div", { className: "ss-steps", children: [box] });
@@ -23,7 +23,7 @@ import {
type NegStyle, type NegStyle,
} from "./spreadsheet_format_code"; } from "./spreadsheet_format_code";
import { dialogKeys } from "./spreadsheet_actions"; import { dialogKeys } from "./spreadsheet_actions";
import { isAutoInk } from "./spreadsheet_cellstyle"; import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt"; import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types"; import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["centerContinuous", "선택 영역 가운데"], ["centerContinuous", "선택 영역 가운데"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement; }) as HTMLSelectElement;
hSelect.value = draft.가로 ?? "general"; hSelect.value = shownHAlign(draft.가로);
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign)); hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
const vSelect = el("select", { const vSelect = el("select", {
@@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["bottom", "아래"], ["bottom", "아래"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })), ].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement; }) as HTMLSelectElement;
vSelect.value = draft.세로 ?? "bottom"; vSelect.value = shownVAlign(draft.세로);
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign)); vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
@@ -263,3 +263,16 @@
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text { .ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
visibility: hidden; visibility: hidden;
} }
/* 손가락(기기대응 4-2) — 채우기 손잡이를 키움 · 격자는 한 손가락 밀기만 브라우저에(두 손가락 = 격자 확대) */
html[data-touch="on"] .aislo-grid__fill-handle {
width: 14px;
height: 14px;
margin: -4px 0 0 -4px;
}
@media (any-pointer: coarse) {
.aislo-grid {
touch-action: pan-x pan-y;
}
}
+6 -1
View File
@@ -553,7 +553,12 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null; const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
if (handleAt) { if (handleAt) {
const screen = screenBoxOf(handleAt.r, handleAt.c); const screen = screenBoxOf(handleAt.r, handleAt.c);
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) { // 손가락이면 손잡이 잡는 자리를 넓힘(기기대응 4-2)
const grip = document.documentElement.dataset.touch === "on" ? 20 : 5;
if (
Math.abs(x - (screen.x + screen.w)) <= grip &&
Math.abs(y - (screen.y + screen.h)) <= grip
) {
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c }; return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
} }
} }
@@ -13,6 +13,7 @@ import {
isAutoInk, isAutoInk,
resolveBoxBorders, resolveBoxBorders,
setCellText, setCellText,
shownHAlign,
type ResolvedBorders, type ResolvedBorders,
} from "./spreadsheet_cellstyle"; } from "./spreadsheet_cellstyle";
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers"; import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
@@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
} }
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = { const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left", general: "center",
left: "left", left: "left",
right: "right", right: "right",
center: "center", center: "center",
@@ -218,7 +219,7 @@ function cellDisplay(
const scalar = scalarAt(ctx, sheet, r, c); const scalar = scalarAt(ctx, sheet, r, c);
const formatted = formatValue(scalar, code); const formatted = formatValue(scalar, code);
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar; const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬; const hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1)
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : ""; const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
const isNumeric = const isNumeric =
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
@@ -255,11 +255,61 @@ export function attachMouse(
}; };
const onLeave = (): void => hooks.hover?.(null); const onLeave = (): void => hooks.hover?.(null);
// 손가락(기기대응 4-2) — 톡 = 칸 고름(브라우저 호환 마우스 그대로) · 고른 칸 · 행열 머리 · 채우기 손잡이에서
// 끌기 = 범위 · 채우기(마우스 끌기와 같은 길) · 안 고른 칸에서 끌기 = 화면 밀기 ·
// 두 번 톡 = 고침(손 동작 안에서 바로 열어 화면 자판이 뜸)
let touchDrag = false;
let lastTap = 0;
const asMouse = (t: Touch, type: string): MouseEvent =>
new MouseEvent(type, { clientX: t.clientX, clientY: t.clientY, button: 0, bubbles: true });
const onTouchStart = (e: TouchEvent): void => {
if (e.touches.length !== 1 || e.target === ed.ta) return;
const t = e.touches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const grab =
hit &&
(hit.kind === "fillHandle" ||
hit.kind === "rowHead" ||
hit.kind === "colHead" ||
(hit.kind === "cell" && inSelection(hit.r, hit.c)));
if (!grab) return;
e.preventDefault(); // 화면 밀기 대신 범위 · 호환 마우스 이벤트도 안 옴
touchDrag = true;
onDown(asMouse(t, "mousedown"));
};
const onTouchMove = (e: TouchEvent): void => {
if (!touchDrag) return;
e.preventDefault();
root.dispatchEvent(asMouse(e.touches[0], "mousemove")); // 올라가 window 끌기 듣개에 닿음
};
const onTouchEnd = (e: TouchEvent): void => {
if (touchDrag) {
touchDrag = false;
root.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); // M02 칸 고르기도 받음
}
if (e.touches.length || e.changedTouches.length !== 1) return;
const t = e.changedTouches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const now = Date.now();
if (now - lastTap < 350 && hit?.kind === "cell" && !ed.editing() && !ctx.readOnly) {
lastTap = 0;
ed.begin();
return;
}
lastTap = now;
};
root.addEventListener("mousedown", onDown); root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl); root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover); root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave); root.addEventListener("mouseleave", onLeave);
root.addEventListener("touchstart", onTouchStart, { passive: false });
root.addEventListener("touchmove", onTouchMove, { passive: false });
root.addEventListener("touchend", onTouchEnd);
return () => { return () => {
root.removeEventListener("touchstart", onTouchStart);
root.removeEventListener("touchmove", onTouchMove);
root.removeEventListener("touchend", onTouchEnd);
root.removeEventListener("mousemove", onHover); root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave); root.removeEventListener("mouseleave", onLeave);
stopDrag?.(); stopDrag?.();
@@ -87,7 +87,7 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void {
const caseBox = el("input", { attrs: { type: "checkbox" } }); const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" }); const list = el("div", { className: "ss-sort__keys" });
const box = el("div", { const box = el("div", {
className: "ss-sort__box", className: "ss-sort__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") }, attrs: { role: "dialog", "aria-label": t("Title") },
}); });
const shade = el("div", { className: "ss-sort", children: [box] }); const shade = el("div", { className: "ss-sort", children: [box] });
@@ -15,6 +15,7 @@ export function setSpreadsheetLanguage(index: 0 | 1): void {
const TEXT = { const TEXT = {
Undo: ["되돌리기", "Undo"], Undo: ["되돌리기", "Undo"],
CellMenu: ["칸 메뉴(우클릭과 같음)", "Cell menu (same as right-click)"],
Redo: ["다시", "Redo"], Redo: ["다시", "Redo"],
FontName: ["글꼴", "Font"], FontName: ["글꼴", "Font"],
FontSize: ["크기", "Size"], FontSize: ["크기", "Size"],
@@ -291,3 +291,23 @@
justify-content: flex-end; justify-content: flex-end;
gap: var(--spacing-8); gap: var(--spacing-8);
} }
/* ⋯ 칸 메뉴(기기대응 3-5) — 터치 모드에서만 */
.ss-toolbar .ss-toolbar__more {
display: none;
}
html[data-touch="on"] .ss-toolbar .ss-toolbar__more {
display: inline-flex;
}
/* 자판 없는 기기 단추 줄(기기대응 4-3 · spreadsheet_touch_keys.ts) — 터치 모드에서만 */
.ss-touch-keys {
display: none;
}
html[data-touch="on"] .ss-touch-keys {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
+28 -3
View File
@@ -13,6 +13,7 @@
import { createButton, el } from "@ui/ui_template_elements"; import { createButton, el } from "@ui/ui_template_elements";
import { confirmMerge } from "./spreadsheet_actions"; import { confirmMerge } from "./spreadsheet_actions";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { cellToText, textToCell } from "./spreadsheet_editor"; import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text"; import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types"; import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
@@ -147,6 +148,30 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
undoBtn.title = st("Undo"); undoBtn.title = st("Undo");
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() }); const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
redoBtn.title = st("Redo"); redoBtn.title = st("Redo");
// 손가락 — 우클릭 대신 활성 칸 메뉴를 여는 ⋯(기기대응 3-5 · 터치 모드에서만 보임)
const moreBtn = createButton({
label: "⋯",
variant: "ghost",
onClick: () => {
const { r, c } = ctx.selection.활성;
const { box } = ctx.grid.editorSlot(r, c);
const at = ctx.grid.root.getBoundingClientRect();
const x = at.left + box.x + Math.min(box.w / 2, 24);
const y = at.top + box.y + box.h / 2;
const target = document.elementFromPoint(x, y) ?? ctx.grid.root;
target.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
button: 2,
}),
);
},
});
moreBtn.classList.add("ss-toolbar__more");
moreBtn.title = st("CellMenu");
// ── 글꼴 · 크기 ──────────────────────────────────────────────────────── // ── 글꼴 · 크기 ────────────────────────────────────────────────────────
const fontInput = el("input", { const fontInput = el("input", {
@@ -500,7 +525,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
}); });
root.append( root.append(
group(undoBtn, redoBtn), group(undoBtn, redoBtn, moreBtn),
group(fontInput, sizeInput), group(fontInput, sizeInput),
group(boldBtn, italicBtn, underlineBtn, strikeBtn), group(boldBtn, italicBtn, underlineBtn, strikeBtn),
group(textColor, textColorClear, fillColor, fillColorClear), group(textColor, textColorClear, fillColor, fillColorClear),
@@ -528,8 +553,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
strikeBtn.classList.toggle("is-on", !!style.취소선); strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000"; textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff"; fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general"; hAlignSelect.value = shownHAlign(style.가로);
vAlignSelect.value = style.세로 ?? "bottom"; vAlignSelect.value = shownVAlign(style.세로);
wrapBtn.classList.toggle("is-on", !!style.줄바꿈); wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? ""; numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0]; const range = ctx.selection.범위[0];
@@ -0,0 +1,43 @@
/* =============================================================================
* spreadsheet_touch_keys.ts
* 자판 없는 기기용 단추 줄(기기대응 4-3) — 단축키로만 되던 일을 같은 키 길(`attachKeys`)로 보냄.
* 입력줄(`ed.bar`) 끝에 붙고 터치 모드에서만 보임(spreadsheet_toolbar.css). 마우스 화면은 그대로.
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import type { Editor } from "./spreadsheet_editor";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const KEYS: [label: string, title: string, init: KeyboardEventInit, edit: boolean][] = [
["🔍", "찾기 · 바꾸기(Ctrl+F)", { key: "f", code: "KeyF", ctrlKey: true }, false],
["⇉", "고른 칸 모두 같은 값(Ctrl+Enter)", { key: "Enter", ctrlKey: true }, true],
["↵", "칸 안 줄바꿈(Alt+Enter)", { key: "Enter", altKey: true }, true],
["✕", "고침 취소(Esc)", { key: "Escape" }, true],
["📅", "오늘 날짜(Ctrl+;)", { key: ";", code: "Semicolon", ctrlKey: true }, true],
["$", "참조 $ 돌리기(F4)", { key: "F4" }, true],
];
export function mountTouchKeys(ctx: SpreadsheetContext, ed: Editor): void {
const row = document.createElement("div");
row.className = "ss-touch-keys";
for (const [label, title, init, edit] of KEYS) {
if (edit && ctx.readOnly) continue;
const b = createButton({
label,
variant: "ghost",
onClick: () => {
// 고치는 중이면 그 칸(입력줄 · 칸 편집기) — 아니면 칸 편집기로
const target = document.activeElement === ed.formula ? ed.formula : ed.ta;
target.dispatchEvent(
new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true }),
);
},
});
b.title = title;
b.setAttribute("aria-label", title);
// 누를 때 초점을 뺏지 않음 — 고치던 칸이 그대로(손가락은 호환 mousedown 이 옴)
b.addEventListener("mousedown", (e) => e.preventDefault());
row.append(b);
}
ed.bar.append(row);
}
+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]]: async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size) return await asyncio.to_thread(_search_sync, query.strip(), size)
def _reverse_sync(lon: float, lat: float) -> str | None:
"""좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번)."""
query = urllib.parse.urlencode(
{
"service": "address",
"request": "getAddress",
"version": "2.0",
"crs": "epsg:4326",
"point": f"{lon},{lat}",
"type": "both",
"format": "json",
"key": VWORLD_API_KEY,
}
)
try:
body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8"))
except Exception:
return None
items = (body.get("response") or {}).get("result") or []
texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)}
return texts.get("road") or texts.get("parcel") or None
async def reverse_geocode(lon: float, lat: float) -> str | None:
return await asyncio.to_thread(_reverse_sync, lon, lat)
@@ -19,6 +19,8 @@ export interface AddressFieldHandle {
root: HTMLElement; root: HTMLElement;
/** 저장할 한 줄 주소. 비면 null. */ /** 저장할 한 줄 주소. 비면 null. */
value: () => string | null; value: () => string | null;
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
point: () => { lat: number; lon: number } | null;
} }
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */ /** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
@@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
label: "지도 확인", label: "지도 확인",
variant: "ghost", variant: "ghost",
onClick: async function onB01_Address_Map_Click() { onClick: async function onB01_Address_Map_Click() {
await map.show(base.input.value.trim()); picked = await map.show(base.input.value.trim());
}, },
}); });
mapBtn.hidden = true; mapBtn.hidden = true;
@@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root); root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
let manual = false; let manual = false;
let picked: { lat: number; lon: number } | null = null;
function pick(item: AddressCandidate): void { function pick(item: AddressCandidate): void {
base.input.value = candidateLine(item); base.input.value = candidateLine(item);
@@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
zip.hidden = !item.zipcode; zip.hidden = !item.zipcode;
list.hidden = true; list.hidden = true;
list.innerHTML = ""; list.innerHTML = "";
picked = { lat: item.lat, lon: item.lon };
map.showPoint(item.lat, item.lon); map.showPoint(item.lat, item.lon);
detail.input.focus(); detail.input.focus();
} }
@@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
}); });
// 이미 있는 주소는 열자마자 지도로 보여 준다. // 이미 있는 주소는 열자마자 지도로 보여 준다.
if (initial && initial.trim()) void map.show(initial.trim()); if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p));
return { return {
root, root,
@@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim(); const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
return merged || null; return merged || null;
}, },
point: () => picked,
}; };
} }
@@ -58,11 +58,17 @@ export function buildKnowledgeChat(): HTMLElement {
input.rows = 2; input.rows = 2;
input.required = true; input.required = true;
input.maxLength = 500; input.maxLength = 500;
input.placeholder = "메시지를 입력하세요"; input.placeholder = "메시지를 입력하세요 (Shift+Enter 줄바꿈)";
input.setAttribute("aria-label", "질문"); input.setAttribute("aria-label", "질문");
const submit = document.createElement("button"); const submit = document.createElement("button");
submit.type = "submit"; submit.type = "submit";
submit.textContent = "보내기"; submit.textContent = "보내기";
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit.click();
}
});
form.append(input, submit); form.append(input, submit);
panel.append(header, messages, form); panel.append(header, messages, form);
wrap.append(toggle, panel); wrap.append(toggle, panel);
+6 -4
View File
@@ -32,7 +32,8 @@ function tileIndex(
export function buildAddressMap(): { export function buildAddressMap(): {
root: HTMLElement; root: HTMLElement;
show: (address: string) => Promise<void>; /** 찾은 좌표를 돌려줌 — 못 찾으면 null. */
show: (address: string) => Promise<{ lat: number; lon: number } | null>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */ /** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void; showPoint: (lat: number, lon: number) => void;
} { } {
@@ -110,17 +111,18 @@ export function buildAddressMap(): {
note.textContent = message; note.textContent = message;
} }
const show = async (address: string): Promise<void> => { const show = async (address: string): Promise<{ lat: number; lon: number } | null> => {
if (!address.trim()) { if (!address.trim()) {
fail("주소를 먼저 입력하십시오."); fail("주소를 먼저 입력하십시오.");
return; return null;
} }
const point = await geocodeAddress(address).catch(() => null); const point = await geocodeAddress(address).catch(() => null);
if (!point) { if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오."); fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return; return null;
} }
draw(point.lat, point.lon); draw(point.lat, point.lon);
return { lat: point.lat, lon: point.lon };
}; };
return { root, show, showPoint: draw }; return { root, show, showPoint: draw };
+18 -11
View File
@@ -536,8 +536,8 @@
.b01-knowledge-chat__toggle, .b01-knowledge-chat__toggle,
.b01-knowledge-chat__form button { .b01-knowledge-chat__form button {
border: 0; border: 0;
background: var(--color-royal-amethyst, #6843a7); background: var(--color-primary);
color: #fff; color: var(--color-primary-text);
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
} }
@@ -560,12 +560,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: min(380px, calc(100vw - 32px)); width: min(380px, calc(100vw - 32px));
height: min(680px, calc(100vh - 104px)); height: min(680px, calc(100dvh - 104px));
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 12px; border-radius: 12px;
background: var(--color-surface, #fff); background: var(--color-surface, #fff);
box-shadow: 0 8px 28px #0003; box-shadow: 0 8px 28px #0003;
color: var(--color-text-primary, #222); color: var(--color-text-body);
overflow: hidden; overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
@@ -588,8 +588,8 @@
height: 34px; height: 34px;
place-items: center; place-items: center;
border-radius: 50%; border-radius: 50%;
background: var(--color-royal-amethyst, #6843a7); background: var(--color-primary);
color: #fff; color: var(--color-primary-text);
} }
.b01-knowledge-chat__avatar svg { .b01-knowledge-chat__avatar svg {
@@ -631,14 +631,15 @@
.b01-knowledge-chat__user { .b01-knowledge-chat__user {
align-self: flex-end; align-self: flex-end;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
background: var(--color-royal-amethyst, #6843a7); background: var(--color-primary);
color: #fff; color: var(--color-primary-text);
} }
.b01-knowledge-chat__reply { .b01-knowledge-chat__reply {
align-self: flex-start; align-self: flex-start;
border: 1px solid var(--color-border);
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
background: var(--color-background, #f4f4f4); background: var(--color-surface-raised);
} }
.b01-knowledge-chat__reply details { .b01-knowledge-chat__reply details {
@@ -722,7 +723,7 @@
.b01-knowledge-source-modal__content code { .b01-knowledge-source-modal__content code {
padding: 0.1em 0.35em; padding: 0.1em 0.35em;
border-radius: 4px; border-radius: 4px;
background: var(--color-background, #f4f4f4); background: var(--color-surface);
} }
.b01-knowledge-source-modal__content .ui-markdown-table { .b01-knowledge-source-modal__content .ui-markdown-table {
@@ -746,7 +747,7 @@
} }
.b01-knowledge-source-modal__content th { .b01-knowledge-source-modal__content th {
background: var(--color-background, #f4f4f4); background: var(--color-surface);
} }
.b01-knowledge-chat__form { .b01-knowledge-chat__form {
@@ -765,11 +766,17 @@
padding: 8px; padding: 8px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit; font: inherit;
resize: vertical; resize: vertical;
overflow-y: auto; overflow-y: auto;
} }
.b01-knowledge-chat__form textarea::placeholder {
color: var(--color-text-muted);
}
.b01-knowledge-chat__form button { .b01-knowledge-chat__form button {
padding: 9px 14px; padding: 9px 14px;
border-radius: 8px; border-radius: 8px;
+1 -1
View File
@@ -1,5 +1,5 @@
.b03-file { .b03-file {
min-height: calc(100vh - var(--app-header-height, 0px)); min-height: calc(100dvh - var(--app-header-height, 0px));
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
var(--color-canvas, #ffffff) 0%, var(--color-canvas, #ffffff) 0%,
+1 -1
View File
@@ -74,7 +74,7 @@ export function createTempPicker(
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop"; backdrop.className = "b03-file__modal-backdrop";
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.className = "b03-file__modal b03-file__temp-modal"; modal.className = "b03-file__modal b03-file__temp-modal ui-popup";
modal.setAttribute("role", "dialog"); modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true"); modal.setAttribute("aria-modal", "true");
+1 -1
View File
@@ -585,7 +585,7 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). /** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
* *
* `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */ * 빈 목록 = 관 0개(312) · 기본 관 + 자동 보충으로 되돌리기는 `resetDetailPipePoints`(DELETE)만. */
export async function computeDetailBasins( export async function computeDetailBasins(
projectId: string, projectId: string,
points: DetailPipeInput[], points: DetailPipeInput[],
+13 -9
View File
@@ -62,16 +62,16 @@ def _build(
) -> tuple[DrainageDetail | None, list[PipePoint]]: ) -> tuple[DrainageDetail | None, list[PipePoint]]:
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다. """세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
`points`가 없으면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다. `points`가 None 이면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의 있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다. 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
""" """
directory = drainage_dir(stored_path) directory = drainage_dir(stored_path)
chainages = [point.chainage_m for point in points] if points else None chainages = [point.chainage_m for point in points] if points is not None else None
detail = build_detail(directory, context.vertices, chainages) detail = build_detail(directory, context.vertices, chainages)
if detail is None: if detail is None:
return None, [] return None, []
if points: if points is not None:
_retag(detail.pipes, points) _retag(detail.pipes, points)
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes] rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관 # 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
@@ -357,26 +357,30 @@ def _basin_features(
async def _resolve( async def _resolve(
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse: ) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse:
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.""" """요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
"""
context, reason = await load_drainage_context(project_id) context, reason = await load_drainage_context(project_id)
if context is None: if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason}) return JSONResponse(status_code=404, content={"status": "error", "message": reason})
try: try:
requested = parse_pipe_points((payload or {}).get("points")) raw = (payload or {}).get("points")
requested = None if raw is None else parse_pipe_points(raw)
except ValueError as error: # 집계 항목(summary_item) 모양이 틀림 except ValueError as error: # 집계 항목(summary_item) 모양이 틀림
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
signature = route_signature(context.vertices) signature = route_signature(context.vertices)
stored = ( stored = (
load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None
) )
points = requested or stored or None points = requested if requested is not None else stored
result = await asyncio.to_thread(_build, context.stored_path, context, points) result = await asyncio.to_thread(_build, context.stored_path, context, points)
detail, resolved = result detail, resolved = result
if detail is None: if detail is None:
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS}) return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
return context, detail, resolved, bool(stored) and not requested return context, detail, resolved, requested is None and stored is not None
@router.get("/{project_id}/drainage/pipe-points", response_model=None) @router.get("/{project_id}/drainage/pipe-points", response_model=None)
@@ -395,8 +399,8 @@ async def post_detail_basins(
) -> dict[str, Any] | JSONResponse: ) -> dict[str, Any] | JSONResponse:
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). """화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
`points`를 비우고 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 — `points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
""" """
resolved = await _resolve(project_id, payload, use_stored=False) resolved = await _resolve(project_id, payload, use_stored=False)
if isinstance(resolved, JSONResponse): if isinstance(resolved, JSONResponse):
+2 -1
View File
@@ -40,6 +40,7 @@ import {
resampleRoute, resampleRoute,
type RoutePoint, type RoutePoint,
} from "./B04_PreProcess_UI_RouteSamples"; } from "./B04_PreProcess_UI_RouteSamples";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
function L(key: keyof typeof ui_locales): string { function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
@@ -296,7 +297,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
function selectBasinOfPipe(pipeIndex: number): void { function selectBasinOfPipe(pipeIndex: number): void {
const pipe = pipes[pipeIndex]; const pipe = pipes[pipeIndex];
if (!pipe) return; if (!pipe) return;
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51); const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < PIPE_MATCH_M);
selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null; selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null;
say(summary()); say(summary());
} }
+78 -20
View File
@@ -225,10 +225,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
if (options.blocked?.() || !controls.enabled) return; if (options.blocked?.() || !controls.enabled) return;
if (event.button === 1 && panPointerId === null) { if (event.button === 1 && panPointerId === null) {
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다. // 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
pickPivot(event); startGrabPan(event);
panAnchor.copy(pivot);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
panPointerId = event.pointerId; panPointerId = event.pointerId;
element.setPointerCapture?.(event.pointerId); element.setPointerCapture?.(event.pointerId);
return; return;
@@ -251,11 +248,15 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
* 휠을 위로 올리면 멀어진다(사용자 지시). */ * 휠을 위로 올리면 멀어진다(사용자 지시). */
function onWheel(event: WheelEvent): void { function onWheel(event: WheelEvent): void {
const camera = getCamera();
if (!controls.enabled || options.blocked?.()) return; if (!controls.enabled || options.blocked?.()) return;
event.preventDefault(); event.preventDefault();
pickPivot(event); zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP);
const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP; }
/** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
function zoomAround(at: { clientX: number; clientY: number }, factor: number): void {
const camera = getCamera();
pickPivot(at);
const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor); const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor);
const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor); const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다. // 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
@@ -276,19 +277,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
function onPointerMove(event: PointerEvent): void { function onPointerMove(event: PointerEvent): void {
const camera = getCamera(); const camera = getCamera();
if (panPointerId === event.pointerId) { if (panPointerId === event.pointerId) {
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다. grabPanTo(event);
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
pointer.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
const delta = panAnchor.clone().sub(panPoint);
camera.position.add(delta);
controls.target.add(delta);
controls.update();
return; return;
} }
if (pointerId !== event.pointerId) return; if (pointerId !== event.pointerId) return;
@@ -351,6 +340,67 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
syncPivotMarker(); syncPivotMarker();
} }
/** 그랩 팬 — 잡은 점이 커서(손가락 가운데) 아래에 계속 오도록 카메라·target을 평행 이동한다. */
function grabPanTo(at: { clientX: number; clientY: number }): void {
const camera = getCamera();
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
pointer.set(
((at.clientX - rect.left) / rect.width) * 2 - 1,
-((at.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
const delta = panAnchor.clone().sub(panPoint);
camera.position.add(delta);
controls.target.add(delta);
controls.update();
}
function startGrabPan(at: { clientX: number; clientY: number }): void {
pickPivot(at);
panAnchor.copy(pivot);
getCamera().getWorldDirection(viewDirection);
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
}
// 손가락(기기대응 3-1) — 하나 = 회전(왼쪽 단추와 같음) · 둘 = 벌려 확대 · 같이 끌어 이동.
// 3D 는 자체 처리 — 손가락 번역(`touch_bridge`)은 이 요소를 건너뜀.
element.dataset.touchOwn = "3d";
element.style.touchAction = "none";
const touches = new Map<number, { clientX: number; clientY: number }>();
let pinchDist = 0;
const touchMid = (): { clientX: number; clientY: number; dist: number } => {
const [a, b] = [...touches.values()];
return {
clientX: (a.clientX + b.clientX) / 2,
clientY: (a.clientY + b.clientY) / 2,
dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
};
};
function onTouchDown(event: PointerEvent): void {
if (event.pointerType !== "touch" || options.blocked?.() || !controls.enabled) return;
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
if (touches.size !== 2) return;
stop(); // 한 손가락 회전을 멈추고 두 손가락으로
const m = touchMid();
pinchDist = m.dist;
startGrabPan(m);
}
function onTouchMove(event: PointerEvent): void {
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
if (touches.size !== 2) return;
const m = touchMid();
grabPanTo(m);
if (pinchDist > 0 && m.dist > 0) zoomAround(m, pinchDist / m.dist);
pinchDist = m.dist;
startGrabPan(m); // 확대 뒤 다시 잡음 — 다음 이동이 새 배율 기준
}
function onTouchUp(event: PointerEvent): void {
touches.delete(event.pointerId);
}
function stop(): void { function stop(): void {
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) { if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
element.releasePointerCapture(pointerId); element.releasePointerCapture(pointerId);
@@ -381,6 +431,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.addEventListener("pointercancel", onPointerEnd); element.addEventListener("pointercancel", onPointerEnd);
element.addEventListener("pointerleave", onPointerLeave); element.addEventListener("pointerleave", onPointerLeave);
element.addEventListener("wheel", onWheel, { passive: false }); element.addEventListener("wheel", onWheel, { passive: false });
element.addEventListener("pointerdown", onTouchDown);
element.addEventListener("pointermove", onTouchMove);
element.addEventListener("pointerup", onTouchUp);
element.addEventListener("pointercancel", onTouchUp);
return () => { return () => {
element.removeEventListener("pointerdown", onPointerDown); element.removeEventListener("pointerdown", onPointerDown);
@@ -389,6 +443,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.removeEventListener("pointercancel", onPointerEnd); element.removeEventListener("pointercancel", onPointerEnd);
element.removeEventListener("pointerleave", onPointerLeave); element.removeEventListener("pointerleave", onPointerLeave);
element.removeEventListener("wheel", onWheel); element.removeEventListener("wheel", onWheel);
element.removeEventListener("pointerdown", onTouchDown);
element.removeEventListener("pointermove", onTouchMove);
element.removeEventListener("pointerup", onTouchUp);
element.removeEventListener("pointercancel", onTouchUp);
if (pivotMarker) { if (pivotMarker) {
pivotMarker.removeFromParent(); pivotMarker.removeFromParent();
pivotMarker.material.map?.dispose(); pivotMarker.material.map?.dispose();
@@ -1,4 +1,5 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { createProgressCircle } from "@ui/ui_template_progress"; import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { import {
@@ -546,7 +547,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
wheelWidth, wheelWidth,
8, 8,
); );
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87))); scale = Math.min(maxScale, Math.max(0.5, scale * (wheelZoomsIn(event) ? 1.15 : 0.87)));
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정. // 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
// screen = center + (base - center)·scale + offset 이므로, // screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale). // 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
+1 -1
View File
@@ -119,7 +119,7 @@
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
max-width: none; max-width: none;
min-height: calc(100vh - var(--spacing-64)); min-height: calc(100dvh - var(--app-header-h));
} }
.b04-surface__workspace > .b04-surface__status { .b04-surface__workspace > .b04-surface__status {
+2
View File
@@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse {
route_id: number; route_id: number;
profile_alignment: unknown; profile_alignment: unknown;
grade_summary: RouteGradeSummary | null; grade_summary: RouteGradeSummary | null;
/** 측점 · 관 · 구조물 자리가 없어 서버가 버린 계획고 편집 자리(m · 213-2). */
dropped_edit_chainages?: number[];
} }
/** /**
@@ -214,6 +214,30 @@ def snap_to_stations(
) )
def drop_unplaced_edits(
edits: dict[str, dict[str, float]], places: np.ndarray
) -> tuple[dict[str, dict[str, float]], list[float]]:
"""측점 · 관 · 구조물 자리(`places`) 0.006m 안에 없는 편집 키를 버린다(213-2).
옮긴 관의 옛 자리 · 지운 시설 자리에 남은 키는 측점선 없는 꼭짓점(유령)이 된다 — 세션 초안이
옛 키를 다시 보내도 [저장]에서 걸러진다. (남은 편집, 버린 자리 누가거리).
"""
kept: dict[str, dict[str, float]] = {}
dropped: set[float] = set()
for name, values in edits.items():
kept[name] = {}
for key, value in values.items():
try:
chainage = float(key)
except (TypeError, ValueError):
continue
if places.size and float(np.min(np.abs(places - chainage))) < SAME_STATION_SNAP_M:
kept[name][key] = value
else:
dropped.add(round(chainage, _KEY_DECIMALS))
return kept, sorted(dropped)
def resolve_pvi( def resolve_pvi(
base_s: np.ndarray, base_s: np.ndarray,
base_z: np.ndarray, base_z: np.ndarray,
+49 -22
View File
@@ -29,7 +29,6 @@ from B05_Profile.B05_Profile_Structures_Schema import (
structure_type_map, structure_type_map,
) )
from common_util.common_util_drainage_pipes import ( from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE, PIPE_FACILITY_PIPE,
PIPE_FACILITY_REVET, PIPE_FACILITY_REVET,
PipePoint, PipePoint,
@@ -208,9 +207,7 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None: def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음.""" """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖(물넘이포장 포함 · 286-1) = 종류 이름."""
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
return None
if pipe.facility == PIPE_FACILITY_PIPE: if pipe.facility == PIPE_FACILITY_PIPE:
values = pipe.options or {} values = pipe.options or {}
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
@@ -267,11 +264,12 @@ def resolve_extra_stations(
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다 (2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다. — B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다 물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1).
(2026-08-28 사용자 확정).
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를 라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
되짚기 때문이다(B05_Profile_Structures_Migration). 되짚기 때문이다(B05_Profile_Structures_Migration).
TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) ·
거울 시험 = `resources/tester/test_272_1_station_list.py`.
""" """
types = structure_type_map() types = structure_type_map()
derived: list[tuple[float, str]] = [] derived: list[tuple[float, str]] = []
@@ -549,7 +547,7 @@ def run_section_generation(
} }
def generate_irregular_sections( def sample_structure_sections(
project_root: Path, project_root: Path,
route_data_path: str, route_data_path: str,
filter_key: str, filter_key: str,
@@ -559,16 +557,15 @@ def generate_irregular_sections(
extra_stations: tuple[tuple[float, str], ...], extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None, options: SectionGenerationOptions | None = None,
crs: str | None = None, crs: str | None = None,
) -> list[dict[str, Any]]: ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다. """구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋 격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각 (`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로 (`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
""" """
if not extra_stations: if not extra_stations:
return [] return [], []
base = options or SectionGenerationOptions() base = options or SectionGenerationOptions()
merged_options = SectionGenerationOptions( merged_options = SectionGenerationOptions(
station_interval_m=base.station_interval_m, station_interval_m=base.station_interval_m,
@@ -589,18 +586,48 @@ def generate_irregular_sections(
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다. # 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root), design_curves=_load_design_curves(project_root),
) )
irregular_stations = [
station def structural(entry: dict[str, Any]) -> bool:
for station in result["longitudinal"]["stations"] return entry.get("kind") == "irregular" or bool(entry.get("structure"))
if station.get("kind") == "irregular"
] 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: if not irregular_stations:
return [] return []
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections" cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
cross_dir.mkdir(parents=True, exist_ok=True) cross_dir.mkdir(parents=True, exist_ok=True)
for cross_section in result["cross_sections"]: for cross_section in crosses:
if cross_section.get("kind") != "irregular":
continue
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"])) cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
atomic_write_json(cross_file, cross_section) atomic_write_json(cross_file, cross_section)
return irregular_stations return irregular_stations
@@ -415,8 +415,10 @@ def generate_sections(
kind = "bp" kind = "bp"
elif abs(float(value) - total) <= 1e-6: elif abs(float(value) - total) <= 1e-6:
kind = "ep" kind = "ep"
elif rounded in extras and rounded not in regular_rounded: elif rounded in extras:
# 격자와 겹치지 않는 비정규 측점만 irregular. 규칙 격자와 겹치면 규칙 측점 우선. # 구조물 측점이 위(272 · 요구 167) — 격자와 같은 m 여도 구조물 측점(irregular) 하나 ·
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
kind = "irregular" kind = "irregular"
else: else:
kind = "regular" kind = "regular"
@@ -466,9 +468,10 @@ def generate_sections(
"uphill_side": uphill_side, "uphill_side": uphill_side,
"frame": frame, "frame": frame,
} }
# 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지).
if rounded in extras: if rounded in extras:
station["structure"] = extras[rounded] station["structure"] = extras[rounded]
if rounded in regular_rounded:
station["grid"] = True # 숨은 일반 측점 자리(272)
stations.append(station) stations.append(station)
samples = [] samples = []
+39
View File
@@ -241,6 +241,45 @@ async def update_longitudinal_grade_summary(
) )
async def sync_cross_section_rows(
connection: aiomysql.Connection, route_id: int, chainages: list[float]
) -> int:
"""종단 정본 측점에 횡단 행을 맞춘다 — 정본에 없는 측점의 행(옮긴 · 지운 구조물 측점의 옛 자리).
0.5 m 안에 행 없는 새 측점이 있으면 그리로 옮긴다(옛 소수 → 정수 m · 사용자 설계 값 그대로 ·
면적은 저장 뒤 서버 재계산) · 나머지는 지운다 — 남기면 수량(B08 토적)이 옛 자리 측점을 그대로
셈(272). 같은 측점 = 0.01 m 안(`_rowless_station_chainages` 와 같은 폭). 측점 목록이 비면
아무것도 안 건드린다. 옮기거나 지운 행 수.
"""
if not chainages:
return 0
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT id, chainage_m FROM cross_sections WHERE route_id = %s", (route_id,)
)
rows = [(int(row[0]), float(row[1])) for row in await cursor.fetchall()]
orphans = [
(row_id, at) for row_id, at in rows if all(abs(at - c) >= 0.01 for c in chainages)
]
free = [c for c in chainages if all(abs(c - at) >= 0.01 for _, at in rows)]
pairs = sorted(
(abs(c - at), row_id, c) for row_id, at in orphans for c in free if abs(c - at) <= 0.5
)
moved: dict[int, float] = {}
for _gap, row_id, chainage in pairs:
if row_id not in moved and chainage not in moved.values():
moved[row_id] = chainage
for row_id, chainage in moved.items():
await cursor.execute(
"UPDATE cross_sections SET chainage_m = %s WHERE id = %s", (chainage, row_id)
)
dropped = [row_id for row_id, _ in orphans if row_id not in moved]
if dropped:
marks = ", ".join(["%s"] * len(dropped))
await cursor.execute(f"DELETE FROM cross_sections WHERE id IN ({marks})", dropped)
return len(orphans)
async def get_surface_crs_epsg( async def get_surface_crs_epsg(
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
) -> int | None: ) -> int | None:
+43 -6
View File
@@ -8,6 +8,7 @@ from typing import Any
from uuid import UUID from uuid import UUID
import aiomysql import aiomysql
import numpy as np
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -15,6 +16,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
from B05_Profile.B05_Profile_Debug import log_b05_debug from B05_Profile.B05_Profile_Debug import log_b05_debug
from B05_Profile.B05_Profile_Engine import run_route_design from B05_Profile.B05_Profile_Engine import run_route_design
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
@@ -384,13 +386,30 @@ async def update_contour_interval(
) )
def _structure_marks(project_root: Path, route_data_path: str) -> list[float]:
"""관 · 구조물 자리(m) — 관 지점 전부 + 구조물 측점 자리(시작 · 기준 · 종료)."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
resolve_extra_stations,
)
pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path))
extras = resolve_extra_stations(project_root, pipes)
return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras]
def _apply_alignment_edits( def _apply_alignment_edits(
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any] project_root: Path,
) -> tuple[dict[str, Any], dict[str, Any]]: longitudinal_file_path: str,
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다. edits: dict[str, Any],
marks: list[float] | None = None,
) -> tuple[dict[str, Any], dict[str, Any], list[float]]:
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약, 버린 자리)를 반환한다.
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에 화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. 남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. `marks`(관 · 구조물 자리)를 주면
측점 · 관 · 구조물 어디에도 없는 편집 키를 버린다(213-2 — 저장 차례가 관 · 구조물 뒤라 최신).
""" """
root = project_root.resolve() root = project_root.resolve()
path = (root / longitudinal_file_path).resolve() path = (root / longitudinal_file_path).resolve()
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
if not path.is_file(): if not path.is_file():
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.") raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
longitudinal = json.loads(path.read_text(encoding="utf-8")) longitudinal = json.loads(path.read_text(encoding="utf-8"))
dropped: list[float] = []
if marks is not None:
stations = [float(s.get("chainage_m", 0.0)) for s in longitudinal.get("stations") or []]
edits, dropped = drop_unplaced_edits(
{
"station_offsets": dict(edits.get("station_offsets") or {}),
"curve_radii": dict(edits.get("curve_radii") or {}),
},
np.array(stations + marks, dtype=np.float64),
)
alignment, profile = rebuild_alignment_profile(longitudinal, edits) alignment, profile = rebuild_alignment_profile(longitudinal, edits)
longitudinal["profile_alignment"] = alignment longitudinal["profile_alignment"] = alignment
profiles = longitudinal.get("design_profiles") profiles = longitudinal.get("design_profiles")
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
else: else:
longitudinal["design_profiles"] = [profile] longitudinal["design_profiles"] = [profile]
atomic_write_json(path, longitudinal) atomic_write_json(path, longitudinal)
return alignment, {"id": profile["id"], **profile["summary"]} return alignment, {"id": profile["id"], **profile["summary"]}, dropped
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse) @router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
@@ -426,11 +455,18 @@ async def save_profile_alignment(
) )
stored_path = await get_project_storage_relative_path(connection, project_id) stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path)) project_root = Path(resolve_stored_project_path(stored_path))
alignment, grade_summary = await asyncio.to_thread( route = await get_latest_route(connection, project_id)
marks = None
if route and route["id"] == request.route_id and route.get("route_data_path"):
marks = await asyncio.to_thread(
_structure_marks, project_root, str(route["route_data_path"])
)
alignment, grade_summary, dropped = await asyncio.to_thread(
_apply_alignment_edits, _apply_alignment_edits,
project_root, project_root,
str(longitudinal["longitudinal_file_path"]), str(longitudinal["longitudinal_file_path"]),
request.edits(), request.edits(),
marks,
) )
await connection.begin() await connection.begin()
try: try:
@@ -446,6 +482,7 @@ async def save_profile_alignment(
route_id=request.route_id, route_id=request.route_id,
profile_alignment=alignment, profile_alignment=alignment,
grade_summary=grade_summary, grade_summary=grade_summary,
dropped_edit_chainages=dropped,
) )
except FileNotFoundError as exc: except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
+111 -24
View File
@@ -6,6 +6,7 @@
import asyncio import asyncio
import json import json
import math
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -15,9 +16,19 @@ import aiomysql
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import generate_irregular_sections from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
find_cross_file,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg from B05_Profile.B05_Profile_Repository import (
get_surface_crs_epsg,
sync_cross_section_rows,
update_longitudinal_grade_summary,
)
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
from B06_Section.B06_Section_Repository import ( from B06_Section.B06_Section_Repository import (
get_latest_section_options, get_latest_section_options,
@@ -96,6 +107,7 @@ async def sync_uphill_overrides_into_designs(
compute_cross_design, compute_cross_design,
curve_widening_args, curve_widening_args,
stored_shoulder_extend, stored_shoulder_extend,
stored_tilt,
) )
from B06_Section.B06_Section_Repository import get_cross_section_designs from B06_Section.B06_Section_Repository import get_cross_section_designs
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
@@ -158,12 +170,13 @@ async def sync_uphill_overrides_into_designs(
paved=bool(design.get("paved", False)), paved=bool(design.get("paved", False)),
standard=stored_standard, standard=stored_standard,
rock_boundary_offset_m=design.get("rock_boundary_offset_m"), rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", True)), two_stage_slope=bool(design.get("two_stage_slope", False)),
ditch_enabled=design.get("ditch_enabled"), ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"), ditch_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops), surface_drop_m=ford_drop_at(float(chainage), ford_drops),
**curve_widening_args(cross_record), **curve_widening_args(cross_record),
**stored_shoulder_extend(design), # 노견 확장(139-6) **stored_shoulder_extend(design), # 노견 확장(139-6)
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
) )
next_design["status"] = design.get("status", "provisional") next_design["status"] = design.get("status", "provisional")
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
@@ -188,12 +201,16 @@ async def sync_uphill_overrides_into_designs(
_SAME_STATION_M = 0.006 _SAME_STATION_M = 0.006
def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[str, Any] | None: def _follow_moved_stations(
data: dict[str, Any], before: list[float], previous: list[float] | None = None
) -> dict[str, Any] | None:
"""사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다. """사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다.
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에 남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다. 편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`. 자리(272)만 옮기고 나머지는 지운다.
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
아니면 `None`.
""" """
stored = data.get("profile_alignment") stored = data.get("profile_alignment")
if not isinstance(stored, dict) or not stored.get("base_pvi"): if not isinstance(stored, dict) or not stored.get("base_pvi"):
@@ -213,6 +230,11 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
), ),
None, None,
) )
# 옛 소수 측점 → 정수 m 맞춤(272 · 요구 167) — 맞춘 자리가 새 측점이면 꼭짓점 · 편집도
# 그리로(지우면 계획선이 그 자리에서 바뀜 · 화면 `followMovedNodes` 와 짝)
snapped = chainage_key(math.floor(old + 0.5))
if near is None and snapped in after_keys and snapped not in before_keys:
near = float(snapped)
moves[chainage_key(old)] = None if near is None else chainage_key(near) moves[chainage_key(old)] = None if near is None else chainage_key(near)
def moved(value: float) -> tuple[bool, str | None]: def moved(value: float) -> tuple[bool, str | None]:
@@ -249,7 +271,15 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
changed = True changed = True
if new_key is not None: if new_key is not None:
base.append({**node, "chainage_m": float(new_key)}) base.append({**node, "chainage_m": float(new_key)})
if not changed: # 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
# 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌
rows = stored.get("stations")
listed = (
[round(float(row.get("chainage_m", 0.0)), 6) for row in rows]
if isinstance(rows, list)
else previous
)
if not changed and listed in (None, [round(value, 6) for value in after]):
return None return None
stored["base_pvi"] = base stored["base_pvi"] = base
alignment, profile = rebuild_alignment_profile(data, next_edits) alignment, profile = rebuild_alignment_profile(data, next_edits)
@@ -265,12 +295,17 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
def _merge_irregular_into_longitudinal( def _merge_irregular_into_longitudinal(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]] project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬). """종단 정본 stations 의 구조물 측점을 **지금 목록 전체로 다시 맞춘다**(272 · 요구 167).
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과 `irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은 `grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` — 구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그
바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다. 격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다.
B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다
(`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations`
— 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다.
TS 짝 = `common_util_structure_station.ts` `oneStationList`(화면 측점 한 벌) · 거울 시험 =
`resources/tester/test_272_1_station_list.py`.
""" """
root = project_root.resolve() root = project_root.resolve()
path = (root / longitudinal_file_path).resolve() path = (root / longitudinal_file_path).resolve()
@@ -285,15 +320,53 @@ def _merge_irregular_into_longitudinal(
for station in stations for station in stations
if station.get("kind") == "irregular" if station.get("kind") == "irregular"
] ]
regular = [station for station in stations if station.get("kind") != "irregular"] fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
merged = regular + list(irregular_stations) kept: dict[float, dict[str, Any]] = {}
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0))) left: list[dict[str, Any]] = []
data["stations"] = merged for station in stations:
summary = _follow_moved_stations(data, before) if station.get("kind") == "irregular" and not station.get("grid"):
continue # 옛 구조물 측점 — 새 목록이 갈음
key = round(float(station.get("chainage_m", 0.0)), 6)
if key not in fresh and ("structure" in station or station.get("grid")):
# 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함)
station = {k: v for k, v in station.items() if k not in ("grid", "structure")}
if station.get("kind") == "irregular":
station["kind"] = "regular"
left.append(station)
kept[key] = station
kept.update(fresh)
data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0)))
previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations]
summary = _follow_moved_stations(data, before, previous)
atomic_write_json(path, data) atomic_write_json(path, data)
_relabel_cross_files(path.parent.parent / "cross_sections", left)
return summary return summary
async def drop_rows_off_stations(
connection: aiomysql.Connection, route_id: int, project_root: Path, longitudinal_path: str
) -> None:
"""측점을 다시 맞춘 뒤 — 종단 정본에 없는 측점의 횡단 행을 새 측점으로 옮기거나 지운다(272)."""
path = (project_root.resolve() / longitudinal_path).resolve()
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
await sync_cross_section_rows(
connection, route_id, [float(station["chainage_m"]) for station in stations]
)
def _relabel_cross_files(cross_dir: Path, stations: list[dict[str, Any]]) -> None:
"""일반 측점으로 돌아온 자리의 횡단 파일 글(종류 · 구조물)도 맞춘다 — 카드 제목(272)."""
for station in stations:
cross_path = find_cross_file(cross_dir, float(station.get("chainage_m", 0.0)))
if not cross_path.is_file():
continue
cross = json.loads(cross_path.read_text(encoding="utf-8"))
cross.pop("structure", None)
cross.pop("grid", None)
cross["kind"] = station.get("kind")
atomic_write_json(cross_path, cross)
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해. #: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다. #: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
SAMPLING_SNAPSHOT_NAME = "sampling.json" SAMPLING_SNAPSHOT_NAME = "sampling.json"
@@ -350,24 +423,38 @@ async def _append_irregular_cross_sections(
save_sampling_snapshot(project_root, request) save_sampling_snapshot(project_root, request)
stored_options = await get_latest_section_options(connection, project_id) stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id) crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread( route_data_path = str(route["route_data_path"])
generate_irregular_sections,
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, project_root,
str(route["route_data_path"]), route_data_path,
request.filter_key, request.filter_key,
request.method, request.method,
request.smooth, request.smooth,
extra_stations=request.extra_stations(), extra_stations=extras,
options=_section_options_from_stored(stored_options), options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
) )
if not irregular_stations:
return # 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(272)
irregular_stations = await asyncio.to_thread(_generate)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"]) longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
if longitudinal: if longitudinal:
await asyncio.to_thread( summary = await asyncio.to_thread(
_merge_irregular_into_longitudinal, _merge_irregular_into_longitudinal,
project_root, project_root,
str(longitudinal["longitudinal_file_path"]), str(longitudinal["longitudinal_file_path"]),
irregular_stations, irregular_stations,
) )
if summary is not None: # 계획선을 다시 셈 — DB 등급 요약도 같이(B06 측점 만들기와 같은 길)
await update_longitudinal_grade_summary(
connection, route_id=route["id"], grade_summary=summary
)
await drop_rows_off_stations(
connection, route["id"], project_root, str(longitudinal["longitudinal_file_path"])
)
@@ -173,7 +173,9 @@ async def apply_profile_target(
root = Path(resolve_stored_project_path(stored_path)) root = Path(resolve_stored_project_path(stored_path))
relative = str(row["longitudinal_file_path"]) relative = str(row["longitudinal_file_path"])
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target) count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
_alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {}) _alignment, summary, _dropped = await asyncio.to_thread(
_apply_alignment_edits, root, relative, {}
)
async with pool.acquire() as connection: async with pool.acquire() as connection:
await connection.begin() await connection.begin()
await update_longitudinal_grade_summary( await update_longitudinal_grade_summary(
+6 -7
View File
@@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel):
route_id: int route_id: int
profile_alignment: dict[str, Any] profile_alignment: dict[str, Any]
grade_summary: dict[str, Any] | None = None grade_summary: dict[str, Any] | None = None
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
dropped_edit_chainages: list[float] = Field(default_factory=list)
class RouteSolveResponse(BaseModel): class RouteSolveResponse(BaseModel):
@@ -266,7 +268,8 @@ class RouteConfirmRequest(BaseModel):
"""경로 확정 요청. """경로 확정 요청.
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값 확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다. (solve 때 쓰던 필터/방법/모델)을 함께 받는다. 셋이 다 오면 구조물 측점을 서버 정본 목록
전체로 다시 맞춘다(빈 목록이어도 · 272).
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다. 모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
""" """
@@ -284,12 +287,8 @@ class RouteConfirmRequest(BaseModel):
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations) return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
def can_regenerate(self) -> bool: def can_regenerate(self) -> bool:
return bool( # 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272)
self.irregular_stations return bool(self.filter_key and self.method and self.surface_model_id is not None)
and self.filter_key
and self.method
and self.surface_model_id is not None
)
class RouteConfirmResponse(BaseModel): class RouteConfirmResponse(BaseModel):
+5 -2
View File
@@ -20,6 +20,7 @@ import {
writeCachedBytes, writeCachedBytes,
} from "../A00_Common/b_asset_cache"; } from "../A00_Common/b_asset_cache";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; 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 type { RoutePoint } from "./B05_Profile_Api_Fetch";
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import { import {
@@ -247,7 +248,7 @@ function withSections(
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). /** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거).
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */ * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */
export function refreshCorridor( export async function refreshCorridor(
viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
projectId: string, projectId: string,
routeId: number | undefined, routeId: number | undefined,
@@ -256,7 +257,9 @@ export function refreshCorridor(
designSamples?: ProfileSamples, designSamples?: ProfileSamples,
rebuild = true, rebuild = true,
): Promise<void> { ): 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) return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
.then((build) => viewer.setCorridor(withSections(build, detail))) .then((build) => viewer.setCorridor(withSections(build, detail)))
.catch(() => viewer.setCorridor(null)); .catch(() => viewer.setCorridor(null));
@@ -181,7 +181,8 @@ function fnv1a(text: string): string {
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다. // (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
// 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이 // 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이
// 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다. // 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다.
const BUILD_VERSION = 93; // 94 = 물넘이포장 노면을 파지 않음(287-4) — 설계 노면이 콘크리트 윗면 · 저장본을 만료한다.
const BUILD_VERSION = 94;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+2 -17
View File
@@ -9,7 +9,6 @@
* (2026-08-23 사용자 지시)의 핵심. * (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 type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; 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; if (!design || !line || line.length < 2) return null;
const roadL = design.road_edges.left.offset_m; const roadL = design.road_edges.left.offset_m;
const roadR = design.road_edges.right.offset_m; const roadR = design.road_edges.right.offset_m;
// 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려 // 물넘이포장 — 설계 노면이 곧 콘크리트 윗면(287-4 · 서버가 지대 낮은 쪽 한 경사로 냄) · 파지 않음
// 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라 const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
// 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다.
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);
const cwL = design.carriageway_edges?.left.offset_m ?? roadL; const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
const cwR = design.carriageway_edges?.right.offset_m ?? roadR; const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
@@ -487,6 +487,8 @@ export function buildCorridorStructures(
chuteBands( chuteBands(
fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments), fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments),
section.ford?.chute, section.ford?.chute,
undefined,
fordLayout.ground,
).forEach((band) => pushSwept("chute", band, half, half, null, "chute")); ).forEach((band) => pushSwept("chute", band, half, half, null, "chute"));
// 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면** // 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면**
// (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은 // (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은
@@ -595,7 +597,12 @@ export function buildCorridorStructures(
); );
} }
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3). // 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 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"), pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"),
); );
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
@@ -182,6 +182,8 @@ export function boxSolids(
const chutes: CorridorStructure[] = chuteBands( const chutes: CorridorStructure[] = chuteBands(
layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments), layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments),
box.chute, box.chute,
undefined,
layout.ground,
).map((band) => ({ ).map((band) => ({
chainage_m: chainage, chainage_m: chainage,
kind: "chute", kind: "chute",
@@ -10,7 +10,11 @@
import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; 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"; import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
/** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */ /** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */
@@ -25,10 +29,11 @@ export function chuteBands(
segments: ReadonlyArray<FillSegment>, segments: ReadonlyArray<FillSegment>,
chute: ChuteSpec | null | undefined, chute: ChuteSpec | null | undefined,
keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut", keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut",
/** 원지반 — 길이가 사면보다 길면 이어 그림(294-4 · 횡단 띠와 같은 `chuteRun`). */
ground?: readonly OffsetPoint[],
): OffsetPoint[][] { ): OffsetPoint[][] {
if (!chute) return []; if (!chute) return [];
return segments.flatMap((segment) => { return chuteRun(segments.filter(keep), chute.length_m, ground).flatMap((segment) => {
if (!keep(segment)) return [];
const band = chuteBandOutline(segment.points, chute.thickness_m); const band = chuteBandOutline(segment.points, chute.thickness_m);
return band ? [band.outline] : []; return band ? [band.outline] : [];
}); });
@@ -51,6 +56,12 @@ export function fordPavementChute(
); );
if (owner !== section) return null; 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; return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null;
} }
@@ -16,7 +16,10 @@ import type {
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */ /** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> { function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
return chute ? ["ch", chute.structure, chute.thickness_m] : []; if (!chute) return [];
// 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로
const base = ["ch", chute.structure, chute.thickness_m];
return chute.length_m ? [...base, chute.length_m] : base;
} }
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ /** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
+26 -35
View File
@@ -56,7 +56,6 @@ import {
type SpecForm, type SpecForm,
type SpecLinks, type SpecLinks,
} from "../A00_Common/design/design_structure_form"; } 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 { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import { import {
BOX_SPEC, BOX_SPEC,
@@ -65,11 +64,12 @@ import {
boxOptionsIn, boxOptionsIn,
} from "../A00_Common/design/design_structure_box"; } from "../A00_Common/design/design_structure_box";
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford"; 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 { import {
pipeDrawn, pipeDrawn,
pipeExtraCount, pipeExtraCount,
pipeLengthM, pipeLengthM,
pipeLengthText,
} from "../A00_Common/design/design_structure_pipe_calc"; } from "../A00_Common/design/design_structure_pipe_calc";
import { import {
hasLinks, hasLinks,
@@ -339,7 +339,7 @@ export function createFacilityOptionsForm(
auto: (_c, item) => { auto: (_c, item) => {
if (item.id !== "length") return ""; if (item.id !== "length") return "";
const length = pipeLengthM(chainage); const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length)); return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length);
}, },
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
@@ -371,38 +371,21 @@ export function createFacilityOptionsForm(
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) — // 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) —
// 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4) // 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4)
let fordFlow: number | null = null; let fordFlow: number | null = null;
let heightPending = false; const fordFlowBox = overflowSync(
const overflow = (form: () => SpecForm, box: string) => { () => fordForm,
const values = () => form().state()[box]?.[0] ?? {}; "ford",
const need = (): number | null => fordHeightM(fordFlow, Number(values().width ?? "")); () => fordFlow,
return { emit,
onChange: (c: SpecContainer, item: SpecItem) => { );
if (c.id === box && item.id === "height") heightPending = false; const guardFields = guardChoiceFields(
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");
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
const fordForm: SpecForm = createSpecForm(specLinks, {
onChange: fordFlowBox.onChange,
fields: guardChoiceFields(
() => chainage, () => chainage,
() => fordForm.refresh(), () => fordForm.refresh(),
() => fordForm.recount(), () => fordForm.recount(),
), );
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
const fordForm: SpecForm = createSpecForm(specLinks, {
onChange: fordFlowBox.onChange,
fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)],
count: guardSoilCount( count: guardSoilCount(
() => chainage, () => chainage,
() => fordForm.state().outlet?.[0]?.structure, () => fordForm.state().outlet?.[0]?.structure,
@@ -484,8 +467,16 @@ export function createFacilityOptionsForm(
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 · // ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 ·
// 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ── // 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
const paveFlowBox = overflow(() => paveForm, "slope"); const paveFlowBox = overflowSync(
const paveForm: SpecForm = createSpecForm(specLinks, { onChange: paveFlowBox.onChange }); () => paveForm,
"slope",
() => fordFlow,
emit,
);
const paveForm: SpecForm = createSpecForm(specLinks, {
onChange: paveFlowBox.onChange,
fields: paveFlowBox.fields,
});
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`. // 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
@@ -602,7 +593,7 @@ export function createFacilityOptionsForm(
}, },
setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) { setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) {
current = facility; current = facility;
heightPending = fresh; [fordFlowBox, paveFlowBox].forEach((box) => box.arm(fresh));
hiddenOptions.remember(options); hiddenOptions.remember(options);
fordFlow = designFlow ?? null; fordFlow = designFlow ?? null;
if (facility === null) { if (facility === null) {
@@ -12,18 +12,29 @@ import {
FORD_PAVEMENT_DEFAULT_WIDTH_M, FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend"; } from "@config/config_frontend";
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; 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> = { const FORD_KINDS: Record<string, number> = {
ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M, ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M,
ford_pavement: FORD_PAVEMENT_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( export function waitsFordHeight(
facility: string | undefined, facility: string | undefined,
options?: Record<string, string | number>, options?: Record<string, string | number>,
): boolean { ): 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` 에서 뺌. /** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌.
@@ -39,7 +50,7 @@ export function fillFordHeights(
): boolean { ): boolean {
let filled = false; let filled = false;
for (const wait of [...pending]) { 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)) { if (!point?.facility || !(point.facility in FORD_KINDS)) {
pending.delete(wait); // 관이 사라졌거나 종류가 바뀜 pending.delete(wait); // 관이 사라졌거나 종류가 바뀜
continue; continue;
@@ -48,7 +59,9 @@ export function fillFordHeights(
pending.delete(wait); // 사용자가 넣은 값 — 안 덮음 pending.delete(wait); // 사용자가 넣은 값 — 안 덮음
continue; 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 width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility];
const need = fordHeightM(flow?.design_flow_m3s ?? null, width); const need = fordHeightM(flow?.design_flow_m3s ?? null, width);
if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸 if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸
@@ -8,6 +8,7 @@
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다. * 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
* ========================================================================== */ * ========================================================================== */
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { import {
computeMaxScale, computeMaxScale,
lonLatToScreen, lonLatToScreen,
@@ -17,6 +18,7 @@ import {
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { DetailBasin } 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 { 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 { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import type { createMapContextMenu } from "@ui/ui_template_context_menu"; import type { createMapContextMenu } from "@ui/ui_template_context_menu";
@@ -33,6 +35,8 @@ export interface DrainageInteractParams {
setScale: (value: number) => void; setScale: (value: number) => void;
/** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */ /** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */
getMeta: () => VWorldMeta | null; getMeta: () => VWorldMeta | null;
/** 줌아웃 한계 배율 — 노선 기준(계획서 281). */
getMinScale: () => number;
getOffset: () => { x: number; y: number }; getOffset: () => { x: number; y: number };
setOffset: (x: number, y: number) => void; setOffset: (x: number, y: number) => void;
scheduleDraw: () => void; scheduleDraw: () => void;
@@ -62,7 +66,13 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
// 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시). // 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시).
const view = currentView(); const view = currentView();
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16); 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); params.setScale(scale);
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale; const ratio = scale / prevScale;
+45 -24
View File
@@ -1,5 +1,6 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { viewOf } from "../common_util/common_util_view_window";
import { import {
computeDetailBasins, computeDetailBasins,
fetchDetailPipePoints, 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 { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { snapStructureStation } from "../common_util/common_util_structure_station";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report"; 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 { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange";
import { import {
mountDrainageToggles, mountDrainageToggles,
DRAINAGE_LAYERS, DRAINAGE_LAYERS,
fetchDrainageLayers, fetchDrainageLayers,
createProgressReporter, createProgressReporter,
analyzeFailed,
fitCanvasToViewport, fitCanvasToViewport,
fitViewToRoute, fitViewToRoute,
observeViewportSize, observeViewportSize,
@@ -172,10 +177,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
let frameHandle = 0; let frameHandle = 0;
/** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */
let frameView: Window = window;
let loadSequence = 0; let loadSequence = 0;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택 let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
/** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */ /** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */
const fordWaits = new Set<number>(); const fordWaits = () => fordWaitsOf(projectId);
let canvasWidth = 0; let canvasWidth = 0;
let canvasHeight = 0; let canvasHeight = 0;
let canvasDpr = 0; let canvasDpr = 0;
@@ -284,24 +291,25 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */ /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
function syncPipeSelection(): void { function syncPipeSelection(notify = true): void {
const index = pipeEditor.selected(); const index = pipeEditor.selected();
deleteButton.disabled = index === null; deleteButton.disabled = index === null;
const picked = index === null ? null : pipeEditor.pipes()[index]; const picked = index === null ? null : pipeEditor.pipes()[index];
selectBasin(basinIndexOfPipe(basins, picked ?? null)); selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은 // 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
// Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply) // Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply)
// 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게. // 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게.
const pickedChainage = picked ? picked.chainage_m : null; const pickedChainage = picked ? picked.chainage_m : null;
if (pickedChainage !== lastNotifiedPipeChainage) { if (pickedChainage !== lastNotifiedPipeChainage) {
lastNotifiedPipeChainage = pickedChainage; lastNotifiedPipeChainage = pickedChainage;
callbacks.onPipeSelected?.(pickedChainage); if (notify) callbacks.onPipeSelected?.(pickedChainage);
} }
} }
function scheduleDraw(): void { function scheduleDraw(): void {
if (frameHandle) return; if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => { frameView = viewOf(root);
frameHandle = frameView.requestAnimationFrame(() => {
frameHandle = 0; frameHandle = 0;
draw(); 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); facilityStore.replaceFromResponse(response.pipe_points);
if (fordFilled) rememberPipes(); if (fordFilled) rememberPipes();
// 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직 // 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직
@@ -384,8 +392,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
} catch (error) { } catch (error) {
if (seq !== requestSeq) return; if (seq !== requestSeq) return;
status.hidden = false; status.hidden = false;
status.textContent = status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
} finally { } finally {
if (seq === requestSeq) { if (seq === requestSeq) {
analyzeButton.disabled = false; analyzeButton.disabled = false;
@@ -414,7 +421,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const loadSaved = (): Promise<void> => const loadSaved = (): Promise<void> =>
run(() => { run(() => {
const pending = readPendingPipes(projectId as string); const pending = readPendingPipes(projectId as string);
return pending?.length return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
? computeDetailBasins(projectId as string, pending) ? computeDetailBasins(projectId as string, pending)
: fetchDetailPipePoints(projectId as string); : fetchDetailPipePoints(projectId as string);
}); });
@@ -455,6 +462,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
offsetY = fitted.offsetY; offsetY = fitted.offsetY;
} }
function zoomOutLimit(): number {
const view = currentView();
return computeZoomOutLimit(meta, routePoints, basins, view.width, view.height);
}
async function loadLayers(): Promise<void> { async function loadLayers(): Promise<void> {
if (!projectId) return; if (!projectId) return;
const activeProjectId = projectId; const activeProjectId = projectId;
@@ -511,6 +523,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
scale = value; scale = value;
}, },
getMeta: () => meta, getMeta: () => meta,
getMinScale: zoomOutLimit,
getOffset: () => ({ x: offsetX, y: offsetY }), getOffset: () => ({ x: offsetX, y: offsetY }),
setOffset: (x, y) => { setOffset: (x, y) => {
offsetX = x; offsetX = x;
@@ -534,8 +547,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
offsetY = next.offsetY; offsetY = next.offsetY;
}, },
redraw: () => { redraw: () => {
// 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. // 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. 늘려서 줌아웃 한계 밖이면 노선 맞춤(281-2).
if (fitPending) fitToRoute(); if (fitPending || scale < zoomOutLimit() * 0.999) fitToRoute();
scheduleDraw(); scheduleDraw();
}, },
}); });
@@ -567,15 +580,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
movePipe(fromChainage, toChainage) { movePipe(fromChainage, toChainage) {
const index = pipeEditor const index = pipeEditor
.pipes() .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; if (index < 0) return;
pipeEditor.moveTo(index, toChainage); const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게)
facilityStore.move(pipeEditor.pipes()[index].chainage_m, to);
pipeEditor.moveTo(index, to);
}, },
selectBasinByChainage(chainageM) { selectBasinByChainage(chainageM) {
const picked = const picked =
chainageM === null chainageM === null
? 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); selectBasin(picked ? picked.index : null, false);
}, },
@@ -601,23 +616,27 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
scheduleDraw(); scheduleDraw();
}, },
addPipe(chainageM, attributes) { addPipe(chainageM, attributes) {
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
facilityStore.set(chainageM, attributes ?? null); 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); pipeEditor.addAtChainage(chainageM);
}, },
updatePipeFacility(fromChainageM, toChainageM, attributes) { updatePipeFacility(fromChainageM, toChainageM, attributes) {
// 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리
if (Math.abs(toChainageM - fromChainageM) > 0.005)
toChainageM = snapStructureStation(toChainageM);
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임. // ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
const previous = facilityStore.get(fromChainageM); const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null); facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes)); 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) { if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고, // 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다. // attach가 새 위치로 시설 정보를 승계한다.
const index = pipeEditor const index = pipeEditor
.pipes() .pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51); .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M);
if (index >= 0) pipeEditor.moveTo(index, toChainageM); if (index >= 0) pipeEditor.moveTo(index, toChainageM);
else void analyze(); else void analyze();
} else { } else {
@@ -630,21 +649,23 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
removePipe(chainageM) { removePipe(chainageM) {
const index = pipeEditor const index = pipeEditor
.pipes() .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; if (index < 0) return;
pipeEditor.select(index); pipeEditor.select(index);
pipeEditor.deleteSelected(); pipeEditor.deleteSelected();
}, },
selectPipeAtChainage(chainageM) { selectPipeAtChainage(chainageM, notify = true) {
const index = const index =
chainageM === null chainageM === null
? 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; const next = index === -1 ? null : index;
if (pipeEditor.selected() === next) return; if (pipeEditor.selected() === next) return;
// select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다. // select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다.
pipeEditor.select(next); pipeEditor.select(next);
syncPipeSelection(); syncPipeSelection(notify);
scheduleDraw(); scheduleDraw();
}, },
async savePipes() { async savePipes() {
@@ -667,7 +688,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
dispose() { dispose() {
loadSequence += 1; loadSequence += 1;
if (frameHandle) { if (frameHandle) {
window.cancelAnimationFrame(frameHandle); frameView.cancelAnimationFrame(frameHandle);
frameHandle = 0; frameHandle = 0;
} }
resizeObserver.disconnect(); resizeObserver.disconnect();
@@ -45,7 +45,8 @@ export interface DrainagePanel {
selectBasinByChainage: (chainageM: number | null) => void; selectBasinByChainage: (chainageM: number | null) => void;
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */ * 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
selectPipeAtChainage: (chainageM: number | null) => void; /** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(밖으로 안 알림 · 재계산 뒤 다시 얹음 · 233-3). */
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
markStation: (chainageM: number | null) => void; markStation: (chainageM: number | null) => void;
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
+13 -4
View File
@@ -9,6 +9,7 @@
import { stateKey } from "../A00_Common/b_page_state"; import { stateKey } from "../A00_Common/b_page_state";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette"; 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 { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { 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 { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; 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 { function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
@@ -153,7 +155,7 @@ export function renderBasinRows(
.replace("{relief}", basin.relief_m.toFixed(1)) .replace("{relief}", basin.relief_m.toFixed(1))
.replace("{flow}", String(Math.round(basin.flow_length_m))) .replace("{flow}", String(Math.round(basin.flow_length_m)))
.replace("{pipe}", pipe); .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) { if (basin.tc_minutes != null && basin.design_flow_m3s != null) {
row.title += row.title +=
@@ -352,7 +354,7 @@ export function reconcilePipes(
return null; return null;
} }
return next.map((chainage) => { 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" }; 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 { 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"); if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb");
return basinColor(basin.index).replace(/0\.45\)$/, "1)"); return basinColor(basin.index).replace(/0\.45\)$/, "1)");
} }
@@ -527,7 +536,7 @@ export function basinIndexOfPipe(
pipe: { chainage_m: number } | null, pipe: { chainage_m: number } | null,
): number | null { ): number | null {
if (!pipe) return 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; return basin ? basin.index : null;
} }
+17 -14
View File
@@ -1,6 +1,8 @@
import { themeColor } from "@ui/ui_template_palette"; import { themeColor } from "@ui/ui_template_palette";
import { pickPx } from "../A00_Common/touch_bridge";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { snapStructureStation } from "../common_util/common_util_structure_station";
// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제. // 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제.
// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선 // 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선
@@ -74,6 +76,9 @@ export function createPipeEditor(
/** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다 /** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다
* (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */ * (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */
let dragDownAt: { x: number; y: number } | null = null; let dragDownAt: { x: number; y: number } | null = null;
/** 노선 안 · 관 기준 측점 = 정수 m(일반 측점 1 m 안이면 그 측점 · 234-1). */
const onRoute = (chainageM: number): number =>
Math.min(snapStructureStation(Math.max(0, Math.min(totalChainage, chainageM))), totalChainage);
/** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */
function screenToMetric( function screenToMetric(
@@ -192,21 +197,21 @@ export function createPipeEditor(
if (!xy) continue; if (!xy) continue;
const screen = metricToScreen(view, xy.x, xy.y); const screen = metricToScreen(view, xy.x, xy.y);
if (!screen) continue; if (!screen) continue;
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) return i; if (Math.hypot(screenX - screen.x, screenY - screen.y) <= pickPx(HIT_RADIUS_PX)) return i;
} }
return null; return null;
}, },
moveTo(index, chainageM) { moveTo(index, chainageM) {
const pipe = pipeList[index]; const pipe = pipeList[index];
if (!pipe) return; if (!pipe) return;
pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM)); pipe.chainage_m = onRoute(chainageM);
pipe.reason = "confirmed"; pipe.reason = "confirmed";
sortPipes(); sortPipes();
onChange(); onChange();
onCommit(); onCommit();
}, },
addAtChainage(chainageM) { addAtChainage(chainageM) {
const clamped = Math.max(0, Math.min(totalChainage, chainageM)); const clamped = onRoute(chainageM);
pipeList.push({ chainage_m: clamped, reason: "confirmed" }); pipeList.push({ chainage_m: clamped, reason: "confirmed" });
sortPipes(); sortPipes();
selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6); selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6);
@@ -217,25 +222,24 @@ export function createPipeEditor(
const metric = screenToMetric(view, screenX, screenY); const metric = screenToMetric(view, screenX, screenY);
if (!metric) return false; if (!metric) return false;
const nearest = nearestChainage(metric.x, metric.y); const nearest = nearestChainage(metric.x, metric.y);
return !!nearest && nearest.distance * pxPerMeter(view) <= ADD_SNAP_PX; return !!nearest && nearest.distance * pxPerMeter(view) <= pickPx(ADD_SNAP_PX);
}, },
chainageAt(view, screenX, screenY) { chainageAt(view, screenX, screenY) {
const metric = screenToMetric(view, screenX, screenY); const metric = screenToMetric(view, screenX, screenY);
if (!metric) return null; if (!metric) return null;
const nearest = nearestChainage(metric.x, metric.y); const nearest = nearestChainage(metric.x, metric.y);
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null; if (!nearest || nearest.distance * pxPerMeter(view) > pickPx(ADD_SNAP_PX)) return null;
return Number(nearest.chainage.toFixed(2)); return onRoute(nearest.chainage);
}, },
addAt(view, screenX, screenY) { addAt(view, screenX, screenY) {
const metric = screenToMetric(view, screenX, screenY); const metric = screenToMetric(view, screenX, screenY);
if (!metric) return false; if (!metric) return false;
const nearest = nearestChainage(metric.x, metric.y); const nearest = nearestChainage(metric.x, metric.y);
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; if (!nearest || nearest.distance * pxPerMeter(view) > pickPx(ADD_SNAP_PX)) return false;
pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" }); const at = onRoute(nearest.chainage);
pipeList.push({ chainage_m: at, reason: "confirmed" });
sortPipes(); sortPipes();
selectedIndex = pipeList.findIndex( selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - at) < 1e-6);
(pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6,
);
onChange(); onChange();
onCommit(); onCommit();
return true; return true;
@@ -250,7 +254,7 @@ export function createPipeEditor(
if (!xy) continue; if (!xy) continue;
const screen = metricToScreen(view, xy.x, xy.y); const screen = metricToScreen(view, xy.x, xy.y);
if (!screen) continue; if (!screen) continue;
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { if (Math.hypot(screenX - screen.x, screenY - screen.y) <= pickPx(HIT_RADIUS_PX)) {
selectedIndex = i; selectedIndex = i;
draggingIndex = i; draggingIndex = i;
dragFrom = pipeList[i].chainage_m; dragFrom = pipeList[i].chainage_m;
@@ -271,8 +275,7 @@ export function createPipeEditor(
if (!metric) return true; if (!metric) return true;
const nearest = nearestChainage(metric.x, metric.y); const nearest = nearestChainage(metric.x, metric.y);
if (!nearest) return true; if (!nearest) return true;
const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage)); pipeList[draggingIndex].chainage_m = onRoute(nearest.chainage);
pipeList[draggingIndex].chainage_m = clamped;
pipeList[draggingIndex].reason = "confirmed"; pipeList[draggingIndex].reason = "confirmed";
dragMoved = true; dragMoved = true;
onChange(); onChange();
@@ -7,6 +7,7 @@
import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types"; import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. /** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
* 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, * 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
@@ -20,7 +21,7 @@ export function buildPipeReports(
): DrainagePipeReport[] { ): DrainagePipeReport[] {
return pipes.map((pipe) => { return pipes.map((pipe) => {
const chainage = Math.round(pipe.chainage_m * 100) / 100; const chainage = Math.round(pipe.chainage_m * 100) / 100;
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M);
const attributes = facilityStore.get(chainage); const attributes = facilityStore.get(chainage);
return { return {
chainage_m: chainage, chainage_m: chainage,
+5 -66
View File
@@ -12,8 +12,7 @@
* · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()` * · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()`
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems` * · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
* · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()` * · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()`
* 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역 * 두 보기(종단 · 횡단)에서 같은 판을 끼운다 — 판은 늘 보이고 접기 · 펼치기는 판 안 손잡이 하나로.
* 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다.
* ========================================================================== */ * ========================================================================== */
import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다. import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다.
@@ -23,71 +22,11 @@ import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */ /** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */
export type DrainageSideHost = DesignSideHost; export type DrainageSideHost = DesignSideHost;
/** 종단도 side 판 = 배수유역 판 창구 그대로 + 켜고 끔. */ /** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. 접기 · 펼치기는 판 손잡이 몫. */
export type DrainageSideBoard = DesignSideBoard & { export function createDrainageSide(host: DrainageSideHost): DesignSideBoard {
/** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */
setVisible: (visible: boolean) => void;
isVisible: () => boolean;
/** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */
onVisibleChange: (listener: (visible: boolean) => void) => () => void;
};
export interface DrainageSideOptions {
/** 처음 켤지(기본 켬). */
visible?: boolean;
}
/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. */
export function createDrainageSide(
host: DrainageSideHost,
options: DrainageSideOptions = {},
): DrainageSideBoard {
const panel = createDrainagePanel(host);
const listeners = new Set<(visible: boolean) => void>();
let visible = true;
function setVisible(next: boolean): void {
if (next === visible) return;
visible = next;
panel.root.hidden = !visible;
listeners.forEach((listener) => listener(visible));
}
// 처음 상태는 알림 없이 맞춘다.
if (options.visible === false) {
visible = false;
panel.root.hidden = true;
}
return { return {
...panel, ...createDrainagePanel(host),
// 보기가 바뀌어도 판은 다시 안 만든다 — 켬 · 끔은 끼우는 쪽이 `setVisible` 로 정한다. // 보기가 바뀌어도 판은 다시 안 만든다.
setView: () => {}, setView: () => {},
setVisible,
isVisible: () => visible,
onVisibleChange(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
listeners.clear();
panel.dispose();
},
}; };
} }
/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 판을 뒤집는다. */
export function createDrainageToggleButton(side: DrainageSideBoard): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "ui-btn ui-btn--ghost b05-drainage__toggle";
button.textContent = "배수유역도";
button.title = "배수유역도 판을 켜고 끕니다";
const sync = (visible: boolean): void => {
button.setAttribute("aria-pressed", String(visible));
button.classList.toggle("is-active", visible);
};
sync(side.isVisible());
side.onVisibleChange(sync);
button.addEventListener("click", () => side.setVisible(!side.isVisible()));
return button;
}
@@ -0,0 +1,83 @@
/* =============================================================================
* B05_Profile_UI_Drainage_ZoomRange.ts
* 배수유역 지도의 줌아웃 한계(가장 작은 배율) — 도엽 전체가 아니라 **노선 기준**(계획서 281).
*
* 한계 = 노선 범위를 가운데 두고 ZOOM_OUT_ROUTE_FACTOR 배로 넓힌 범위가 화면에 들어오는 배율.
* 유역 폴리곤이 그 밖으로 나가면 유역까지 넓힌다. 처음 보기(노선 + 여유)보다 좁히지 않고,
* 도엽 전체(배율 1)보다 멀리는 안 간다. 노선이 없으면 종전 꼴(ZOOM_OUT_FALLBACK_SCALE).
* 화면 그리기 없는 순수 셈 — 시험이 Node 로 바로 돌린다.
* ========================================================================== */
import type { DetailBasin, VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
computeRouteView,
createNormalizer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
/** 노선 범위를 몇 배까지 멀리 볼지 — 키우면 더 멀리 빠진다(사용자가 바꿀 수 있는 값). */
export const ZOOM_OUT_ROUTE_FACTOR = 2;
/** 노선이 없을 때의 줌아웃 한계 — 도엽 전체의 절반까지(종전 값). */
export const ZOOM_OUT_FALLBACK_SCALE = 0.5;
/** 줌아웃 한계 배율. 지도 메타가 없거나 노선이 두 점 미만이면 종전 값. */
export function computeZoomOutLimit(
meta: VWorldMeta | null,
routePoints: ReadonlyArray<{ x: number; y: number }>,
basins: ReadonlyArray<Pick<DetailBasin, "polygon_lonlat">>,
width: number,
height: number,
factor: number = ZOOM_OUT_ROUTE_FACTOR,
): number {
if (!meta || routePoints.length < 2 || width <= 0 || height <= 0) {
return ZOOM_OUT_FALLBACK_SCALE;
}
const route = {
x_min: Math.min(...routePoints.map((point) => point.x)),
x_max: Math.max(...routePoints.map((point) => point.x)),
y_min: Math.min(...routePoints.map((point) => point.y)),
y_max: Math.max(...routePoints.map((point) => point.y)),
};
// 도엽 정규 좌표(0~1, 위가 0)로 모아 잰다 — 노선(m)과 유역(경위도)을 한 자로 본다.
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
const centerX = ((route.x_min + route.x_max) / 2 - meta.x_min) / spanX;
const centerY = 1 - ((route.y_min + route.y_max) / 2 - meta.y_min) / spanY;
const halfW = ((route.x_max - route.x_min) / spanX) * (factor / 2);
const halfH = ((route.y_max - route.y_min) / spanY) * (factor / 2);
let x0 = centerX - halfW;
let x1 = centerX + halfW;
let y0 = centerY - halfH;
let y1 = centerY + halfH;
const normalizer = createNormalizer(meta);
basins.forEach((basin) =>
basin.polygon_lonlat.forEach(([lon, lat]) => {
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
x0 = Math.min(x0, nx);
x1 = Math.max(x1, nx);
y0 = Math.min(y0, ny);
y1 = Math.max(y1, ny);
}),
);
// 배율 s 에서 화면에 드는 정규 폭 = 화면 폭 / (지도 사각형 폭 · s).
const mapRect = computeMapRect(meta, width, height);
const fit = Math.min(
width / (mapRect.width * Math.max(x1 - x0, 1e-9)),
height / (mapRect.height * Math.max(y1 - y0, 1e-9)),
);
// 처음 보기(노선 + 여유 200m)는 늘 한계 안 — 짧은 노선에서 처음 화면보다 좁아지지 않게.
const initial = computeRouteView(meta, route, width, height).scale;
return Math.max(1, Math.min(fit, initial));
}
/** 휠 한 번의 새 배율 — 한계 안에서 멈춘다. 이미 한계 밖이면 더 빠지지만 않게(튀지 않게) 둔다. */
export function clampWheelScale(
next: number,
previous: number,
minScale: number,
maxScale: number,
): number {
return Math.min(maxScale, Math.max(Math.min(minScale, previous), next));
}
@@ -23,6 +23,7 @@ import {
pickPipeDiameter, pickPipeDiameter,
type StructureType, type StructureType,
} from "@config/config_frontend"; } from "@config/config_frontend";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
export interface IrregularStation { export interface IrregularStation {
id: string; id: string;
@@ -480,7 +481,9 @@ export function createIrregularStationsSection(
// 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다. // 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다.
const restoredGhost = const restoredGhost =
entry.structureType === undefined && entry.structureType === undefined &&
incomingChainages.some((chainage) => Math.abs(entry.chainage_m - chainage) < 0.51); incomingChainages.some(
(chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
);
if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1); if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1);
} }
pipes.forEach((pipe) => { pipes.forEach((pipe) => {
@@ -488,7 +491,7 @@ export function createIrregularStationsSection(
const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null); const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null);
const { station, remainder } = splitChainage(chainage); const { station, remainder } = splitChainage(chainage);
const kept = previousPipes.find( const kept = previousPipes.find(
(entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < 0.51, (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
); );
const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE; const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE;
const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective); const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective);
@@ -530,7 +533,9 @@ export function createIrregularStationsSection(
callbacks.onChange([...stations]); callbacks.onChange([...stations]);
}, },
removeByChainage(chainageM) { removeByChainage(chainageM) {
const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51); const index = stations.findIndex(
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
if (index < 0) return null; if (index < 0) return null;
const [removed] = stations.splice(index, 1); const [removed] = stations.splice(index, 1);
if (editingId === removed.id) loadForm(null); if (editingId === removed.id) loadForm(null);
@@ -540,7 +545,7 @@ export function createIrregularStationsSection(
}, },
moveByChainage(fromChainageM, toChainageM) { moveByChainage(fromChainageM, toChainageM) {
const index = stations.findIndex( const index = stations.findIndex(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51, (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
); );
if (index < 0) return false; if (index < 0) return false;
const { station, remainder } = splitChainage(toChainageM); const { station, remainder } = splitChainage(toChainageM);
+10 -3
View File
@@ -397,6 +397,8 @@ export function createRouteMarkers(
sprite.position.copy(position); sprite.position.copy(position);
sprite.position.y += 3.4; sprite.position.y += 3.4;
sprite.renderOrder = 10; sprite.renderOrder = 10;
// 라벨은 읽기용 — 띠 가운데 위에 떠 누름을 가로채지 않음(232-1)
sprite.raycast = () => undefined;
return sprite; return sprite;
} }
@@ -477,6 +479,8 @@ export function createRouteMarkers(
head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다. head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다.
head.position.y = PIN_HEIGHT + 1.7; head.position.y = PIN_HEIGHT + 1.7;
group.add(beam, head); group.add(beam, head);
// 핀은 표시만 — 누름을 가로채지 않음(띠 가운데 누름이 핀에 먼저 맞았음 · 232-1)
beam.raycast = head.raycast = () => undefined;
group.renderOrder = 9; group.renderOrder = 9;
group.visible = false; group.visible = false;
interactionGroup.add(group); interactionGroup.add(group);
@@ -490,7 +494,8 @@ export function createRouteMarkers(
if (center) selectionPin.position.copy(center); if (center) selectionPin.position.copy(center);
} }
function selectStation(stationId: string | null): void { /** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(알림 없음 · 233-2). */
function selectStation(stationId: string | null, notify = true): void {
selectedStationId = stationId; selectedStationId = stationId;
stationGroup.children.forEach((object) => { stationGroup.children.forEach((object) => {
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다. // 측점 띠(stationId 보유 Mesh)만 색을 바꾼다.
@@ -499,7 +504,7 @@ export function createRouteMarkers(
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15); (object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
}); });
syncSelectionPin(); syncSelectionPin();
stationSelectionListener?.(selectedStationId); if (notify) stationSelectionListener?.(selectedStationId);
} }
return { return {
@@ -520,7 +525,9 @@ export function createRouteMarkers(
deleteSelected, deleteSelected,
selectObject(object: THREE.Object3D | undefined) { selectObject(object: THREE.Object3D | undefined) {
if (typeof object?.userData.stationId === "string") { if (typeof object?.userData.stationId === "string") {
selectStation(object.userData.stationId); // 같은 띠 다시 누름 = 풂(232-1)
const id = object.userData.stationId as string;
selectStation(id === selectedStationId ? null : id);
selectionListener?.(null); selectionListener?.(null);
return; return;
} }
+4 -5
View File
@@ -4,7 +4,7 @@
* 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b). * 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b).
* ========================================================================== */ * ========================================================================== */
import { dropStationsNear } from "./B05_Profile_Util_Station"; import { oneStationList } from "../common_util/common_util_structure_station";
import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers"; import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
@@ -24,7 +24,7 @@ export function stationLinesOf(input: StationLinesInput) {
// 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고 // 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고
// 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다 // 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다
// (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다. // (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다.
const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular"); const regular = oneStationList(detail.longitudinal.stations, []);
const injected = interpolateIrregularStations( const injected = interpolateIrregularStations(
regular, regular,
input.irregular, input.irregular,
@@ -48,9 +48,8 @@ export function stationLinesOf(input: StationLinesInput) {
return best.elevation_m; return best.elevation_m;
}; };
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와 // 측점 한 벌(272) — 같은 m 면 구조물 측점 하나 · 종단 그래프 · 표와 같은 목록(`oneStationList`).
// 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06). return oneStationList(detail.longitudinal.stations, injected).map((station) => {
return [...dropStationsNear(regular, injected), ...injected].map((station) => {
const design = designAt(station.chainage_m); const design = designAt(station.chainage_m);
return { return {
...station, ...station,
+97 -53
View File
@@ -32,6 +32,15 @@ import {
import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRoutePanel } from "./B05_Profile_UI_Panel";
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers"; import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
import { followSnapped } from "./B05_Profile_UI_Structures_Snap";
import { plantsStation } from "./B05_Profile_UI_Profile_Structures";
import {
pipeStationMarks,
SAME_STATION_M,
structureStationMarks,
type StationMark,
} from "../common_util/common_util_structure_station";
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
export interface PipeProjection { export interface PipeProjection {
@@ -39,6 +48,9 @@ export interface PipeProjection {
effective_diameter_mm: number | null; effective_diameter_mm: number | null;
facility: PipeFacility; facility: PipeFacility;
options?: Record<string, string | number>; options?: Record<string, string | number>;
/** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */
start_m?: number | null;
end_m?: number | null;
} }
export interface StructuresBridgeDeps { export interface StructuresBridgeDeps {
@@ -56,17 +68,28 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */ /** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
let irregularStations: IrregularStation[] = []; let irregularStations: IrregularStation[] = [];
function pipesToStations( /** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */
pipes: Array<{ function stationAt(
chainage_m: number; chainage: number,
effective_diameter_mm: number | null; ): Pick<IrregularStation, "station" | "remainder" | "chainage_m"> {
facility: PipeFacility;
}>,
): IrregularStation[] {
const interval = deps.panel().values().stationInterval || 20; const interval = deps.panel().values().stationInterval || 20;
return pipes.map((pipe) => { const station = Math.floor(chainage / interval);
const station = Math.floor(pipe.chainage_m / interval); return { station, remainder: chainage - station * interval, chainage_m: chainage };
const remainder = pipe.chainage_m - station * interval; }
/** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서
* 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */
const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] =>
marks
.filter((mark) => mark.role !== "anchor")
.map((mark) => ({
id: `${id}:${mark.role}`,
...stationAt(mark.chainage_m),
structure: label,
}));
function pipesToStations(pipes: ReadonlyArray<PipeProjection>): IrregularStation[] {
return pipes.flatMap((pipe) => {
const label = const label =
pipe.facility === "pipe" pipe.facility === "pipe"
? structureLabel({ ? structureLabel({
@@ -75,24 +98,20 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm), diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
}) })
: FACILITY_NAMES[pipe.facility]; : FACILITY_NAMES[pipe.facility];
return { const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
id: `pipe-${pipe.chainage_m.toFixed(2)}`, return [
station, {
remainder, id,
chainage_m: pipe.chainage_m, ...stationAt(pipe.chainage_m),
structure: label, structure: label,
structureType: "배관", structureType: "배관" as const,
origin: "pipe", origin: "pipe" as const,
}; },
...spanStations(id, label, pipeStationMarks(pipe)),
];
}); });
} }
/** 물넘이포장은 비정규 측점이 아니다 — 횡단도로 표현할 것이 없어 종단 알약으로만
* 다룬다(2026-08-28 사용자 확정). 관 정본 대조(setPipeChainages)는 전체 목록을 써야
* 물넘이가 지워지지 않으므로, **표시·서버 전송 쪽에서만** 뺀다. */
const withoutFordPavement = (stations: IrregularStation[]): IrregularStation[] =>
stations.filter((station) => station.structure !== FACILITY_NAMES.ford_pavement);
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
function clearProjectedStations(): void { function clearProjectedStations(): void {
pipeStations = []; pipeStations = [];
@@ -103,23 +122,23 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */ /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
function applyIrregularStations(stations: IrregularStation[]): void { function applyIrregularStations(stations: IrregularStation[]): void {
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다. // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다.
// 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김. // 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2)
// — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움.
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3))); const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3))); const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
irregularStations const gone = irregularStations.filter(
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3))) (station) => !nextKeys.has(station.chainage_m.toFixed(3)),
.forEach((station) => {
const same = stations.find(
(next) =>
Math.abs(next.chainage_m - station.chainage_m) < 0.006 &&
!oldKeys.has(next.chainage_m.toFixed(3)),
); );
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
gone.forEach((station) => {
// 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272)
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
}); });
irregularStations = stations; irregularStations = stations;
deps.renderStationLines(); deps.renderStationLines();
deps.profilePanel().setIrregularStations(withoutFordPavement(stations)); deps.profilePanel().setIrregularStations(stations);
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다. // 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다. // 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
deps deps
@@ -141,29 +160,40 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
let pipeStations: IrregularStation[] = []; let pipeStations: IrregularStation[] = [];
let structureTypeMap = new Map<string, { group: string; name: string }>(); let structureTypeMap = new Map<
string,
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
>();
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */ /** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations`
* 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] { function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
const interval = deps.panel().values().stationInterval || 20; return structures.flatMap((structure) => {
return structures const type = structureTypeMap.get(structure.type_id);
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A") const marks = structureStationMarks(structure, type);
.map((structure) => { const anchor = marks.find((mark) => mark.role === "anchor");
const chainage = structure.chainage_m ?? structure.start_m ?? 0; if (!anchor) return [];
const station = Math.floor(chainage / interval); const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`;
return { const label = type?.name ?? structure.type_id;
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`, return [
station, { id, ...stationAt(anchor.chainage_m), structure: label },
remainder: chainage - station * interval, ...spanStations(id, label, marks),
chainage_m: chainage, ];
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
};
}); });
} }
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */ /** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이
* 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을
* 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */
function syncCrossDrainStations(): void { function syncCrossDrainStations(): void {
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]); const kept: IrregularStation[] = [];
for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) {
const taken =
station.origin !== "pipe" &&
kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M);
if (!taken) kept.push(station);
}
applyIrregularStations(kept);
} }
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null). /** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
@@ -324,7 +354,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
deps.profilePanel().setStructureTypes(allTypes); deps.profilePanel().setStructureTypes(allTypes);
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
structureTypeMap = new Map( structureTypeMap = new Map(
allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]), allTypes.map((type) => [
type.type_id,
{
type_id: type.type_id,
group: type.group,
name: type.name,
placement: type.placement,
managed_by: type.managed_by,
},
]),
); );
structureRevision = stored.revision; structureRevision = stored.revision;
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
@@ -360,7 +399,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
}); });
}, },
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
irregularStations: () => withoutFordPavement(irregularStations), irregularStations: () => irregularStations,
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
@@ -374,6 +413,11 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
clearProjectedStations, clearProjectedStations,
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */ /** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
pipeMarkChainage, pipeMarkChainage,
/** 측점 없는 관 알약(물넘이포장)인가 — 고름 값에 알약 id 를 남길지 가름(요구 178). */
stationlessPipeMark: (structureId: string | null): boolean => {
const mark = pipeMarks.find((entry) => entry.structure_id === structureId);
return !!mark && !plantsStation(mark, []);
},
/** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */ /** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */
markChainage, markChainage,
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */ /** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
+3
View File
@@ -69,6 +69,8 @@ interface PanelCallbacks {
onPipeFacilityRemove: (chainageM: number) => void; onPipeFacilityRemove: (chainageM: number) => void;
/** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */ /** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */
onPipeFacilitySelect: (chainageM: number | null) => void; onPipeFacilitySelect: (chainageM: number | null) => void;
/** 목록 다시 누름 · 새로 놓기 = 풀기 한 길(233-3). */
onListRelease?: () => void;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
} }
@@ -104,6 +106,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeUpdate: callbacks.onPipeFacilityUpdate,
onPipeRemove: callbacks.onPipeFacilityRemove, onPipeRemove: callbacks.onPipeFacilityRemove,
onPipeSelect: callbacks.onPipeFacilitySelect, onPipeSelect: callbacks.onPipeFacilitySelect,
onListRelease: callbacks.onListRelease,
}, },
}); });
+1 -1
View File
@@ -31,7 +31,7 @@ registerPanelSection({
views: ["profile"], views: ["profile"],
order: 30, order: 30,
build: (ctx, view) => { build: (ctx, view) => {
const frame = createSectionFrame(view, "계획노선"); const frame = createSectionFrame(view, "계획노선", true); // 311 기본 접힘
frame.body.append(part<RoutePart>(ctx, "route").plannedRouteButton); frame.body.append(part<RoutePart>(ctx, "route").plannedRouteButton);
return frame.root; return frame.root;
}, },
+7 -3
View File
@@ -4,6 +4,7 @@
* 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). * 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b).
* ========================================================================== */ * ========================================================================== */
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields";
/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ /** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */
@@ -33,7 +34,8 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
(["iso", "top", "front", "side"] as const).forEach((preset) => (["iso", "top", "front", "side"] as const).forEach((preset) =>
viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")),
); );
// 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 원근 · 고른 값은 화면 설정에 기억.
const initialProjection = readStateRaw("b05-3d-projection") === "ortho" ? "ortho" : "perspective";
const projectionButtons = ( const projectionButtons = (
[ [
["perspective", "원근"], ["perspective", "원근"],
@@ -47,15 +49,17 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
el.classList.toggle("is-active", other === kind); el.classList.toggle("is-active", other === kind);
el.setAttribute("aria-pressed", String(other === kind)); el.setAttribute("aria-pressed", String(other === kind));
}); });
writeStateRaw("b05-3d-projection", kind);
callbacks.onProjection(kind); callbacks.onProjection(kind);
}, },
"glass", "glass",
); );
element.classList.toggle("is-active", kind === "ortho"); element.classList.toggle("is-active", kind === initialProjection);
element.setAttribute("aria-pressed", String(kind === "ortho")); element.setAttribute("aria-pressed", String(kind === initialProjection));
return { kind, element }; return { kind, element };
}); });
viewButtons.append(...projectionButtons.map(({ element }) => element)); viewButtons.append(...projectionButtons.map(({ element }) => element));
if (initialProjection === "ortho") callbacks.onProjection("ortho");
const visibilityButtons = document.createElement("div"); const visibilityButtons = document.createElement("div");
visibilityButtons.className = "b05-route__view-group"; visibilityButtons.className = "b05-route__view-group";
visibilityButtons.append( visibilityButtons.append(
@@ -182,6 +182,22 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
}; };
} }
/**
* 사라진 비정규 측점의 편집이 따라갈 새 측점(213-2) — 0.006m 안(반올림) · 같은 id · 사라진 것 하나와
* 새것 하나(옮긴 관)면 그것. 없으면 null(편집을 지움).
*/
export function followedStation<T extends { id: string; chainage_m: number }>(
station: T,
gone: ReadonlyArray<T>,
born: ReadonlyArray<T>,
): T | null {
return (
born.find((next) => Math.abs(next.chainage_m - station.chainage_m) < 0.006) ??
born.find((next) => next.id === station.id) ??
(gone.length === 1 && born.length === 1 ? born[0] : null)
);
}
/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */ /** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */
const SAME_STATION_SNAP_M = 0.006; const SAME_STATION_SNAP_M = 0.006;
+8 -4
View File
@@ -12,6 +12,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights"; import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
import { viewOf } from "../common_util/common_util_view_window";
const MIN_CHART_HEIGHT = 100; const MIN_CHART_HEIGHT = 100;
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. /** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
@@ -123,9 +124,12 @@ export interface ProfileDrag {
export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
const { root, body, massHaul, tableOverlay } = ctx; const { root, body, massHaul, tableOverlay } = ctx;
let resizeTimer = 0; let resizeTimer = 0;
/** 시계는 종단이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
let timerView: Window = window;
const scheduleDraw = (): void => { const scheduleDraw = (): void => {
window.clearTimeout(resizeTimer); timerView.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(ctx.draw, 120); timerView = viewOf(root);
resizeTimer = timerView.setTimeout(ctx.draw, 120);
}; };
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
@@ -211,9 +215,9 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
mainDragCooldown = false; mainDragCooldown = false;
}, },
scheduleDraw, scheduleDraw,
cancelDraw: () => window.clearTimeout(resizeTimer), cancelDraw: () => timerView.clearTimeout(resizeTimer),
dispose() { dispose() {
window.clearTimeout(resizeTimer); timerView.clearTimeout(resizeTimer);
heightResizer.dispose(); heightResizer.dispose();
window.removeEventListener("pointerup", clearDragFlags); window.removeEventListener("pointerup", clearDragFlags);
window.removeEventListener("pointercancel", clearDragFlags); window.removeEventListener("pointercancel", clearDragFlags);
+13 -6
View File
@@ -23,6 +23,7 @@ import {
useDesignStore, useDesignStore,
} from "../A00_Common/design/design_store"; } from "../A00_Common/design/design_store";
import { clearState } from "../A00_Common/b_page_state"; import { clearState } from "../A00_Common/b_page_state";
import { viewOf } from "../common_util/common_util_view_window";
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ /** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
const BUTTON_CLEARANCE_PX = 23; const BUTTON_CLEARANCE_PX = 23;
@@ -38,13 +39,18 @@ export const HOLD_INTERVAL_MS = 100;
* 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나 * 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나
* 반대로 멈추지 않는 상황이 생기지 않는다. * 반대로 멈추지 않는 상황이 생기지 않는다.
*/ */
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } { function createHoldRepeater(node: Node): {
start: (action: () => void) => void;
stop: () => void;
} {
let delayTimer = 0; let delayTimer = 0;
let repeatTimer = 0; let repeatTimer = 0;
/** 시계는 버튼 층이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
let timerView: Window = window;
function stop(): void { function stop(): void {
window.clearTimeout(delayTimer); timerView.clearTimeout(delayTimer);
window.clearInterval(repeatTimer); timerView.clearInterval(repeatTimer);
delayTimer = 0; delayTimer = 0;
repeatTimer = 0; repeatTimer = 0;
window.removeEventListener("pointerup", stop); window.removeEventListener("pointerup", stop);
@@ -54,8 +60,9 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () =
function start(action: () => void): void { function start(action: () => void): void {
stop(); stop();
delayTimer = window.setTimeout(() => { timerView = viewOf(node);
repeatTimer = window.setInterval(action, HOLD_INTERVAL_MS); delayTimer = timerView.setTimeout(() => {
repeatTimer = timerView.setInterval(action, HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS); }, HOLD_DELAY_MS);
window.addEventListener("pointerup", stop); window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop); window.addEventListener("pointercancel", stop);
@@ -329,7 +336,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
layer.className = "b05-profile-edit"; layer.className = "b05-profile-edit";
layer.style.width = `${width}px`; layer.style.width = `${width}px`;
const repeater = createHoldRepeater(); const repeater = createHoldRepeater(layer);
const edited = new Set(Object.keys(alignment.edits.station_offsets)); const edited = new Set(Object.keys(alignment.edits.station_offsets));
/** /**
+2 -40
View File
@@ -1,7 +1,7 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_Profile_Guard.ts * B05_Profile_UI_Profile_Guard.ts
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고, * 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고
* 구조물 측점을 종단 정본 측점으로 맞추기. * (구조물 측점 맞추기는 측점 한 벌 `oneStationList` 로 갈음 · 272).
* *
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다 * 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
* (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다. * (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다.
@@ -9,11 +9,6 @@
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
const STATION_SNAP_TOLERANCE_M = 0.1;
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */ /** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
export function sampleAt( export function sampleAt(
@@ -64,36 +59,3 @@ export function planCurveWarning(
}); });
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null; return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
} }
/**
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
*
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
*
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
*/
export function snapToMasterStations(
alignment: ProfileAlignment | null,
stations: IrregularStation[],
): IrregularStation[] {
const master = alignment?.stations;
if (!master?.length) return stations;
return stations.map((station) => {
let best: number | null = null;
let gap = STATION_SNAP_TOLERANCE_M;
for (const node of master) {
const distance = Math.abs(node.chainage_m - station.chainage_m);
if (distance <= gap) {
gap = distance;
best = node.chainage_m;
}
}
return best === null || best === station.chainage_m
? station
: { ...station, chainage_m: best };
});
}
@@ -11,6 +11,7 @@
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
import { frameOf } from "../common_util/common_util_view_window";
/** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */ /** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */
const MIN_CHART_HEIGHT = 100; const MIN_CHART_HEIGHT = 100;
@@ -182,7 +183,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
function scheduleLightSync(): void { function scheduleLightSync(): void {
if (lightSyncPending) return; if (lightSyncPending) return;
lightSyncPending = true; lightSyncPending = true;
requestAnimationFrame(() => { frameOf(deps.body, () => {
lightSyncPending = false; lightSyncPending = false;
syncHeightsLight(); syncHeightsLight();
}); });
+8 -13
View File
@@ -56,6 +56,7 @@ import { createPanelResizer } from "@ui/ui_template_resizer";
import "@util/common_util_mass_haul.css"; import "@util/common_util_mass_haul.css";
import "./B05_Profile_UI_Style_MassHaul.css"; import "./B05_Profile_UI_Style_MassHaul.css";
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
import { frameOf } from "../common_util/common_util_view_window";
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ /* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
@@ -134,8 +135,6 @@ export interface RouteMassHaulDrawParams {
widthPx: number; widthPx: number;
selectedStationId: string | null; selectedStationId: string | null;
onSelectStation: (stationId: string) => void; onSelectStation: (stationId: string) => void;
/** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */
onClearSelection?: () => void;
/** /**
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
@@ -235,7 +234,7 @@ export function createRouteMassHaulPanel(
syncHandlePosition(); syncHandlePosition();
if (resizeRedrawPending) return; if (resizeRedrawPending) return;
resizeRedrawPending = true; resizeRedrawPending = true;
requestAnimationFrame(() => { frameOf(overlay, () => {
resizeRedrawPending = false; resizeRedrawPending = false;
onChanged(); onChanged();
}); });
@@ -249,13 +248,6 @@ export function createRouteMassHaulPanel(
pendingChip.hidden = true; pendingChip.hidden = true;
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
// 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시).
let clearSelection: (() => void) | undefined;
scroll.addEventListener("click", (event) => {
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
clearSelection?.();
});
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
attachWheelHorizontalScroll(scroll); attachWheelHorizontalScroll(scroll);
@@ -367,7 +359,6 @@ export function createRouteMassHaulPanel(
function draw(params: RouteMassHaulDrawParams): void { function draw(params: RouteMassHaulDrawParams): void {
lastParams = params; lastParams = params;
clearSelection = params.onClearSelection;
overlay.hidden = !open; overlay.hidden = !open;
syncHandlePosition(); syncHandlePosition();
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
@@ -488,9 +479,13 @@ export function createRouteMassHaulPanel(
); );
drawnOnce = true; drawnOnce = true;
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
scheduleMassHaulSettle(windowState, () => { scheduleMassHaulSettle(
windowState,
() => {
if (lastParams) draw(lastParams); if (lastParams) draw(lastParams);
}); },
overlay,
);
} }
return { return {
@@ -10,6 +10,7 @@
* ========================================================================== */ * ========================================================================== */
import { isCtrlKey } from "../A00_Common/key_shortcut"; import { isCtrlKey } from "../A00_Common/key_shortcut";
import { viewOf } from "../common_util/common_util_view_window";
import type { import type {
AlignmentBase, AlignmentBase,
AlignmentEdits, AlignmentEdits,
@@ -29,8 +30,10 @@ import {
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
import { createFreeDraw } from "../A00_Common/design/design_profile_free"; import { createFreeDraw } from "../A00_Common/design/design_profile_free";
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import { stepStructureStation } from "../common_util/common_util_structure_station";
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
const KEY_STEP_M = 0.1; const KEY_STEP_M = 0.1;
/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ /** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */
@@ -222,6 +225,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
return true; return true;
} }
// 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1)
if (!arrowsBelongToProfile()) return false;
// 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져 // 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져
// (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측). // (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측).
const station = holdStation ?? selectedIrregular(); const station = holdStation ?? selectedIrregular();
@@ -230,8 +235,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
// 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해 // 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해
// 0.1m 만 움직이고 멈춤(2026-09-04 실측). // 0.1m 만 움직이고 멈춤(2026-09-04 실측).
const fromM = holdChainageM ?? station.chainage_m; const fromM = holdChainageM ?? station.chainage_m;
const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3)); const next = stepStructureStation(fromM, key === "ArrowRight" ? 1 : -1);
if (next < 0) return false; if (next === fromM) return false;
ctx.moveStation({ ...station, chainage_m: fromM }, next); ctx.moveStation({ ...station, chainage_m: fromM }, next);
holdStation = station; holdStation = station;
holdChainageM = next; holdChainageM = next;
@@ -251,11 +256,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
* 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */ * 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */
const HOLD_SETTLE_MS = 700; const HOLD_SETTLE_MS = 700;
let settleTimer = 0; let settleTimer = 0;
/** 시계는 패널이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
let timerView: Window = window;
/** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */ /** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */
function stopHold(): void { function stopHold(): void {
window.clearTimeout(holdDelayTimer); timerView.clearTimeout(holdDelayTimer);
window.clearInterval(holdRepeatTimer); timerView.clearInterval(holdRepeatTimer);
holdDelayTimer = 0; holdDelayTimer = 0;
holdRepeatTimer = 0; holdRepeatTimer = 0;
if (heldKey === null) return; if (heldKey === null) return;
@@ -264,8 +271,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
holdStation = null; holdStation = null;
window.removeEventListener("keyup", onKeyUp); window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", stopHold); window.removeEventListener("blur", stopHold);
window.clearTimeout(settleTimer); timerView.clearTimeout(settleTimer);
settleTimer = window.setTimeout(() => { settleTimer = timerView.setTimeout(() => {
settleTimer = 0; settleTimer = 0;
history.resume(); history.resume();
}, HOLD_SETTLE_MS); }, HOLD_SETTLE_MS);
@@ -275,7 +282,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
function flushHold(): void { function flushHold(): void {
stopHold(); stopHold();
if (!settleTimer) return; if (!settleTimer) return;
window.clearTimeout(settleTimer); timerView.clearTimeout(settleTimer);
settleTimer = 0; settleTimer = 0;
history.resume(); history.resume();
} }
@@ -289,8 +296,9 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
heldKey = key; heldKey = key;
window.addEventListener("keyup", onKeyUp); window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", stopHold); window.addEventListener("blur", stopHold);
holdDelayTimer = window.setTimeout(() => { timerView = viewOf(ctx.root); // 앞선 덩어리는 flushHold 가 이미 닫음
holdRepeatTimer = window.setInterval(() => { holdDelayTimer = timerView.setTimeout(() => {
holdRepeatTimer = timerView.setInterval(() => {
if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다. if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다.
}, HOLD_INTERVAL_MS); }, HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS); }, HOLD_DELAY_MS);

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