refactor(B05·B06): 종단도 700줄 정리 — 오버레이 · 스크롤 듣개 새 파일로 떼어 641줄(213-3)

- design_profile_overlays.ts — 유토곡선 · 테이블 오버레이 · 유토 총괄 배지 만들기
- design_profile_viewport.ts — 가로 스크롤 다시 그림 · 휠 가로 스크롤 · 크기 바뀜 듣개(dispose 때 뗌)
- 동작 그대로 · test_215_1 줄 수 기준 = 700(규칙) · 새 두 파일도 검사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 15:53:42 +09:00
co-authored by Claude Opus 5.5
parent dc8f201877
commit e0ad372e70
4 changed files with 153 additions and 82 deletions
+21 -80
View File
@@ -15,13 +15,10 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drain
import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import type {
StructureInstance,
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
import {
hasLegacyAlignment,
readAlignment,
@@ -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();
},
};
}
@@ -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 };
}
@@ -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();
}
+3 -2
View File
@@ -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)