From e0ad372e70cf77273ad6c7b477d0afbe49a850c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:53:42 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05=C2=B7B06):=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20700=EC=A4=84=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=C2=B7=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EB=93=A3=EA=B0=9C=20=EC=83=88=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=EB=96=BC=EC=96=B4=20641=EC=A4=84(213-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_profile_overlays.ts — 유토곡선 · 테이블 오버레이 · 유토 총괄 배지 만들기 - design_profile_viewport.ts — 가로 스크롤 다시 그림 · 휠 가로 스크롤 · 크기 바뀜 듣개(dispose 때 뗌) - 동작 그대로 · test_215_1 줄 수 기준 = 700(규칙) · 새 두 파일도 검사 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_profile.ts | 101 ++++--------------- A00_Common/design/design_profile_overlays.ts | 63 ++++++++++++ A00_Common/design/design_profile_viewport.ts | 66 ++++++++++++ resources/tester/test_215_1_free_profile.py | 5 +- 4 files changed, 153 insertions(+), 82 deletions(-) create mode 100644 A00_Common/design/design_profile_overlays.ts create mode 100644 A00_Common/design/design_profile_viewport.ts diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index ca82056cc..8145a6ba3 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -15,13 +15,10 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drain import { readState, writeState } from "../b_page_state"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; -import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; import type { StructureInstance, StructureType, } from "../../B05_Profile/B05_Profile_Api_Structures"; -import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay"; import { hasLegacyAlignment, readAlignment, @@ -56,11 +53,7 @@ import { structureIdKeeping, } from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { - createRouteMassHaulPanel, - type MassHaulSummaryValues, - type RouteMassHaulContext, -} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; +import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance"; import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights"; import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render"; @@ -83,10 +76,11 @@ import { snapToMasterStations, } from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; -import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; -import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; +import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; import { noticeDroppedEdits } from "./design_save"; +import { createProfileOverlays } from "./design_profile_overlays"; +import { bindProfileViewport } from "./design_profile_viewport"; import "../../B06_Section/B06_Section_UI_Style.css"; // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. @@ -162,40 +156,14 @@ export function createDesignProfile(ctx: DesignProfileContext) { if (drag.isSubDragging()) scheduleLightSync(); else draw(); }; - /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). - * 유토곡선을 접어도 이 값은 남아야 한다. */ - const massBadge = createMassHaulBadge(); - // 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. - massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); - bodyWrap.append(massBadge.root); - let massHaulSummary: MassHaulSummaryValues | null = null; - // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 - // 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다. - const haulPrefetch = createHaulPlanPrefetch( + // 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계). + const { massHaul, tableOverlay } = createProfileOverlays({ projectId, - () => routeId ?? undefined, - subPanelChanged, - ); - const massHaul = createRouteMassHaulPanel( - subPanelChanged, - (summary) => { - const before = massHaulSummary; - massHaulSummary = summary; - if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary); - }, - haulPrefetch, - ); - bodyWrap.append(massHaul.overlay, massHaul.handle); - // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. - massHaul.attachScrollSync(body); - // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). - const tableOverlay = createProfileTableOverlay(subPanelChanged); - bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); - tableOverlay.attachScrollSync(body); - // 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시). - // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. - body.addEventListener("contextmenu", (event) => event.preventDefault()); - massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); + routeId: () => routeId, + bodyWrap, + body, + onChange: subPanelChanged, + }); const content = document.createElement("div"); content.className = "b05-route-profile__content"; /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ @@ -463,44 +431,17 @@ export function createDesignProfile(ctx: DesignProfileContext) { }); } - /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ - const SCROLL_SETTLE_MS = 80; - /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ - let settledScrollLeft = 0; - let scrollSettleTimer = 0; - // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 - // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 - // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. - body.addEventListener("scroll", () => { - if (elevationHold.editing()) return; - const fitted = updateElevationWindow?.() ?? null; - window.clearTimeout(scrollSettleTimer); - if (!needsFullRedraw(fitted)) return; - scrollSettleTimer = window.setTimeout(() => { - if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; - settledScrollLeft = body.scrollLeft; - draw(); - }, SCROLL_SETTLE_MS); + // 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계). + const unbindViewport = bindProfileViewport({ + body, + editing: () => elevationHold.editing(), + fitElevation: () => updateElevationWindow?.() ?? null, + draw: () => draw(), + lastSize: () => ({ width: lastWidth, height: lastHeight }), + scheduleLightSync, + scheduleDraw: () => drag.scheduleDraw(), }); - // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 - // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). - attachWheelHorizontalScroll(body); - - const resizeObserver = new ResizeObserver(() => { - if ( - body.clientWidth <= 0 || - body.clientHeight <= 0 || - (Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1) - ) - return; - // 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번. - // (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고) - scheduleLightSync(); - drag.scheduleDraw(); - }); - resizeObserver.observe(body); - // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw)); @@ -691,7 +632,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { setListening(false); drag.dispose(); crossPreview.dispose(); - resizeObserver.disconnect(); + unbindViewport(); }, }; } diff --git a/A00_Common/design/design_profile_overlays.ts b/A00_Common/design/design_profile_overlays.ts new file mode 100644 index 000000000..eb60de408 --- /dev/null +++ b/A00_Common/design/design_profile_overlays.ts @@ -0,0 +1,63 @@ +/* ============================================================================= + * design_profile_overlays.ts + * 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌). + * 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. + * ========================================================================== */ + +import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; +import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; +import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay"; +import { + createRouteMassHaulPanel, + type MassHaulSummaryValues, +} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; + +export interface ProfileOverlaysDeps { + projectId: string; + routeId: () => number | null; + /** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */ + bodyWrap: HTMLElement; + /** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */ + body: HTMLElement; + /** 서브패널이 바뀜(펼침 · 범례 · 리사이저 · 유토 배분 도착). */ + onChange: () => void; +} + +export function createProfileOverlays(deps: ProfileOverlaysDeps) { + const { bodyWrap, body, onChange } = deps; + /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). + * 유토곡선을 접어도 이 값은 남아야 한다. */ + const massBadge = createMassHaulBadge(); + // 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. + massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); + bodyWrap.append(massBadge.root); + let massHaulSummary: MassHaulSummaryValues | null = null; + // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 + // 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다. + const haulPrefetch = createHaulPlanPrefetch( + deps.projectId, + () => deps.routeId() ?? undefined, + onChange, + ); + const massHaul = createRouteMassHaulPanel( + onChange, + (summary) => { + const before = massHaulSummary; + massHaulSummary = summary; + if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary); + }, + haulPrefetch, + ); + bodyWrap.append(massHaul.overlay, massHaul.handle); + // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. + massHaul.attachScrollSync(body); + // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). + const tableOverlay = createProfileTableOverlay(onChange); + bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); + tableOverlay.attachScrollSync(body); + // 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시). + // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. + body.addEventListener("contextmenu", (event) => event.preventDefault()); + massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); + return { massHaul, tableOverlay }; +} diff --git a/A00_Common/design/design_profile_viewport.ts b/A00_Common/design/design_profile_viewport.ts new file mode 100644 index 000000000..ea193f514 --- /dev/null +++ b/A00_Common/design/design_profile_viewport.ts @@ -0,0 +1,66 @@ +/* ============================================================================= + * design_profile_viewport.ts + * 종단 스크롤러의 가로 스크롤 · 휠 · 크기 바뀜 듣개(213-3 · 700줄 한계로 뗌). + * 다시 그리기 · 높이 동기화는 종단도가 넘긴 함수를 부른다. + * ========================================================================== */ + +import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; +import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; + +/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ +const SCROLL_SETTLE_MS = 80; + +export interface ProfileViewportDeps { + body: HTMLElement; + /** ▲▼ 계획고 편집으로 Y 축을 잡고 있는 중. */ + editing: () => boolean; + /** 세로 창 갱신기 — 그릴 때마다 렌더러가 새로 준다(없으면 null). */ + fitElevation: () => ElevationWindowResult | null; + draw: () => void; + /** 마지막으로 그린 본문 크기 — 같으면 크기 바뀜을 무시한다. */ + lastSize: () => { width: number; height: number }; + scheduleLightSync: () => void; + scheduleDraw: () => void; +} + +/** 듣개를 붙이고 떼는 함수를 돌려준다. */ +export function bindProfileViewport(deps: ProfileViewportDeps): () => void { + const { body } = deps; + /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 + // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 + // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. + body.addEventListener("scroll", () => { + if (deps.editing()) return; + const fitted = deps.fitElevation(); + window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; + scrollSettleTimer = window.setTimeout(() => { + if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = body.scrollLeft; + deps.draw(); + }, SCROLL_SETTLE_MS); + }); + + // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 + // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). + attachWheelHorizontalScroll(body); + + const resizeObserver = new ResizeObserver(() => { + const last = deps.lastSize(); + if ( + body.clientWidth <= 0 || + body.clientHeight <= 0 || + (Math.abs(body.clientWidth - last.width) < 1 && Math.abs(body.clientHeight - last.height) < 1) + ) + return; + // 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번. + // (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고) + deps.scheduleLightSync(); + deps.scheduleDraw(); + }); + resizeObserver.observe(body); + return () => resizeObserver.disconnect(); +} diff --git a/resources/tester/test_215_1_free_profile.py b/resources/tester/test_215_1_free_profile.py index e245e7a61..2a4133378 100644 --- a/resources/tester/test_215_1_free_profile.py +++ b/resources/tester/test_215_1_free_profile.py @@ -147,6 +147,7 @@ def test_줄_수_늘리지_않음(): "A00_Common/design/design_profile.ts", "A00_Common/design/design_profile_free.ts", "A00_Common/design/design_profile_free_view.ts", + "A00_Common/design/design_profile_overlays.ts", + "A00_Common/design/design_profile_viewport.ts", ): - assert len(_read(relative).splitlines()) <= 700 - assert len(_read("A00_Common/design/design_profile.ts").splitlines()) <= 698 + assert len(_read(relative).splitlines()) <= 700 # 소스 700줄 초과 금지(213-3)