feat(B05 노선편집): 345-5 덧 뜬 판 가려짐 정리 — 노선 맞춤 = 뜬 종단 판 위 보이는 영역 가운데 · 상태 · 범례 · 잰 값 글판 = 판 바로 위(판 높이 끌기 · 접기 따라감)
- 새 logic/B05_Profile_RouteEdit_Visible(덮인 높이 · 보이는 영역 맞춤 — 덮인 높이 0 이면 computeRouteView 와 같음) - 새 _RouteEdit_Cover(칸 · 판 ResizeObserver → --b05-routeedit-cover) · fitCanvas 가 편집 평면 칸만 붙임 - 보던 화면 되살림도 판 밑 노드는 안 보인 것으로 - 시험: test_345_5b_plan_visible_area 2 · 관련 68 통과 · typecheck 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
@@ -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<HTMLElement>();
|
||||
|
||||
/** 지금 평면 아래 덮인 높이(px). */
|
||||
export function coveredPx(): number {
|
||||
return covered;
|
||||
}
|
||||
|
||||
/** 같은 가운데 칸의 종단 판 — 새 창으로 떼면 없음. */
|
||||
function panelOf(wrap: HTMLElement): HTMLElement | null {
|
||||
return (
|
||||
wrap.closest(".design-page__main")?.querySelector<HTMLElement>(".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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<HTMLElement>(".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
|
||||
Reference in New Issue
Block a user