feat(b05,b06): 배수유역 지도 줌아웃 한계 = 노선 기준 — 노선 범위 2 배 · 유역 밖이면 유역까지 · 도엽 전체까지 · 창 크기 바뀌어 밖이면 노선 맞춤 (281)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<Pick<DetailBasin, "polygon_lonlat">>,
|
||||
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));
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user