From 973ac7138f4d422197edb1a64d24ac48d1bed6af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 20:46:30 +0900 Subject: [PATCH 01/14] =?UTF-8?q?fix(B07):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=95=EC=8A=A4=20=EA=B0=84=EA=B2=A9=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=C2=B7=EB=A6=AC=EB=B3=B8=20=EC=84=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A0=9C=EA=B1=B0=C2=B7=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EB=9D=BC=EB=B2=A8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단면도 장 배치의 칸 여백 8mm → 0mm — 이웃 박스 테두리가 맞닿음. - CAD 리본 높이 116px → 122px — 내용(85px)이 보이는 높이(81px)를 넘어 생기던 세로 스크롤바 제거. 캔버스 상단이 6px 내려감. - 장 라벨을 누가거리 범위 「1060~1070m」에서 시작 측점 「No.53」으로 교체. - 좌측 패널 2열 격자 단추의 최소폭을 풀어 패널을 14px 넘어가던 것을 막고, 이름이 길면 `…` 로 잘리게 함. Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py | 3 ++- B07_DesignDetail/B07_DesignDetail_Router_Support.py | 9 +++++---- B07_DesignDetail/B07_DesignDetail_UI_Style.css | 3 +++ B07_DesignDetail/openwebcad/src/App.css | 4 +++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index cce9992e..7a67125f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -41,7 +41,8 @@ from config.config_system import DRAWING_SCALE_CROSS CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?") # 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. -_BLOCK_GAP_MM = 8.0 +# 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거). +_BLOCK_GAP_MM = 0.0 _BORDER_PAD_MM = 4.0 diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 76e1627c..fed5bbbd 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -136,17 +136,18 @@ def _drawing_list( except (FileNotFoundError, ValueError, OSError) as exc: logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc) sheets = [] + # 장 라벨은 누가거리 범위가 아니라 **그 장의 시작 측점**으로 짓는다(2026-09-04 사용자 + # 지시) — 「1060~1070m」식 범위는 좌측 패널 2열 단추를 넘칠 만큼 길었다. + interval = infer_station_interval(longitudinal.get("stations") or []) for sheet in sheets: chainages = sheet["chainages"] first = station_by_chainage.get(chainages[0], {}) - span = f"{chainages[0]}m" - if len(chainages) > 1: - span = f"{chainages[0]}~{chainages[-1]}m" + station = station_no_label(float(first.get("chainage_m", chainages[0])), interval) drawings.append( DesignDrawingItem( id=sheet["id"], kind="cross", - label=f"{sheet['number']}장 ({span})", + label=f"{sheet['number']}장 ({station})", chainage_m=float(first.get("chainage_m", chainages[0])), confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), ) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index c9a95180..1dcfce89 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -84,6 +84,9 @@ align-items: center; justify-content: center; width: 100%; + /* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가 + 오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */ + min-width: 0; min-height: 34px; padding: var(--spacing-4) var(--spacing-8); /* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */ diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 6d9438ae..f69191bf 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -4,7 +4,9 @@ :root { --cad-title-height: 42px; - --cad-ribbon-height: 116px; + /* 116px 에서는 리본 줄의 내용(85px)이 보이는 높이(81px)를 넘어 세로 스크롤바가 + 생겼다(2026-09-04 실측) — 모자란 4px + 여유 2px 를 더한다. */ + --cad-ribbon-height: 122px; /* 명령행 높이 — 상태막대의 [명령행] 토글이 0px로 바꾼다 */ --cad-command-height: 86px; --cad-status-height: 28px; From c2f681f9d39445712aced4627e98b0f20f4e6b0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 21:07:55 +0900 Subject: [PATCH 02/14] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=C2=B7?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20=ED=9C=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=B6=80=EB=93=9C=EB=9F=BD=EA=B2=8C=20+=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=84=EC=82=B0=20=ED=91=9C=EC=8B=9C=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세 곳(종단 그래프·유토곡선·측점 테이블)에 복사돼 있던 휠 처리를 `B05_Profile_UI_Profile_Wheel.ts` 한 벌로 통합. - 이동은 rAF 로 목표를 쌓아 프레임마다 좁힘 — `scrollLeft` 직접 대입의 120px 점프 제거. 브라우저 `scrollTo({behavior:"smooth"})` 는 휠마다 애니메이션이 처음부터 다시 시작돼 1.6초에 17px 만 움직여 쓰지 않음(실측). - 세로 자동 맞춤 디바운스 160 → 80ms. - 「다시 계산 중…」 을 요약 막대에서 떼어 곡선 영역 우측 상단 오버레이로 이동 — 막대 줄바꿈으로 곡선이 밀리던 창밀림 제거. 자체검증(공용 브라우저 5173, 임시 탭): 휠 10칸 1,200px 중 1,199px 이동·궤적 연속, 마지막 휠 500ms → 재구성 813ms. 칩 표시/숨김에서 요약 막대 높이 53px·곡선 자리 (top 1078·height 226) 동일 — 창밀림 0. 칩은 곡선 영역 안 우측(범례 아래 4px). Co-Authored-By: Claude Opus 5 --- .../B05_Profile_UI_Profile_MassHaul.ts | 39 ++++------ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 21 ++---- .../B05_Profile_UI_Profile_TableOverlay.ts | 16 +--- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 73 +++++++++++++++++++ B05_Profile/B05_Profile_UI_Style_MassHaul.css | 16 +++- 5 files changed, 107 insertions(+), 58 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_Wheel.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 42c0cdfc..f702240f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -51,6 +51,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; +import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ const OPEN_KEY = "b05-route-profile-masshaul-open"; @@ -216,7 +217,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa }); }, }); - overlay.append(resizer.root, bar, scroll, legendLayer); + // 재계산 대기 표시 — 곡선 영역 우측 상단에 **떠 있는** 알림이다. 예전에는 요약 막대에 + // 붙였는데 막대가 줄바꿈되며 곡선을 아래로 밀어냈다(2026-09-04 사용자 보고: 「창 밀림」). + const pendingChip = document.createElement("span"); + pendingChip.className = "b05-profile__masshaul-pending"; + pendingChip.textContent = "다시 계산 중…"; + pendingChip.hidden = true; + overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); // 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시). let clearSelection: (() => void) | undefined; @@ -227,20 +234,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. - scroll.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const limit = scroll.scrollWidth - scroll.clientWidth; - if (limit <= 0) return; - const delta = event.deltaY; - if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) - return; - scroll.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(scroll); /** * 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와 @@ -298,6 +292,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa function note(message: string): void { drawnOnce = false; + pendingChip.hidden = true; bar.replaceChildren(); scroll.replaceChildren(); legendLayer.replaceChildren(); @@ -324,19 +319,11 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa // 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만 // 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다. if (params.pendingRecalc) { - if (drawnOnce) { - const pending = bar.querySelector(".b05-profile__masshaul-pending"); - if (!pending) { - const chip = document.createElement("span"); - chip.className = "b05-profile__masshaul-pending"; - chip.textContent = "다시 계산 중…"; - bar.append(chip); - } - } else { - note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); - } + if (drawnOnce) pendingChip.hidden = false; + else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); return; } + pendingChip.hidden = true; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. const series: MassHaulSeries[] = computeMassHaulSeries( params.longitudinal, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index a2580a54..45fec08d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -97,6 +97,7 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60; /** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로 * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; +import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( @@ -508,8 +509,10 @@ export function createRouteProfilePanel( }); } - /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ - const SCROLL_SETTLE_MS = 160; + /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. + * 재구성이 실측 50ms 미만이라 160 → 80ms 로 줄였다 — 멈춘 뒤 따라오는 시간을 반으로 + * (2026-09-04 사용자 지시: 「응답이 늦게 쫓아온다」). */ + const SCROLL_SETTLE_MS = 80; /** 마지막으로 세로를 맞춘 가로 위치 — 같은 자리면 다시 그리지 않는다(재구성 되먹임 차단). */ let settledScrollLeft = 0; let scrollSettleTimer = 0; @@ -525,19 +528,7 @@ export function createRouteProfilePanel( // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). - body.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const delta = event.deltaY; - const limit = body.scrollWidth - body.clientWidth; - if (limit <= 0) return; - if ((delta < 0 && body.scrollLeft <= 0) || (delta > 0 && body.scrollLeft >= limit)) return; - body.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(body); const resizeObserver = new ResizeObserver(() => { if ( diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index cfea791e..2738f154 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -12,6 +12,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; +import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; const OPEN_KEY = "b05:profile:table:open"; const HEIGHT_KEY = "b05:profile:table:height"; @@ -87,20 +88,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa scroll.addEventListener("contextmenu", (event) => event.preventDefault()); // 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다). - scroll.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const limit = scroll.scrollWidth - scroll.clientWidth; - if (limit <= 0) return; - const delta = event.deltaY; - if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) - return; - scroll.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(scroll); /** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다 * — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts new file mode 100644 index 00000000..c2e0454e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Wheel.ts + * 세로 휠 → 가로 이동. 종단 그래프·유토곡선·측점 테이블 세 스크롤러가 같은 문법을 쓴다 + * (2026-08-04 사용자 지시). 셋에 똑같이 복사돼 있던 것을 한 벌로 모았다(2026-09-04). + * + * **부드럽게 이동**(2026-09-04 사용자 지시) — 예전처럼 `scrollLeft`에 곧바로 더하면 휠 + * 한 칸(120px)이 통째로 점프해 툭툭 끊긴다. + * + * 브라우저 기본 `scrollTo({ behavior: "smooth" })`는 쓰지 않는다. 휠을 굴리는 동안 매번 + * 애니메이션이 **처음부터 다시 시작**돼 느린 구간만 반복하기 때문이다 — 실측으로 1.6초 + * 동안 1,200px 중 17px 만 움직였다(2026-09-04). 대신 목표만 쌓아 두고 프레임마다 남은 + * 거리의 일정 비율씩 좁힌다. 목표가 늘어나도 속도가 이어지므로 굴릴수록 뒤처지지 않는다. + * + * Shift+휠은 브라우저 기본 가로 스크롤이라 건드리지 않는다. + * ========================================================================== */ + +/** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — + * 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */ +const EASE = 0.35; +/** 이보다 가까우면 도착으로 보고 딱 맞춘다(px). */ +const SNAP_PX = 0.5; + +export function attachWheelHorizontalScroll(scroller: HTMLElement): void { + let target = -1; + /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ + let applied = -1; + let frame = 0; + + function stop(): void { + if (frame) cancelAnimationFrame(frame); + frame = 0; + target = -1; + applied = -1; + } + + function step(): void { + frame = 0; + // 스크롤바 끌기·스크롤 동기화처럼 다른 경로가 자리를 옮겼으면 이동을 접는다. + if (applied >= 0 && Math.abs(scroller.scrollLeft - applied) > 1) return stop(); + const limit = scroller.scrollWidth - scroller.clientWidth; + const goal = Math.min(limit, Math.max(0, target)); + const rest = goal - scroller.scrollLeft; + if (Math.abs(rest) <= SNAP_PX) { + scroller.scrollLeft = goal; + return stop(); + } + const next = scroller.scrollLeft + rest * EASE; + scroller.scrollLeft = next; + applied = scroller.scrollLeft; + frame = requestAnimationFrame(step); + } + + scroller.addEventListener( + "wheel", + (event) => { + if (event.shiftKey || event.deltaY === 0) return; + const limit = scroller.scrollWidth - scroller.clientWidth; + if (limit <= 0) return; + // 이동 중이면 **직전 목표**에 이어 더한다 — 현재 위치에 더하면 아직 못 간 거리를 + // 잃어 굴릴수록 뒤처진다. + const from = frame ? target : scroller.scrollLeft; + const next = Math.min(limit, Math.max(0, from + event.deltaY)); + if (next === from && (next <= 0 || next >= limit)) return; + target = next; + if (!frame) { + applied = scroller.scrollLeft; + frame = requestAnimationFrame(step); + } + event.preventDefault(); + }, + { passive: false }, + ); +} diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index c482c4ca..85c65516 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -112,12 +112,22 @@ padding-top: 0; } -/* 재계산 대기 표시 — 곡선을 지우지 않고 요약줄 끝에만 붙인다(2026-09-03 깜빡임 제거). */ +/* 재계산 대기 표시 — 곡선을 지우지 않고(2026-09-03 깜빡임 제거) 곡선 영역 우측 상단에 + **떠 있는** 알림으로 둔다. 요약 막대에 붙이면 막대가 줄바꿈되며 곡선을 밀어냈다 + (2026-09-04 사용자 보고). 범례(top: 34px) 아래 줄에 앉혀 서로 가리지 않는다. */ .b05-profile__masshaul-pending { - margin-left: auto; + position: absolute; + top: 62px; + right: var(--spacing-8); + z-index: 4; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); color: var(--color-text-secondary); font-size: var(--text-caption); - opacity: 0.8; + line-height: 1.2; + pointer-events: none; } .b05-profile__masshaul-empty { From c322f538459ed3cadd1729ebc15f35cc818cbe87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 21:22:27 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(B05):=20=EC=BD=94=EB=A6=AC=EB=8F=84?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EC=B2=98=EB=A6=AC=20=EC=B2=B4=EC=9D=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=AF=B8=EB=A6=AC=20=EB=A7=8C=EB=93=A4=EC=96=B4=20?= =?UTF-8?q?=EC=98=81=EA=B5=AC=EC=A0=80=EC=9E=A5=20+=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92=20=ED=8E=B8=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-04) — 「다른 로직과 동일하게: 전처리 계산 마지막에 연산 후 영구저장, 진입 시 로딩, 조작은 캐시, 저장·확정 때 저장」. 예전에는 사용자가 B05에 처음 들어간 그 순간 브라우저가 17MB 규모를 만들어 첫 진입이 느렸음. 계산은 **재구현하지 않음** — 브라우저가 쓰는 TS 빌더(8,209줄)를 Node 로 그대로 돌림. - `B05_Profile_UI_Corridor_Envelope.ts` 신설 — 저장 형식·버전 해시·직렬화를 `_UI_Corridor.ts` 에서 그대로 떼어 브라우저·서버 공용으로 둠(내용 불변). - `B05_Profile_Corridor_Node.ts` 신설 — 입력 JSON 을 받아 저장본을 내놓는 진입점. `npm run build:corridor` 로 번들(335kB), `npm run build` 에 물림. - `B05_Profile_Corridor_Prebuild.py` 신설 — 상세·노선점을 모아 Node 실행 후 저장. 번들이 TS 원본보다 낡으면 스스로 다시 만듦(두 그림이 갈라지는 것을 막는 장치). - 체인 마지막(스냅샷 직전)에 호출. 실패는 비치명적 — 저장본이 없으면 브라우저 폴백. - 초기값 — 스냅샷이 코리도를 `initial_corridor.json` 으로 함께 뜨고, [초기화] 복원 때 **새 route id** 이름으로 되돌림(복원이 새 번호를 만들기 때문). - `window.__corridorSource` 디버그 훅 — 저장본을 썼는지 다시 만들었는지 화면 밖 확인용. 자체검증: 전체 시험 381 통과·17 건너뜀(옛 테스트 2건은 이름·경로 변경에 맞춰 갱신). 실경로 — 용화 프로젝트(route 139)로 사전 생성 성공, 브라우저가 그 저장본을 `source: "stored"`, 해시 `377c2a84` 로 **그대로 채택**(재빌드 0회, 전송 278ms). Co-Authored-By: Claude Opus 5 --- .gitignore | 5 +- B03_FileInput/B03_FileInput_Service_Chain.py | 10 + B05_Profile/B05_Profile_Corridor_Node.ts | 47 +++ B05_Profile/B05_Profile_Corridor_Prebuild.py | 151 ++++++++ B05_Profile/B05_Profile_Router_Corridor.py | 7 +- B05_Profile/B05_Profile_Router_Lifecycle.py | 10 +- B05_Profile/B05_Profile_UI_Corridor.ts | 340 ++---------------- .../B05_Profile_UI_Corridor_Envelope.ts | 329 +++++++++++++++++ common_util/common_util_initial_snapshot.py | 24 +- package.json | 3 +- 10 files changed, 599 insertions(+), 327 deletions(-) create mode 100644 B05_Profile/B05_Profile_Corridor_Node.ts create mode 100644 B05_Profile/B05_Profile_Corridor_Prebuild.py create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Envelope.ts diff --git a/.gitignore b/.gitignore index 0095f399..5e816280 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,7 @@ resources/data_global_contours/national_contours.gpkg .tmp_* *.log.err tmp/ -**/.obsidian/ \ No newline at end of file +**/.obsidian/ +# 코리도 서버 사전 생성 번들 — `npm run build:corridor` 산출물(2026-09-04). +# 소스가 바뀌면 서버가 스스로 다시 만든다(B05_Profile_Corridor_Prebuild.py). +config/corridor_node/ diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index cc37af0d..b826fda4 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -270,6 +270,16 @@ async def run_auto_design_chain( route_id, ) + # 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다 + # (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가 + # 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다. + try: + from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor + + await prebuild_corridor(project_id, route_id, project_root) + except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다 + logger.exception("코리도 사전 생성 실패: project_id=%s", project_id) + # 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장). # 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨 # [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정). diff --git a/B05_Profile/B05_Profile_Corridor_Node.ts b/B05_Profile/B05_Profile_Corridor_Node.ts new file mode 100644 index 00000000..013eeb13 --- /dev/null +++ b/B05_Profile/B05_Profile_Corridor_Node.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * B05_Profile_Corridor_Node.ts + * 코리도(3D 예상형상)를 **서버에서 한 번** 만들어 저장본 형식으로 내놓는 진입점. + * + * 왜 있나(2026-09-04 사용자 확정) — 다른 데이터와 같은 흐름으로 맞춘다: 파일 입력 뒤 + * 전처리 체인 마지막에 계산해 영구저장 → 사용자는 진입 시 로딩만 → 조작은 캐시 → + * [저장]·[확정] 때 저장. 예전에는 사용자가 B05에 처음 들어간 그 순간 브라우저가 만들어 + * 첫 진입이 느렸다. + * + * **계산을 다시 짜지 않는다.** 브라우저가 쓰는 빌더(`_UI_Corridor_Build`)와 저장 형식 + * (`_UI_Corridor_Envelope`)을 그대로 부른다 — 두 벌이 되면 화면과 서버가 다른 그림을 + * 만든다. 빌더 계통에는 Three.js·화면 API가 없어 Node에서 그대로 돈다. + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { detail: 종횡단 상세(API와 같은 꼴), route_points: 노선 폴리라인 } + * 출력 브라우저가 GET으로 받는 저장본 그대로(버전·해시 포함) + * 끝 코드: 0 성공 / 2 인자 오류 / 3 측점 부족(만들 것 없음) + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import { buildCorridor } from "./B05_Profile_UI_Corridor_Build"; +import { corridorHash, serialize } from "./B05_Profile_UI_Corridor_Envelope"; + +interface CorridorNodeInput { + detail: SectionDetailResponse; + route_points: RoutePoint[]; +} + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} + +const input = JSON.parse(readFileSync(inputPath, "utf8")) as CorridorNodeInput; +const detail = input.detail; +const routePoints = input.route_points ?? []; +// 편집 전이므로 계획선 샘플은 정본 그대로다 — 브라우저의 폴백 경로와 같은 값이다. +const samples = detail.longitudinal?.design_profiles?.[0]?.samples; +const build = buildCorridor(detail.cross_sections, routePoints, samples); +if (!build) { + console.error("코리도 없음 — 분류 가능한 측점이 2개 미만."); + process.exit(3); +} +writeFileSync(outputPath, JSON.stringify(serialize(build, corridorHash(detail, routePoints)))); diff --git a/B05_Profile/B05_Profile_Corridor_Prebuild.py b/B05_Profile/B05_Profile_Corridor_Prebuild.py new file mode 100644 index 00000000..a69be261 --- /dev/null +++ b/B05_Profile/B05_Profile_Corridor_Prebuild.py @@ -0,0 +1,151 @@ +"""코리도(3D 예상형상) 사전 생성 — 전처리 체인 마지막에 서버가 한 번 만들어 영구저장한다. + +왜(2026-09-04 사용자 확정) — 다른 데이터와 같은 흐름으로 맞춘다: 파일 입력 뒤 계산해 +영구저장 → 사용자는 진입 시 로딩만 → 조작은 캐시 → [저장]·[확정] 때 저장. 예전에는 +사용자가 B05에 처음 들어간 그 순간 브라우저가 만들어(실측 17MB 규모) 첫 진입이 느렸다. + +**계산을 다시 짜지 않는다.** 브라우저가 쓰는 TS 빌더를 Node로 그대로 돌린다 +(`B05_Profile_Corridor_Node.ts` → 번들). 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 +화면과 3D가 갈라진다. + +실패는 비치명적이다 — 저장본이 없으면 브라우저가 예전처럼 직접 만든다(폴백 유지). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any +from uuid import UUID + +from B05_Profile.B05_Profile_Repository import get_route_points +from B05_Profile.B05_Profile_Router_Corridor import corridor_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parents[1] +BUNDLE = ROOT / "config" / "corridor_node" / "B05_Profile_Corridor_Node.js" +# 번들이 낡았는지 재는 대상 — 빌더 계통이 걸쳐 있는 폴더. +_SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util") +# 번들 만들기·실행 상한(초). 실측 번들 0.1초, 빌드 65측점 3초 수준이라 넉넉하다. +_BUILD_TIMEOUT_S = 300 +_RUN_TIMEOUT_S = 600 + + +def _node_env() -> dict[str, str]: + """config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약).""" + env = os.environ.copy() + node_modules = ROOT / "config" / "node_modules" + env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}" + env["NODE_PATH"] = str(node_modules) + return env + + +def _bundle_stale() -> bool: + """번들이 없거나 TS 원본보다 오래됐으면 참. + + 번들이 낡으면 서버와 화면이 **다른 기하**를 만든다 — 이 판정이 그것을 막는 유일한 + 장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다. + """ + if not BUNDLE.is_file(): + return True + built_at = BUNDLE.stat().st_mtime + for directory in _SOURCE_DIRS: + for path in (ROOT / directory).rglob("*.ts"): + if path.stat().st_mtime > built_at: + return True + return False + + +def _build_bundle() -> bool: + result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음 + "npm run build:corridor", + shell=True, + cwd=str(ROOT), + env=_node_env(), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_BUILD_TIMEOUT_S, + ) + if result.returncode != 0: + logger.error("코리도 번들 빌드 실패:\n%s", result.stderr) + return False + return True + + +def _run_node(input_path: Path, output_path: Path) -> int: + result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐 + ["node", str(BUNDLE), str(input_path), str(output_path)], + cwd=str(ROOT), + env=_node_env(), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_RUN_TIMEOUT_S, + ) + if result.returncode != 0: + logger.warning("코리도 사전 생성 실패(끝 코드 %s): %s", result.returncode, result.stderr) + return result.returncode + + +async def _section_detail(project_id: UUID | str, route_id: int) -> dict[str, Any] | None: + """브라우저가 받는 것과 **같은** 종횡단 상세를 만든다 — 같은 라우터 함수를 부른다.""" + from B06_Section.B06_Section_Router import get_section_detail + + response = await get_section_detail(UUID(str(project_id)), route_id) + payload = getattr(response, "model_dump", None) + if payload is None: # JSONResponse = 실패 + logger.warning("코리도 사전 생성: 종횡단 상세를 못 받음 (route_id=%s)", route_id) + return None + return payload(mode="json") + + +async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root: Path) -> bool: + """코리도를 만들어 영구저장소에 남긴다. 성공하면 참.""" + detail = await _section_detail(project_id, route_id) + if detail is None: + return False + pool = get_db_pool() + async with pool.acquire() as connection: + points = await get_route_points(connection, route_id) + if len(points) < 2: + logger.info("코리도 사전 생성 건너뜀 — 노선 점이 부족함 (route_id=%s)", route_id) + return False + + if _bundle_stale() and not await asyncio.to_thread(_build_bundle): + return False + + target = corridor_path(Path(project_root), route_id) + with tempfile.TemporaryDirectory(prefix="corridor_") as workdir: + source = Path(workdir) / "input.json" + built = Path(workdir) / "corridor.json" + await asyncio.to_thread( + source.write_text, + json.dumps({"detail": detail, "route_points": points}, default=float), + "utf-8", + ) + if await asyncio.to_thread(_run_node, source, built) != 0: + return False + await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True) + await asyncio.to_thread(_replace, built, target) + logger.info( + "코리도 사전 생성 완료: route_id=%s 크기=%.1fMB", + route_id, + target.stat().st_size / (1024 * 1024), + ) + return True + + +def _replace(source: Path, target: Path) -> None: + """임시 폴더가 다른 드라이브일 수 있어 os.replace 대신 복사 후 지운다.""" + target.write_bytes(source.read_bytes()) + source.unlink(missing_ok=True) diff --git a/B05_Profile/B05_Profile_Router_Corridor.py b/B05_Profile/B05_Profile_Router_Corridor.py index cb4214b7..4da39233 100644 --- a/B05_Profile/B05_Profile_Router_Corridor.py +++ b/B05_Profile/B05_Profile_Router_Corridor.py @@ -28,7 +28,8 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Corridor"]) MAX_CORRIDOR_BYTES = 64 * 1024 * 1024 -def _corridor_path(project_root: Path, route_id: int) -> Path: +def corridor_path(project_root: Path, route_id: int) -> Path: + """코리도 저장본 자리 — 사전 생성(체인)·초기값 복원도 같은 자리를 쓴다.""" return project_root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json" @@ -72,7 +73,7 @@ async def get_corridor(project_id: UUID, route_id: int) -> Response: return JSONResponse( status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."} ) - path = _corridor_path(project_root, route_id) + path = corridor_path(project_root, route_id) if not path.is_file(): return JSONResponse( status_code=404, @@ -109,7 +110,7 @@ async def put_corridor(project_id: UUID, route_id: int, payload: dict = Body(... return JSONResponse( status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."} ) - path = _corridor_path(project_root, route_id) + path = corridor_path(project_root, route_id) await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True) await asyncio.to_thread(atomic_write_json, path, payload) return JSONResponse(content={"status": "ok"}) diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 474b3426..8450ff4e 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -198,6 +198,8 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: }, ) + # 복원이 만든 새 route id — 코리도 초기값을 그 번호로 되돌리는 데 쓴다. + restored_route_id: int | None = None async with pool.acquire() as connection: # 확정 지표면 모델을 초기 체인과 **같은 기준**으로 다시 찾는다 — 체인은 stage 1 # 확정 선택값을 쓴다(2026-08-30). config 기본값을 쓰면 그 선택과 어긋난 모델을 @@ -219,7 +221,9 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: deleted = cursor.rowcount # 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다. if restored and project_root: - await restore_initial_snapshot(connection, project_root, str(project_id)) + restored_route_id = await restore_initial_snapshot( + connection, project_root, str(project_id) + ) await connection.commit() except Exception: await connection.rollback() @@ -227,8 +231,8 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: if restored and project_root: # 정본 파일도 스냅샷본으로 되돌린다 — 이것을 빼면 구조물·관 편집분이 남아 - # 초기값이 오염된다(2026-08-29). - await asyncio.to_thread(restore_snapshot_files, project_root) + # 초기값이 오염된다(2026-08-29). 코리도는 새 route id 이름으로 되돌아간다. + await asyncio.to_thread(restore_snapshot_files, project_root, restored_route_id) else: # 스냅샷이 없어 재계산으로 초기값을 만드는 경로 — 편집 정본을 먼저 걷어내야 # 진짜 초기값이 나온다. 남기면 구조물 측점이 사용자 편집분에서 다시 파생된다 diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 52fe3221..8e96f937 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -16,12 +16,14 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { - buildCorridor, - type CorridorBuildResult, - type CorridorRibbon, -} from "./B05_Profile_UI_Corridor_Build"; -import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures"; + corridorHash, + ENVELOPE_VERSION, + deserialize, + serialize, + type CorridorEnvelope, +} from "./B05_Profile_UI_Corridor_Envelope"; /** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */ export interface CorridorStructureHook { @@ -29,58 +31,6 @@ export interface CorridorStructureHook { applies: (chainageStart: number, chainageEnd: number) => boolean; } -/** - * 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다. - * - * 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다. - * - * 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26. - * 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27. - * 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자). - * 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가 - * 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02). - */ -const ENVELOPE_VERSION = 6; - -interface CorridorEnvelope { - version: number; - hash: string; - ribbons: Array<{ - kind: CorridorRibbon["kind"]; - side: CorridorRibbon["side"]; - colCount: number; - chainages: number[]; - positionsBase64: string; - /** - * 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도 - * 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연 - * 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다. - */ - patch?: boolean; - patchClip?: boolean; - /** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */ - cellMaskBase64?: string; - /** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */ - trimTrisBase64?: string; - trimUvsBase64?: string; - }>; - outline: CorridorBuildResult["outline"]; - /** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */ - caps?: CorridorBuildResult["caps"]; - /** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */ - structures?: CorridorBuildResult["structures"]; - /** - * 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면 - * 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다. - */ - planCurves?: CorridorBuildResult["planCurves"]; - /** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */ - cutWalls?: Array<{ - side: "left" | "right"; - positionsBase64: string; - }>; -} - interface CacheEntry { hash: string; build: CorridorBuildResult; @@ -93,267 +43,6 @@ function keyOf(projectId: string, routeId: number): string { return `${projectId}:${routeId}`; } -/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */ -function fnv1a(text: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i += 1) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - -/** - * 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.** - * - * 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아 - * 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다 - * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 - * 섞어 두면 배포와 동시에 저장본이 만료된다. - */ -// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정· -// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). -// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 -// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. -// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 -// 아니라 리본이 끝난 자리다(2026-08-27 사용자). -// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) + -// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정), -// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토) -// (2026-08-27 사용자). -// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다 -// (2026-08-27 사용자). -// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴 -// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자). -// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인) -// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반 -// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계 -// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형 -// 성토면이 있어야 한다 — 삭제가 아니다). -// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서 -// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이 -// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복"). -// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다 -// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다), -// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`, -// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로. -// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토 -// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자). -// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라 -// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서 -// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자: -// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림"). -// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`, -// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로 -// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다 -// (2026-08-27 사용자: "좌측 스윕 더 가야 함"). -// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과 -// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.) -// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단 -// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로"). -// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에 -// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.) -// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`). -// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다 -// (2026-08-27 사용자). 안쪽은 안 자른다. -// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니 -// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로 -// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다 -// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지"). -// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거, -// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고 -// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다 -// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다). -// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은 -// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 — -// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측). -// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자). -// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열 -// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는 -// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다** -// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선 -// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다. -// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥 -// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이 -// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해 -// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히 -// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이 -// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다"). -// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로 -// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지 -// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m). -// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취 -// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다 -// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출"). -// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용). -// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로 -// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선 -// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고 -// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음 -// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이 -// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가 -// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은 -// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다. -// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로 -// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을 -// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다 -// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로 -// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다 -// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까"). -// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어 -// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**. -// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다 -// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다. -// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을 -// 가르고 **닫힌 고리만** 담는다. -// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견 -// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자). -// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤 -// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자). -// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2, -// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계). -// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와 -// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자). -// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만 -// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치 -// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취 -// 영역을 내므로 같은 반대 규칙이 성립한다. -// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가 -// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆 -// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 — -// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다. -// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를 -// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길 -// 부재를 못 정하므로 만료한다. -// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로 -// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다. -const BUILD_VERSION = 92; - -/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ -export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { - const parts: Array = [`v${BUILD_VERSION}`, routePoints.length]; - routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2))); - detail.cross_sections.forEach((section) => { - const design = section.design; - parts.push( - section.chainage_m.toFixed(3), - section.center_x.toFixed(2), - section.center_y.toFixed(2), - ); - if (!design) { - parts.push("nodesign"); - return; - } - parts.push( - design.design_elevation_m.toFixed(3), - design.section_mode, - design.ditch_side ?? "", - design.ditch_type ?? "", - String(design.ditch_enabled ?? ""), - design.design_line.length, - ); - design.design_line.forEach((point) => - parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)), - ); - // 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다. - structureHashParts(section).forEach((part) => parts.push(part)); - }); - return fnv1a(parts.join("|")); -} - -function base64FromFloats(values: Float64Array): string { - const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength); - let binary = ""; - const CHUNK = 0x8000; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -} - -function base64FromBytes(bytes: Uint8Array): string { - let binary = ""; - const CHUNK = 0x8000; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -} - -function bytesFromBase64(encoded: string): Uint8Array { - const binary = atob(encoded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -function floatsFromBase64(encoded: string): Float64Array { - const binary = atob(encoded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - return new Float64Array(bytes.buffer); -} - -function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { - return { - version: ENVELOPE_VERSION, - hash, - ribbons: build.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - colCount: ribbon.colCount, - chainages: ribbon.chainages, - positionsBase64: base64FromFloats(ribbon.positions), - ...(ribbon.patch ? { patch: true } : {}), - ...(ribbon.patchClip ? { patchClip: true } : {}), - ...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}), - ...(ribbon.trimTris && ribbon.trimUvs - ? { - trimTrisBase64: base64FromFloats(ribbon.trimTris), - trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))), - } - : {}), - })), - outline: build.outline, - caps: build.caps, - structures: build.structures, - planCurves: build.planCurves, - cutWalls: (build.cutWalls ?? []).map((wall) => ({ - side: wall.side, - positionsBase64: base64FromFloats(wall.positions), - })), - }; -} - -function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { - return { - ribbons: envelope.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - colCount: ribbon.colCount, - chainages: ribbon.chainages, - positions: floatsFromBase64(ribbon.positionsBase64), - ...(ribbon.patch ? { patch: true } : {}), - ...(ribbon.patchClip ? { patchClip: true } : {}), - ...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}), - ...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64 - ? { - trimTris: floatsFromBase64(ribbon.trimTrisBase64), - trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer), - } - : {}), - })), - outline: envelope.outline, - caps: envelope.caps ?? [], - structures: envelope.structures ?? [], - planCurves: envelope.planCurves ?? [], - cutWalls: (envelope.cutWalls ?? []).map((wall) => ({ - side: wall.side, - positions: floatsFromBase64(wall.positionsBase64), - })), - }; -} - async function requestCorridor(path: string, init: RequestInit): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); @@ -412,6 +101,19 @@ export type ProfileSamples = Array<{ ground_elevation_m?: number; }>; +/** 마지막 코리도가 **어디서 왔는지** — 저장본을 그대로 썼나, 브라우저가 다시 만들었나. + * 서버 사전 생성(2026-09-04)이 실제로 먹었는지 화면 밖에서 수치로 확인하는 창구다 + * (`__corridorScene`·`__corridorSkirt`와 같은 디버그 훅). */ +declare global { + interface Window { + __corridorSource?: { source: "stored" | "built"; hash: string; at: number }; + } +} + +function markSource(source: "stored" | "built", hash: string): void { + window.__corridorSource = { source, hash, at: Date.now() }; +} + export async function ensureCorridor( projectId: string, routeId: number, @@ -429,6 +131,7 @@ export async function ensureCorridor( try { const build = deserialize(stored); cache.set(key, { hash, build, dirty: false }); + markSource("stored", hash); return build; } catch { // 손상 저장본 — 빌드로 폴백. @@ -447,6 +150,7 @@ export async function ensureCorridor( cache.delete(key); return null; } + markSource("built", hash); if (stored === null) { // 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행). cache.set(key, { hash, build, dirty: false }); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts new file mode 100644 index 00000000..feb3aef9 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts @@ -0,0 +1,329 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Envelope.ts + * 코리도 저장본 형식 — 버전·해시·직렬화 한 벌. **브라우저와 서버가 같은 것을 쓴다.** + * + * 서버(전처리 체인 끝)가 같은 빌더로 코리도를 미리 만들어 저장하므로(2026-09-04 사용자 + * 확정), 저장 형식과 버전 해시가 두 벌이 되면 브라우저가 서버 저장본을 남으로 보고 + * 통째로 다시 만든다. 그래서 이 파일 하나만 둔다 — 화면 API 를 쓰지 않아 Node 에서도 + * 그대로 돈다(`btoa`·`atob` 는 Node 16+ 전역). + * + * 원래 `_UI_Corridor.ts` 안에 있던 것을 그대로 옮겼다(2026-09-04 분리, 내용 불변). + * ========================================================================== */ + +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import type { CorridorBuildResult, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build_Types"; +import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures"; + +/** + * 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다. + * + * 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다. + * + * 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26. + * 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27. + * 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자). + * 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가 + * 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02). + */ +export const ENVELOPE_VERSION = 6; + +export interface CorridorEnvelope { + version: number; + hash: string; + ribbons: Array<{ + kind: CorridorRibbon["kind"]; + side: CorridorRibbon["side"]; + colCount: number; + chainages: number[]; + positionsBase64: string; + /** + * 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도 + * 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연 + * 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다. + */ + patch?: boolean; + patchClip?: boolean; + /** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */ + cellMaskBase64?: string; + /** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */ + trimTrisBase64?: string; + trimUvsBase64?: string; + }>; + outline: CorridorBuildResult["outline"]; + /** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */ + caps?: CorridorBuildResult["caps"]; + /** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */ + structures?: CorridorBuildResult["structures"]; + /** + * 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면 + * 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다. + */ + planCurves?: CorridorBuildResult["planCurves"]; + /** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */ + cutWalls?: Array<{ + side: "left" | "right"; + positionsBase64: string; + }>; +} + +/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */ +function fnv1a(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** + * 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.** + * + * 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아 + * 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다 + * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 + * 섞어 두면 배포와 동시에 저장본이 만료된다. + */ +// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정· +// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). +// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 +// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. +// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 +// 아니라 리본이 끝난 자리다(2026-08-27 사용자). +// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) + +// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정), +// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토) +// (2026-08-27 사용자). +// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다 +// (2026-08-27 사용자). +// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴 +// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자). +// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인) +// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반 +// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계 +// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형 +// 성토면이 있어야 한다 — 삭제가 아니다). +// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서 +// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이 +// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복"). +// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다 +// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다), +// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`, +// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로. +// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토 +// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자). +// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라 +// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서 +// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자: +// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림"). +// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`, +// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로 +// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다 +// (2026-08-27 사용자: "좌측 스윕 더 가야 함"). +// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과 +// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.) +// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단 +// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로"). +// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에 +// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.) +// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`). +// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다 +// (2026-08-27 사용자). 안쪽은 안 자른다. +// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니 +// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로 +// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다 +// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지"). +// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거, +// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고 +// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다 +// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다). +// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은 +// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 — +// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측). +// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자). +// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열 +// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는 +// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다** +// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선 +// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다. +// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥 +// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이 +// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해 +// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히 +// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이 +// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다"). +// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로 +// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지 +// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m). +// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취 +// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다 +// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출"). +// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용). +// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로 +// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선 +// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고 +// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음 +// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이 +// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가 +// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은 +// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다. +// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로 +// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을 +// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다 +// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로 +// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다 +// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까"). +// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어 +// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**. +// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다 +// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다. +// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을 +// 가르고 **닫힌 고리만** 담는다. +// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견 +// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자). +// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤 +// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자). +// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2, +// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계). +// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와 +// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자). +// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만 +// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치 +// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취 +// 영역을 내므로 같은 반대 규칙이 성립한다. +// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가 +// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆 +// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 — +// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다. +// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를 +// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길 +// 부재를 못 정하므로 만료한다. +// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로 +// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다. +const BUILD_VERSION = 92; + +/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ +export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { + const parts: Array = [`v${BUILD_VERSION}`, routePoints.length]; + routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2))); + detail.cross_sections.forEach((section) => { + const design = section.design; + parts.push( + section.chainage_m.toFixed(3), + section.center_x.toFixed(2), + section.center_y.toFixed(2), + ); + if (!design) { + parts.push("nodesign"); + return; + } + parts.push( + design.design_elevation_m.toFixed(3), + design.section_mode, + design.ditch_side ?? "", + design.ditch_type ?? "", + String(design.ditch_enabled ?? ""), + design.design_line.length, + ); + design.design_line.forEach((point) => + parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)), + ); + // 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다. + structureHashParts(section).forEach((part) => parts.push(part)); + }); + return fnv1a(parts.join("|")); +} + +function base64FromFloats(values: Float64Array): string { + const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength); + let binary = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +function base64FromBytes(bytes: Uint8Array): string { + let binary = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +function bytesFromBase64(encoded: string): Uint8Array { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function floatsFromBase64(encoded: string): Float64Array { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return new Float64Array(bytes.buffer); +} + +export function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { + return { + version: ENVELOPE_VERSION, + hash, + ribbons: build.ribbons.map((ribbon) => ({ + kind: ribbon.kind, + side: ribbon.side, + colCount: ribbon.colCount, + chainages: ribbon.chainages, + positionsBase64: base64FromFloats(ribbon.positions), + ...(ribbon.patch ? { patch: true } : {}), + ...(ribbon.patchClip ? { patchClip: true } : {}), + ...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}), + ...(ribbon.trimTris && ribbon.trimUvs + ? { + trimTrisBase64: base64FromFloats(ribbon.trimTris), + trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))), + } + : {}), + })), + outline: build.outline, + caps: build.caps, + structures: build.structures, + planCurves: build.planCurves, + cutWalls: (build.cutWalls ?? []).map((wall) => ({ + side: wall.side, + positionsBase64: base64FromFloats(wall.positions), + })), + }; +} + +export function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { + return { + ribbons: envelope.ribbons.map((ribbon) => ({ + kind: ribbon.kind, + side: ribbon.side, + colCount: ribbon.colCount, + chainages: ribbon.chainages, + positions: floatsFromBase64(ribbon.positionsBase64), + ...(ribbon.patch ? { patch: true } : {}), + ...(ribbon.patchClip ? { patchClip: true } : {}), + ...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}), + ...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64 + ? { + trimTris: floatsFromBase64(ribbon.trimTrisBase64), + trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer), + } + : {}), + })), + outline: envelope.outline, + caps: envelope.caps ?? [], + structures: envelope.structures ?? [], + planCurves: envelope.planCurves ?? [], + cutWalls: (envelope.cutWalls ?? []).map((wall) => ({ + side: wall.side, + positions: floatsFromBase64(wall.positionsBase64), + })), + }; +} diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index 9b68e25c..39934bf7 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -45,6 +45,11 @@ _FILE_TREES = ( # 배수유역을 폴더 통째로 넓히기 전(2026-09-04)에 찍힌 스냅샷이 갖고 있는 자리. _LEGACY_DRAINAGE_TREE = "B04_PreProcess__drainage__edits" +# 코리도(3D 예상형상) 초기값 — 체인이 만든 저장본 한 벌(2026-09-04 사용자 확정). +# 작업본 파일명에는 route id가 박히는데 [초기화]는 **새 route id**를 만드므로, 스냅샷에는 +# 번호 없는 이름으로 두었다가 복원 때 새 번호로 되돌린다. +_CORRIDOR_NAME = "initial_corridor.json" + # `routes.id`를 참조하는 표는 스키마상 이 넷이 전부다(001_create_schema.sql:539~556). _CHILD_TABLES = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections") @@ -198,6 +203,13 @@ async def save_initial_snapshot( for tree in _FILE_TREES: _copy_tree(root / tree, target / tree.replace("/", "__")) + # 코리도는 체인이 마지막에 만든다 — 있으면 함께 뜬다(없으면 브라우저 폴백 그대로). + from B05_Profile.B05_Profile_Router_Corridor import corridor_path + + corridor = corridor_path(root, route_id) + if corridor.is_file(): + shutil.copy2(corridor, target / _CORRIDOR_NAME) + async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute("SELECT * FROM routes WHERE id = %s", (route_id,)) route = await cursor.fetchone() @@ -245,7 +257,7 @@ def wipe_edited_masters(project_root: Path) -> list[str]: return removed -def restore_snapshot_files(project_root: Path) -> None: +def restore_snapshot_files(project_root: Path, route_id: int | None = None) -> None: """스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다. 배수유역 범위를 넓히기 전(2026-09-04)에 찍힌 스냅샷은 `edits/`만 갖고 있다 — @@ -261,6 +273,16 @@ def restore_snapshot_files(project_root: Path) -> None: continue _copy_tree(stored, root / tree) + # 코리도는 복원으로 만든 **새 route id** 이름으로 되돌린다 — 이름이 어긋나면 브라우저가 + # 저장본을 못 찾아 초기화 뒤 첫 진입마다 통째로 다시 만든다. + from B05_Profile.B05_Profile_Router_Corridor import corridor_path + + corridor = source / _CORRIDOR_NAME + if route_id is not None and corridor.is_file(): + target = corridor_path(root, int(route_id)) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(corridor, target) + async def restore_initial_snapshot( connection: aiomysql.Connection, project_root: Path, project_id: str diff --git a/package.json b/package.json index 00290cfa..a5e66137 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:b07-cad", + "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", From db3503fd95aa8e8637d81749921f6fefbb2b0895 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 21:31:16 +0900 Subject: [PATCH 04/14] =?UTF-8?q?fix(B05):=20=ED=9C=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EA=B0=90=EC=86=8D=EC=9D=84=20=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=9E=84=20=EC=88=98=EA=B0=80=20=EC=95=84=EB=8B=8C=20=ED=9D=90?= =?UTF-8?q?=EB=A5=B8=20=EC=8B=9C=EA=B0=84=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프레임당 고정 비율(0.35)로 좁히면 주사율에 따라 속도가 달라짐 — 120Hz 화면에서는 두 배 빨라 툭 끊겨 보이고, 프레임을 한 번 놓치면 그만큼 덜컥 뜀. 흐른 시간으로 환산해 어느 화면에서나 같은 시간에 도착하게 함. 탭이 잠들었다 깬 큰 간격은 64ms로 자름. 자체검증(공용 브라우저 임시 탭, 휠 20칸): 프레임 중앙값 16.6ms(60fps) 유지, 최대 프레임 47.6 → 34.3ms, 20ms 초과 프레임 3 → 2건. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index c2e0454e..5ee726db 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -19,21 +19,29 @@ const EASE = 0.35; /** 이보다 가까우면 도착으로 보고 딱 맞춘다(px). */ const SNAP_PX = 0.5; +/** EASE의 기준 프레임 간격(ms) — 60Hz 한 칸. */ +const BASE_FRAME_MS = 1000 / 60; +/** 한 프레임으로 인정하는 최대 간격(ms). 탭이 잠들었다 깨면 간격이 수백 ms라 그대로 + * 쓰면 한 번에 목표까지 튄다. */ +const MAX_FRAME_MS = 64; export function attachWheelHorizontalScroll(scroller: HTMLElement): void { let target = -1; /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ let applied = -1; let frame = 0; + /** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */ + let lastFrameAt = 0; function stop(): void { if (frame) cancelAnimationFrame(frame); frame = 0; target = -1; applied = -1; + lastFrameAt = 0; } - function step(): void { + function step(now: number): void { frame = 0; // 스크롤바 끌기·스크롤 동기화처럼 다른 경로가 자리를 옮겼으면 이동을 접는다. if (applied >= 0 && Math.abs(scroller.scrollLeft - applied) > 1) return stop(); @@ -44,8 +52,13 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { scroller.scrollLeft = goal; return stop(); } - const next = scroller.scrollLeft + rest * EASE; - scroller.scrollLeft = next; + // 프레임당 고정 비율로 좁히면 화면 주사율에 따라 속도가 달라진다 — 120Hz에서는 두 배 + // 빨라 툭 끊겨 보이고, 프레임을 한 번 놓치면 그만큼 덜컥 뛴다. 흐른 시간으로 환산해 + // **어느 화면에서나 같은 시간**에 도착하게 한다(2026-09-04). + const elapsed = lastFrameAt ? Math.min(MAX_FRAME_MS, now - lastFrameAt) : BASE_FRAME_MS; + lastFrameAt = now; + const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS); + scroller.scrollLeft = scroller.scrollLeft + rest * ratio; applied = scroller.scrollLeft; frame = requestAnimationFrame(step); } From 8dca4bbfb323401d500c3191d4075da60a46321b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 21:56:25 +0900 Subject: [PATCH 05/14] =?UTF-8?q?perf(B05):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=84=B8=EB=A1=9C=20=EB=A7=9E=EC=B6=A4=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=A4=91=20=EC=8B=A4=EC=8B=9C=EA=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-04) — 「화면을 리프레시하는 느낌. 가장 강한 지연은 멈춘 뒤 끊김. 실시간처럼 되려면 근본적으로 표현을 바꿔야 하지 않나」. 확정: 변환 방식. 원인 — 세로 위치가 선 하나하나의 좌표에 박혀 있어, 세로 창이 바뀌면 그래프를 통째로 다시 만들 수밖에 없었음(SVG 요소 457개, 실측 34ms = 60Hz 두 프레임 누락). - 세로에 딸린 도형(눈금선·절성토 음영·지반선·계획선)을 `b06-chart__ywindow` 한 겹으로 묶고, 세로 창을 `translate`+`scale` 한 줄로 표현. 두 창의 대응이 1차식이라 정확히 겹침. - 자를 영역(clip)은 변환 밖, 글자는 변환 밖에서 자리만 옮김(늘어남 방지). 글자 내림 4px 은 변환 뒤에 더함 — 배율과 함께 커지면 선에서 떨어짐(실측 6.4px). - 선 굵기·점선 간격은 `vector-effect: non-scaling-stroke`(변환이 걸렸을 때만 적용해 B06 은 예전 그대로). - 눈금 간격이 어긋나면(배율 0.8~1.25 밖) **눈금층만** 다시 만듦(요소 20개 안팎). 눈금은 그릴 때의 좌표계에 놓아 같은 변환이 그대로 먹음. - 눈금 계산을 `yWindowTickValues` 로 빼 그리는 쪽과 갱신 쪽이 같은 눈금을 씀. - 패널은 스크롤마다 변환 갱신만 부름. 전체 재구성은 눈금으로도 못 살릴 때만 남김. 자체검증(공용 브라우저 임시 탭, 휠 20칸 4,800px): ① 전체 재구성 **0회**(고침 전 다회) ② 최대 프레임 **59.9 → 20.0ms**, 20ms 초과 0건 ③ 눈금 글자와 눈금선 간격 6.4 → **0.5px** ④ 지반선의 한 점을 변환으로 놓은 자리와 눈금이 말하는 자리가 **0.000px** 일치(배율 2.715 상태) ⑤ B06 종단은 변환 겹 3개 모두 변환 없음(항등) — 화면 불변 ⑥ 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 --- .../B05_Profile_UI_Profile_MassHaul.ts | 4 + B05_Profile/B05_Profile_UI_Profile_Panel.ts | 19 +- B05_Profile/B05_Profile_UI_Profile_Render.ts | 19 ++ B05_Profile/B05_Profile_UI_Profile_YWindow.ts | 176 ++++++++++++++++++ B06_Section/B06_Section_UI_Longitudinal.ts | 159 ++++++++++++---- B06_Section/B06_Section_UI_Style_Cross.css | 6 + 6 files changed, 337 insertions(+), 46 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_YWindow.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index f702240f..61006117 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -47,6 +47,7 @@ import { createMassHaulSummary, MASS_HAUL_MIN_HEIGHT, } from "@util/common_util_mass_haul_view"; +import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; @@ -102,6 +103,9 @@ export function buildStickyYAxis( const tick = document.createElement("span"); tick.className = "b05-profile__yaxis-tick"; tick.style.top = `${y}px`; + // 종단 그래프의 세로 창 변환이 이 눈금도 같이 옮긴다 — 그릴 때의 y를 남겨 둔다 + // (`_UI_Profile_YWindow`, 2026-09-04). 유토곡선 축에는 갱신이 오지 않아 무해하다. + tick.setAttribute(Y_WINDOW_BAKED_ATTR, String(y)); tick.textContent = label; inner.append(tick); }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 45fec08d..c8ba1d46 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -98,6 +98,7 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60; * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { needsFullRedraw, type ElevationWindowResult } from "./B05_Profile_UI_Profile_YWindow"; export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( @@ -342,6 +343,8 @@ export function createRouteProfilePanel( /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ let elevationWindow: { min: number; max: number } | undefined; + /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ + let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; /** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */ let heightEditing = false; const holdElevationRange = ( @@ -506,19 +509,25 @@ export function createRouteProfilePanel( selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, redraw: draw, + setElevationWindowUpdater: (update) => { + updateElevationWindow = update; + }, }); } - /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. - * 재구성이 실측 50ms 미만이라 160 → 80ms 로 줄였다 — 멈춘 뒤 따라오는 시간을 반으로 - * (2026-09-04 사용자 지시: 「응답이 늦게 쫓아온다」). */ + /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ const SCROLL_SETTLE_MS = 80; - /** 마지막으로 세로를 맞춘 가로 위치 — 같은 자리면 다시 그리지 않는다(재구성 되먹임 차단). */ + /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ let settledScrollLeft = 0; let scrollSettleTimer = 0; - // 스크롤하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤에 한 번만 다시 맞춘다(2026-09-04). + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 + // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 + // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. body.addEventListener("scroll", () => { + if (heightEditing) return; + const fitted = updateElevationWindow?.() ?? null; window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; scrollSettleTimer = window.setTimeout(() => { if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 6d4662c3..53035c34 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -28,6 +28,7 @@ import { } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; +import { applyElevationWindow, type ElevationWindowResult } from "./B05_Profile_UI_Profile_YWindow"; import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; @@ -104,6 +105,9 @@ export interface ProfileRenderContext { stationIdAtStructure: (structureId: string | null) => string | null; /** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */ redraw: () => void; + /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기를 패널에 넘긴다(2026-09-04). + * 가로 스크롤마다 이것만 부르면 그래프 재구성(실측 34ms)이 사라진다. */ + setElevationWindowUpdater: (update: (() => ElevationWindowResult | null) | null) => void; } /** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */ @@ -323,6 +327,21 @@ export function renderProfile(ctx: ProfileRenderContext): void { }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), }); + // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 + // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). + ctx.setElevationWindowUpdater(() => { + const from = Math.max(0, toChainage(body.scrollLeft)); + const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth)); + const next = ctx.holdElevationRange( + windowElevationRange( + [graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)], + from, + to, + ) ?? null, + ); + return applyElevationWindow(chartWrap, next); + }); + // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). diff --git a/B05_Profile/B05_Profile_UI_Profile_YWindow.ts b/B05_Profile/B05_Profile_UI_Profile_YWindow.ts new file mode 100644 index 00000000..2f561f58 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_YWindow.ts @@ -0,0 +1,176 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_YWindow.ts + * 종단 그래프의 **세로 창을 변환으로 갈아 끼운다** — 다시 그리지 않는다(2026-09-04 확정). + * + * 예전에는 스크롤을 멈출 때마다 그래프를 통째로 새로 만들었다(SVG 요소 457개, 실측 + * 34ms = 60Hz 두 프레임 누락). 사용자에게는 「화면을 리프레시하는 느낌」으로 왔다. + * + * 세로 매핑은 1차식이라(`y = zero − (표고 − center) × pxPerM`) 옛 창과 새 창의 관계도 + * 1차식이다. 그래서 도형을 건드리지 않고 겹 하나에 `translate`+`scale` 만 걸면 정확히 + * 겹친다. 글자는 늘어나므로 변환에 넣지 않고 자리만 옮긴다(눈금 10개 안팎). + * + * 창이 많이 벌어져 눈금 간격이 원래 의도(1·2·5 계열)와 어긋나면 **눈금만** 다시 만든다. + * 그때도 눈금은 **그릴 때의 좌표계**에 놓아 같은 변환이 그대로 먹게 한다. + * ========================================================================== */ + +import { + Y_GRID_CLASS, + Y_TICK_CLASS, + Y_WINDOW_ATTRS, + Y_WINDOW_BAKED_ATTR, + Y_WINDOW_CLASS, + Y_WINDOW_SPAN_PAD, + yWindowTickValues, +} from "../B06_Section/B06_Section_UI_Longitudinal"; + +/** 눈금을 다시 만드는 문턱 — 그릴 때 대비 세로 배율이 이 밖으로 나가면 간격이 어긋난다. */ +const TICK_KEEP_MIN = 0.8; +const TICK_KEEP_MAX = 1.25; +/** 플롯 안에 남아야 하는 최소 눈금 수. */ +const TICK_KEEP_MIN_COUNT = 4; +/** 눈금 글자는 선보다 이만큼 아래에 앉는다(그리는 쪽과 같은 값). */ +const TICK_TEXT_DROP_PX = 4; + +interface Baked { + svg: SVGSVGElement; + center: number; + pxPerM: number; + zero: number; + top: number; + height: number; +} + +export interface ElevationWindowResult { + /** 그릴 때 대비 세로 배율(1이면 그릴 때와 같은 창). */ + scale: number; + /** 플롯 안에 남아 있는 눈금 수. */ + visibleTicks: number; + /** 이번 호출에서 눈금을 다시 만들었는가. */ + rebuiltTicks: boolean; +} + +function readBaked(root: ParentNode): Baked | null { + const svg = root.querySelector(`svg[${Y_WINDOW_ATTRS.auto}="1"]`); + if (!svg) return null; + const read = (name: string): number => Number(svg.getAttribute(name)); + const baked: Baked = { + svg, + center: read(Y_WINDOW_ATTRS.center), + pxPerM: read(Y_WINDOW_ATTRS.pxPerM), + zero: read(Y_WINDOW_ATTRS.zero), + top: read(Y_WINDOW_ATTRS.top), + height: read(Y_WINDOW_ATTRS.height), + }; + const numbers = [baked.center, baked.pxPerM, baked.zero, baked.top, baked.height]; + if (!numbers.every(Number.isFinite) || baked.pxPerM <= 0 || baked.height <= 0) return null; + return baked; +} + +/** 눈금선·눈금 글자·좌측 고정 축을 새 창의 눈금값으로 다시 만든다(그래프는 건드리지 않는다). + * + * 자리는 **그릴 때의 좌표계**로 잡는다 — 그래야 아래 변환이 눈금에도 그대로 먹는다. */ +function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: number): boolean { + const grid = baked.svg.querySelector(`.${Y_GRID_CLASS}`); + const labels = baked.svg.querySelector(`.${Y_TICK_CLASS}`); + if (!grid || !labels) return false; + const sampleLine = grid.querySelector("line"); + const sampleText = labels.querySelector("text"); + if (!sampleLine || !sampleText) return false; + const x1 = sampleLine.getAttribute("x1") ?? "0"; + const x2 = sampleLine.getAttribute("x2") ?? "0"; + const lineClass = sampleLine.getAttribute("class") ?? ""; + const textX = sampleText.getAttribute("x") ?? "0"; + const textClass = sampleText.getAttribute("class") ?? ""; + const axis = root.querySelector(".b05-profile__yaxis-inner"); + + const ns = "http://www.w3.org/2000/svg"; + const lines: SVGLineElement[] = []; + const texts: SVGTextElement[] = []; + const spans: HTMLElement[] = []; + for (const { value, label } of yWindowTickValues(center, span, baked.height)) { + const y = baked.zero - (value - baked.center) * baked.pxPerM; + const line = document.createElementNS(ns, "line"); + line.setAttribute("x1", x1); + line.setAttribute("x2", x2); + line.setAttribute("y1", String(y)); + line.setAttribute("y2", String(y)); + line.setAttribute("class", lineClass); + lines.push(line); + const text = document.createElementNS(ns, "text"); + text.setAttribute("x", textX); + text.setAttribute("y", String(y + TICK_TEXT_DROP_PX)); + text.setAttribute("text-anchor", "end"); + text.setAttribute("class", textClass); + text.setAttribute(Y_WINDOW_BAKED_ATTR, String(y)); + text.textContent = label; + texts.push(text); + if (axis) { + const span = document.createElement("span"); + span.className = "b05-profile__yaxis-tick"; + span.style.top = `${y}px`; + span.setAttribute(Y_WINDOW_BAKED_ATTR, String(y)); + span.textContent = label; + spans.push(span); + } + } + grid.replaceChildren(...lines); + labels.replaceChildren(...texts); + if (axis) axis.replaceChildren(...spans); + return true; +} + +/** + * 종단 그래프의 세로 창을 옮긴다. 대상 그래프가 없거나(고정 배율 B06) 창이 없으면 null. + * 좌측 고정 축(`b05-profile__yaxis-tick`)도 같은 식으로 따라 옮긴다. + */ +export function applyElevationWindow( + root: ParentNode, + range: { min: number; max: number } | undefined, +): ElevationWindowResult | null { + const baked = readBaked(root); + if (!baked || !range) return null; + + // 새 창 — 그리는 쪽과 **같은 규칙**으로 만든다(같은 상수를 같이 쓴다). + const span = Math.max(range.max - range.min, 1) * Y_WINDOW_SPAN_PAD; + const center = (range.min + range.max) / 2; + const pxPerM = baked.height / span; + const scale = pxPerM / baked.pxPerM; + + // 배율이 크게 벌어지면 눈금 간격이 원래 의도와 어긋난다 — 눈금만 새로 만든다(요소 20개 + // 안팎). 그래프 전체를 다시 만들던 예전 방식이 34ms 끊김의 원인이었다. + let rebuiltTicks = false; + if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX) { + rebuiltTicks = rebuildTicks(baked, root, center, span); + } + + const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM; + const transform = `translate(0 ${offset}) scale(1 ${scale})`; + baked.svg.querySelectorAll(`.${Y_WINDOW_CLASS}`).forEach((layer) => { + layer.setAttribute("transform", transform); + }); + + const place = (bakedY: number): number => scale * bakedY + offset; + const inside = (y: number): boolean => y >= baked.top && y <= baked.top + baked.height; + let visibleTicks = 0; + baked.svg.querySelectorAll(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => { + const moved = place(Number(label.getAttribute(Y_WINDOW_BAKED_ATTR))); + // 글자를 내리는 몫은 **변환 뒤에** 더한다 — 배율과 함께 커지면 선에서 떨어져 보인다. + label.setAttribute("y", String(moved + TICK_TEXT_DROP_PX)); + // 플롯 밖으로 나간 눈금 글자는 감춘다 — 축 라벨·측점 띠 위로 흘러나오면 안 된다. + label.style.display = inside(moved) ? "" : "none"; + if (inside(moved)) visibleTicks += 1; + }); + root + .querySelectorAll(`.b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`) + .forEach((tick) => { + const moved = place(Number(tick.getAttribute(Y_WINDOW_BAKED_ATTR))); + tick.style.top = `${moved}px`; + tick.style.display = inside(moved) ? "" : "none"; + }); + return { scale, visibleTicks, rebuiltTicks }; +} + +/** 눈금이 더는 쓸 만하지 않은가 — 패널이 그래프 전체를 다시 만들지 판단하는 기준. */ +export function needsFullRedraw(result: ElevationWindowResult | null): boolean { + return !result || (result.visibleTicks < TICK_KEEP_MIN_COUNT && !result.rebuiltTicks); +} diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index d942ac73..5cae3c89 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -25,6 +25,61 @@ import { } from "./B06_Section_UI_Section_Common"; /** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */ +/** + * 세로 창 변환 규약 — 그리는 쪽(이 파일)과 갱신하는 쪽(B05 `_UI_Profile_YWindow`)이 함께 쓴다. + * + * 세로 위치를 좌표에 박아 두면 창이 바뀔 때마다 그래프를 통째로 다시 만들어야 한다 + * (실측 34ms · 60Hz 두 프레임). 세로에 딸린 도형만 한 겹으로 묶어 두면 **변환 한 줄**로 + * 창을 옮길 수 있다(2026-09-04 사용자 확정). + */ +/** 자동 세로 창의 여유 — 최고·최저가 축선에 붙지 않게 위아래로 10%씩(2026-08-23 사용자). */ +export const Y_WINDOW_SPAN_PAD = 1.2; +/** 세로 창 변환을 받는 겹 — 세로에 딸린 **도형만** 들어간다(글자는 늘어나므로 제외). */ +export const Y_WINDOW_CLASS = "b06-chart__ywindow"; +/** 눈금선 겹·눈금 글자 겹 — 창이 크게 바뀌면 이 둘만 다시 만든다(그래프는 그대로). */ +export const Y_GRID_CLASS = "b06-chart__ygrid"; +export const Y_TICK_CLASS = "b06-chart__yticks"; +/** 글자가 그릴 때의 y를 남기는 속성 — 변환 대신 이 값으로 자리만 옮긴다. */ +export const Y_WINDOW_BAKED_ATTR = "data-yw-y"; +/** 바깥에서 새 창의 변환을 계산하는 데 필요한 기준값(SVG 속성 이름). */ +export const Y_WINDOW_ATTRS = { + /** "1"이면 자동 세로 창(B05) — 고정 배율(B06)은 갱신 대상이 아니다. */ + auto: "data-yw-auto", + /** 그릴 때 쓴 창 중심 표고(m). */ + center: "data-yw-center", + /** 표고 1m당 화면 px. */ + pxPerM: "data-yw-pxm", + /** 창 중심 표고가 놓인 y(px). */ + zero: "data-yw-y0", + /** 플롯 상단 y(px)와 높이(px) — 창 밖으로 나간 글자를 감추는 데 쓴다. */ + top: "data-yw-top", + height: "data-yw-height", +} as const; + +/** + * 세로 눈금값 목록 — 창(중심·폭)에 대해 1·2·5 계열의 딱 떨어지는 표고를 고른다. + * 라벨 글자(최대 13px)가 겹치지 않게 눈금 간격은 16px 이상, 표고 눈금은 1m 아래로 + * 내려가지 않는다(2026-08-23 사용자 지시). + * + * 그릴 때(이 파일)와 창이 바뀌어 눈금만 다시 만들 때(B05 `_UI_Profile_YWindow`)가 + * **같은 눈금**을 써야 해서 여기로 뺐다(2026-09-04). + */ +export function yWindowTickValues( + center: number, + span: number, + plotHeight: number, +): Array<{ value: number; label: string }> { + const step = Math.max(niceTickStep(span, 10, Math.floor(plotHeight / 16)), 1); + const decimals = Math.max(0, Math.ceil(-Math.log10(step) - 1e-9)); + const ticks: Array<{ value: number; label: string }> = []; + const topValue = center + span / 2; + for (let value = Math.ceil((center - span / 2) / step) * step; value <= topValue + 1e-9;) { + ticks.push({ value, label: `${value.toFixed(decimals)}m` }); + value += step; + } + return ticks; +} + function offsetStationLabel(chainageM: number, interval: number, stationOffset: number): string { const base = stationLabel(chainageM, interval); if (!stationOffset) return base; @@ -220,7 +275,7 @@ export function createLongitudinalProfile( ? plotHeight / yScaleOptions.pixelsPerMeter : // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다 // (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다. - Math.max(rawMax - rawMin, 1) * 1.2; + Math.max(rawMax - rawMin, 1) * Y_WINDOW_SPAN_PAD; // 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다. const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration); const x = (chainage: number) => @@ -228,47 +283,22 @@ export function createLongitudinalProfile( const xInverse = inverseOf(x, maxChainage); const y = (elevation: number) => LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + // 세로 창 갱신에 필요한 기준값을 SVG에 남긴다 — 그리는 함수의 인자를 늘리지 않으려는 것. + // 매핑은 1차식이다: y = zero − (표고 − center) × pxPerM. + svg.setAttribute(Y_WINDOW_ATTRS.center, String(viewCenter)); + svg.setAttribute(Y_WINDOW_ATTRS.pxPerM, String((exaggeration * plotHeight) / elevationSpan)); + svg.setAttribute(Y_WINDOW_ATTRS.zero, String(LONG_PAD.top + plotHeight / 2)); + svg.setAttribute(Y_WINDOW_ATTRS.top, String(LONG_PAD.top)); + svg.setAttribute(Y_WINDOW_ATTRS.height, String(plotHeight)); + // 고정 배율(B06 공통 Y스케일)·세로 과장이 걸린 그래프는 창을 옮기지 않는다. + if (!yScaleOptions && exaggeration === 1) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1"); const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); // Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의 // 딱 떨어지는 수(범위가 아주 좁을 때만 소수)로 오게 한다 — 0.25 비율 고정 눈금은 // 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고). - const yAxisTicks: Array<{ y: number; label: string }> = []; - const rawSpan = elevationSpan / exaggeration; - const rawTop = viewCenter + rawSpan / 2; - // 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다. - // 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시). - const tickStep = Math.max(niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16)), 1); - const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9)); - for ( - let value = Math.ceil((viewCenter - rawSpan / 2) / tickStep) * tickStep; - value <= rawTop + 1e-9; - value += tickStep - ) { - const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight; - const label = `${value.toFixed(tickDecimals)}m`; - yAxisTicks.push({ y: gridY, label }); - svg.append( - svgElement("line", { - x1: LONG_PAD.left, - y1: gridY, - x2: widthPx - LONG_PAD.right, - y2: gridY, - class: "b06-chart__grid", - }), - svgText(label, { - x: LONG_PAD.left - 9, - y: gridY + 4, - "text-anchor": "end", - class: "b06-chart__tick", - }), - ); - } - // sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음). - onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks }); - - // 표시 표고창(세로 배율·중심 이동) 밖으로 나간 지반선·계획선은 그리지 않는다 — - // 안 자르면 축 라벨·측점 라벨 띠 위로 선이 흘러나온다(2026-09-04). + // 표시 표고창 밖으로 나간 것은 그리지 않는다 — 눈금선·음영·프로파일선이 함께 쓴다. + // (예전에는 이 자리가 아래에 있었다. 눈금선도 세로 변환을 타므로 여기로 올렸다.) const clipId = `b06-long-clip-${Math.random().toString(36).slice(2, 9)}`; const clipPath = svgElement("clipPath", { id: clipId }); clipPath.append( @@ -281,15 +311,60 @@ export function createLongitudinalProfile( ); const defs = svgElement("defs", {}); defs.append(clipPath); + // 세로 창이 바뀌면 좌표를 다시 만들지 않고 **이 겹의 변환만** 갈아 끼운다(2026-09-04 + // 사용자 확정 — 「실시간처럼 되려면 표현을 바꿔야 한다」). 자를 영역은 변환 **밖**이라 + // 창이 움직여도 플롯 테두리는 제자리다. 글자는 늘어나므로 이 겹에 넣지 않는다. + const gridLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + const gridWindow = svgElement("g", { class: `${Y_WINDOW_CLASS} ${Y_GRID_CLASS}` }); + gridLayer.append(gridWindow); + const tickLayer = svgElement("g", { class: Y_TICK_CLASS }); + svg.append(defs, gridLayer, tickLayer); + + const yAxisTicks: Array<{ y: number; label: string }> = []; + const rawSpan = elevationSpan / exaggeration; + const rawTop = viewCenter + rawSpan / 2; + // 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다. + // 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시). + for (const { value, label } of yWindowTickValues(viewCenter, rawSpan, plotHeight)) { + const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight; + yAxisTicks.push({ y: gridY, label }); + gridWindow.append( + svgElement("line", { + x1: LONG_PAD.left, + y1: gridY, + x2: widthPx - LONG_PAD.right, + y2: gridY, + class: "b06-chart__grid", + }), + ); + tickLayer.append( + svgText(label, { + x: LONG_PAD.left - 9, + y: gridY + 4, + "text-anchor": "end", + class: "b06-chart__tick", + // 세로 창 변환이 글자를 늘리지 않고 **자리만** 옮기도록 그릴 때의 y를 남긴다. + // 남기는 값은 **눈금선의 y** — 글자를 내리는 4px 은 변환 뒤에 더해야 배율이 + // 커져도 선과 글자 사이가 벌어지지 않는다(2026-09-04 실측 6.4px 어긋남). + [Y_WINDOW_BAKED_ATTR]: String(gridY), + }), + ); + } + // sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음). + onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks }); + const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); - svg.append(defs, bandLayer); + // 음영은 세로에 딸린 것 — 변환 겹 안. 균형 구역 경계선은 플롯 높이 전체라 밖에 둔다. + const bandWindow = svgElement("g", { class: Y_WINDOW_CLASS }); + bandLayer.append(bandWindow); + svg.append(bandLayer); // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; appendCutFillBands( - bandLayer, + bandWindow, profile, x, (index) => toY(profile.samples[index].elevation_m), @@ -380,9 +455,11 @@ export function createLongitudinalProfile( }) .join(" "); const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + const lineWindow = svgElement("g", { class: Y_WINDOW_CLASS }); + lineLayer.append(lineWindow); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; - lineLayer.append( + lineWindow.append( svgElement("polyline", { points: profile.samples .map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`) @@ -391,7 +468,7 @@ export function createLongitudinalProfile( }), ); } - lineLayer.append(svgElement("polyline", { points, class: "b06-chart__profile" })); + lineWindow.append(svgElement("polyline", { points, class: "b06-chart__profile" })); svg.append( lineLayer, svgElement("line", { diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2cf43267..2a4a874a 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -306,6 +306,12 @@ fill: var(--color-surface-raised); } +/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은 + 그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */ +.b06-chart__ywindow[transform] * { + vector-effect: non-scaling-stroke; +} + .b06-chart__grid { stroke: var(--color-border); stroke-width: 1; From 685d44ac39624552b406f6be94b82cbb356eb218 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 22:13:03 +0900 Subject: [PATCH 06/14] =?UTF-8?q?perf(B05,B06):=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=C2=B7B06=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=84=B8=EB=A1=9C=20=EC=B0=BD=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EC=98=A4=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적(2026-09-04) — 「유토곡선도 반영해야지. B06 페이지도 있잖아. 같은 로직일 텐데 왜 반영이 안 된 거야」. 그리는 쪽(종단 렌더러)은 공용이라 이미 바뀌어 있었고, **스크롤을 받아 창을 옮기는 쪽이 화면마다 따로**라 B05 만 연결돼 있던 것. - 세로 창 모듈을 `common_util_chart_ywindow.ts` 로 옮겨 B05·B06 이 함께 씀. 좌측 고정 축은 표식(`b05-profile__yaxis--ywindow`)이 붙은 것만 옮김 — B06 은 한 컨테이너에 종단·유토곡선 축이 함께 살아 남의 축까지 옮기면 안 됨. - 세로 과장(B06 조절값)을 각인·반영 — 예전에는 과장이 1일 때만 대상이었음. - 유토곡선은 변환이 아니라 **곡선만 다시 그림**(스크롤마다). 곡선 위 말풍선·EP 표·측점 점처럼 세로로 늘어나면 안 되는 것이 많아, 그것만 따로 옮기는 값이 다시 그리는 값보다 큼 (유토 SVG 234개 = 종단의 1/3). - B06 상단 패널을 통째로 다시 그리던 스크롤 처리를 걷어내고, 종단은 변환·유토곡선은 제자리 교체로 바꿈. 700줄 제한에 맞춰 종단 조립을 `_View_Chart.ts`(116줄)로, 유토곡선 조립기를 `_View_MassHaul.ts` 로 분리(727 → 718줄). 자체검증(공용 브라우저 임시 탭): ① B05 유토곡선 눈금이 스크롤을 따라 4.3㎥ → −1,348.8㎥ 로 이동(예전에는 안 따라옴) ② B06 종단 눈금 830/840/850m → 824/826/828m, 유토곡선 68.2㎥ → −3,375.1㎥ 로 동시 이동 ③ B06 최대 프레임 **35.9 → 23.5ms**, 33ms 초과 1 → **0건**(중앙값 16.7ms 유지) ④ B05 최대 프레임 27.4ms, 33ms 초과 0건 ⑤ 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 2 +- B05_Profile/B05_Profile_UI_Profile_Render.ts | 31 +++- B06_Section/B06_Section_UI_Longitudinal.ts | 8 +- B06_Section/B06_Section_UI_Section_View.ts | 163 +++++++++--------- .../B06_Section_UI_Section_View_Chart.ts | 116 +++++++++++++ .../B06_Section_UI_Section_View_MassHaul.ts | 75 ++++++++ .../common_util_chart_ywindow.ts | 23 ++- 7 files changed, 320 insertions(+), 98 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Section_View_Chart.ts rename B05_Profile/B05_Profile_UI_Profile_YWindow.ts => common_util/common_util_chart_ywindow.ts (88%) diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index c8ba1d46..76ed148d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -98,7 +98,7 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60; * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; -import { needsFullRedraw, type ElevationWindowResult } from "./B05_Profile_UI_Profile_YWindow"; +import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 53035c34..cc84fe5d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -28,7 +28,11 @@ import { } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; -import { applyElevationWindow, type ElevationWindowResult } from "./B05_Profile_UI_Profile_YWindow"; +import { + applyElevationWindow, + Y_AXIS_WINDOW_CLASS, + type ElevationWindowResult, +} from "@util/common_util_chart_ywindow"; import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; @@ -329,6 +333,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { }); // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). + // 유토곡선 갱신기 — 아래에서 유토곡선을 그린 뒤 채워진다(그리기 순서상 여기서는 아직 없다). + let massHaulUpdater: ((fromM: number, toM: number) => void) | null = null; ctx.setElevationWindowUpdater(() => { const from = Math.max(0, toChainage(body.scrollLeft)); const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth)); @@ -339,13 +345,19 @@ export function renderProfile(ctx: ProfileRenderContext): void { to, ) ?? null, ); + massHaulUpdater?.(from, to); return applyElevationWindow(chartWrap, next); }); // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). - if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight)); + if (yAxis) { + const axisOverlay = buildStickyYAxis(yAxis, chartHeight); + // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). + axisOverlay.classList.add(Y_AXIS_WINDOW_CLASS); + chartWrap.prepend(axisOverlay); + } // 쉬프트로 고른 구간 강조 — 편집 버튼층보다 아래에 깔아 버튼을 가리지 않는다. if (alignment) { const highlight = createRunHighlight({ @@ -392,7 +404,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정). // X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다. if (alignment && designProfiles[0]) { - massHaul.draw({ + const massHaulParams: RouteMassHaulDrawParams = { stationSource: graphLongitudinal, // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. @@ -429,6 +441,17 @@ export function renderProfile(ctx: ProfileRenderContext): void { onClearSelection: () => { if (ctx.selectedStationId() !== null) ctx.selectStation(null); }, - }); + }; + massHaul.draw(massHaulParams); + // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 + // 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이 + // 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는 + // 값보다 크다(요소 수가 종단의 1/3). + massHaulUpdater = (fromM, toM) => { + massHaul.draw({ + ...massHaulParams, + axis: { ...massHaulParams.axis, viewRange: { fromM, toM } }, + }); + }; } } diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 5cae3c89..3026c01f 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -54,6 +54,8 @@ export const Y_WINDOW_ATTRS = { /** 플롯 상단 y(px)와 높이(px) — 창 밖으로 나간 글자를 감추는 데 쓴다. */ top: "data-yw-top", height: "data-yw-height", + /** 세로 과장(B06 조절값). 새 창의 px 환산에 그대로 곱한다. */ + exaggeration: "data-yw-exag", } as const; /** @@ -290,8 +292,10 @@ export function createLongitudinalProfile( svg.setAttribute(Y_WINDOW_ATTRS.zero, String(LONG_PAD.top + plotHeight / 2)); svg.setAttribute(Y_WINDOW_ATTRS.top, String(LONG_PAD.top)); svg.setAttribute(Y_WINDOW_ATTRS.height, String(plotHeight)); - // 고정 배율(B06 공통 Y스케일)·세로 과장이 걸린 그래프는 창을 옮기지 않는다. - if (!yScaleOptions && exaggeration === 1) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1"); + svg.setAttribute(Y_WINDOW_ATTRS.exaggeration, String(exaggeration)); + // 고정 배율(B06 공통 Y스케일)에 묶인 그래프만 창을 옮기지 않는다 — 세로 과장은 새 창의 + // px 환산에 그대로 곱하면 되므로 대상이다(B06 종단, 2026-09-04). + if (!yScaleOptions) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1"); const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); // Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의 diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 8b445399..d07d48f6 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -40,11 +40,22 @@ import { } from "./B06_Section_UI_Cross_View"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { createLongitudinalProfile, longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; +import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; +import { + buildLongitudinalChart, + visibleChainageRange, + visibleElevationRange, +} from "./B06_Section_UI_Section_View_Chart"; import { applyLegendToggle } from "@util/common_util_mass_haul"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; -import { buildMassHaulPanel } from "./B06_Section_UI_Section_View_MassHaul"; +import { createMassHaulRenderer } from "./B06_Section_UI_Section_View_MassHaul"; +import { + applyElevationWindow, + needsFullRedraw, + Y_AXIS_WINDOW_CLASS, + type ElevationWindowResult, +} from "@util/common_util_chart_ywindow"; import { BASE_PANEL_HEIGHT, chartHeights, @@ -56,7 +67,6 @@ import { PANEL_COLLAPSED_KEY, PANEL_HEIGHT_KEY, readVisibleSeries, - unwrapChart, } from "./B06_Section_UI_Section_View_Panel"; import { CROSS_GRID_GAP, @@ -68,9 +78,6 @@ import { emptyView, inferStationInterval, L, - LONG_PAD, - longitudinalMaxChainage, - windowElevationRange, } from "./B06_Section_UI_Section_Common"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; @@ -455,6 +462,11 @@ export function createSectionView( drawPanel(); }; + /** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */ + let renderMassHaul: ((fromM: number, toM: number) => void) | null = null; + /** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */ + let updateChartWindow: (() => ElevationWindowResult | null) | null = null; + /** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */ function drawPanel(): void { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; @@ -473,93 +485,69 @@ export function createSectionView( const heights = chartHeights(lastChartAvailable); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); - // 종단 그래프의 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, - // B05 와 같은 규칙·같은 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. - const maxChainageM = longitudinalMaxChainage(detail.longitudinal); - const plotWidth = Math.max(1, chartWidth - LONG_PAD.left - LONG_PAD.right); - const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; - const viewFromM = Math.max(0, toChainage(keepScrollLeft)); - const viewToM = Math.min( - maxChainageM, - toChainage(keepScrollLeft + (chartWrap.clientWidth || chartWidth)), - ); - const longElevationRange = windowElevationRange( - [ - detail.longitudinal.samples, - ...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples), - ], - viewFromM, - viewToM, - ); - - // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 - // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). - let longAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; - const nodes: Element[] = [ - unwrapChart( - createLongitudinalProfile( - detail.longitudinal, - selectedStationId, - currentExaggeration, - // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. - undefined, - (stationId) => selectStation(stationId, true), - cachedStationInterval, - chartWidth, - heights.long, - minWidth, - detail.longitudinal.design_profiles ?? [], - 0, - (axis) => { - longAxis = axis; - }, - undefined, - undefined, - 0, - 0, - longElevationRange, - ), - ), - ]; - - // 유토곡선 몫은 전용 모듈이 만든다(2026-09-03 · 700줄 제한) — 곡선 계산부터 - // 범례·요약줄까지 한 덩어리라 붙이는 자리만 여기서 정한다. - const massHaul = buildMassHaulPanel({ + const chart = buildLongitudinalChart({ detail, - conversion: currentConversion, - haulLimits: currentHaulLimits, - naturalSpoilSlope: currentNaturalSpoilSlope, - visibleSeries, selectedStationId, + exaggeration: currentExaggeration, + selectStation: (stationId) => selectStation(stationId, true), stationInterval: cachedStationInterval, chartWidth, + chartHeight: heights.long, minWidth, - massHeight: heights.mass, - longHeight: heights.long, - selectStation: (stationId) => selectStation(stationId, true), - toggleSeries, - redraw: drawPanel, - // 유토곡선도 종단과 같은 창을 본다(2026-09-04). - viewRange: { fromM: viewFromM, toM: viewToM }, + scrollLeft: keepScrollLeft, + viewportWidth: chartWrap.clientWidth || chartWidth, }); - if (massHaul.chart) nodes.push(massHaul.chart); + const longAxis = chart.axis; + const nodes: Element[] = [chart.node]; + const { viewFromM, viewToM } = chart; + chartWrap.replaceChildren(...nodes); - // 고정 Y축 오버레이 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 - // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). 유토곡선 축은 종단 높이만큼 - // 안쪽(inner)을 내려 자기 그래프 구간만 덮는다 — 불투명 배경이 지나간 눈금 노출을 막는다. - if (massHaul.axis) { - const overlay = buildStickyYAxis(massHaul.axis, heights.mass); - (overlay.firstElementChild as HTMLElement).style.top = `${heights.long}px`; + // 유토곡선 몫은 전용 모듈이 만들어 붙인다(2026-09-03 · 700줄 제한). 가로로 스크롤할 + // 때는 **이 함수만** 다시 불러 곡선의 세로 창을 따라오게 한다 — 종단면도는 건드리지 + // 않는다(2026-09-04: 상단 패널을 통째로 다시 그리면 화면이 한 번 끊긴다). + renderMassHaul = createMassHaulRenderer( + { + detail, + conversion: currentConversion, + haulLimits: currentHaulLimits, + naturalSpoilSlope: currentNaturalSpoilSlope, + visibleSeries, + selectedStationId, + stationInterval: cachedStationInterval, + chartWidth, + minWidth, + massHeight: heights.mass, + longHeight: heights.long, + selectStation: (stationId) => selectStation(stationId, true), + toggleSeries, + redraw: drawPanel, + }, + { chartWrap, panel, panelBody, statusNode: panelCount }, + ); + renderMassHaul(viewFromM, viewToM); + // 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 + // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). + if (longAxis) { + const overlay = buildStickyYAxis(longAxis, heights.long); + // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). + overlay.classList.add(Y_AXIS_WINDOW_CLASS); chartWrap.prepend(overlay); } - if (longAxis) chartWrap.prepend(buildStickyYAxis(longAxis, heights.long)); - panel.querySelector(".b06-masshaul__legend")?.remove(); - panel.querySelector(".b06-masshaul__summary")?.remove(); - // 범례는 스크롤 컨테이너 **밖**에 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 - // 끼어들어 두 그래프의 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). - panelBody.append(...massHaul.overlays); - panelCount.textContent = massHaul.statusText; + // 세로 창 갱신기 — 스크롤마다 종단은 변환으로, 유토곡선은 곡선만 다시 그려 따라온다. + updateChartWindow = () => { + const { fromM, toM } = visibleChainageRange( + chart.toChainage, + chart.maxChainageM, + chartWrap.scrollLeft, + chartWrap.clientWidth || chartWidth, + ); + renderMassHaul?.(fromM, toM); + return applyElevationWindow( + chartWrap, + visibleElevationRange(detail, fromM, toM) ?? undefined, + ); + }; chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); @@ -577,13 +565,16 @@ export function createSectionView( } } - /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ - const SCROLL_SETTLE_MS = 160; + /** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ + const SCROLL_SETTLE_MS = 80; let settledScrollLeft = 0; let scrollSettleTimer = 0; - // 스크롤·팬 하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤 한 번만 다시 그린다(2026-09-04). + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정, B05와 같은 규칙) — + // 스크롤마다 겹 하나의 변환만 갈아 끼우므로 실시간으로 따라온다. chartWrap.addEventListener("scroll", () => { + const fitted = updateChartWindow?.() ?? null; window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; scrollSettleTimer = window.setTimeout(() => { if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = chartWrap.scrollLeft; diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts new file mode 100644 index 00000000..468efe3c --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_Chart.ts + * B06 상단 패널의 **종단면도 몫**만 떼어 낸 조립기 (2026-09-04 · 700줄 제한). + * + * 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, B05 와 같은 규칙·같은 + * 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. 스크롤할 때 그래프를 다시 + * 만들지 않고 세로 창을 **변환으로** 옮기는 것도 B05 와 같다 + * (`common_util_chart_ywindow`). + * ========================================================================== */ + +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { createLongitudinalProfile } from "./B06_Section_UI_Longitudinal"; +import { + LONG_PAD, + longitudinalMaxChainage, + windowElevationRange, +} from "./B06_Section_UI_Section_Common"; +import { unwrapChart } from "./B06_Section_UI_Section_View_Panel"; + +export interface LongitudinalChartInput { + detail: SectionDetailResponse; + selectedStationId: string | null; + /** 세로 과장(사용자 조절값). */ + exaggeration: number; + selectStation: (stationId: string) => void; + stationInterval: number; + chartWidth: number; + chartHeight: number; + minWidth: number; + /** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */ + scrollLeft: number; + viewportWidth: number; +} + +export interface LongitudinalChartResult { + node: Element; + axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null; + /** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */ + toChainage: (px: number) => number; + maxChainageM: number; + viewFromM: number; + viewToM: number; +} + +/** 지금 보이는 구간(누가거리 m)을 잰다 — 그릴 때와 스크롤 갱신 때가 **같은 식**을 쓴다. */ +export function visibleChainageRange( + toChainage: (px: number) => number, + maxChainageM: number, + scrollLeft: number, + viewportWidth: number, +): { fromM: number; toM: number } { + return { + fromM: Math.max(0, toChainage(scrollLeft)), + toM: Math.min(maxChainageM, toChainage(scrollLeft + viewportWidth)), + }; +} + +/** 보이는 구간의 표고 범위 — 종단 세로 창의 기준. */ +export function visibleElevationRange( + detail: SectionDetailResponse, + fromM: number, + toM: number, +): { min: number; max: number } | null { + return ( + windowElevationRange( + [ + detail.longitudinal.samples, + ...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples), + ], + fromM, + toM, + ) ?? null + ); +} + +export function buildLongitudinalChart(input: LongitudinalChartInput): LongitudinalChartResult { + const { detail } = input; + const maxChainageM = longitudinalMaxChainage(detail.longitudinal); + const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right); + const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const { fromM, toM } = visibleChainageRange( + toChainage, + maxChainageM, + input.scrollLeft, + input.viewportWidth, + ); + + // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 + // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). + let axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; + const node = unwrapChart( + createLongitudinalProfile( + detail.longitudinal, + input.selectedStationId, + input.exaggeration, + // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. + undefined, + input.selectStation, + input.stationInterval, + input.chartWidth, + input.chartHeight, + input.minWidth, + detail.longitudinal.design_profiles ?? [], + 0, + (next) => { + axis = next; + }, + undefined, + undefined, + 0, + 0, + visibleElevationRange(detail, fromM, toM) ?? undefined, + ), + ); + return { node, axis, toChainage, maxChainageM, viewFromM: fromM, viewToM: toM }; +} diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index 0a728daf..d9ffc4c1 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -12,6 +12,7 @@ * 사용자가 옛 그림을 본다. * ========================================================================== */ +import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul"; import type { EarthworkConversion, HaulEquipmentLimit } from "./B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { @@ -142,3 +143,77 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); return { chart, axis, overlays, statusText }; } + +/** 유토곡선을 붙일 자리 — 뷰 컨트롤러가 들고 있는 DOM 이다. */ +export interface MassHaulMountTargets { + /** 차트가 들어가는 가로 스크롤 컨테이너. 종단면도와 **같은 부모의 형제**여야 한다. */ + chartWrap: HTMLElement; + /** 옛 범례·요약줄을 찾아 지울 뿌리. */ + panel: HTMLElement; + /** 새 범례·요약줄을 붙일 자리(스크롤 컨테이너 밖). */ + panelBody: HTMLElement; + /** 곡선을 못 그린 이유를 적는 상태줄. */ + statusNode: HTMLElement; +} + +/** 붙여 둔 유토곡선 노드 — 다음 갱신에서 **제자리 교체**하는 데 쓴다. */ +export interface MountedMassHaul { + chart: Element | null; + axis: HTMLElement | null; +} + +/** + * 유토곡선을 만들어 붙인다. 이미 붙어 있으면 **그 자리에서 갈아 끼운다** — 종단면도는 + * 건드리지 않는다(2026-09-04). 가로로 스크롤할 때마다 곡선의 세로 창이 따라와야 하는데, + * 상단 패널을 통째로 다시 그리면 종단 그래프까지 새로 만들어져 화면이 한 번 끊긴다. + */ +export function mountMassHaulPanel( + input: MassHaulPanelInput, + targets: MassHaulMountTargets, + previous: MountedMassHaul, +): MountedMassHaul { + const built = buildMassHaulPanel(input); + let chart = previous.chart; + if (built.chart) { + if (chart?.isConnected) chart.replaceWith(built.chart); + else targets.chartWrap.append(built.chart); + chart = built.chart; + } else if (chart?.isConnected) { + chart.remove(); + chart = null; + } + + let axis = previous.axis; + if (built.axis) { + // 고정 Y축은 0크기 sticky 앵커라 **첫 자식**이어야 세로 기준이 컨테이너 상단이 된다. + // 안쪽(inner)은 종단 높이만큼 내려 자기 그래프 구간만 덮는다. + const overlay = buildStickyYAxis(built.axis, input.massHeight); + (overlay.firstElementChild as HTMLElement).style.top = `${input.longHeight}px`; + if (axis?.isConnected) axis.replaceWith(overlay); + else targets.chartWrap.prepend(overlay); + axis = overlay; + } else if (axis?.isConnected) { + axis.remove(); + axis = null; + } + + targets.panel.querySelector(".b06-masshaul__legend")?.remove(); + targets.panel.querySelector(".b06-masshaul__summary")?.remove(); + targets.panelBody.append(...built.overlays); + targets.statusNode.textContent = built.statusText; + return { chart, axis }; +} + +/** + * 「보이는 구간만 바꿔 다시 그리는」 함수를 만든다 — 스크롤 갱신이 부를 것이다. + * 붙여 둔 노드는 이 함수가 스스로 들고 있으므로 부르는 쪽은 구간만 넘기면 된다. + */ +export function createMassHaulRenderer( + base: Omit, + targets: MassHaulMountTargets, +): (fromM: number, toM: number) => void { + let mounted: MountedMassHaul = { chart: null, axis: null }; + return (fromM, toM) => { + mounted = mountMassHaulPanel({ ...base, viewRange: { fromM, toM } }, targets, mounted); + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_YWindow.ts b/common_util/common_util_chart_ywindow.ts similarity index 88% rename from B05_Profile/B05_Profile_UI_Profile_YWindow.ts rename to common_util/common_util_chart_ywindow.ts index 2f561f58..5a631ea8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_YWindow.ts +++ b/common_util/common_util_chart_ywindow.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * B05_Profile_UI_Profile_YWindow.ts + * common_util_chart_ywindow.ts * 종단 그래프의 **세로 창을 변환으로 갈아 끼운다** — 다시 그리지 않는다(2026-09-04 확정). * * 예전에는 스크롤을 멈출 때마다 그래프를 통째로 새로 만들었다(SVG 요소 457개, 실측 @@ -28,6 +28,9 @@ const TICK_KEEP_MIN = 0.8; const TICK_KEEP_MAX = 1.25; /** 플롯 안에 남아야 하는 최소 눈금 수. */ const TICK_KEEP_MIN_COUNT = 4; +/** 세로 창을 따라 움직이는 좌측 고정 축에 붙이는 표식 — 한 컨테이너에 축이 둘 이상일 때 + * (B06: 종단 + 유토곡선) 남의 축까지 옮기지 않으려는 것이다. */ +export const Y_AXIS_WINDOW_CLASS = "b05-profile__yaxis--ywindow"; /** 눈금 글자는 선보다 이만큼 아래에 앉는다(그리는 쪽과 같은 값). */ const TICK_TEXT_DROP_PX = 4; @@ -38,6 +41,8 @@ interface Baked { zero: number; top: number; height: number; + /** 세로 과장 — 새 창의 px 환산에 그대로 곱한다(B06 조절값, B05 는 1). */ + exaggeration: number; } export interface ElevationWindowResult { @@ -60,6 +65,7 @@ function readBaked(root: ParentNode): Baked | null { zero: read(Y_WINDOW_ATTRS.zero), top: read(Y_WINDOW_ATTRS.top), height: read(Y_WINDOW_ATTRS.height), + exaggeration: read(Y_WINDOW_ATTRS.exaggeration) || 1, }; const numbers = [baked.center, baked.pxPerM, baked.zero, baked.top, baked.height]; if (!numbers.every(Number.isFinite) || baked.pxPerM <= 0 || baked.height <= 0) return null; @@ -81,13 +87,18 @@ function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: numb const lineClass = sampleLine.getAttribute("class") ?? ""; const textX = sampleText.getAttribute("x") ?? "0"; const textClass = sampleText.getAttribute("class") ?? ""; - const axis = root.querySelector(".b05-profile__yaxis-inner"); + const axis = root.querySelector(`.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-inner`); const ns = "http://www.w3.org/2000/svg"; const lines: SVGLineElement[] = []; const texts: SVGTextElement[] = []; const spans: HTMLElement[] = []; - for (const { value, label } of yWindowTickValues(center, span, baked.height)) { + // 눈금값은 **과장을 뺀** 표고 폭 기준이다(그리는 쪽 `rawSpan` 과 같은 값). + for (const { value, label } of yWindowTickValues( + center, + span / baked.exaggeration, + baked.height, + )) { const y = baked.zero - (value - baked.center) * baked.pxPerM; const line = document.createElementNS(ns, "line"); line.setAttribute("x1", x1); @@ -133,7 +144,7 @@ export function applyElevationWindow( // 새 창 — 그리는 쪽과 **같은 규칙**으로 만든다(같은 상수를 같이 쓴다). const span = Math.max(range.max - range.min, 1) * Y_WINDOW_SPAN_PAD; const center = (range.min + range.max) / 2; - const pxPerM = baked.height / span; + const pxPerM = (baked.exaggeration * baked.height) / span; const scale = pxPerM / baked.pxPerM; // 배율이 크게 벌어지면 눈금 간격이 원래 의도와 어긋난다 — 눈금만 새로 만든다(요소 20개 @@ -161,7 +172,9 @@ export function applyElevationWindow( if (inside(moved)) visibleTicks += 1; }); root - .querySelectorAll(`.b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`) + .querySelectorAll( + `.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`, + ) .forEach((tick) => { const moved = place(Number(tick.getAttribute(Y_WINDOW_BAKED_ATTR))); tick.style.top = `${moved}px`; From 41c1771fad8c82969342cab23e858b2603753333 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 22:16:14 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat(B03):=20=EB=B6=80=EC=86=8D=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B9=B4=EB=93=9C=EC=97=90=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=86=8D=20=EA=B0=92=20=ED=91=9C=EC=8B=9C=C2=B7?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=ED=84=B0=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=90=EC=84=A0=20=ED=85=8C=EB=91=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개수만 보이던 부속 파일 카드에 파일 안의 중요한 값을 함께 보임. - .shx — 도형 종류(폴리라인 등)와 범위 크기를 머리글에서 읽음. - .dbf — 속성 이름 목록을 머리글 서술자에서 읽음(앞 4개). - .cpg — 같은 세트 .dbf 의 첫 글자 속성을 그 인코딩으로 읽어 견본으로 보임. - .prj — 중앙자오선(투영 매개변수)과 길이 단위. proj 문자열 변환을 쓰지 않아 정보 손실 경고가 나지 않음. - 카드 표시 줄 수를 2 → 3 으로 늘리고, 길이 단위 표기를 m 으로 통일. - GeoTIFF 썸네일에 다른 미리보기와 같은 점선 테두리. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Engine_Analyze.py | 114 +++++++++++++++++- B03_FileInput/B03_FileInput_UI_Preview.ts | 75 ++++++++++-- B03_FileInput/B03_FileInput_UI_Style.css | 5 +- 3 files changed, 180 insertions(+), 14 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 67336adb..60c043a3 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -1,11 +1,13 @@ """B03 원본 입력 파일 메타데이터 분석.""" import base64 +import codecs import csv import io import logging import math import re +import struct from pathlib import Path from threading import get_ident from typing import Any @@ -255,11 +257,43 @@ def analyze_prj_metadata(path: str | Path) -> dict[str, Any]: "authority": crs.to_authority(), "custom_authority_codes": custom_authority_codes, "is_valid": True, + # 카드에 보일 값 — 좌표계 이름만으로는 어느 원점인지 안 보인다(2026-09-04 지시). + **_prj_facts(crs), } ) return metadata +def _prj_facts(crs: Any) -> dict[str, Any]: + """좌표계에서 카드에 보일 값 — 중앙자오선(원점 경도)·길이 단위.""" + from common_util.common_util_crs import strip_bound + + flattened = strip_bound(crs) + horizontal = next( + ( + strip_bound(item) + for item in (flattened.sub_crs_list or [flattened]) + if strip_bound(item).is_projected or strip_bound(item).is_geographic + ), + None, + ) + if horizontal is None: + return {} + facts: dict[str, Any] = {} + # 원점 경도는 투영 매개변수에서 곧장 읽는다 — proj 문자열로 바꾸면 정보가 깎인다. + operation = horizontal.coordinate_operation + for param in operation.params if operation is not None else []: + label = (param.name or "").lower() + if "longitude" in label and ("origin" in label or "meridian" in label): + if isinstance(param.value, (int, float)): + facts["central_meridian_deg"] = float(param.value) + break + axes = list(horizontal.axis_info or []) + if axes and axes[0].unit_name: + facts["unit_name"] = axes[0].unit_name + return facts + + def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]: """TFW의 affine 변환 계수와 유효성을 분석한다.""" source = Path(path) @@ -467,13 +501,32 @@ def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]: } -def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]: - """DBF 머리글만 읽어 레코드 수·속성 수를 낸다 (2026-09-04 카드 표시용). +# DBF 머리글 최대 크기 — 32바이트 고정 + 속성 255개 × 32바이트 + 끝 표시. +_DBF_HEADER_MAX = 32 + 255 * 32 + 1 - 머리글 32바이트가 전부라 파일 크기와 무관하게 즉시 끝난다. + +def _dbf_field_defs(head: bytes) -> list[tuple[str, str, int]]: + """머리글의 속성 서술자에서 (이름, 형식, 길이)를 뽑는다.""" + fields: list[tuple[str, str, int]] = [] + for start in range(32, len(head) - 31, 32): + block = head[start : start + 32] + if block[0] in (0x0D, 0x00): + break + name = block[:11].partition(bytes(1))[0].decode("cp949", errors="replace").strip() + if name: + fields.append((name, chr(block[11]), block[16])) + return fields + + +def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]: + """DBF 머리글만 읽어 레코드 수·속성 수·**속성 이름**을 낸다 (2026-09-04 카드 표시용). + + 머리글은 최대 8KB라 파일 크기와 무관하게 즉시 끝난다. 속성 이름은 카드에서 + 「이 표에 무엇이 들었나」를 한눈에 보이는 값이다(2026-09-04 사용자 지시). """ source = Path(path) - head = source.read_bytes()[:32] + with source.open("rb") as handle: + head = handle.read(_DBF_HEADER_MAX) metadata: dict[str, Any] = { "file": source.name, "extension": "dbf", @@ -485,6 +538,7 @@ def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]: metadata["record_count"] = record_count # 머리글 = 32바이트 고정 + 속성마다 32바이트 + 끝 표시 1바이트. metadata["field_count"] = max(0, (header_length - 33) // 32) + metadata["field_names"] = [name for name, _type, _length in _dbf_field_defs(head)] return metadata @@ -504,19 +558,67 @@ def analyze_shx_metadata(path: str | Path) -> dict[str, Any]: # 24~27바이트: 파일 길이(16비트 워드 단위, 빅엔디안). words = int.from_bytes(head[24:28], "big") metadata["shape_count"] = max(0, (words * 2 - 100) // 8) + if len(head) >= 68: + # 32~35바이트: 도형 종류. 36~67바이트: 범위(Xmin, Ymin, Xmax, Ymax). + metadata["shape_type"] = int.from_bytes(head[32:36], "little") + x_min, y_min, x_max, y_max = struct.unpack("<4d", head[36:68]) + if x_max > x_min and y_max > y_min: + metadata["extent_width_m"] = x_max - x_min + metadata["extent_height_m"] = y_max - y_min return metadata +def _dbf_text_sample(dbf: Path, encoding: str) -> str | None: + """같은 세트 DBF 의 첫 글자 속성 값을 그 인코딩으로 읽어 본다. + + CPG 는 이름 한 줄이 전부라, 그 이름이 맞는지는 **글자가 깨지는지**로만 보인다 + (2026-09-04 사용자 지시). 머리글 + 레코드 한 줄만 읽는다. + """ + try: + with dbf.open("rb") as handle: + head = handle.read(_DBF_HEADER_MAX) + if len(head) < 32: + return None + header_length = int.from_bytes(head[8:10], "little") + fields = _dbf_field_defs(head) + offset = 1 # 레코드 첫 바이트는 삭제 표시 + for _name, kind, length in fields: + if kind == "C": + handle.seek(header_length + offset) + raw = handle.read(length) + return raw.decode(encoding, errors="replace").strip() or None + offset += length + except (OSError, LookupError, ValueError): + return None + return None + + +def _codec_name(label: str) -> str | None: + """CPG 가 적은 이름을 파이썬 코덱 이름으로 바꾼다 (숫자만 적힌 것은 코드페이지).""" + candidate = label.strip() + if candidate.isdigit(): + candidate = f"cp{candidate}" + try: + return codecs.lookup(candidate).name + except LookupError: + return None + + def analyze_cpg_metadata(path: str | Path) -> dict[str, Any]: - """CPG 는 인코딩 이름 한 줄이 전부다 (2026-09-04 카드 표시용).""" + """CPG 는 인코딩 이름 한 줄이 전부다 — 같은 세트 DBF 의 글자 견본을 함께 낸다.""" source = Path(path) text = source.read_text(encoding="utf-8", errors="replace").strip() - return { + metadata: dict[str, Any] = { "file": source.name, "extension": "cpg", "size_bytes": source.stat().st_size, "encoding": text or None, } + codec = _codec_name(text) if text else None + dbf = source.with_suffix(".dbf") + if codec and dbf.is_file(): + metadata["encoding_sample"] = _dbf_text_sample(dbf, codec) + return metadata def analyze_input_metadata(path: str | Path) -> dict[str, Any]: diff --git a/B03_FileInput/B03_FileInput_UI_Preview.ts b/B03_FileInput/B03_FileInput_UI_Preview.ts index 6c60af26..a9fefe44 100644 --- a/B03_FileInput/B03_FileInput_UI_Preview.ts +++ b/B03_FileInput/B03_FileInput_UI_Preview.ts @@ -99,9 +99,33 @@ export function readThumbnail(metadata: Record | undefined): st return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null; } +/** SHP/SHX 머리글의 도형 종류 코드 — 카드에 「무엇이 들었나」로 보인다. */ +const SHAPE_TYPE_LABELS: Record = { + 0: "빈 도형", + 1: "점", + 3: "폴리라인", + 5: "폴리곤", + 8: "다중점", + 11: "점(3D)", + 13: "폴리라인(3D)", + 15: "폴리곤(3D)", + 18: "다중점(3D)", + 21: "점(M)", + 23: "폴리라인(M)", + 25: "폴리곤(M)", + 28: "다중점(M)", + 31: "복합면", +}; + +/** 129.002890277778 → 「129.0029°E」. */ +function meridianLabel(degrees: number): string { + const rounded = Math.abs(Math.round(degrees * 10000) / 10000); + return `${rounded}°${degrees < 0 ? "W" : "E"}`; +} + /** - * 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **한눈에 구분되는 값**을 뽑는다 - * (2026-09-04 사용자 지시). 그림 대신 이 값을 큰 글씨로 보인다. + * 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **파일 안의 중요한 값**을 뽑는다 + * (2026-09-04 사용자 지시 — 개수만이 아니라 내용이 보이게). 첫 줄은 굵게, 나머지는 작게. */ export function readFacts(metadata: Record | undefined): string[] { if (!metadata) return []; @@ -109,15 +133,38 @@ export function readFacts(metadata: Record | undefined): string const number = (value: unknown): number | null => finite(value); const text = (value: unknown): string | null => typeof value === "string" && value.trim() ? value.trim() : null; + const round = (value: number): string => Math.round(value).toLocaleString(); + // SHX — 도형 종류·개수와 그 도형이 덮는 범위. const shapes = number(metadata.shape_count); - if (shapes !== null) facts.push(`도형 ${shapes.toLocaleString()}개`); + if (shapes !== null) { + const kind = SHAPE_TYPE_LABELS[number(metadata.shape_type) ?? -1]; + const count = `도형 ${shapes.toLocaleString()}개`; + facts.push(kind ? `${kind} · ${count}` : count); + const width = number(metadata.extent_width_m); + const height = number(metadata.extent_height_m); + if (width !== null && height !== null) facts.push(`범위 ${round(width)}×${round(height)} m`); + } + + // DBF — 레코드·속성 개수와 속성 이름(무엇이 든 표인지). const records = number(metadata.record_count); - if (records !== null) facts.push(`레코드 ${records.toLocaleString()}개`); const fields = number(metadata.field_count); - if (fields !== null) facts.push(`속성 ${fields}개`); + if (records !== null) { + const count = `레코드 ${records.toLocaleString()}개`; + facts.push(fields !== null ? `${count} · 속성 ${fields}개` : count); + } + const names = Array.isArray(metadata.field_names) + ? metadata.field_names.filter((name): name is string => typeof name === "string") + : []; + if (names.length) facts.push(names.slice(0, 4).join(", ") + (names.length > 4 ? " …" : "")); + + // CPG — 인코딩 이름과 그 인코딩으로 읽은 글자 견본(깨지면 눈에 보인다). const encoding = text(metadata.encoding); if (encoding) facts.push(`인코딩 ${encoding}`); + const sample = text(metadata.encoding_sample); + if (sample) facts.push(`견본 「${sample}」`); + + // TFW — 픽셀 크기와 회전. const pixelX = number(metadata.pixel_size_x); const pixelY = number(metadata.pixel_size_y); if (pixelX !== null && pixelY !== null) { @@ -126,12 +173,26 @@ export function readFacts(metadata: Record | undefined): string (number(metadata.rotation_x) ?? 0) !== 0 || (number(metadata.rotation_y) ?? 0) !== 0; facts.push(rotated ? "회전 있음" : "회전 없음"); } - // PRJ 는 좌표계 이름이 곧 구분값이다. + + // PRJ — 좌표계 이름·EPSG 와 원점(중앙자오선)·길이 단위. const name = text(metadata.name); const epsgLabel = readCrsLabel(metadata); if (name) facts.push(epsgLabel ? `${name} (EPSG:${epsgLabel})` : name); else if (epsgLabel && facts.length === 0) facts.push(`EPSG:${epsgLabel}`); - return facts.slice(0, 2); + const meridian = number(metadata.central_meridian_deg); + const unit = text(metadata.unit_name); + const origin = [ + meridian === null ? null : `중앙자오선 ${meridianLabel(meridian)}`, + unit ? `단위 ${/^met(er|re)s?$/i.test(unit) ? "m" : unit}` : null, + ].filter((part): part is string => part !== null); + if (origin.length) facts.push(origin.join(" · ")); + const vertical = metadata.vertical_crs as Record | null | undefined; + if (vertical && typeof vertical === "object") { + const verticalName = text(vertical.name); + if (verticalName) facts.push(`높이 기준 ${verticalName}`); + } + + return facts.slice(0, 3); } /** diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 254f9634..098c1fba 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -633,10 +633,13 @@ stroke-width: 0.7; } -/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. */ +/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라 + 다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다 + (2026-09-04 사용자 지적). */ .b03-file__preview-thumb { max-width: 100%; max-height: 100%; + border: 1px dashed var(--color-border, #9aa); image-rendering: pixelated; object-fit: contain; } From 331be116a9be0f919add78f9d54a33b6135dd466 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 22:31:09 +0900 Subject: [PATCH 08/14] =?UTF-8?q?fix(B05,B06):=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=EC=9D=B4=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EC=B9=B8=20=EB=B0=96=EC=9C=BC=EB=A1=9C=20=EC=83=88=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 보고(2026-09-04) — 「0선이 갑자기 위로 넘어가고 그 영역 채움색이 눈금 상한을 넘어 표현됨」. 보이는 구간에 세로를 맞추면서 0이 창 밖으로 나가는데, 유토곡선에는 **자르는 영역(clip)이 없어** 0선과 면 채움색이 범례·토글 버튼 위까지 그대로 칠해짐. 종단 그래프는 진작 자르고 있었는데 유토곡선만 빠져 있었음. - 그래프 칸(축 오른쪽 · 상단 여백 아래)을 자르는 겹 둘을 둠 — 측점 세로선이 면과 곡선 **사이**에 서야 해서 앞뒤로 나눔(그리는 순서 유지). - 면·곡선·0선·EP 표식·선택 강조·말풍선·토량 분배를 그 겹 안으로 옮김. - 0선은 창 안에 있을 때만 그리고 눈금에도 그때만 실음 — 밖으로 나갔는데 실으면 좌측 고정 축 맨 위에 남의 값과 겹쳐 찍힘(사용자 확정: 0선은 나가도 됨). - 눈금 글자는 축 **왼쪽**이라 자르는 겹에 넣지 않음(넣으면 사라짐). 자체검증(공용 브라우저 임시 탭): 확대 4칸 + 휠 25칸으로 0을 창 밖으로 보낸 상태에서 ① 자르는 겹 2개 확인 ② 0선·0눈금 사라짐, 눈금 5개 전부 음수(겹쳐 찍힌 0 없음) ③ 영역 캡처로 면 채움색이 그래프 칸 위 경계에서 정확히 멈추는 것 확인. 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 --- .../common_util_mass_haul_balance_view.ts | 11 +- common_util/common_util_mass_haul_view.ts | 110 ++++++++++++------ 2 files changed, 84 insertions(+), 37 deletions(-) diff --git a/common_util/common_util_mass_haul_balance_view.ts b/common_util/common_util_mass_haul_balance_view.ts index dca257ff..0933fb5a 100644 --- a/common_util/common_util_mass_haul_balance_view.ts +++ b/common_util/common_util_mass_haul_balance_view.ts @@ -442,7 +442,14 @@ function appendTransfer( * 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다 * (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다. */ -export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void { +export function appendBalanceLayer( + svg: SVGSVGElement, + plan: HaulPlan, + box: BalanceLayerBox, + /** 실제로 붙일 자리 — 그래프 칸 밖을 자르는 겹이 있으면 그쪽(2026-09-04). 재는 일은 + * `svg`(뷰박스가 필요)가 그대로 맡는다. */ + host: SVGSVGElement | SVGGElement = svg, +): void { const group = svgElement("g", { class: "b06-balance" }); appendBalanceLine(group, plan, box); @@ -482,7 +489,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala balloons.forEach((balloon, index) => bind(balloon, index)); // 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다. svg.addEventListener("click", clearAll); - svg.append(group); + host.append(group); } /** diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index d06e516f..d09d8de6 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -241,7 +241,8 @@ function textWidth(value: string): number { * 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다. */ function appendVolumeCallout( - svg: SVGSVGElement, + /** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */ + svg: SVGSVGElement | SVGGElement, point: MassHaulPoint, stationInterval: number, box: CalloutPlacement, @@ -368,6 +369,27 @@ export function createMassHaulChart( const span = Math.max(max - min, 1e-6); const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight; + // 그래프 칸 밖으로 나간 것은 그리지 않는다(2026-09-04 사용자 보고) — 보이는 구간에 + // 세로를 맞추면 0선과 그 채움색이 눈금 상한을 넘어 **범례·토글 버튼 위까지** 칠해졌다. + // 종단 그래프는 진작 자르고 있었는데 유토곡선만 빠져 있었다. + const plotBottom = heightPx - MASS_PAD_BOTTOM; + const clipId = `b06-mass-clip-${Math.random().toString(36).slice(2, 9)}`; + const clip = svgElement("clipPath", { id: clipId }); + clip.append( + svgElement("rect", { + x: axisX, + y: padTop, + width: Math.max(0, widthPx - axis.padRight - axisX), + height: Math.max(0, plotHeight), + }), + ); + const defs = svgElement("defs", {}); + defs.append(clip); + // 두 겹으로 나누는 이유: 측점 세로선이 면과 곡선 **사이**에 서야 한다(그리는 순서 유지). + const plotBack = svgElement("g", { "clip-path": `url(#${clipId})` }); + const plotFront = svgElement("g", { "clip-path": `url(#${clipId})` }); + svg.append(defs); + // 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다. const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1]; const axisTicks: Array<{ y: number; label: string }> = []; @@ -395,8 +417,10 @@ export function createMassHaulChart( } const zeroY = y(0); - // 0선은 격자보다 진하게 따로 그리므로 고정 축 눈금에도 함께 실어 준다. - axisTicks.push({ y: zeroY, label: "0㎥" }); + // 0선이 창 안에 있을 때만 눈금에 싣는다 — 밖으로 나갔는데도 실으면 고정 축 맨 위에 + // 남의 값과 겹쳐 찍힌다(2026-09-04 사용자 보고). + const zeroInside = zeroY >= padTop && zeroY <= plotBottom; + if (zeroInside) axisTicks.push({ y: zeroY, label: "0㎥" }); onAxis?.({ padLeft: axisX, ticks: axisTicks }); const visible = series.filter((entry) => visibleKeys.has(entry.key)); @@ -420,7 +444,7 @@ export function createMassHaulChart( const banded = visible[0]; if (banded) { const last = banded.result.points[banded.result.points.length - 1]; - svg.append( + plotBack.append( svgElement("path", { // 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다. d: @@ -431,6 +455,8 @@ export function createMassHaulChart( ); } + svg.append(plotBack); + // 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화). // 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지). for (const station of longitudinal.stations) { @@ -465,27 +491,33 @@ export function createMassHaulChart( svg.append(marker); } - // 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다. - // Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다. - svg.append( - svgElement("line", { - x1: axisX, - y1: zeroY, - x2: widthPx - axis.padRight, - y2: zeroY, - class: "b06-masshaul__zero", - }), - svgText("0", { - x: axisX - 9, - y: zeroY + 4, - "text-anchor": "end", - class: "b06-chart__tick b06-masshaul__zero-tick", - }), - ); + // 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다. + // 보이는 구간에 세로를 맞추면 0이 창 밖으로 나갈 수 있다(2026-09-04 사용자 확정: + // 「나가도 됨」) — 그때는 아예 그리지 않는다. 글자는 축 **왼쪽**이라 자르는 겹에 넣으면 + // 사라지므로 따로 붙인다. + if (zeroInside) { + plotFront.append( + svgElement("line", { + x1: axisX, + y1: zeroY, + x2: widthPx - axis.padRight, + y2: zeroY, + class: "b06-masshaul__zero", + }), + ); + svg.append( + svgText("0", { + x: axisX - 9, + y: zeroY + 4, + "text-anchor": "end", + class: "b06-chart__tick b06-masshaul__zero-tick", + }), + ); + } // 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다. for (const entry of [...visible].reverse()) { - svg.append( + plotFront.append( svgElement("path", { d: curvePath(entry.result.points, x, y), class: seriesClass(entry, "b06-masshaul__curve"), @@ -504,7 +536,7 @@ export function createMassHaulChart( // 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다. const labelX = Math.max(epX - 8, axis.padLeft + labelWidth); const labelY = Math.min(Math.max(epY - 8, padTop + 12), heightPx - MASS_PAD_BOTTOM - 4); - svg.append( + plotFront.append( svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }), svgElement("rect", { x: labelX - labelWidth, @@ -527,23 +559,29 @@ export function createMassHaulChart( // 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을 // 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면). if (haulPlan) { - appendBalanceLayer(svg, haulPlan, { - x, - y, - left: axis.padLeft, - right: widthPx - axis.padRight, - top: padTop, - bottom: heightPx - MASS_PAD_BOTTOM, - curveYAt, - stationInterval, - }); + appendBalanceLayer( + svg, + haulPlan, + { + x, + y, + left: axis.padLeft, + right: widthPx - axis.padRight, + top: padTop, + bottom: heightPx - MASS_PAD_BOTTOM, + curveYAt, + stationInterval, + }, + // 그리는 자리는 자르는 겹 — 창 밖으로 나간 평형선·말풍선이 눈금 위로 새지 않는다. + plotFront, + ); } // 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선. const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null; if (focus) { const focusX = x(focus.chainage_m); - svg.append( + plotFront.append( svgElement("circle", { cx: focusX, cy: y(focus.cumulative_volume_m3), @@ -551,7 +589,7 @@ export function createMassHaulChart( class: "b06-masshaul__focus", }), ); - appendVolumeCallout(svg, focus, stationInterval, { + appendVolumeCallout(plotFront, focus, stationInterval, { anchorX: focusX, anchorY: y(focus.cumulative_volume_m3), left: axis.padLeft, @@ -562,6 +600,8 @@ export function createMassHaulChart( }); } + svg.append(plotFront); + svg.append( svgElement("line", { x1: axisX, From 2b1fe118d1c139a1efbeafabe247021ca1bcba60 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 22:40:41 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix(B05,B06):=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EC=84=B8=EB=A1=9C=EC=B0=BD=20=EB=B2=84?= =?UTF-8?q?=ED=8B=B0=EA=B8=B0=20+=20=EB=B6=80=EB=93=9C=EB=9F=BD=EA=B2=8C?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-04) — 「버티기 + 부드럽게」. 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 세로 폭이 한 칸에 228 → 1,019㎥(4.5배)로 튀었음(실측). - **버티기** — 곡선이 지금 창 안에 들어오고 창을 55% 넘게 채우면 창을 아예 안 건드림. - **부드럽게** — 바꿔야 할 때도 한 번에 안 가고 다시 그릴 때마다 남은 만큼 20%씩 좁힘 (약 0.15초에 걸쳐 미끄러짐). 아직 안 끝났으면 다음 프레임에 한 걸음 더. - 여유를 5% → **20%** 로 넓혀 곡선이 위아래 끝에 딱 붙지 않게 함(종단과 같은 값). - 곡선 계산(`computeMassHaulSeries`)을 입력 객체 단위로 아껴 둠 — 스크롤 중에는 입력이 그대로인데 그릴 때마다 다시 계산해 프레임을 먹었음. 자체검증(공용 브라우저 임시 탭): ① 한 칸씩 8번 굴려 세로 폭 257·257·257·257 → 1,031 … : **4칸은 아예 그대로**(버티기) ② 창이 바뀌는 순간 궤적 257 → 711(42ms) → 882(80ms) → 1,030(151ms): 계단이 아니라 미끄러짐 ③ 휠 20칸 기준 최대 프레임 27.4 → **24.0ms**, 33ms 초과 **0건**(고치기 전보다 나아짐) ④ B06 최대 프레임 21.2ms, 33ms 초과 0건 ⑤ 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 --- .../B05_Profile_UI_Profile_MassHaul.ts | 58 +++++++++++-- .../B06_Section_UI_Section_View_MassHaul.ts | 43 ++++++++-- common_util/common_util_mass_haul_view.ts | 82 ++++++++++++++++++- 3 files changed, 168 insertions(+), 15 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 61006117..8ab1394a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -45,7 +45,9 @@ import { createMassHaulChart, createMassHaulLegend, createMassHaulSummary, + createMassHaulWindowState, MASS_HAUL_MIN_HEIGHT, + scheduleMassHaulSettle, } from "@util/common_util_mass_haul_view"; import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; @@ -306,7 +308,48 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa bar.append(empty); } + /** 곡선 계산 아낌 — 입력 객체가 그대로면 다시 계산하지 않는다. */ + let seriesCache: { + longitudinal: RouteMassHaulDrawParams["longitudinal"]; + crossSections: RouteMassHaulDrawParams["crossSections"]; + context: RouteMassHaulContext; + series: MassHaulSeries[]; + } | null = null; + + function cachedSeries( + params: RouteMassHaulDrawParams, + current: RouteMassHaulContext, + ): MassHaulSeries[] { + if ( + seriesCache && + seriesCache.longitudinal === params.longitudinal && + seriesCache.crossSections === params.crossSections && + seriesCache.context === current + ) { + return seriesCache.series; + } + const series = computeMassHaulSeries( + params.longitudinal, + params.crossSections, + current.conversion, + current.naturalSpoilMinSlope, + ); + seriesCache = { + longitudinal: params.longitudinal, + crossSections: params.crossSections, + context: current, + series, + }; + return series; + } + + // 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04). + const windowState = createMassHaulWindowState(); + /** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */ + let lastParams: RouteMassHaulDrawParams | null = null; + function draw(params: RouteMassHaulDrawParams): void { + lastParams = params; clearSelection = params.onClearSelection; overlay.hidden = !open; syncHandlePosition(); @@ -329,12 +372,9 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa } pendingChip.hidden = true; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. - const series: MassHaulSeries[] = computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ); + // 가로로 스크롤할 때는 **입력이 그대로**인데 그릴 때마다 다시 계산하면 프레임을 잡아먹는다 + // (2026-09-04 실측: 세로창 이동 중 33ms 넘는 프레임 다수). 같은 입력이면 아껴 둔 것을 쓴다. + const series: MassHaulSeries[] = cachedSeries(params, context); if (!series.length) { note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); return; @@ -365,7 +405,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa series, visible, params.stationSource, - params.axis, + { ...params.axis, window: windowState }, params.selectedStationId, params.stationInterval, params.widthPx, @@ -396,6 +436,10 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa }), ); drawnOnce = true; + // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. + scheduleMassHaulSettle(windowState, () => { + if (lastParams) draw(lastParams); + }); } return { diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index d9ffc4c1..5a72fa2c 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -26,6 +26,9 @@ import { createMassHaulChart, createMassHaulLegend, createMassHaulSummary, + createMassHaulWindowState, + scheduleMassHaulSettle, + type MassHaulWindowState, } from "@util/common_util_mass_haul_view"; import { L, @@ -59,6 +62,8 @@ export interface MassHaulPanelInput { redraw: () => void; /** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */ viewRange?: { fromM: number; toM: number }; + /** 세로창 버티기·부드러운 이동 상태(2026-09-04). */ + windowState?: MassHaulWindowState; } export interface MassHaulPanelResult { @@ -77,18 +82,37 @@ export interface MassHaulPanelResult { * 컨테이너 폭 계산이 어긋나 측점 세로선이 밀린다) 범례는 스크롤 컨테이너 **밖**이라 * 붙일 자리가 서로 다르기 때문이다. */ +/** 곡선 계산 아낌 — 스크롤 중에는 입력이 그대로다. 그릴 때마다 다시 계산하면 프레임을 + * 잡아먹는다(2026-09-04 실측). */ +let seriesCache: { + detail: SectionDetailResponse; + conversion: EarthworkConversion | null; + slope: number | undefined; + series: MassHaulSeries[]; +} | null = null; + export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult { const { detail, visibleSeries } = input; const pendingRecalc = hasStaleDesigns(detail); + const conversion = input.conversion ?? null; + const cached = + seriesCache && + seriesCache.detail === detail && + seriesCache.conversion === conversion && + seriesCache.slope === input.naturalSpoilSlope + ? seriesCache.series + : null; const series: MassHaulSeries[] = - input.conversion && !pendingRecalc + cached ?? + (conversion && !pendingRecalc ? computeMassHaulSeries( detail.longitudinal, detail.cross_sections, - input.conversion, + conversion, input.naturalSpoilSlope, ) - : []; + : []); + if (!cached) seriesCache = { detail, conversion, slope: input.naturalSpoilSlope, series }; // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); @@ -108,6 +132,7 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu padLeft: LONG_PAD.left, padRight: LONG_PAD.right, viewRange: input.viewRange, + window: input.windowState, }, input.selectedStationId, input.stationInterval, @@ -213,7 +238,15 @@ export function createMassHaulRenderer( targets: MassHaulMountTargets, ): (fromM: number, toM: number) => void { let mounted: MountedMassHaul = { chart: null, axis: null }; - return (fromM, toM) => { - mounted = mountMassHaulPanel({ ...base, viewRange: { fromM, toM } }, targets, mounted); + const windowState = createMassHaulWindowState(); + const render = (fromM: number, toM: number): void => { + mounted = mountMassHaulPanel( + { ...base, viewRange: { fromM, toM }, windowState }, + targets, + mounted, + ); + // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더(2026-09-04 「부드럽게」). + scheduleMassHaulSettle(windowState, () => render(fromM, toM)); }; + return render; } diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index d09d8de6..4ef8c6fc 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -49,8 +49,52 @@ export interface MassHaulAxis { * 전 구간 기준 ±200㎥ 고정이다. */ viewRange?: { fromM: number; toM: number }; + /** 세로창 버티기·부드러운 이동 상태. 넘기면 창이 한 칸에 확 튀지 않는다(2026-09-04). */ + window?: MassHaulWindowState; } +/** + * 세로창이 스크롤 한 칸에 몇 배씩 튀는 것을 막는 상태(2026-09-04 사용자 확정: 「버티기 + + * 부드럽게」). 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 폭이 한 번에 + * 4~5배로 바뀌었다(실측 228 → 1,019㎥). + * + * 규칙 둘. + * ① **버티기** — 곡선이 지금 창 안에 들어오고 창을 절반 넘게 채우면 **그대로 둔다**. + * ② **부드럽게** — 그래도 바꿔야 하면 한 번에 안 가고 다시 그릴 때마다 남은 만큼 좁힌다. + */ +export interface MassHaulWindowState { + /** 지금 쓰고 있는 창. */ + held: { min: number; max: number } | null; + /** 목표에 도착했는가 — 아니면 부르는 쪽이 다음 프레임에 다시 그린다. */ + settled: boolean; + /** 예약해 둔 다음 프레임(중복 예약 방지). */ + frame: number; +} + +export function createMassHaulWindowState(): MassHaulWindowState { + return { held: null, settled: true, frame: 0 }; +} + +/** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */ +export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void { + if (state.frame) cancelAnimationFrame(state.frame); + state.frame = 0; + if (state.settled) return; + state.frame = requestAnimationFrame(() => { + state.frame = 0; + redraw(); + }); +} + +/** 새 창에 두는 여유(위아래 각각). 종단 그래프와 같은 20%. */ +const WINDOW_PAD_RATIO = 0.2; +/** 곡선이 창을 이만큼 채우고 있으면 창을 그대로 둔다(버티기). */ +const WINDOW_KEEP_FILL = 0.55; +/** 다시 그릴 때마다 목표까지 남은 거리에서 좁히는 비율. 0.2면 약 0.2초에 걸쳐 미끄러진다 — + * 0.4는 0.08초 만에 끝나 여전히 「툭」 바뀌어 보였고, 0.15는 그리는 횟수가 늘어 프레임을 + * 더 먹었다(2026-09-04 실측). */ +const WINDOW_EASE = 0.2; + /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ export const MASS_HAUL_MIN_HEIGHT = 90; /** 패널을 건드리지 않았을 때의 유토곡선 높이. */ @@ -137,6 +181,7 @@ const VOLUME_RANGE_BASE_M3 = 200; function volumeRange( series: MassHaulSeries[], viewRange?: { fromM: number; toM: number }, + state?: MassHaulWindowState, ): { min: number; max: number } { let rawMin = 0; let rawMax = 0; @@ -168,8 +213,35 @@ function volumeRange( } if (found) { // 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다. - const padding = Math.max((rawMax - rawMin) * 0.05, 1); - return { min: rawMin - padding, max: rawMax + padding }; + const padding = Math.max((rawMax - rawMin) * WINDOW_PAD_RATIO, 1); + const target = { min: rawMin - padding, max: rawMax + padding }; + if (!state) return target; + const held = state.held; + if (held) { + // 버티기 — 곡선이 지금 창 안에 들어오고 절반 넘게 채우면 아예 건드리지 않는다. + const heldSpan = held.max - held.min; + const inside = rawMin >= held.min && rawMax <= held.max; + const fills = rawMax - rawMin >= heldSpan * WINDOW_KEEP_FILL; + if (inside && fills) { + state.settled = true; + return held; + } + // 부드럽게 — 남은 만큼씩 좁혀 간다(다시 그릴 때마다 한 걸음). + const next = { + min: held.min + (target.min - held.min) * WINDOW_EASE, + max: held.max + (target.max - held.max) * WINDOW_EASE, + }; + const tolerance = Math.max((target.max - target.min) * 0.01, 0.5); + const done = + Math.abs(next.min - target.min) <= tolerance && + Math.abs(next.max - target.max) <= tolerance; + state.held = done ? target : next; + state.settled = done; + return state.held; + } + state.held = target; + state.settled = true; + return target; } } for (const entry of series) { @@ -365,7 +437,11 @@ export function createMassHaulChart( // 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 — // 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백. const rangeSource = series.filter((entry) => visibleKeys.has(entry.key)); - const { min, max } = volumeRange(rangeSource.length ? rangeSource : series, axis.viewRange); + const { min, max } = volumeRange( + rangeSource.length ? rangeSource : series, + axis.viewRange, + axis.window, + ); const span = Math.max(max - min, 1e-6); const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight; From 5666086c6e01262ff4ce8b9bf9daa54fe14f5b44 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 22:42:57 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix(B03):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20=ED=9B=84=20=EB=8B=A4=EC=8B=9C=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20?= =?UTF-8?q?=E2=80=94=20=EB=81=9D=EB=82=9C=20=EC=B9=B4=EB=93=9C=20[?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=84=A0=ED=83=9D]=20+=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EB=8C=80=EA=B8=B0=20=EA=B4=80=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 완료 카드에서도 [파일 선택]을 보임. 올리는 중에만 감춤. 교체 확인 모달과 교체 업로드 규칙은 이미 있던 것을 그대로 씀. - 업로드 관문이 전처리 1단계 IN_PROGRESS 를 무조건 「분석 중」으로 읽어, 자동 확정이 보류된 프로젝트는 영영 새 자료를 받지 못했음. 전처리 진행 파일이 awaiting_confirmation·completed·failed 로 끝나 있으면 받도록 고침. 실제로 도는 중(analyzing)이면 그대로 막음. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Style.css | 5 ++-- common_util/common_util_workflow_state.py | 36 ++++++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 098c1fba..df638aff 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -699,8 +699,9 @@ display: none; } -.b03-file__card--uploading .b03-file__card-select, -.b03-file__card--completed .b03-file__card-select { +/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아 + 끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */ +.b03-file__card--uploading .b03-file__card-select { display: none; } diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 502c6267..258fd6da 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -260,7 +260,41 @@ async def is_analysis_running(cursor: aiomysql.DictCursor, project_id: str) -> b if not row: return False state = row["state"] if isinstance(row, dict) else row[0] - return str(state) == "IN_PROGRESS" + if str(state) != "IN_PROGRESS": + return False + # 자동 확정이 보류되면 1단계는 사용자가 B04에서 모델을 고를 때까지 IN_PROGRESS 로 + # 남는다 — 계산은 이미 끝난 상태다. 그 사이에도 새 자료는 받아야 한다 + # (2026-09-04 사용자 지시 — 같은 프로젝트로 전처리를 되풀이 시험). + return not await _surface_run_settled(cursor, project_id) + + +# 계산이 끝나 사용자의 다음 조작을 기다리는 진행 단계 — 도는 중이 아니다. +_SETTLED_SURFACE_STAGES = frozenset({"awaiting_confirmation", "completed", "failed"}) + + +async def _surface_run_settled(cursor: aiomysql.DictCursor, project_id: str) -> bool: + """전처리 진행 파일이 「끝났음」으로 적혀 있는가. 파일이 없으면 판단하지 않는다.""" + from pathlib import Path + + from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress + from common_util.common_util_storage import resolve_stored_project_path + + await cursor.execute( + "SELECT storage_path FROM projects WHERE id = %s AND deleted_at IS NULL", + (project_id,), + ) + row = await cursor.fetchone() + if not row: + return False + storage_path = row["storage_path"] if isinstance(row, dict) else row[0] + if not storage_path: + return False + try: + progress = read_surface_progress(Path(resolve_stored_project_path(str(storage_path)))) + except (OSError, ValueError): + return False + stage = str((progress or {}).get("current_stage") or "") + return stage in _SETTLED_SURFACE_STAGES async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_id: str) -> None: From 0ea75e512f7cde0e537bec028764e3052e76f82a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 22:58:45 +0900 Subject: [PATCH 11/14] =?UTF-8?q?style(B03):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=A0=90=EC=84=A0=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20=EC=8B=A4=EC=84=A0=20=EB=85=B9=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계획노선처럼 그릴 선이 있는 자료에는 범위 점선 상자를 그리지 않음. 점구름·래스터는 그대로 상자를 그림. - 노선 선 색을 녹색으로. 지형 범위를 벗어나면 붉은 경고색으로 덮어쓰는 규칙은 유지. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Preview.ts | 23 +++++++++++++---------- B03_FileInput/B03_FileInput_UI_Style.css | 4 +++- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Preview.ts b/B03_FileInput/B03_FileInput_UI_Preview.ts index a9fefe44..d85fe6ce 100644 --- a/B03_FileInput/B03_FileInput_UI_Preview.ts +++ b/B03_FileInput/B03_FileInput_UI_Preview.ts @@ -302,16 +302,19 @@ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): v svg.setAttribute("role", "img"); const project = projector(extent); - // 자료가 덮는 땅 범위 — 어느 자료든 늘 그린다. - const [rectX, rectTop] = project(extent.xMin, extent.yMax); - const [rectRight, rectBottom] = project(extent.xMax, extent.yMin); - const rect = document.createElementNS(SVG_NS, "rect"); - rect.setAttribute("class", "b03-file__preview-extent"); - rect.setAttribute("x", String(rectX)); - rect.setAttribute("y", String(rectTop)); - rect.setAttribute("width", String(rectRight - rectX)); - rect.setAttribute("height", String(rectBottom - rectTop)); - svg.append(rect); + // 자료가 덮는 땅 범위 — 그릴 도형이 없는 자료에만 그린다. 계획노선은 선 자체가 + // 범위를 보여 주므로 점선 상자를 빼고 선만 남긴다(2026-09-04 사용자 지시). + if (!path) { + const [rectX, rectTop] = project(extent.xMin, extent.yMax); + const [rectRight, rectBottom] = project(extent.xMax, extent.yMin); + const rect = document.createElementNS(SVG_NS, "rect"); + rect.setAttribute("class", "b03-file__preview-extent"); + rect.setAttribute("x", String(rectX)); + rect.setAttribute("y", String(rectTop)); + rect.setAttribute("width", String(rectRight - rectX)); + rect.setAttribute("height", String(rectBottom - rectTop)); + svg.append(rect); + } let label = L("B03_File_Preview_Extent"); // 라이다 탑뷰 점 그림 — 분석기가 훑는 길에 주워 둔 XY 를 그대로 찍는다. diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index df638aff..1aa9cba5 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -679,7 +679,9 @@ .b03-file__preview-route { fill: none; - stroke: var(--color-primary, #4a3aff); + /* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서 + 붉은색으로 덮어써 경고로 쓴다. */ + stroke: var(--color-success, #16a34a); stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.5; From 79dc0cc82d38002bea286afcfd3a2daa44e8df6d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 23:10:22 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix(B05,B06):=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EA=B3=A0=EB=A5=BC=20=EC=A1=B0=EC=A0=88=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EA=B8=B0=EC=A4=80=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=EC=9D=B4=20=EC=95=88=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EC=98=A4=EB=8D=98=20=EA=B2=83=20=E2=80=94=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 보고(2026-09-04) — 「종단 곡선 조절에 따라 횡단 기준 유토곡선 변경 안 됨」. 오늘 프레임을 아끼려고 넣은 곡선 계산 캐시가 원인. 횡단 설계는 `refreshCrossDesigns` 가 **같은 객체를 제자리에서 고치므로**(배열·요소 동일성 유지) 「입력 객체가 같으면 안 바뀐 것」 이라는 캐시 판정이 성립하지 않음. B06 은 `detail` 동일성으로 캐시해 **한 번 계산한 뒤로는 영원히 낡은 곡선**을 보였음. - 두 곳(B05 패널·B06 조립기)의 캐시를 걷어내고 그릴 때마다 계산하게 되돌림. - 왜 캐시하면 안 되는지 주석으로 못 박음. 자체검증(공용 브라우저 임시 탭): ① B06 — 횡단 지반 종류를 바꾸자 절토 4,500.3 → 4,500.7㎥, 토취 10,211.5 → 10,211.1㎥, 곡선 경로도 바뀜(고치기 전에는 완전 동일) ② B05 — 계획고 6칸 올리자 계획선·유토곡선 둘 다 바뀜(성토 14,729 → 17,513㎥) ③ 휠 20칸 기준 최대 프레임 27.0ms, 33ms 초과 **0건**(캐시 없어도 예산 안) ④ 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 --- .../B05_Profile_UI_Profile_MassHaul.ts | 46 ++++--------------- .../B06_Section_UI_Section_View_MassHaul.ts | 28 +++-------- 2 files changed, 14 insertions(+), 60 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 8ab1394a..bc6979ad 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -308,41 +308,6 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa bar.append(empty); } - /** 곡선 계산 아낌 — 입력 객체가 그대로면 다시 계산하지 않는다. */ - let seriesCache: { - longitudinal: RouteMassHaulDrawParams["longitudinal"]; - crossSections: RouteMassHaulDrawParams["crossSections"]; - context: RouteMassHaulContext; - series: MassHaulSeries[]; - } | null = null; - - function cachedSeries( - params: RouteMassHaulDrawParams, - current: RouteMassHaulContext, - ): MassHaulSeries[] { - if ( - seriesCache && - seriesCache.longitudinal === params.longitudinal && - seriesCache.crossSections === params.crossSections && - seriesCache.context === current - ) { - return seriesCache.series; - } - const series = computeMassHaulSeries( - params.longitudinal, - params.crossSections, - current.conversion, - current.naturalSpoilMinSlope, - ); - seriesCache = { - longitudinal: params.longitudinal, - crossSections: params.crossSections, - context: current, - series, - }; - return series; - } - // 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04). const windowState = createMassHaulWindowState(); /** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */ @@ -372,9 +337,14 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa } pendingChip.hidden = true; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. - // 가로로 스크롤할 때는 **입력이 그대로**인데 그릴 때마다 다시 계산하면 프레임을 잡아먹는다 - // (2026-09-04 실측: 세로창 이동 중 33ms 넘는 프레임 다수). 같은 입력이면 아껴 둔 것을 쓴다. - const series: MassHaulSeries[] = cachedSeries(params, context); + // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 + // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). + const series: MassHaulSeries[] = computeMassHaulSeries( + params.longitudinal, + params.crossSections, + context.conversion, + context.naturalSpoilMinSlope, + ); if (!series.length) { note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); return; diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index 5a72fa2c..b28167fe 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -82,37 +82,21 @@ export interface MassHaulPanelResult { * 컨테이너 폭 계산이 어긋나 측점 세로선이 밀린다) 범례는 스크롤 컨테이너 **밖**이라 * 붙일 자리가 서로 다르기 때문이다. */ -/** 곡선 계산 아낌 — 스크롤 중에는 입력이 그대로다. 그릴 때마다 다시 계산하면 프레임을 - * 잡아먹는다(2026-09-04 실측). */ -let seriesCache: { - detail: SectionDetailResponse; - conversion: EarthworkConversion | null; - slope: number | undefined; - series: MassHaulSeries[]; -} | null = null; - export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult { const { detail, visibleSeries } = input; const pendingRecalc = hasStaleDesigns(detail); - const conversion = input.conversion ?? null; - const cached = - seriesCache && - seriesCache.detail === detail && - seriesCache.conversion === conversion && - seriesCache.slope === input.naturalSpoilSlope - ? seriesCache.series - : null; + // 계산 결과를 아껴 두지 **않는다**. 횡단 설계는 같은 객체를 제자리에서 고치므로 + // (`refreshCrossDesigns`) 객체가 같은지로는 바뀐 것을 못 잰다 — 2026-09-04 에 캐시를 + // 넣었다가 계획고를 조절해도 횡단 기준 곡선이 그대로였다(사용자 보고). const series: MassHaulSeries[] = - cached ?? - (conversion && !pendingRecalc + input.conversion && !pendingRecalc ? computeMassHaulSeries( detail.longitudinal, detail.cross_sections, - conversion, + input.conversion, input.naturalSpoilSlope, ) - : []); - if (!cached) seriesCache = { detail, conversion, slope: input.naturalSpoilSlope, series }; + : []; // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); From 4a57272e7295bbbfa1f598e2c6e699abba06ec0c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 23:19:07 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix(B05,B06):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=EB=A5=BC=20=EC=99=94=EB=8B=A4?= =?UTF-8?q?=EA=B0=94=EB=8B=A4=ED=95=98=EB=A9=B4=20Y=EC=B6=95=20=EB=88=88?= =?UTF-8?q?=EA=B8=88=EC=9D=B4=20=EB=B9=84=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 보고(2026-09-04) — 「종단 그래프 왔다갔다하면 Y축 값이 비는 경우가 있음」. 세로창을 변환으로 옮기면서 눈금을 **배율이 크게 바뀔 때만** 다시 만들게 해 뒀는데, 창이 위아래로 **이동만** 하면 배율은 그대로라 눈금이 한쪽으로 쓸려 나가고 그 자리가 빔. - 새 창에 있어야 할 눈금 수와 화면에 남는 수가 다르면 눈금을 다시 만들게 함(요소 20개 안팎이라 값싸다). 배율 문턱 판정은 그대로 둠. 자체검증(공용 브라우저 임시 탭): 확대 4칸 뒤 좌우로 6번(+900·+900·−600·+1400·−1800·+2400px) 오가며 눈금이 플롯을 덮는지 측정 — 눈금 6~11개가 항상 이어지고 **위·아래 빈틈 최대 37px** (플롯 높이 329px, 창 여유 20%에 해당). 고치기 전에는 플롯 절반이 비었음. 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 --- common_util/common_util_chart_ywindow.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/common_util/common_util_chart_ywindow.ts b/common_util/common_util_chart_ywindow.ts index 5a631ea8..f0ad8d96 100644 --- a/common_util/common_util_chart_ywindow.ts +++ b/common_util/common_util_chart_ywindow.ts @@ -130,6 +130,16 @@ function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: numb return true; } +/** 지금 그려져 있는 눈금 글자 중 새 창 안에 남는 개수. */ +function countTicksInside(baked: Baked, scale: number, offset: number): number { + let count = 0; + baked.svg.querySelectorAll(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => { + const moved = scale * Number(label.getAttribute(Y_WINDOW_BAKED_ATTR)) + offset; + if (moved >= baked.top && moved <= baked.top + baked.height) count += 1; + }); + return count; +} + /** * 종단 그래프의 세로 창을 옮긴다. 대상 그래프가 없거나(고정 배율 B06) 창이 없으면 null. * 좌측 고정 축(`b05-profile__yaxis-tick`)도 같은 식으로 따라 옮긴다. @@ -147,14 +157,20 @@ export function applyElevationWindow( const pxPerM = (baked.exaggeration * baked.height) / span; const scale = pxPerM / baked.pxPerM; - // 배율이 크게 벌어지면 눈금 간격이 원래 의도와 어긋난다 — 눈금만 새로 만든다(요소 20개 - // 안팎). 그래프 전체를 다시 만들던 예전 방식이 34ms 끊김의 원인이었다. + const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM; + + // 눈금을 새로 만들 때 — 둘 중 하나만 걸려도 만든다(요소 20개 안팎이라 싸다). + // ① 배율이 크게 벌어짐 — 눈금 간격이 원래 의도(1·2·5 계열)와 어긋난다. + // ② **새 창에 있어야 할 눈금 수와 지금 화면에 남은 수가 다름** — 창이 위아래로 이동만 + // 하면 배율은 그대로인데 눈금이 한쪽으로 쓸려 나가 그 자리가 빈다(2026-09-04 사용자 + // 보고: 「종단 그래프 왔다갔다하면 Y축 값이 비는 경우」). + const wanted = yWindowTickValues(center, span / baked.exaggeration, baked.height).length; + const staying = countTicksInside(baked, scale, offset); let rebuiltTicks = false; - if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX) { + if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX || staying !== wanted) { rebuiltTicks = rebuildTicks(baked, root, center, span); } - const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM; const transform = `translate(0 ${offset}) scale(1 ${scale})`; baked.svg.querySelectorAll(`.${Y_WINDOW_CLASS}`).forEach((layer) => { layer.setAttribute("transform", transform); From 5446f0cfd9cbb43439e99b1680c4c00a90096a46 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 23:33:32 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=EA=B3=84=ED=9A=8D=EA=B3=A0=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EC=9D=B4=20=EA=B0=80=EC=A7=9C=20=EB=B3=80=ED=99=94?= =?UTF-8?q?=EC=A0=90=EC=9D=84=20=EB=A7=8C=EB=93=A4=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=95=EB=B3=B8=20=EB=88=84=EA=B0=80=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 보고(2026-09-04) — 배수관 2 자리에서 계획선이 수직으로 꺾이고 최대 기울기가 **155,791 %**. 원인은 「가까운 점」이 아니라 **한 구조물이 층마다 다른 누가거리를 든 것**: 횡단 측점 264.054626 · 종단 정본 변화점 264.055 · 배수 정본 파일 **264.06**(cm 반올림). 사이드 목록이 든 264.06 으로 계획고를 편집하면 정본 옆 5mm 자리에 변화점이 하나 더 서고, 둘 사이 종단곡선이 mm 로 쭈그러들어 기울기가 거리 0 에 가까운 값으로 나뉘었음. 2026-09-03 에 ▲▼ 버튼 경로만 막아 뒀고(같은 증상 11,858%), 측점 테이블·도구 경로가 남아 다시 터진 것. - **저장**(`common_util_drainage_pipes.py`) — 누가거리를 좌표(x·y)와 같은 **밀리미터 기준**으로 남김(cm 반올림 제거). 종단 정본 변화점(3자리)과 값이 어긋나지 않음. - **화면**(`_UI_Profile_Panel.ts`) — 구조물 측점 목록이 들어오는 **한 자리**에서 종단 정본 측점(0.1m 안)으로 누가거리를 갈아 끼움. 버튼·테이블·도구 **모든 편집 경로**가 함께 막힘. 이미 cm 로 저장된 옛 프로젝트도 이 경로로 안전해짐. 자체검증(공용 브라우저 임시 탭): ① 264 부근 그래프 측점이 **264.055 하나**로 모임(고치기 전 264.06 이 따로 있었음) ② 그 구조물 측점 계획고를 8칸 올려도 **수직 꺾임 0건**, 계획선 점 수 1,208 불변, 최대 기울기 66.0 → 183.5%(8칸 올린 만큼의 정상값. 고치기 전 155,791%) ③ 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 35 ++++++++++++++++++++- common_util/common_util_drainage_pipes.py | 6 +++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 76ed148d..3ea62def 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -85,6 +85,9 @@ const MAX_PANEL_HEIGHT_RATIO = 0.9; * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */ const CROSS_PREVIEW_DEBOUNCE_MS = 60; +/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는 + * mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */ +const STATION_SNAP_TOLERANCE_M = 0.1; /** * 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**. @@ -341,6 +344,36 @@ export function createRouteProfilePanel( 세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */ const profileZoom = createProfileZoom(() => draw()); + /** + * 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04). + * + * 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`, + * 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든 + * 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이 + * 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). + * + * 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다. + * 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다. + */ + function snapToMasterStations(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 }; + }); + } + /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ let elevationWindow: { min: number; max: number } | undefined; /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ @@ -652,7 +685,7 @@ export function createRouteProfilePanel( }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { - irregularStations = stations; + irregularStations = snapToMasterStations(stations); draw(); }, /** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */ diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 07a7edb7..b27ac82a 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -90,7 +90,11 @@ class PipePoint: def as_dict(self) -> dict[str, Any]: # 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다. payload: dict[str, Any] = { - "chainage_m": round(float(self.chainage_m), 2), + # 누가거리는 좌표(x·y)와 **같은 밀리미터 기준**으로 남긴다. cm 로 반올림하던 + # 옛 규칙은 종단 정본 변화점(3자리)과 최대 5mm 어긋났고, 그 값으로 계획고를 + # 편집하면 정본 옆 mm 자리에 가짜 변화점이 하나 더 서서 종단곡선이 mm 로 + # 쭈그러들었다 — 그 구간 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). + "chainage_m": round(float(self.chainage_m), 3), "source": self.source, } if self.facility != PIPE_FACILITY_PIPE: