Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
|
||||
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때
|
||||
다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임).
|
||||
기본값 · 저장된 제약 결과(`default`)는 「최근 벌」을 따로 센다(351-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -77,12 +78,26 @@ def request_fingerprint(
|
||||
|
||||
|
||||
def is_default(
|
||||
mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any
|
||||
mode: str,
|
||||
weights: dict[str, float] | None,
|
||||
criteria: dict[str, Any] | None,
|
||||
terrain: Any,
|
||||
saved: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음."""
|
||||
empty = not any(value is not None for value in (criteria or {}).values())
|
||||
plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS
|
||||
return empty and plain and terrain is None
|
||||
"""지킬 결과 — 기본값(빈 제약 · 50/25/25 · 설계 지형) 또는 저장된 제약(`initial_choice`) 그대로.
|
||||
|
||||
모달이 여는 값이라 「최근 2벌」 정리에서 따로 센다(351-1 · 사용자 시도에 안 밀림).
|
||||
"""
|
||||
if terrain is not None:
|
||||
return False
|
||||
filled = {key: value for key, value in (criteria or {}).items() if value is not None}
|
||||
used = resolve_weights(mode, weights)
|
||||
if not filled and (mode != "weighted" or used == DEFAULT_WEIGHTS):
|
||||
return True
|
||||
saved = saved or {}
|
||||
return filled == (saved.get("criteria") or {}) and (
|
||||
mode != "weighted" or used == saved.get("weights")
|
||||
)
|
||||
|
||||
|
||||
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
|
||||
@@ -125,23 +140,26 @@ def write_stored(
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
prune_stored(project_root, request)
|
||||
prune_stored(project_root, request, default)
|
||||
return True
|
||||
|
||||
|
||||
def _kind(request: Any) -> tuple[Any, Any]:
|
||||
def _kind(request: Any, default: Any) -> tuple[Any, Any, bool]:
|
||||
request = request if isinstance(request, dict) else {}
|
||||
return request.get("mode"), request.get("terrain")
|
||||
return request.get("mode"), request.get("terrain"), bool(default)
|
||||
|
||||
|
||||
def prune_stored(project_root: Path, request: dict[str, Any]) -> int:
|
||||
"""같은 종류(갈래 · 지형) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다. 지운 수."""
|
||||
kind = _kind(request)
|
||||
def prune_stored(project_root: Path, request: dict[str, Any], default: bool = False) -> int:
|
||||
"""같은 종류(갈래 · 지형 · 기본값 여부) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다.
|
||||
|
||||
기본값 · 저장된 제약 결과(`default`)는 따로 셈 — 사용자 시도가 밀어내지 않음(351-1). 지운 수.
|
||||
"""
|
||||
kind = _kind(request, default)
|
||||
same: list[tuple[int, Path]] = []
|
||||
for path in store_dir(project_root).glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if _kind(data.get("request")) == kind:
|
||||
if _kind(data.get("request"), data.get("default")) == kind:
|
||||
same.append((path.stat().st_mtime_ns, path))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
@@ -76,6 +76,7 @@ async def compute_initial(
|
||||
if paths is None:
|
||||
return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING)
|
||||
project_root, _ = paths
|
||||
current = await asyncio.to_thread(read_initial_choice, project_root)
|
||||
expected = await asyncio.to_thread(replan._vertices_of, expected_route_csv_path(project_root))
|
||||
if not expected:
|
||||
expected = await asyncio.to_thread(replan._vertices_of, design_route_csv_path(project_root))
|
||||
@@ -144,6 +145,7 @@ async def compute_initial(
|
||||
weights,
|
||||
criteria.model_dump() if criteria else None,
|
||||
request.terrain,
|
||||
current,
|
||||
),
|
||||
)
|
||||
profile = result["metrics"].get("profile")
|
||||
@@ -162,7 +164,6 @@ async def compute_initial(
|
||||
result["metrics"]["length_m"],
|
||||
result["metrics"]["offset_avg_m"],
|
||||
)
|
||||
current = await asyncio.to_thread(read_initial_choice, project_root)
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
@@ -173,22 +174,99 @@ async def compute_initial(
|
||||
|
||||
|
||||
async def precompute_defaults(project_id: UUID) -> None:
|
||||
"""업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만."""
|
||||
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯.
|
||||
|
||||
모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만.
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import DEFAULT_WEIGHTS
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
|
||||
|
||||
started = time.perf_counter()
|
||||
for mode in DEFAULT_MODES:
|
||||
# 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`)
|
||||
request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow")
|
||||
outcome = await compute_initial(project_id, request)
|
||||
if isinstance(outcome, JSONResponse):
|
||||
logger.warning(
|
||||
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
|
||||
variants: list[tuple[Any, Any]] = [(None, None)]
|
||||
try:
|
||||
paths = await replan._project_paths(project_id)
|
||||
except LookupError:
|
||||
paths = None
|
||||
if paths is not None:
|
||||
saved = await asyncio.to_thread(read_initial_choice, paths[0])
|
||||
weights = saved["weights"] if saved["logic"] == "weighted" else None
|
||||
if saved["criteria"] or (weights and weights != DEFAULT_WEIGHTS):
|
||||
variants.append((saved["criteria"], weights))
|
||||
for criteria, weights in variants:
|
||||
for mode in DEFAULT_MODES:
|
||||
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 추적만 종단 끔(`withProfileOf`)
|
||||
request = replan.RouteInitialRequest(
|
||||
mode=mode,
|
||||
with_profile=mode != "follow",
|
||||
criteria=criteria,
|
||||
weights=weights if mode == "weighted" else None,
|
||||
)
|
||||
return
|
||||
outcome = await compute_initial(project_id, request)
|
||||
if isinstance(outcome, JSONResponse):
|
||||
logger.warning(
|
||||
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
|
||||
)
|
||||
return
|
||||
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
|
||||
|
||||
|
||||
def schedule_precompute(project_id: UUID) -> None:
|
||||
"""추천 미리 셈 예약 — 업로드 · 재확정 체인 끝(351-1). 기다리지 않음 · 실패는 로그만."""
|
||||
try:
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
|
||||
|
||||
_schedule_background_task(
|
||||
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
|
||||
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
|
||||
|
||||
|
||||
async def refresh_expected_route(project_id: UUID) -> None:
|
||||
"""B01 노선 사용 범위를 바꾼 뒤(351-1) — 예상노선을 새 범위로 다시 쓰고 추천 미리 셈.
|
||||
|
||||
체인을 아직 안 돈 프로젝트(예상노선 없음)는 건너뜀 — 체인이 새 범위로 적는다. 설계 노선
|
||||
(수정본 · 초기 폴리라인)은 그대로 — 추천의 시점 · 종점만 따라간다.
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_route_geometry import load_design_route, write_route_csv
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
surface = await get_surface_confirmation_params(connection, str(project_id))
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT route_start_m, route_end_m FROM projects WHERE id = %s",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored_path:
|
||||
return
|
||||
root = Path(resolve_stored_project_path(stored_path))
|
||||
path = expected_route_csv_path(root)
|
||||
if not path.is_file():
|
||||
return
|
||||
planned = await asyncio.to_thread(
|
||||
functools.partial(
|
||||
load_design_route,
|
||||
root,
|
||||
surface,
|
||||
(row[0], row[1]) if row else None,
|
||||
from_source=True,
|
||||
)
|
||||
)
|
||||
if planned is None:
|
||||
logger.warning("예상노선 다시 쓰기 건너뜀(노선 못 읽음): project_id=%s", project_id)
|
||||
return
|
||||
points = [{"x": v.x, "y": v.y} for v in planned.vertices]
|
||||
await asyncio.to_thread(write_route_csv, path, points)
|
||||
logger.info("예상노선 다시 씀(사용 범위): project_id=%s 점 %d", project_id, len(points))
|
||||
await precompute_defaults(project_id)
|
||||
|
||||
|
||||
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
|
||||
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
|
||||
import json
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
|
||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
import { viewOf } from "../common_util/common_util_view_window";
|
||||
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||
/** 접힌 테이블 손잡이가 앉을 본문 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||
* 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
@@ -93,7 +92,7 @@ export function createElevationHold(
|
||||
};
|
||||
}
|
||||
|
||||
/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */
|
||||
/** 겹침 판(표) 중 끌기가 보는 것만. */
|
||||
interface SubPanel {
|
||||
overlay: HTMLElement;
|
||||
commitHeight: (height: number) => void;
|
||||
@@ -102,7 +101,8 @@ interface SubPanel {
|
||||
export interface ProfileDragContext {
|
||||
root: HTMLElement;
|
||||
body: HTMLElement;
|
||||
massHaul: SubPanel;
|
||||
/** 유토곡선 판 — 그 리사이저 끌기 출처만 본다(자기 판이라 높이 나눔에 안 낌 · 354-1). */
|
||||
massHaul: { overlay: HTMLElement };
|
||||
tableOverlay: SubPanel & { isOpen: () => boolean };
|
||||
/** 손을 뗀 뒤 한 번 다시 세운다. */
|
||||
draw: () => void;
|
||||
@@ -112,7 +112,7 @@ export interface ProfileDrag {
|
||||
resizerRoot: HTMLElement;
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
mainDragRef: () => { chart: number; table: number } | null;
|
||||
isMainDragCooldown: () => boolean;
|
||||
clearMainDragCooldown: () => void;
|
||||
/** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */
|
||||
@@ -133,23 +133,21 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
};
|
||||
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
||||
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
||||
// 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2).
|
||||
const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay);
|
||||
const heightResizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
target: root,
|
||||
cssVar: "--b05-profile-height",
|
||||
direction: -1,
|
||||
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
|
||||
// 동적 하한: 종단 최소 + 열린 표 최소 높이(2026-08-05 사용자 확정) — 유토곡선 판 높이는
|
||||
// chrome(뿌리 − 본문)에 이미 들어 있다(354-1).
|
||||
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
|
||||
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
|
||||
min: () => {
|
||||
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
|
||||
const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
return Math.max(
|
||||
MIN_PANEL_HEIGHT,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + tableMin,
|
||||
);
|
||||
},
|
||||
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
|
||||
@@ -166,19 +164,14 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
|
||||
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
|
||||
let mainDragCooldown = false;
|
||||
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
||||
/** 메인 드래그 시작 시점의 두 영역(종단·테이블) 실제 높이 — 비례 연동 기준.
|
||||
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
||||
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
|
||||
let mainDragRef: { chart: number; table: number } | null = null;
|
||||
heightResizer.root.addEventListener("pointerdown", () => {
|
||||
mainPanelDragging = true;
|
||||
const massH = massDocked() ? massHaul.overlay.offsetHeight : 0;
|
||||
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
|
||||
mainDragRef = {
|
||||
chart: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
mass: massH,
|
||||
chart: Math.max(MIN_CHART_HEIGHT, body.clientHeight - tableH - MASSHAUL_HANDLE_GUTTER_PX),
|
||||
table: tableH,
|
||||
};
|
||||
});
|
||||
@@ -193,9 +186,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
|
||||
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
|
||||
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
|
||||
if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
||||
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
mainDragRef = null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Heights.ts
|
||||
* 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙.
|
||||
* 종단 패널의 높이 배분 — 그래프·테이블이 한 본문 높이를 나눠 갖는 규칙.
|
||||
* 유토곡선은 종단 열 아래 자기 판이라 본문 높이에서 이미 빠져 있다 — 나눔에 안 낀다(354-1).
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
||||
* 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면
|
||||
@@ -8,14 +9,13 @@
|
||||
* 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
/** 접힌 유토곡선 손잡이가 차지하는 바닥 여백(px). */
|
||||
/** 접힌 테이블 손잡이가 차지하는 바닥 여백(px). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
@@ -32,21 +32,15 @@ export interface HeightCascadeDeps {
|
||||
root: HTMLElement;
|
||||
/** 본문(그래프+서브패널이 들어가는 스크롤 영역). */
|
||||
body: HTMLElement;
|
||||
massHaul: {
|
||||
overlay: HTMLElement;
|
||||
desiredHeight: () => number;
|
||||
syncHandle: () => void;
|
||||
};
|
||||
tableOverlay: {
|
||||
overlay: HTMLElement;
|
||||
isOpen: () => boolean;
|
||||
desiredHeight: () => number;
|
||||
setBottomOffset: (value: number) => void;
|
||||
};
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
/** 메인 드래그 시작 시점의 3영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
/** 메인 드래그 시작 시점의 두 영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; table: number } | null;
|
||||
/** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */
|
||||
isMainDragCooldown: () => boolean;
|
||||
/** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */
|
||||
@@ -67,21 +61,17 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
// 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다
|
||||
// (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소).
|
||||
const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX);
|
||||
// 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2).
|
||||
const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay);
|
||||
const tableOpen = deps.tableOverlay.isOpen();
|
||||
// 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면
|
||||
// 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
|
||||
// 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을
|
||||
// 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면
|
||||
// 몇 번을 호출해도 같은 답이 나온다(멱등).
|
||||
const massDesired = massOpen ? deps.massHaul.desiredHeight() : 0;
|
||||
const tableDesired = tableOpen ? deps.tableOverlay.desiredHeight() : 0;
|
||||
let massHeight = massDesired;
|
||||
let tableOverlayHeight = tableDesired;
|
||||
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
// ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·유토곡선·테이블을 같이 늘리고
|
||||
// ── 메인 드래그 중: 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·테이블을 같이 늘리고
|
||||
// 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다.
|
||||
// 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다.
|
||||
const dragRef = deps.mainDragRef();
|
||||
@@ -89,15 +79,12 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
const items = [
|
||||
{ key: "chart", ref: Math.max(1, dragRef.chart), min: MIN_CHART_HEIGHT },
|
||||
...(massOpen
|
||||
? [{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }]
|
||||
: []),
|
||||
...(tableOpen
|
||||
? [{ key: "table", ref: Math.max(1, dragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }]
|
||||
: []),
|
||||
];
|
||||
// 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다.
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 2개라 최대 2회에 끝난다.
|
||||
const out: Record<string, number> = {};
|
||||
let pool = content;
|
||||
let active = items;
|
||||
@@ -116,50 +103,34 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
active = active.filter((item) => !pinned.includes(item));
|
||||
}
|
||||
// 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수.
|
||||
massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0;
|
||||
tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0;
|
||||
if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
// 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다.
|
||||
deps.massHaul.syncHandle();
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
return {
|
||||
chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight),
|
||||
};
|
||||
return { chartHeight: Math.max(MIN_CHART_HEIGHT, content - tableOverlayHeight) };
|
||||
}
|
||||
if (deps.isSubDragging()) {
|
||||
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
|
||||
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
|
||||
// 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다.
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
} else if (massDesired + tableDesired > budget) {
|
||||
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
|
||||
} else if (tableDesired > budget) {
|
||||
// ② 줄이기 — 줄일 수 있는 여유분만큼 축소, 최소 높이 하한.
|
||||
// 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다.
|
||||
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
const shrinkable = massDesired - massMin + (tableDesired - tableMin);
|
||||
const over = massDesired + tableDesired - budget;
|
||||
const shrinkable = tableDesired - tableMin;
|
||||
const over = tableDesired - budget;
|
||||
const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1;
|
||||
massHeight = Math.round(massDesired - (massDesired - massMin) * ratio);
|
||||
tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio);
|
||||
// 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아
|
||||
// 패널을 다시 키우면 원래 높이로 돌아온다.
|
||||
if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
} else {
|
||||
// 공간 충분 — 임시 축소 해제(저장 높이로 복귀).
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
// 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는
|
||||
// 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition).
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
// ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우)
|
||||
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대
|
||||
// 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다.
|
||||
const deficit =
|
||||
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
const deficit = MIN_CHART_HEIGHT + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) {
|
||||
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
|
||||
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
|
||||
@@ -174,7 +145,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
return {
|
||||
chartHeight: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
available - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_MassHaul.ts
|
||||
* B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**.
|
||||
* B05 유토곡선 — 종단 열 아래 **자기 판**(354-1 · 배수유역도처럼 손잡이 · 높이 · 접기 따로).
|
||||
*
|
||||
* ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ──────────────
|
||||
* 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세
|
||||
* 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로
|
||||
* (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴
|
||||
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고,
|
||||
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준만 · 종단 기준 숨김 354-2)를 쓰고,
|
||||
* 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다.
|
||||
*
|
||||
* 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라
|
||||
@@ -14,12 +14,11 @@
|
||||
* 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을
|
||||
* 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다.
|
||||
*
|
||||
* ── 배치 (2026-08-04 사용자 확정: 오버레이 전환) ─────────────────────
|
||||
* 예전에는 펼치면 종단 그래프와 세로 1:1 분할 + 도면 테이블 숨김이었다. 이제는 종단도·
|
||||
* 테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는
|
||||
* 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고
|
||||
* 세션 동안 유지한다. 곡선 SVG는 오버레이 안 자체 가로 스크롤러에 들어가며, 종단
|
||||
* 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
|
||||
* ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ─────────────────────
|
||||
* 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도
|
||||
* 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다.
|
||||
* 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에
|
||||
* 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
@@ -56,7 +55,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";
|
||||
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
||||
@@ -65,12 +64,10 @@ import { frameOf } from "../common_util/common_util_view_window";
|
||||
const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? "";
|
||||
/** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */
|
||||
const OVERLAY_MIN_HEIGHT = 160;
|
||||
/** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
|
||||
export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
|
||||
const OVERLAY_DEFAULT_HEIGHT = 280;
|
||||
/** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */
|
||||
const MASS_FIT_PAD_RIGHT = 12;
|
||||
/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */
|
||||
/** 판이 종단 열을 다 차지하지 않게 남기는 상한 비율. */
|
||||
const OVERLAY_MAX_RATIO = 0.8;
|
||||
/** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */
|
||||
const HEIGHT_VAR = "--b05-masshaul-height";
|
||||
@@ -147,24 +144,18 @@ export interface RouteMassHaulDrawParams {
|
||||
}
|
||||
|
||||
export interface RouteMassHaulPanel {
|
||||
/** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */
|
||||
/** 판 머리줄의 접기 손잡이(펼치기/접기) — 판 안에 있다. */
|
||||
handle: HTMLElement;
|
||||
/** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */
|
||||
/** 유토곡선 판 — 호출한 쪽이 종단 열 아래(`b05-route-profile__column` 끝)에 붙인다. */
|
||||
overlay: HTMLElement;
|
||||
isOpen(): boolean;
|
||||
/** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다.
|
||||
* Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */
|
||||
desiredHeight(): number;
|
||||
/** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이
|
||||
* 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */
|
||||
commitHeight(px: number): void;
|
||||
/** 손잡이를 오버레이 위 경계로 다시 맞춘다 — 캐스케이드가 인라인 높이를 바꾼 직후 호출. */
|
||||
syncHandle(): void;
|
||||
setContext(next: RouteMassHaulContext | null): void;
|
||||
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
|
||||
attachScrollSync(main: HTMLElement): void;
|
||||
/** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */
|
||||
/** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */
|
||||
draw(params: RouteMassHaulDrawParams): void;
|
||||
/** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */
|
||||
drawWindow(params: RouteMassHaulDrawParams): void;
|
||||
}
|
||||
|
||||
function readVisible(): Set<string> {
|
||||
@@ -173,8 +164,11 @@ function readVisible(): Set<string> {
|
||||
if (!raw) return new Set(DEFAULT_VISIBLE);
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙).
|
||||
// 종단 기준은 안 그림(354-2) — 저장된 범례가 종단이면 횡단으로 맞춘다.
|
||||
return Array.isArray(parsed)
|
||||
? normalizeVisibleBasis(new Set(parsed.map(String)))
|
||||
? normalizeVisibleBasis(
|
||||
new Set(parsed.map((key) => (key === "longitudinal" ? "cross" : String(key)))),
|
||||
)
|
||||
: new Set(DEFAULT_VISIBLE);
|
||||
} catch {
|
||||
return new Set(DEFAULT_VISIBLE);
|
||||
@@ -205,8 +199,12 @@ export function createRouteMassHaulPanel(
|
||||
handle.className = "b05-profile__masshaul-handle";
|
||||
handle.append(handleControl.root);
|
||||
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
|
||||
// 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1).
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-profile__masshaul-head";
|
||||
head.append(handle);
|
||||
|
||||
// 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례.
|
||||
// 판 뼈대 — 위 경계 리사이저 + 머리줄 + 안내 문구 자리 + 가로 스크롤러 + 범례.
|
||||
// (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.)
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-profile__masshaul-overlay";
|
||||
@@ -233,8 +231,7 @@ export function createRouteMassHaulPanel(
|
||||
max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO,
|
||||
storageKey: OVERLAY_HEIGHT_KEY,
|
||||
onResize: () => {
|
||||
// 손잡이는 경계를 즉시 따라가고(가벼움), 전체 redraw는 프레임당 한 번만.
|
||||
syncHandlePosition();
|
||||
// 전체 redraw는 프레임당 한 번만.
|
||||
if (resizeRedrawPending) return;
|
||||
resizeRedrawPending = true;
|
||||
frameOf(overlay, () => {
|
||||
@@ -249,20 +246,14 @@ export function createRouteMassHaulPanel(
|
||||
pendingChip.className = "b05-profile__masshaul-pending";
|
||||
pendingChip.textContent = "다시 계산 중…";
|
||||
pendingChip.hidden = true;
|
||||
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
|
||||
overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);
|
||||
|
||||
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
||||
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/**
|
||||
* 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와
|
||||
* 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널
|
||||
* 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정).
|
||||
*/
|
||||
function syncHandlePosition(): void {
|
||||
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
|
||||
}
|
||||
/** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */
|
||||
const shown = (): boolean => open || overlay.classList.contains("is-popout");
|
||||
|
||||
function applyOpen(next: boolean): void {
|
||||
open = next;
|
||||
@@ -271,14 +262,14 @@ export function createRouteMassHaulPanel(
|
||||
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
|
||||
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", next);
|
||||
overlay.hidden = !next;
|
||||
syncHandlePosition();
|
||||
// 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음)
|
||||
overlay.classList.toggle("is-collapsed", !next);
|
||||
onChanged();
|
||||
}
|
||||
handleControl.setOpen(open);
|
||||
handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", open);
|
||||
overlay.hidden = !open;
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
||||
|
||||
function toggleSeries(key: string): void {
|
||||
@@ -288,32 +279,29 @@ export function createRouteMassHaulPanel(
|
||||
onChanged();
|
||||
}
|
||||
|
||||
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다.
|
||||
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3).
|
||||
// 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 ·
|
||||
// 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태.
|
||||
let syncing = false;
|
||||
let fitted = false;
|
||||
function mirror(from: HTMLElement, to: HTMLElement): void {
|
||||
from.addEventListener("scroll", () => {
|
||||
if (syncing || fitted) return;
|
||||
syncing = true;
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
syncing = false;
|
||||
});
|
||||
}
|
||||
let syncedMain: HTMLElement | null = null;
|
||||
let putScroll: ((to: HTMLElement, left: number) => void) | null = null;
|
||||
/** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */
|
||||
let syncedIn: Document | null = null;
|
||||
function attachScrollSync(main: HTMLElement): void {
|
||||
if (syncedMain === main) return;
|
||||
syncedMain = main;
|
||||
mirror(main, scroll);
|
||||
mirror(scroll, main);
|
||||
putScroll = linkScrollLeft(main, scroll, () => fitted);
|
||||
}
|
||||
|
||||
/** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */
|
||||
let drawnOnce = false;
|
||||
/** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */
|
||||
let lastSeries: MassHaulSeries[] | null = null;
|
||||
|
||||
function note(message: string): void {
|
||||
drawnOnce = false;
|
||||
lastSeries = null;
|
||||
syncedIn = null;
|
||||
pendingChip.hidden = true;
|
||||
bar.replaceChildren();
|
||||
scroll.replaceChildren();
|
||||
@@ -360,18 +348,33 @@ export function createRouteMassHaulPanel(
|
||||
);
|
||||
}
|
||||
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */
|
||||
function draw(params: RouteMassHaulDrawParams): void {
|
||||
pipelineWork("유토곡선", () => drawNow(params));
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */
|
||||
function draw(params: RouteMassHaulDrawParams, reuse = false): void {
|
||||
pipelineWork("유토곡선", () => drawNow(params, reuse));
|
||||
}
|
||||
|
||||
function drawNow(params: RouteMassHaulDrawParams): void {
|
||||
/** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) ·
|
||||
* 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */
|
||||
let windowDirty = false;
|
||||
function drawWindow(params: RouteMassHaulDrawParams): void {
|
||||
if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return;
|
||||
lastParams = params;
|
||||
overlay.hidden = !open;
|
||||
syncHandlePosition();
|
||||
// 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림
|
||||
if (windowDirty || windowState.frame) return;
|
||||
windowDirty = true;
|
||||
frameOf(overlay, () => {
|
||||
if (windowDirty && lastParams) draw(lastParams, true);
|
||||
});
|
||||
}
|
||||
|
||||
function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void {
|
||||
lastParams = params;
|
||||
windowDirty = false;
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
||||
// 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번).
|
||||
if (!open) {
|
||||
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
|
||||
if (!shown()) {
|
||||
syncedIn = null;
|
||||
reportSummary(params);
|
||||
return;
|
||||
}
|
||||
@@ -400,23 +403,29 @@ export function createRouteMassHaulPanel(
|
||||
.map((chainage) => `${chainage.toFixed(2)} m`)
|
||||
.join(" · ")}${stale.length > 5 ? " …" : ""}`
|
||||
: "다시 계산 중…";
|
||||
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
|
||||
// B06과 같은 계산 — 횡단 기준(정식)만 낸다. 종단 기준은 안 셈 · 안 그림 · 범례 단추 없음
|
||||
// (354-2 · 계산 코드는 공용에 남김).
|
||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
const series: MassHaulSeries[] = computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
);
|
||||
// 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3).
|
||||
const series: MassHaulSeries[] =
|
||||
reuse && lastSeries
|
||||
? lastSeries
|
||||
: computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
);
|
||||
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
|
||||
emitSummary(series);
|
||||
if (series !== lastSeries) emitSummary(series);
|
||||
lastSeries = series;
|
||||
if (!series.length) {
|
||||
note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
bar.replaceChildren();
|
||||
legendLayer.replaceChildren();
|
||||
const visible = readVisible();
|
||||
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
|
||||
const banded = series.find((entry) => visible.has(entry.key));
|
||||
@@ -486,24 +495,30 @@ export function createRouteMassHaulPanel(
|
||||
wrap.append(chart);
|
||||
// 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙).
|
||||
if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight));
|
||||
// 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이
|
||||
// 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다.
|
||||
const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null;
|
||||
scroll.replaceChildren(wrap);
|
||||
// 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다.
|
||||
// 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
|
||||
if (fitAll) scroll.scrollLeft = 0;
|
||||
else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft;
|
||||
else if (keepLeft !== null) scroll.scrollLeft = keepLeft;
|
||||
else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft);
|
||||
syncedIn = fitAll ? null : scroll.ownerDocument;
|
||||
|
||||
legendLayer.append(
|
||||
createMassHaulLegend(series, visible, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
// 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다.
|
||||
if (!reuse || !legendLayer.firstChild)
|
||||
legendLayer.replaceChildren(
|
||||
createMassHaulLegend(series, visible, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
drawnOnce = true;
|
||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
||||
scheduleMassHaulSettle(
|
||||
windowState,
|
||||
() => {
|
||||
if (lastParams) draw(lastParams);
|
||||
if (lastParams) draw(lastParams, true);
|
||||
},
|
||||
overlay,
|
||||
);
|
||||
@@ -512,22 +527,12 @@ export function createRouteMassHaulPanel(
|
||||
return {
|
||||
handle,
|
||||
overlay,
|
||||
isOpen: () => open,
|
||||
desiredHeight() {
|
||||
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT;
|
||||
},
|
||||
commitHeight(px) {
|
||||
const next = Math.round(px);
|
||||
overlay.style.setProperty(HEIGHT_VAR, `${next}px`);
|
||||
writeStateRaw("masshaul-height", String(next));
|
||||
syncHandlePosition();
|
||||
},
|
||||
syncHandle: () => syncHandlePosition(),
|
||||
isOpen: shown,
|
||||
setContext(next) {
|
||||
context = next;
|
||||
},
|
||||
attachScrollSync,
|
||||
draw,
|
||||
draw: (params) => draw(params),
|
||||
drawWindow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@ export interface ProfileRenderContext {
|
||||
* 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */
|
||||
onStaleDesigns?: (key: string) => boolean;
|
||||
callbacks: RouteProfilePanelCallbacks | undefined;
|
||||
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
|
||||
massHaul: {
|
||||
draw: (params: RouteMassHaulDrawParams) => void;
|
||||
drawWindow: (params: RouteMassHaulDrawParams) => void;
|
||||
};
|
||||
tableOverlay: {
|
||||
isOpen: () => boolean;
|
||||
contentHeight: () => number;
|
||||
@@ -452,9 +455,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시
|
||||
// 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이
|
||||
// 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는
|
||||
// 값보다 크다(요소 수가 종단의 1/3).
|
||||
// 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3).
|
||||
massHaulUpdater = (fromM, toM) => {
|
||||
massHaul.draw({
|
||||
massHaul.drawWindow({
|
||||
...massHaulParams,
|
||||
axis: { ...massHaulParams.axis, viewRange: { fromM, toM } },
|
||||
});
|
||||
|
||||
@@ -4,17 +4,15 @@
|
||||
* 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시).
|
||||
*
|
||||
* - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존.
|
||||
* - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가
|
||||
* 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다.
|
||||
* - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서
|
||||
* 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
||||
* - 종단 칸 바닥에 붙는다 — 유토곡선은 그 아래 자기 판이라 쌓지 않는다(354-1).
|
||||
* - 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout";
|
||||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||||
@@ -54,8 +52,6 @@ export interface RouteProfileTableOverlay {
|
||||
setTable: (table: HTMLElement | null) => void;
|
||||
/** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */
|
||||
attachScrollSync: (main: HTMLElement) => void;
|
||||
/** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */
|
||||
setBottomOffset: (px: number) => void;
|
||||
}
|
||||
|
||||
export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay {
|
||||
@@ -73,7 +69,6 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
// 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다
|
||||
// (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인).
|
||||
let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지)
|
||||
let bottomOffset = 0;
|
||||
let resizeRedrawPending = false;
|
||||
const resizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
@@ -102,11 +97,9 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
// 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다).
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다
|
||||
* — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 종단 칸 바닥을 따라간다(2026-08-05 사용자 지시). */
|
||||
function syncHandlePosition(): void {
|
||||
handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`;
|
||||
overlay.style.bottom = `${bottomOffset}px`;
|
||||
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
|
||||
}
|
||||
|
||||
function applyOpen(next: boolean): void {
|
||||
@@ -125,16 +118,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
overlay.hidden = !open;
|
||||
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
||||
|
||||
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일).
|
||||
let syncing = false;
|
||||
function mirror(from: HTMLElement, to: HTMLElement): void {
|
||||
from.addEventListener("scroll", () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
syncing = false;
|
||||
});
|
||||
}
|
||||
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(유토곡선과 같음 · 354-3).
|
||||
let syncedMain: HTMLElement | null = null;
|
||||
|
||||
return {
|
||||
@@ -161,12 +145,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
attachScrollSync(main) {
|
||||
if (syncedMain === main) return;
|
||||
syncedMain = main;
|
||||
mirror(main, scroll);
|
||||
mirror(scroll, main);
|
||||
},
|
||||
setBottomOffset(px) {
|
||||
bottomOffset = px;
|
||||
syncHandlePosition();
|
||||
linkScrollLeft(main, scroll);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,35 @@ const BASE_FRAME_MS = 1000 / 60;
|
||||
* 쓰면 한 번에 목표까지 튄다. */
|
||||
const MAX_FRAME_MS = 64;
|
||||
|
||||
/**
|
||||
* 두 스크롤러의 가로 자리를 서로 따라가게 잇는다 — 종단 그래프 ↔ 유토곡선 · 표(354-3).
|
||||
* 써 넣은 값으로 **다음 틀에** 오는 scroll(메아리)은 무시한다. 동기 플래그로 막던 예전 길은
|
||||
* 메아리가 한 틀 전 값을 되써 앞서 간 쪽을 뒤로 튀게 했고, 그 튐이 휠 이동을 접어 끊겼다.
|
||||
* `paused` 가 참이면 안 따라감. 돌려주는 `put` = 따라가는 쪽에 손으로 써 넣을 때(메아리 표시).
|
||||
*/
|
||||
export function linkScrollLeft(
|
||||
a: HTMLElement,
|
||||
b: HTMLElement,
|
||||
paused: () => boolean = () => false,
|
||||
): (to: HTMLElement, left: number) => void {
|
||||
const echo = new Map<HTMLElement, number>();
|
||||
const put = (to: HTMLElement, left: number): void => {
|
||||
const before = to.scrollLeft;
|
||||
to.scrollLeft = left;
|
||||
if (to.scrollLeft !== before) echo.set(to, to.scrollLeft);
|
||||
};
|
||||
const follow = (from: HTMLElement, to: HTMLElement): void =>
|
||||
from.addEventListener("scroll", () => {
|
||||
const wrote = echo.get(from);
|
||||
echo.delete(from);
|
||||
if (wrote !== undefined && Math.abs(from.scrollLeft - wrote) < 1) return;
|
||||
if (!paused() && to.scrollLeft !== from.scrollLeft) put(to, from.scrollLeft);
|
||||
});
|
||||
follow(a, b);
|
||||
follow(b, a);
|
||||
return put;
|
||||
}
|
||||
|
||||
export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
let target = -1;
|
||||
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
|
||||
|
||||
@@ -43,26 +43,68 @@
|
||||
}
|
||||
|
||||
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다.
|
||||
오버레이의 절대배치 기준이기도 하다. */
|
||||
테이블 겹침 판의 절대배치 기준이기도 하다. */
|
||||
.b05-route-profile__body-wrap {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다
|
||||
(2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
|
||||
.b05-profile__masshaul-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
/* 종단 열 = 종단 칸 + 그 아래 유토곡선 판(354-1) — 배수유역도는 열 오른쪽. */
|
||||
.b05-route-profile__column {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 유토곡선 판 — 종단 칸 아래 자기 칸(354-1). 종단도 · 테이블을 덮지 않는다.
|
||||
높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
|
||||
.b05-profile__masshaul-overlay {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
height: var(--b05-masshaul-height, 280px);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 -4px 12px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
/* 머리줄 — 펼침에선 곡선 위 여백(padTop)에 떠 자리를 안 먹고, 접으면 판에 머리줄만 남는다. */
|
||||
.b05-profile__masshaul-head {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 0;
|
||||
left: var(--spacing-8);
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-head > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 2px var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */
|
||||
.b05-route-profile.is-collapsed .b05-profile__masshaul-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */
|
||||
@@ -142,13 +184,12 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 패널 본문 바닥에 붙고, 오버레이(z-index 5) 위에 뜬다.
|
||||
접혔을 때 테이블 손잡이와 **일렬**로 놓이도록 중앙에서 우측으로 비켜 세운다
|
||||
(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
|
||||
/* 접기 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 판 머리줄에 앉아 접어도 닿는다(354-1). 테이블 손잡이와
|
||||
겹치지 않게 중앙에서 우측으로 비켜 세운다(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
|
||||
.b05-profile__masshaul-handle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: calc(50% + 44px);
|
||||
z-index: 6;
|
||||
transform: translateX(-50%);
|
||||
@@ -163,8 +204,7 @@
|
||||
}
|
||||
|
||||
/* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ──────────────────────
|
||||
유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절).
|
||||
위→아래 순서: 종단도 → 테이블 → 유토곡선. */
|
||||
종단 칸 바닥에 붙어 종단도 위를 덮는다. 유토곡선은 그 아래 자기 판(354-1). */
|
||||
.b05-profile__table-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
Reference in New Issue
Block a user