diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts new file mode 100644 index 000000000..4a0c83b1e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts @@ -0,0 +1,51 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Cover.ts + * 노선 편집 평면 아래를 **뜬 종단 판**이 덮은 높이를 잰다(345-5 덧). + * + * 칸 · 판 크기가 바뀔 때마다(창 · 판 높이 끌기 · 접기 · 새 창 떼기) 다시 재서 + * 칸에 `--b05-routeedit-cover`(px)를 적는다 — 상태 · 범례 · 잰 값 글판이 판 바로 위로 오름. + * 맞춤(`_View.openingView`)은 `coveredPx()` 를 읽는다. 셈은 `logic/_RouteEdit_Visible`. + * ========================================================================== */ + +import { coveredBottomPx } from "./logic/B05_Profile_RouteEdit_Visible"; + +// ponytail: 편집기는 페이지에 하나 — 덮인 높이 한 값 · 둘이 되면 칸마다 값으로. +let covered = 0; +const watching = new WeakSet(); + +/** 지금 평면 아래 덮인 높이(px). */ +export function coveredPx(): number { + return covered; +} + +/** 같은 가운데 칸의 종단 판 — 새 창으로 떼면 없음. */ +function panelOf(wrap: HTMLElement): HTMLElement | null { + return ( + wrap.closest(".design-page__main")?.querySelector(".b05-route-profile") ?? null + ); +} + +function span(element: HTMLElement): { top: number; bottom: number } { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom }; +} + +function measure(wrap: HTMLElement, panel: HTMLElement | null): void { + covered = coveredBottomPx(span(wrap), panel ? span(panel) : null); + wrap.style.setProperty("--b05-routeedit-cover", `${covered}px`); +} + +/** 칸을 지켜본다(여러 번 불러도 한 번만 붙음) · 부를 때마다 한 번 잰다. */ +export function watchCover(wrap: HTMLElement): void { + if (watching.has(wrap)) return measure(wrap, panelOf(wrap)); + watching.add(wrap); + let observed: HTMLElement | null = null; + const watch = new ResizeObserver(() => { + if (!wrap.isConnected) return watch.disconnect(); // 떠남 + const panel = panelOf(wrap); + if (panel && panel !== observed) watch.observe((observed = panel)); + measure(wrap, panel); + }); + watch.observe(wrap); + measure(wrap, panelOf(wrap)); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_View.ts b/B05_Profile/B05_Profile_UI_RouteEdit_View.ts index c32688f06..406740ed5 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_View.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_View.ts @@ -14,11 +14,13 @@ import { readState, writeState } from "../A00_Common/b_page_state"; import { computeMapRect, computeMaxScale, - computeRouteView, metricToScreen, + ROUTE_VIEW_MARGIN_M, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { coveredPx, watchCover } from "./B05_Profile_UI_RouteEdit_Cover"; +import { fitRouteToVisible } from "./logic/B05_Profile_RouteEdit_Visible"; type Vertex = [number, number]; @@ -114,22 +116,36 @@ export function openingView( const reach = Math.min(view.width, view.height) / 2; const seen = nodes.some(([x, y]) => { const [px, py] = metricToScreen(meta, restored, x, y); - return Math.hypot(px - view.width / 2, py - view.height / 2) < reach; + // 뜬 종단 판 밑은 안 보임 — 판 위 영역 안만(345-5 덧). + return ( + py < view.height - coveredPx() && + Math.hypot(px - view.width / 2, py - view.height / 2) < reach + ); }); if (seen) return { view: restored, rotationRad }; } const xs = nodes.map((vertex) => vertex[0]); const ys = nodes.map((vertex) => vertex[1]); - const bounds = { - x_min: Math.min(...xs), - x_max: Math.max(...xs), - y_min: Math.min(...ys), - y_max: Math.max(...ys), - }; - return { - view: { ...view, ...computeRouteView(meta, bounds, view.width, view.height) }, - rotationRad: 0, - }; + const [xMin, xMax, yMin, yMax] = [ + Math.min(...xs), + Math.max(...xs), + Math.min(...ys), + Math.max(...ys), + ]; + // 노선 맞춤 = 뜬 종단 판 위 보이는 영역 가운데(345-5 덧 · 덮인 높이 0 이면 computeRouteView 와 같음). + const fitted = fitRouteToVisible({ + mapWidthM: meta.width_meters, + mapHeightM: meta.height_meters, + mapRect: computeMapRect(meta, view.width, view.height), + width: view.width, + height: view.height, + coveredPx: coveredPx(), + centerXM: (xMin + xMax) / 2 - meta.x_min, + centerYM: (yMin + yMax) / 2 - meta.y_min, + wantWidthM: Math.max(xMax - xMin, 1) + ROUTE_VIEW_MARGIN_M * 2, + wantHeightM: Math.max(yMax - yMin, 1) + ROUTE_VIEW_MARGIN_M * 2, + }); + return { view: { ...view, ...fitted }, rotationRad: 0 }; } /** 캔버스 버퍼를 감싼 칸 크기(고해상도 배율 포함)에 맞춘다. 칸의 CSS 크기를 돌려준다. */ @@ -146,6 +162,8 @@ export function fitCanvas( canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; context.setTransform(ratio, 0, 0, ratio, 0, 0); + // 편집 평면만(추천 지도도 이 셈을 씀) — 뜬 종단 판이 덮은 높이 · 맞춤 · 글판 자리(345-5 덧). + if (wrap.classList.contains("b05-routeedit__canvas-wrap")) watchCover(wrap); return { width, height }; } diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index e0d1d894e..a2d8481e0 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -93,9 +93,10 @@ max-width: 52%; } -/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫). */ +/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 판 바로 위(345-5 덧 · + `--b05-routeedit-cover` = `_RouteEdit_Cover` 가 잰 덮인 높이 · 접으면 0). */ .b05-routeedit__info { - bottom: var(--spacing-12); + bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px)); left: var(--spacing-12); display: flex; flex-direction: column; @@ -274,11 +275,11 @@ display: none; } -/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */ +/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판 위 · 345-5 덧). 닫으면 잰 것이 지워진다. */ .b05-routeedit__measure { position: absolute; right: var(--spacing-12); - bottom: var(--spacing-12); + bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px)); z-index: 1; display: flex; align-items: flex-start; diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_Visible.ts b/B05_Profile/logic/B05_Profile_RouteEdit_Visible.ts new file mode 100644 index 000000000..e59023538 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_RouteEdit_Visible.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * B05_Profile_RouteEdit_Visible.ts + * 노선 편집 평면의 **보이는 영역**(345-5 덧) — 아래 뜬 종단 판이 덮은 높이 · 그 위 영역에 노선 맞춤. + * + * 덮인 높이 = 칸 아래 끝 − 판 위 끝(0 ~ 칸 높이) · 판 없음 · 접힘(높이 0)이면 0. + * 맞춤 = B04 `computeRouteView` 와 같은 공식 · 세로 몫만 보이는 높이 비율로 줄이고 가운데를 그 영역 + * 가운데로 · 덮인 높이 0 이면 `computeRouteView` 와 같은 값. + * ========================================================================== */ + +export interface VerticalSpan { + top: number; + bottom: number; +} + +/** 칸(`wrap`) 아래쪽을 판(`panel`)이 덮은 높이(px). */ +export function coveredBottomPx(wrap: VerticalSpan, panel: VerticalSpan | null): number { + if (!panel || panel.bottom - panel.top <= 1) return 0; // 없음 · 접힘(높이 0 + 테두리) + const height = Math.max(wrap.bottom - wrap.top, 0); + return Math.min(Math.max(wrap.bottom - panel.top, 0), height); +} + +export interface FitInput { + /** 도엽 크기(m) — `meta.width_meters` · `height_meters`. */ + mapWidthM: number; + mapHeightM: number; + /** 배율 1 일 때 도엽이 놓이는 칸 안 사각형(`computeMapRect`). */ + mapRect: { x: number; y: number; width: number; height: number }; + /** 칸 크기(px) · 아래 덮인 높이(px). */ + width: number; + height: number; + coveredPx: number; + /** 노선 가운데 · 크기(m) — 도엽 왼쪽 아래 기준 · 여유 포함. */ + centerXM: number; + centerYM: number; + wantWidthM: number; + wantHeightM: number; +} + +/** 노선(+여유)이 보이는 영역 가운데 들도록 배율 · 이동량. */ +export function fitRouteToVisible(input: FitInput): { + scale: number; + offsetX: number; + offsetY: number; +} { + const { mapRect, width, height } = input; + const ratio = height > 0 ? Math.max(height - input.coveredPx, 1) / height : 1; + const fit = Math.min( + input.mapWidthM / input.wantWidthM, + (input.mapHeightM / input.wantHeightM) * ratio, + ); + // 하한 1(빈 여백 없음 · computeRouteView) — 덮인 만큼 내려 줌(노선이 판 밑에 안 숨게). + const scale = Math.max(fit, Math.min(1, ratio)); + const baseX = mapRect.x + (input.centerXM / input.mapWidthM) * mapRect.width; + const baseY = mapRect.y + (1 - input.centerYM / input.mapHeightM) * mapRect.height; + return { + scale, + offsetX: -(baseX - width / 2) * scale, + offsetY: -(baseY - height / 2) * scale - input.coveredPx / 2, + }; +} diff --git a/resources/tester/test_345_5b_plan_visible_area.py b/resources/tester/test_345_5b_plan_visible_area.py new file mode 100644 index 000000000..1c86636e7 --- /dev/null +++ b/resources/tester/test_345_5b_plan_visible_area.py @@ -0,0 +1,132 @@ +"""345-5 덧 — 노선 편집 평면의 보이는 영역(뜬 종단 판 위) · 노선 맞춤 · 글판 자리. + +`logic/B05_Profile_RouteEdit_Visible.ts` 를 tsc 로 옮겨 node 로 돌림 · 배선 · CSS 는 글로 봄. +./venv/Scripts/python.exe -m pytest resources/tester/test_345_5b_plan_visible_area.py -q +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +TS_FILE = B05 / "logic" / "B05_Profile_RouteEdit_Visible.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +# 도엽 1000 × 800 m · 칸 1200 × 900 px · 노선 가운데 (500, 400) · 크기 300 × 200 m(+여유 200 m) +_FIT = { + "mapWidthM": 1000, + "mapHeightM": 800, + "width": 1200, + "height": 900, + "centerXM": 500, + "centerYM": 400, + "wantWidthM": 700, + "wantHeightM": 600, +} + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as visible from "./B05_Profile/logic/B05_Profile_RouteEdit_Visible.js"; +const { coveredBottomPx, fitRouteToVisible } = visible; +const fit = JSON.parse(process.argv[3]); +const rect = { x: 37.5, y: 0, width: 1125, height: 900 }; +const at = (coveredPx) => fitRouteToVisible({ ...fit, mapRect: rect, coveredPx }); +writeFileSync(process.argv[2], JSON.stringify({ + cover: [ + coveredBottomPx({ top: 0, bottom: 900 }, null), + coveredBottomPx({ top: 0, bottom: 900 }, { top: 360, bottom: 900 }), + coveredBottomPx({ top: 0, bottom: 900 }, { top: 500, bottom: 900 }), + coveredBottomPx({ top: 0, bottom: 900 }, { top: 899, bottom: 900 }), + coveredBottomPx({ top: 0, bottom: 900 }, { top: -50, bottom: 900 }), + coveredBottomPx({ top: 0, bottom: 900 }, { top: 950, bottom: 1300 }), + ], + none: at(0), + half: at(450), + most: at(880), +})); +""" + + +def _run(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result), json.dumps(_FIT)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def _screen_center(fit: dict) -> tuple[float, float]: + """노선 가운데의 화면 자리 — B04 `createMetricProjector` 와 같은 식.""" + rect = {"x": 37.5, "y": 0, "width": 1125, "height": 900} + s = fit["scale"] + nx = _FIT["centerXM"] / _FIT["mapWidthM"] + ny = 1 - _FIT["centerYM"] / _FIT["mapHeightM"] + x = rect["x"] * s + _FIT["width"] / 2 * (1 - s) + fit["offsetX"] + nx * rect["width"] * s + y = rect["y"] * s + _FIT["height"] / 2 * (1 - s) + fit["offsetY"] + ny * rect["height"] * s + return x, y + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_덮인_높이와_보이는_영역_맞춤(tmp_path: Path) -> None: + out = _run(tmp_path) + # 판 없음 · 판 높이 540 · 400 · 접힘(테두리만) · 칸보다 큼(칸 높이까지) · 칸 밖 + assert out["cover"] == [0, 540, 400, 0, 900, 0] + # 덮인 높이 0 = B04 computeRouteView 와 같음(배율 = min(1000/700, 800/600) · 가운데 = 칸 가운데) + none = out["none"] + assert none["scale"] == pytest.approx(min(1000 / 700, 800 / 600)) + assert _screen_center(none) == pytest.approx((600, 450)) + # 아래 반을 덮음 — 노선 가운데 = 보이는 영역(0 ~ 450) 가운데 · 노선 높이가 그 영역 안 + half = out["half"] + assert _screen_center(half) == pytest.approx((600, 225)) + route_px = _FIT["wantHeightM"] * (900 / 800) * half["scale"] + assert route_px <= 450 + 1e-6 + # 거의 다 덮음 — 남은 20 px 에 들게 줄임 · 가운데는 남은 20 px 가운데 + most = out["most"] + assert most["scale"] == pytest.approx((800 / 600) * (20 / 900)) + assert _screen_center(most)[1] == pytest.approx(10) + + +def test_배선과_글판_자리() -> None: + view = (B05 / "B05_Profile_UI_RouteEdit_View.ts").read_text(encoding="utf-8") + assert "coveredPx: coveredPx()," in view and "computeRouteView(" not in view + assert 'wrap.classList.contains("b05-routeedit__canvas-wrap")) watchCover(wrap);' in view + cover = (B05 / "B05_Profile_UI_RouteEdit_Cover.ts").read_text(encoding="utf-8") + assert 'querySelector(".b05-route-profile")' in cover + assert 'setProperty("--b05-routeedit-cover"' in cover + css = (B05 / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8") + # 상태 · 범례 · 잰 값 글판 = 판 바로 위(접으면 덮인 높이 0 = 원래 자리) + assert css.count("bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px));") == 2 + # 접힌 판은 높이 0 — 덮인 높이도 0 이 됨 + style = (B05 / "B05_Profile_UI_Style.css").read_text(encoding="utf-8") + assert ".b05-route-profile.is-collapsed {\n height: 0;" in style