Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-12 19:16:26 +09:00
5 changed files with 323 additions and 3 deletions
@@ -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;
+9 -2
View File
@@ -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