From 219e7fc223820e1f997afe85ccc27b4b5f44dbc7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 19:15:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(b05,b06):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EC=A7=80=EB=8F=84=20=EC=A4=8C=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=ED=95=9C=EA=B3=84=20=3D=20=EB=85=B8=EC=84=A0=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=E2=80=94=20=EB=85=B8=EC=84=A0=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=202=20=EB=B0=B0=20=C2=B7=20=EC=9C=A0=EC=97=AD=20=EB=B0=96?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20=EC=9C=A0=EC=97=AD=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EC=97=BD=20=EC=A0=84=EC=B2=B4=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=C2=B7=20=EC=B0=BD=20=ED=81=AC=EA=B8=B0=20=EB=B0=94?= =?UTF-8?q?=EB=80=8C=EC=96=B4=20=EB=B0=96=EC=9D=B4=EB=A9=B4=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=A7=9E=EC=B6=A4=20(281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B05_Profile_UI_Drainage_Interact.ts | 11 +- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 11 +- .../B05_Profile_UI_Drainage_ZoomRange.ts | 83 +++++++++++ .../helper_281_1_drainage_zoom_range.cjs | 130 ++++++++++++++++++ .../tester/test_281_1_drainage_zoom_range.py | 91 ++++++++++++ 5 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts create mode 100644 resources/tester/helper_281_1_drainage_zoom_range.cjs create mode 100644 resources/tester/test_281_1_drainage_zoom_range.py diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index a56742384..c3f1a57aa 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -17,6 +17,7 @@ import { import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { clampWheelScale } from "./B05_Profile_UI_Drainage_ZoomRange"; import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import type { createMapContextMenu } from "@ui/ui_template_context_menu"; @@ -33,6 +34,8 @@ export interface DrainageInteractParams { setScale: (value: number) => void; /** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */ getMeta: () => VWorldMeta | null; + /** 줌아웃 한계 배율 — 노선 기준(계획서 281). */ + getMinScale: () => number; getOffset: () => { x: number; y: number }; setOffset: (x: number, y: number) => void; scheduleDraw: () => void; @@ -62,7 +65,13 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { // 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시). const view = currentView(); const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16); - const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87))); + // 하한은 노선 기준(계획서 281) — 도엽 전체까지 빠지지 않는다. + const scale = clampWheelScale( + prevScale * (event.deltaY > 0 ? 1.15 : 0.87), + prevScale, + params.getMinScale(), + maxScale, + ); params.setScale(scale); // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). const ratio = scale / prevScale; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 3e5118aa8..856a97175 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -41,6 +41,7 @@ import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; +import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange"; import { mountDrainageToggles, DRAINAGE_LAYERS, @@ -461,6 +462,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra offsetY = fitted.offsetY; } + function zoomOutLimit(): number { + const view = currentView(); + return computeZoomOutLimit(meta, routePoints, basins, view.width, view.height); + } + async function loadLayers(): Promise { if (!projectId) return; const activeProjectId = projectId; @@ -517,6 +523,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra scale = value; }, getMeta: () => meta, + getMinScale: zoomOutLimit, getOffset: () => ({ x: offsetX, y: offsetY }), setOffset: (x, y) => { offsetX = x; @@ -540,8 +547,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra offsetY = next.offsetY; }, redraw: () => { - // 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. - if (fitPending) fitToRoute(); + // 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. 늘려서 줌아웃 한계 밖이면 노선 맞춤(281-2). + if (fitPending || scale < zoomOutLimit() * 0.999) fitToRoute(); scheduleDraw(); }, }); diff --git a/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts b/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts new file mode 100644 index 000000000..85846e75e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_ZoomRange.ts + * 배수유역 지도의 줌아웃 한계(가장 작은 배율) — 도엽 전체가 아니라 **노선 기준**(계획서 281). + * + * 한계 = 노선 범위를 가운데 두고 ZOOM_OUT_ROUTE_FACTOR 배로 넓힌 범위가 화면에 들어오는 배율. + * 유역 폴리곤이 그 밖으로 나가면 유역까지 넓힌다. 처음 보기(노선 + 여유)보다 좁히지 않고, + * 도엽 전체(배율 1)보다 멀리는 안 간다. 노선이 없으면 종전 꼴(ZOOM_OUT_FALLBACK_SCALE). + * 화면 그리기 없는 순수 셈 — 시험이 Node 로 바로 돌린다. + * ========================================================================== */ + +import type { DetailBasin, VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + computeMapRect, + computeRouteView, + createNormalizer, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; + +/** 노선 범위를 몇 배까지 멀리 볼지 — 키우면 더 멀리 빠진다(사용자가 바꿀 수 있는 값). */ +export const ZOOM_OUT_ROUTE_FACTOR = 2; + +/** 노선이 없을 때의 줌아웃 한계 — 도엽 전체의 절반까지(종전 값). */ +export const ZOOM_OUT_FALLBACK_SCALE = 0.5; + +/** 줌아웃 한계 배율. 지도 메타가 없거나 노선이 두 점 미만이면 종전 값. */ +export function computeZoomOutLimit( + meta: VWorldMeta | null, + routePoints: ReadonlyArray<{ x: number; y: number }>, + basins: ReadonlyArray>, + width: number, + height: number, + factor: number = ZOOM_OUT_ROUTE_FACTOR, +): number { + if (!meta || routePoints.length < 2 || width <= 0 || height <= 0) { + return ZOOM_OUT_FALLBACK_SCALE; + } + const route = { + x_min: Math.min(...routePoints.map((point) => point.x)), + x_max: Math.max(...routePoints.map((point) => point.x)), + y_min: Math.min(...routePoints.map((point) => point.y)), + y_max: Math.max(...routePoints.map((point) => point.y)), + }; + // 도엽 정규 좌표(0~1, 위가 0)로 모아 잰다 — 노선(m)과 유역(경위도)을 한 자로 본다. + const spanX = meta.width_meters || 1; + const spanY = meta.height_meters || 1; + const centerX = ((route.x_min + route.x_max) / 2 - meta.x_min) / spanX; + const centerY = 1 - ((route.y_min + route.y_max) / 2 - meta.y_min) / spanY; + const halfW = ((route.x_max - route.x_min) / spanX) * (factor / 2); + const halfH = ((route.y_max - route.y_min) / spanY) * (factor / 2); + let x0 = centerX - halfW; + let x1 = centerX + halfW; + let y0 = centerY - halfH; + let y1 = centerY + halfH; + const normalizer = createNormalizer(meta); + basins.forEach((basin) => + basin.polygon_lonlat.forEach(([lon, lat]) => { + const nx = (lon - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + x0 = Math.min(x0, nx); + x1 = Math.max(x1, nx); + y0 = Math.min(y0, ny); + y1 = Math.max(y1, ny); + }), + ); + // 배율 s 에서 화면에 드는 정규 폭 = 화면 폭 / (지도 사각형 폭 · s). + const mapRect = computeMapRect(meta, width, height); + const fit = Math.min( + width / (mapRect.width * Math.max(x1 - x0, 1e-9)), + height / (mapRect.height * Math.max(y1 - y0, 1e-9)), + ); + // 처음 보기(노선 + 여유 200m)는 늘 한계 안 — 짧은 노선에서 처음 화면보다 좁아지지 않게. + const initial = computeRouteView(meta, route, width, height).scale; + return Math.max(1, Math.min(fit, initial)); +} + +/** 휠 한 번의 새 배율 — 한계 안에서 멈춘다. 이미 한계 밖이면 더 빠지지만 않게(튀지 않게) 둔다. */ +export function clampWheelScale( + next: number, + previous: number, + minScale: number, + maxScale: number, +): number { + return Math.min(maxScale, Math.max(Math.min(minScale, previous), next)); +} diff --git a/resources/tester/helper_281_1_drainage_zoom_range.cjs b/resources/tester/helper_281_1_drainage_zoom_range.cjs new file mode 100644 index 000000000..8d066aab7 --- /dev/null +++ b/resources/tester/helper_281_1_drainage_zoom_range.cjs @@ -0,0 +1,130 @@ +/* 281-1 배수유역 지도 줌아웃 한계 헬퍼 — `B05_Profile_UI_Drainage_ZoomRange.ts` 를 + * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 낸다. + * `.cjs` 까닭은 `helper_b05_curve_radius_floor.cjs` 와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +/** TS 한 장을 CommonJS 로 옮겨 실행한다. `@ui/` 는 색 함수만 쓰므로 빈 값으로 갈음한다. */ +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@ui/")) return { themeColor: () => "" }; + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const zoom = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_ZoomRange.ts")); +const render = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapRender.ts")); +const { computeZoomOutLimit, clampWheelScale, ZOOM_OUT_ROUTE_FACTOR, ZOOM_OUT_FALLBACK_SCALE } = + zoom; + +// 도엽 4000 × 3000 m — 경위도는 1 도 = 1000 m 로 단순하게 둔다. +const META = { + x_min: 0, + x_max: 4000, + y_min: 0, + y_max: 3000, + width_meters: 4000, + height_meters: 3000, + lon_min: 127, + lon_max: 131, + lat_min: 36, + lat_max: 39, +}; +const W = 800; +const H = 600; // 도엽과 같은 비율 — 배율 1 = 화면 폭 4000 m +const toLonLat = (x, y) => [127 + x / 1000, 36 + y / 1000]; +/** 배율 s 에서 화면 폭에 드는 실폭(m). */ +const viewWidthM = (s) => 4000 / s; + +// 노선 1000 × 500 m (가운데 2000, 1500) — 2 배 = 2000 × 1000 m. +const ROUTE = [ + { x: 1500, y: 1250 }, + { x: 2500, y: 1750 }, +]; +const routeOnly = computeZoomOutLimit(META, ROUTE, [], W, H); + +// 유역이 노선 2 배 범위 밖(서쪽 400 m 까지)으로 나감 → 폭 2600 m. +const basinOut = computeZoomOutLimit( + META, + ROUTE, + [{ polygon_lonlat: [toLonLat(400, 1500), toLonLat(1500, 1400), toLonLat(1500, 1600)] }], + W, + H, +); +// 유역이 2 배 범위 안 → 한계 그대로. +const basinIn = computeZoomOutLimit( + META, + ROUTE, + [{ polygon_lonlat: [toLonLat(1800, 1400), toLonLat(2200, 1400), toLonLat(2000, 1600)] }], + W, + H, +); + +// 노선이 도엽만큼 길면 2 배가 도엽보다 큼 → 도엽 전체(배율 1)에서 멈춤. +const wide = computeZoomOutLimit( + META, + [ + { x: 100, y: 100 }, + { x: 3900, y: 2900 }, + ], + [], + W, + H, +); + +// 짧은 노선(100 m) — 2 배(200 m)가 처음 보기(노선 + 200 m 여유 양쪽)보다 좁음 → 처음 보기에 맞춤. +const SHORT = [ + { x: 1950, y: 1500 }, + { x: 2050, y: 1500 }, +]; +const short = computeZoomOutLimit(META, SHORT, [], W, H); +const shortInitial = render.computeRouteView( + META, + { x_min: 1950, x_max: 2050, y_min: 1500, y_max: 1500 }, + W, + H, +).scale; + +// 휠 — 밀기(×0.87)를 계속하면 한계에서 멈춤 · 당기기(×1.15)는 상한에서 멈춤. +let scale = 3; +for (let i = 0; i < 40; i += 1) scale = clampWheelScale(scale * 0.87, scale, routeOnly, 200); +const wheelOutStop = scale; +for (let i = 0; i < 80; i += 1) scale = clampWheelScale(scale * 1.15, scale, routeOnly, 200); +const wheelInStop = scale; +// 이미 한계 밖(더 멀리)에 있으면 밀어도 더 안 빠지고 튀지도 않음. +const outside = clampWheelScale(1.2 * 0.87, 1.2, routeOnly, 200); + +process.stdout.write( + JSON.stringify({ + factor: ZOOM_OUT_ROUTE_FACTOR, + fallbackConst: ZOOM_OUT_FALLBACK_SCALE, + routeOnly, + routeOnlyViewM: viewWidthM(routeOnly), + basinOutViewM: viewWidthM(basinOut), + basinIn, + noRoute: computeZoomOutLimit(META, [], [], W, H), + noMeta: computeZoomOutLimit(null, ROUTE, [], W, H), + wide, + short, + shortInitial, + wheelOutStop, + wheelInStop, + outside, + }), +); diff --git a/resources/tester/test_281_1_drainage_zoom_range.py b/resources/tester/test_281_1_drainage_zoom_range.py new file mode 100644 index 000000000..2895de0f5 --- /dev/null +++ b/resources/tester/test_281_1_drainage_zoom_range.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""281 B05 · B06 아래 종단 패널 배수유역 지도 — 줌아웃 한계 = 노선 기준. + +- 한계 = 노선 범위 2 배가 화면에 드는 배율 · 유역이 밖이면 유역까지 · 도엽 전체(배율 1)보다 멀리 안 감 +- 노선이 없으면 종전 0.5 · 처음 보기(노선 + 여유)보다 좁히지 않음 +- 휠은 한계 · 상한에서 멈춤 · 이미 한계 밖이면 더 안 빠지고 튀지도 않음 + +Node 헬퍼(`helper_281_1_drainage_zoom_range.cjs`)가 TS 를 그 자리에서 돌린다. +도엽 4000 × 3000 m · 화면 800 × 600 — 배율 1 = 화면 폭 4000 m. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_281_1_drainage_zoom_range.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_상수는_한_곳(r): + assert r["factor"] == 2 + assert r["fallbackConst"] == 0.5 + + +def test_노선만_2배가_화면에(r): + """노선 1000 × 500 m → 2 배 2000 × 1000 m · 폭이 이김 → 화면 폭 2000 m(배율 2).""" + assert r["routeOnly"] == pytest.approx(2.0) + assert r["routeOnlyViewM"] == pytest.approx(2000) + + +def test_유역이_밖이면_유역까지(r): + """유역 서쪽 끝 400 m → 범위 400 ~ 3000 m = 폭 2600 m.""" + assert r["basinOutViewM"] == pytest.approx(2600) + + +def test_유역이_안이면_그대로(r): + assert r["basinIn"] == pytest.approx(r["routeOnly"]) + + +def test_노선_없음은_종전(r): + assert r["noRoute"] == 0.5 + assert r["noMeta"] == 0.5 + + +def test_도엽보다_큰_범위는_도엽_전체에서_멈춤(r): + assert r["wide"] == pytest.approx(1.0) + + +def test_짧은_노선은_처음_보기보다_좁히지_않음(r): + assert r["short"] == pytest.approx(r["shortInitial"]) + + +def test_휠이_한계_안에서_멈춤(r): + assert r["wheelOutStop"] == pytest.approx(r["routeOnly"]) + assert r["wheelInStop"] == pytest.approx(200) + assert r["outside"] == pytest.approx(1.2) + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_휠과_창_크기가_한계를_씀(): + interact = _read("B05_Profile/B05_Profile_UI_Drainage_Interact.ts") + assert "Math.max(0.5" not in interact + assert "params.getMinScale()" in interact + panel = _read("B05_Profile/B05_Profile_UI_Drainage_Panel.ts") + assert "getMinScale: zoomOutLimit" in panel + assert "scale < zoomOutLimit() * 0.999) fitToRoute()" in panel + assert len(panel.splitlines()) <= 700 + + +def test_B04_공용은_그대로(): + """B04 지도 · 노선 편집 지도는 0.5 하한을 그대로 씀(공용 함수 안 바꿈).""" + render = _read("B04_PreProcess/B04_PreProcess_UI_MapRender.ts") + assert "ZoomRange" not in render