From 031b5be1d44712291592da9a6b135a5514f752c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 13:55:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EB=85=B8=EC=84=A0=ED=8E=B8=EC=A7=91?= =?UTF-8?q?):=20349-0=20=EC=B2=98=EC=9D=8C=20=EA=B7=B8=EB=A6=BC=20=C2=B7?= =?UTF-8?q?=20[=EB=A7=9E=EC=B6=A4]=20=3D=20=EB=93=B1=EA=B3=A0=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20=EB=9C=AC=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=ED=8C=90=EC=9D=80=20=EB=8D=AE=EC=9D=B4=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=E2=80=94=20=ED=8C=90=EC=9D=B4=20=EC=97=86=EB=8B=A4?= =?UTF-8?q?=EA=B3=A0=20=EB=B3=B4=EA=B3=A0=20=EC=B9=B8=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EA=B0=80=EC=9A=B4=EB=8D=B0=EC=97=90=20=EB=93=B1=EA=B3=A0?= =?UTF-8?q?=EC=84=A0=20=EB=B2=94=EC=9C=84=20=E2=88=AA=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=EC=9D=84=2085%=20=EB=AA=AB(=EB=91=98=EB=A0=88=20=EC=97=AC?= =?UTF-8?q?=EC=9C=A0)=20=C2=B7=20=ED=8C=90=20=EB=81=8C=EA=B8=B0=20=C2=B7?= =?UTF-8?q?=20=EC=A0=91=EA=B8=B0=EC=97=90=20=ED=8F=89=EB=A9=B4=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EA=B8=80=ED=8C=90=20=C2=B7=20=EC=9E=B0=20?= =?UTF-8?q?=EA=B0=92=20=EC=B0=BD=20=C2=B7=20=EA=B3=A1=EC=84=A0=20=EB=B0=94?= =?UTF-8?q?=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 logic/B05_Profile_RouteEdit_Fit(unionArea · fitAreaToView) — 옛 _Visible 자리 - _RouteEdit_Cover 지움 · View · CurveBar · CSS 의 덮인 높이 따라가기 걷음(345-5 덧 · 덧 2) - 글판 · 도움말 접기(덧 3)는 그대로 - 시험: test_345_5b_plan_visible_area 3 고쳐 씀 · test_345_5c 자리 줄 고침 · 관련 86 통과 · typecheck 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 6 +- .../B05_Profile_UI_RouteEdit_Chrome.ts | 2 +- B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts | 61 -------- .../B05_Profile_UI_RouteEdit_CurveBar.ts | 7 +- B05_Profile/B05_Profile_UI_RouteEdit_View.ts | 60 ++++---- .../B05_Profile_UI_Style_RouteEdit.css | 9 +- .../logic/B05_Profile_RouteEdit_Fit.ts | 68 +++++++++ .../logic/B05_Profile_RouteEdit_Visible.ts | 60 -------- .../tester/test_345_5b_plan_visible_area.py | 138 ++++++++---------- .../tester/test_345_5c_info_hint_collapse.py | 4 +- 10 files changed, 171 insertions(+), 244 deletions(-) delete mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts create mode 100644 B05_Profile/logic/B05_Profile_RouteEdit_Fit.ts delete mode 100644 B05_Profile/logic/B05_Profile_RouteEdit_Visible.ts diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index e13a17937..584fa0aa6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -308,15 +308,15 @@ export async function mountRouteEditor( let viewWanted = false; // 자료를 읽었다 — 칸 크기가 서면 보던 화면(18 ③) · 없으면 노선 전체로. function openView(): void { if (!viewWanted || !meta || view.width <= 0) return; - const opening = openingView(projectId, meta, view, planned); + const opening = openingView(projectId, meta, view, planned, contours?.layer ?? null); view = opening.view; rotation.set(opening.rotationRad); viewReady = true; } - // [맞춤] — 노선 전체를 뜬 종단 판 위 보이는 영역에(회전 0 · 345-5 덧 2). 셈은 `_View` 몫. + // [맞춤] — 처음 그림과 같은 등고 영역 맞춤(회전 0 · 349-0). 셈은 `_View` 몫. overlay.querySelector('[data-act="fit"]')!.addEventListener("click", () => { if (!meta || view.width <= 0 || !planned.length) return; - view = fitView(meta, view, planned); + view = fitView(meta, view, planned, contours?.layer ?? null); rotation.set(0); draw(); }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index ab2d9b032..466021224 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -71,7 +71,7 @@ export function createRouteEditChrome( + title="등고 영역(등고선 범위 · 노선)을 평면 가운데 알맞게 맞춥니다 (회전은 풂)">맞춤 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts deleted file mode 100644 index 6b0563340..000000000 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cover.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_RouteEdit_Cover.ts - * 노선 편집 평면 아래를 **뜬 종단 판**이 덮은 높이를 잰다(345-5 덧). - * - * 칸 · 판 크기가 바뀔 때마다(창 · 판 높이 끌기 · 접기 · 새 창 떼기) 다시 재서 - * 칸에 `--b05-routeedit-cover`(px)를 적는다 — 상태 · 범례 · 잰 값 글판이 판 바로 위로 오름 · - * 곡선 바는 `onCoverChange` 로 다시 자리 잡음. - * 맞춤(`_View.fitView`)은 `coveredPx()` 를 읽는다. 셈은 `logic/_RouteEdit_Visible`. - * ========================================================================== */ - -import { coveredBottomPx } from "./logic/B05_Profile_RouteEdit_Visible"; - -// ponytail: 편집기는 페이지에 하나 — 덮인 높이 한 값 · 둘이 되면 칸마다 값으로. -let covered = 0; -const watching = new WeakSet(); -const listeners = new Set<() => boolean>(); - -/** 지금 평면 아래 덮인 높이(px). */ -export function coveredPx(): number { - return covered; -} - -/** 덮인 높이가 바뀌면 부름(곡선 바 자리 · 345-5 덧 2) — `false` 를 돌려주면 뗌(떠난 편집기). */ -export function onCoverChange(listener: () => boolean): void { - listeners.add(listener); -} - -/** 같은 가운데 칸의 종단 판 — 새 창으로 떼면 없음. */ -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 { - const before = covered; - covered = coveredBottomPx(span(wrap), panel ? span(panel) : null); - wrap.style.setProperty("--b05-routeedit-cover", `${covered}px`); - if (covered !== before) - for (const listener of listeners) if (!listener()) listeners.delete(listener); -} - -/** 칸을 지켜본다(여러 번 불러도 한 번만 붙음) · 부를 때마다 한 번 잰다. */ -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_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index b15278aa0..4ae2590c2 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -22,7 +22,6 @@ import { createCurveLabel, type CurveLabel, } from "./B05_Profile_UI_RouteEdit_Label"; -import { coveredPx, onCoverChange } from "./B05_Profile_UI_RouteEdit_Cover"; /** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */ export interface CurveBarState { @@ -146,8 +145,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { left: rect.left + 8, top: rect.top + 8, right: rect.right - 8, - // 뜬 종단 판 밑은 안 보임 — 판 바로 위까지(345-5 덧 2 · 접으면 0). - bottom: rect.bottom - 8 - coveredPx(), + bottom: rect.bottom - 8, }; const last = planned.length - 1; if (picked === 0 || picked === last) { @@ -204,8 +202,5 @@ export function createCurveBar(params: CurveBarParams): CurveBar { }); } - // 판 끌기 · 접기 — 덮인 높이가 바뀌면 다시 자리 잡음(그리기 없이 · 떠나면 뗌). - onCoverChange(() => (params.container.isConnected ? (sync(), true) : false)); - return { label, sync }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_View.ts b/B05_Profile/B05_Profile_UI_RouteEdit_View.ts index 9498c0d06..0cb0a4d7d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_View.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_View.ts @@ -15,12 +15,11 @@ import { computeMapRect, computeMaxScale, metricToScreen, - ROUTE_VIEW_MARGIN_M, + type PreparedLayer, 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"; +import { fitAreaToView, unionArea, type NormalizedArea } from "./logic/B05_Profile_RouteEdit_Fit"; type Vertex = [number, number]; @@ -100,12 +99,13 @@ export function captureView( return { cx, cy, pxPerMeter: viewPxPerMeter(meta, view), rotationDeg }; } -/** 창을 열 때의 화면 — 보던 화면이 있으면 그대로, 없으면 노선 전체에 맞춘다(회전 0). */ +/** 창을 열 때의 화면 — 보던 화면이 있으면 그대로, 없으면 등고 영역에 맞춘다(회전 0). */ export function openingView( projectId: string, meta: VWorldMeta, view: ViewState, nodes: ReadonlyArray, + contours: PreparedLayer | null, ): { view: ViewState; rotationRad: number } { const saved = readSavedView(projectId); if (saved) { @@ -116,45 +116,43 @@ 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); - // 뜬 종단 판 밑은 안 보임 — 판 위 영역 안만(345-5 덧). - return ( - py < view.height - coveredPx() && - Math.hypot(px - view.width / 2, py - view.height / 2) < reach - ); + return Math.hypot(px - view.width / 2, py - view.height / 2) < reach; }); if (seen) return { view: restored, rotationRad }; } - return { view: fitView(meta, view, nodes), rotationRad: 0 }; + return { view: fitView(meta, view, nodes, contours), rotationRad: 0 }; } -/** 노선 전체를 뜬 종단 판 위 보이는 영역 가운데에(회전 0 기준) — 처음 열기 · [맞춤](345-5 덧). */ +/** 등고 영역(등고선 범위 ∪ 노선)을 칸 전체 가운데에 알맞게(회전 0) — 처음 열기 · [맞춤](349-0). + * 뜬 종단 판은 없다고 봄 — 판은 덮이기만 함. 등고선이 없으면 노선만. */ export function fitView( meta: VWorldMeta, view: ViewState, nodes: ReadonlyArray, + contours: PreparedLayer | null, ): ViewState { - const xs = nodes.map((vertex) => vertex[0]); - const ys = nodes.map((vertex) => vertex[1]); - 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), + const spanX = meta.width_meters || 1; + const spanY = meta.height_meters || 1; + const route: NormalizedArea | null = nodes.length + ? { + minX: (Math.min(...nodes.map((vertex) => vertex[0])) - meta.x_min) / spanX, + maxX: (Math.max(...nodes.map((vertex) => vertex[0])) - meta.x_min) / spanX, + minY: 1 - (Math.max(...nodes.map((vertex) => vertex[1])) - meta.y_min) / spanY, + maxY: 1 - (Math.min(...nodes.map((vertex) => vertex[1])) - meta.y_min) / spanY, + } + : null; + const area = unionArea([route, ...(contours?.features ?? [])]); + if (!area) return view; + const mapRect = computeMapRect(meta, view.width, view.height); + const fitted = fitAreaToView({ + mapRect, 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, + area, + minScale: MIN_SCALE, + maxScale: computeMaxScale(meta, mapRect.width, view.width, MAX_SCALE_FALLBACK), }); - return { ...view, ...fitted }; + return { ...view, mapRect, ...fitted }; } /** 캔버스 버퍼를 감싼 칸 크기(고해상도 배율 포함)에 맞춘다. 칸의 CSS 크기를 돌려준다. */ @@ -171,8 +169,6 @@ 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 26f4fbc70..a9cc78586 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -116,11 +116,10 @@ display: grid; } -/* 상태 · 범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 판 바로 위(345-5 덧 · - `--b05-routeedit-cover` = `_RouteEdit_Cover` 가 잰 덮인 높이 · 접으면 0). +/* 상태 · 범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 가려진 채(349-0). 두 줄 안 · 좁은 폭(345-5 덧 3) — 왼쪽 상태 글(두 줄에서 자름) · 가운데 범례 두 줄 · 오른쪽 ▾. */ .b05-routeedit__info { - bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px)); + bottom: var(--spacing-12); left: var(--spacing-12); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; @@ -345,11 +344,11 @@ display: none; } -/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판 위 · 345-5 덧). 닫으면 잰 것이 지워진다. */ +/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */ .b05-routeedit__measure { position: absolute; right: var(--spacing-12); - bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px)); + bottom: var(--spacing-12); z-index: 1; display: flex; align-items: flex-start; diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_Fit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_Fit.ts new file mode 100644 index 000000000..eab6ebda3 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_RouteEdit_Fit.ts @@ -0,0 +1,68 @@ +/* ============================================================================= + * B05_Profile_RouteEdit_Fit.ts + * 노선 편집 평면의 **처음 그림 · [맞춤]**(349-0) — 등고 영역(등고선 범위 ∪ 노선)을 칸 가운데에 + * 알맞게(꽉 차지 않게 · 둘레 여유) 넣는 배율 · 이동량. + * + * 뜬 종단 판은 없다고 보고 칸 전체 기준 — 판은 위에 덮이기만 한다(다시 맞추지 않음). + * 영역은 도엽 정규화 좌표(0~1 · y 는 위가 0 · `PreparedFeature` 와 같은 꼴). + * ========================================================================== */ + +/** 칸에서 등고 영역이 차지하는 몫 — 나머지가 둘레 여유. */ +export const AREA_FILL = 0.85; + +export interface NormalizedArea { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export interface FitInput { + /** 배율 1 일 때 도엽이 놓이는 칸 안 사각형(`computeMapRect`). */ + mapRect: { x: number; y: number; width: number; height: number }; + /** 칸 크기(px). */ + width: number; + height: number; + area: NormalizedArea; + /** 배율 하한 · 상한(지도 조작과 같은 값). */ + minScale: number; + maxScale: number; +} + +/** 영역들을 하나로 — 빈 목록이면 null. */ +export function unionArea(areas: ReadonlyArray): NormalizedArea | null { + let union: NormalizedArea | null = null; + // 등고선 가닥이 수만 개일 수 있음 — 펼쳐 넘기지 않고 하나씩. + for (const area of areas) { + if (!area) continue; + union = union + ? { + minX: Math.min(union.minX, area.minX), + minY: Math.min(union.minY, area.minY), + maxX: Math.max(union.maxX, area.maxX), + maxY: Math.max(union.maxY, area.maxY), + } + : { minX: area.minX, minY: area.minY, maxX: area.maxX, maxY: area.maxY }; + } + return union; +} + +/** 영역이 칸 가운데 `AREA_FILL` 몫으로 들도록 배율 · 이동량. */ +export function fitAreaToView(input: FitInput): { + scale: number; + offsetX: number; + offsetY: number; +} { + const { mapRect, width, height, area } = input; + const areaWidth = Math.max((area.maxX - area.minX) * mapRect.width, 1e-6); + const areaHeight = Math.max((area.maxY - area.minY) * mapRect.height, 1e-6); + const fit = AREA_FILL * Math.min(width / areaWidth, height / areaHeight); + const scale = Math.min(input.maxScale, Math.max(input.minScale, fit)); + const baseX = mapRect.x + ((area.minX + area.maxX) / 2) * mapRect.width; + const baseY = mapRect.y + ((area.minY + area.maxY) / 2) * mapRect.height; + return { + scale, + offsetX: -(baseX - width / 2) * scale, + offsetY: -(baseY - height / 2) * scale, + }; +} diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_Visible.ts b/B05_Profile/logic/B05_Profile_RouteEdit_Visible.ts deleted file mode 100644 index e59023538..000000000 --- a/B05_Profile/logic/B05_Profile_RouteEdit_Visible.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* ============================================================================= - * 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 index 4fc02a0c2..0c81d8523 100644 --- a/resources/tester/test_345_5b_plan_visible_area.py +++ b/resources/tester/test_345_5b_plan_visible_area.py @@ -1,6 +1,8 @@ -"""345-5 덧 — 노선 편집 평면의 보이는 영역(뜬 종단 판 위) · 노선 맞춤 · 글판 · 곡선 바 자리. +"""345-5 · 349-0 노선 편집 평면 처음 그림 · [맞춤] = 등고 영역 맞춤 · 뜬 종단 판은 덮이기만. -`logic/B05_Profile_RouteEdit_Visible.ts` 를 tsc 로 옮겨 node 로 돌림 · 배선 · CSS 는 글로 봄. +349-0(요구 234) — 판이 올라와도 다시 맞추지 않음 · 글판 · 잰 값 창 · 곡선 바는 판 따라 안 옮김 · +처음 그림은 판이 없다고 보고 칸 전체 기준으로 등고 영역(등고선 범위 ∪ 노선)을 둘레 여유 두고 맞춤. +`logic/B05_Profile_RouteEdit_Fit.ts` 를 tsc 로 옮겨 node 로 돌림 · 배선 · CSS 는 글로 봄. ./venv/Scripts/python.exe -m pytest resources/tester/test_345_5b_plan_visible_area.py -q """ @@ -14,40 +16,28 @@ import pytest ROOT = Path(__file__).resolve().parents[2] B05 = ROOT / "B05_Profile" -TS_FILE = B05 / "logic" / "B05_Profile_RouteEdit_Visible.ts" +TS_FILE = B05 / "logic" / "B05_Profile_RouteEdit_Fit.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, -} - +# 칸 1200 × 900 px · 배율 1 도엽 사각형 1125 × 900(가로 37.5 px 여백) _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 }); +import { AREA_FILL, fitAreaToView, unionArea } from "./B05_Profile/logic/B05_Profile_RouteEdit_Fit.js"; +const mapRect = { x: 37.5, y: 0, width: 1125, height: 900 }; +const at = (area) => + fitAreaToView({ mapRect, width: 1200, height: 900, area, minScale: 0.5, maxScale: 16 }); +const contours = [ + { minX: 0.2, minY: 0.3, maxX: 0.5, maxY: 0.6, labelText: null }, + { minX: 0.4, minY: 0.2, maxX: 0.6, maxY: 0.5, labelText: null }, +]; +const route = { minX: 0.3, minY: 0.35, maxX: 0.65, maxY: 0.45 }; 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), + fill: AREA_FILL, + union: unionArea([route, null, ...contours]), + none: unionArea([null]), + whole: at({ minX: 0, minY: 0, maxX: 1, maxY: 1 }), + part: at(unionArea([route, ...contours])), + dot: at({ minX: 0.5, minY: 0.5, maxX: 0.5, maxY: 0.5 }), })); """ @@ -78,7 +68,7 @@ def _run(tmp_path: Path) -> dict: (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)], + ["node", str(out / "runner.mjs"), str(result)], cwd=str(ROOT), check=True, capture_output=True, @@ -86,65 +76,65 @@ def _run(tmp_path: Path) -> dict: 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} +def _screen(fit: dict, nx: float, ny: float) -> tuple[float, float]: + """정규화 자리의 화면 자리 — B04 `affineOf` 와 같은 식.""" 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 + x = 37.5 * s + 600 * (1 - s) + fit["offsetX"] + nx * 1125 * s + y = 0 * s + 450 * (1 - s) + fit["offsetY"] + ny * 900 * s return x, y @pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") -def test_덮인_높이와_보이는_영역_맞춤(tmp_path: Path) -> None: +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) + assert 0.7 <= out["fill"] < 1 # 꽉 차지 않게 · 둘레 여유 + # 등고선 범위 ∪ 노선 · 빈 목록은 없음 + assert out["union"] == {"minX": 0.2, "minY": 0.2, "maxX": 0.65, "maxY": 0.6} + assert out["none"] is None + # 도엽 전체 = 칸 가운데 · 짧은 쪽(세로)이 fill 몫 + whole = out["whole"] + assert whole["scale"] == pytest.approx(out["fill"]) + assert _screen(whole, 0.5, 0.5) == pytest.approx((600, 450)) + # 일부 영역 = 그 가운데가 칸 가운데 · 네 모서리 모두 칸 안(둘레 여유) + part = out["part"] + assert _screen(part, 0.425, 0.4) == pytest.approx((600, 450)) + x0, y0 = _screen(part, 0.2, 0.2) + x1, y1 = _screen(part, 0.65, 0.6) + assert x0 > 0 and y0 > 0 and x1 < 1200 and y1 < 900 + assert max((x1 - x0) / 1200, (y1 - y0) / 900) == pytest.approx(out["fill"]) + # 한 점 = 배율 상한에서 멈춤 + assert out["dot"]["scale"] == 16 -def test_배선과_글판_자리() -> None: +def test_판은_덮이기만_따라_안_옮김() -> None: + """뜬 종단 판이 올라와도 평면 자리 · 글판 · 잰 값 창 · 곡선 바 그대로(349-0).""" + assert not (B05 / "B05_Profile_UI_RouteEdit_Cover.ts").exists() + assert not (B05 / "logic" / "B05_Profile_RouteEdit_Visible.ts").exists() 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 + assert "coveredPx" not in view and "watchCover" not in view 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 이 됨 + assert "--b05-routeedit-cover" not in css + bar = (B05 / "B05_Profile_UI_RouteEdit_CurveBar.ts").read_text(encoding="utf-8") + assert "bottom: rect.bottom - 8," in bar + assert "coveredPx" not in bar and "onCoverChange" not in bar + # 칸 크기가 바뀌어도 보던 가운데 · 배율 유지(다시 맞추지 않음) + editor = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert "if (meta && kept) view = { ...view, ...restoreSavedView(meta, view, kept) };" in editor + # 접힌 판은 높이 0 style = (B05 / "B05_Profile_UI_Style.css").read_text(encoding="utf-8") assert ".b05-route-profile.is-collapsed {\n height: 0;" in style -def test_곡선_바는_판_위_맞춤_단추(): - """345-5 덧 2 — 곡선 바 아래 한계 = 판 바로 위 · 판 끌기 · 접기 따라감 · [맞춤] 단추.""" - bar = (B05 / "B05_Profile_UI_RouteEdit_CurveBar.ts").read_text(encoding="utf-8") - assert "bottom: rect.bottom - 8 - coveredPx()," in bar - assert "onCoverChange(() => (params.container.isConnected ? (sync(), true) : false));" in bar - cover = (B05 / "B05_Profile_UI_RouteEdit_Cover.ts").read_text(encoding="utf-8") - assert "if (covered !== before)" in cover and "listeners.delete(listener)" in cover +def test_처음_열기와_맞춤_단추_같은_셈() -> None: + view = (B05 / "B05_Profile_UI_RouteEdit_View.ts").read_text(encoding="utf-8") + assert "return { view: fitView(meta, view, nodes, contours), rotationRad: 0 };" in view + assert "unionArea([route, ...(contours?.features ?? [])])" in view chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8") assert 'data-act="fit"' in chrome and ">맞춤" in chrome editor = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert "openingView(projectId, meta, view, planned, contours?.layer ?? null)" in editor at = editor.index("""overlay.querySelector('[data-act="fit"]')!""") handler = editor[at : at + 260] - assert "view = fitView(meta, view, planned);" in handler and "rotation.set(0);" in handler + assert "view = fitView(meta, view, planned, contours?.layer ?? null);" in handler + assert "rotation.set(0);" in handler assert len(editor.splitlines()) <= 700 - view = (B05 / "B05_Profile_UI_RouteEdit_View.ts").read_text(encoding="utf-8") - assert "return { view: fitView(meta, view, nodes), rotationRad: 0 };" in view diff --git a/resources/tester/test_345_5c_info_hint_collapse.py b/resources/tester/test_345_5c_info_hint_collapse.py index c1684cc4c..fb674d3b4 100644 --- a/resources/tester/test_345_5c_info_hint_collapse.py +++ b/resources/tester/test_345_5c_info_hint_collapse.py @@ -56,8 +56,8 @@ def test_상태_범례_접기_한_줄_기억() -> None: def test_상태_글판_작게_반투명_자리() -> None: info = _rule(".b05-routeedit__info") - # 판 바로 위(345-5 덧) 그대로 · 폭 줄임 - assert "bottom: calc(var(--spacing-12) + var(--b05-routeedit-cover, 0px));" in info + # 왼쪽 아래 제자리 — 뜬 판이 덮으면 가려진 채(349-0) · 폭 줄임 + assert "bottom: var(--spacing-12);" in info assert "max-width: min(460px, 46%);" in info and "grid-template-columns:" in info # 상태 글 두 줄에서 자름 · 범례는 옆 칸 두 줄 assert "-webkit-line-clamp: 2;" in _rule(".b05-routeedit__status")