("routeedit-snap-step", projectId);
+ select.value = saved && STEPS.includes(saved) ? saved : "station";
+ select.addEventListener("change", () => {
+ writeState("routeedit-snap-step", select.value, projectId);
+ onChange(false);
+ });
+ chrome.stationSnapButton.addEventListener("click", () => onChange(true));
+ return () => (select.value === "station" ? stationIntervalM : Number(select.value));
+}
diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
index 9a436bcc2..5baf9863a 100644
--- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
+++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
@@ -321,6 +321,15 @@
display: none;
}
+/* 분석 지형이 숨으면 앞 구분선도(390-1) · 스냅 끔이면 간격 고르기 흐림(390-2). */
+.b05-route__view-separator:has(+ .b05-routeedit__terrain[hidden]) {
+ display: none;
+}
+
+[data-act="station-snap"][aria-pressed="false"] + .b05-routeedit__snap-step {
+ opacity: 0.5;
+}
+
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */
.b05-routeedit__measure {
position: absolute;
diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
index c2e1da9ea..609e1cadb 100644
--- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
+++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
@@ -7,6 +7,7 @@
* 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
+ * · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
* ========================================================================== */
@@ -29,6 +30,9 @@ import { curveSpans } from "./B05_Profile_UI_RouteEdit_Frame";
/** MC 가 측점에서 이만큼 안이면 「위」(m) — 파이썬 `MC_TOL_M` 과 같은 값. */
export const MC_TOL_M = 0.05;
+/** 간격별 허용 오차 — 0.1 m 간격도 실제로 맞게 간격의 1/20 이하(390-2 · 1 m 이상은 `MC_TOL_M`). */
+export const mcTolM = (intervalM: number): number => Math.min(MC_TOL_M, intervalM / 20);
+
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */
export interface FitLimits {
minRadiusM: number;
@@ -66,7 +70,7 @@ export function offStationCount(
for (const [start, end] of spans) {
const mc = (start + end) / 2;
const n = Math.round(mc / intervalM);
- if (n >= 1 && Math.abs(mc - n * intervalM) <= MC_TOL_M) hits.add(n);
+ if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM)) hits.add(n);
}
return spans.length - hits.size;
}
@@ -122,7 +126,7 @@ function solve(
const tried = at(d1);
if (!tried || d1 === d0) return null;
const f1 = tried.mc - target;
- if (Math.abs(f1) <= MC_TOL_M / 5) return tried;
+ if (Math.abs(f1) <= mcTolM(limits.intervalM) / 5) return tried;
const slope = (f1 - f0) / (d1 - d0);
if (Math.abs(slope) < 0.2) return null;
d0 = d1;
@@ -149,7 +153,7 @@ export function fitCurveStations(
const mc = mcOf(built, k);
if (mc === null) continue;
const near = Math.max(lastStation + 1, Math.round(mc / interval));
- const onStation = Math.abs(mc - near * interval) <= MC_TOL_M;
+ const onStation = Math.abs(mc - near * interval) <= mcTolM(interval);
const fitted =
onStation || k < from || cols.apexLock[k] || Math.abs(near * interval - mc) > interval / 2
? null
@@ -162,7 +166,7 @@ export function fitCurveStations(
) &&
(previous < 0 ||
Math.abs((mcOf(fitted.built, previous) ?? Infinity) - (mcOf(built, previous) ?? 0)) <=
- MC_TOL_M / 5);
+ mcTolM(interval) / 5);
if (fitted && keep) ({ planned, built } = fitted);
if (onStation || keep) lastStation = near;
previous = k;
diff --git a/resources/tester/helper_366_3_station_fit.cjs b/resources/tester/helper_366_3_station_fit.cjs
index 37e872e41..ca331318d 100644
--- a/resources/tester/helper_366_3_station_fit.cjs
+++ b/resources/tester/helper_366_3_station_fit.cjs
@@ -56,7 +56,7 @@ function columns(planned, lock = []) {
};
}
-function measure(planned) {
+function measure(planned, interval = INTERVAL) {
const built = curve.buildEditedPolyline(
planned,
planned.map(() => true),
@@ -74,9 +74,9 @@ function measure(planned) {
return {
curves: built.curves.length,
mcs,
- residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / INTERVAL) * INTERVAL)),
+ residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / interval) * interval)),
innerStations: total.filter((s) => s > 0 && s < length).length,
- off: fit.offStationCount(built.vertices, built.curves, INTERVAL),
+ off: fit.offStationCount(built.vertices, built.curves, interval),
};
}
@@ -96,6 +96,15 @@ console.log(
fromLater: { ...measure(fromLater), moved: moved(ROUTE, fromLater) },
everyLocked: { ...measure(everyLocked), moved: moved(ROUTE, everyLocked) },
again: moved(all, fit.fitCurveStations(columns(all), LIMITS, 1)),
+ // 390-2 스냅 간격 — 0.1 · 1 · 10 m 배수 · 측점 밖 개수도 같은 간격
+ steps: Object.fromEntries(
+ [0.1, 1, 10].map((step) => {
+ const fitted = fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: step }, 1);
+ const offBefore = measure(ROUTE, step).off;
+ return [step, { offBefore, ...measure(fitted, step), moved: moved(ROUTE, fitted) }];
+ }),
+ ),
+ tol: [0.1, 1, 10, 20].map((step) => fit.mcTolM(step)),
noInterval: moved(ROUTE, fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: 0 }, 1)),
start: {
moved4: fit.fitStart(
diff --git a/resources/tester/helper_b05_routeedit_view.cjs b/resources/tester/helper_b05_routeedit_view.cjs
index f42d35c7f..5380bd918 100644
--- a/resources/tester/helper_b05_routeedit_view.cjs
+++ b/resources/tester/helper_b05_routeedit_view.cjs
@@ -165,8 +165,51 @@ const afterInsert = JSON.parse(JSON.stringify(columns));
edits.spliceNode(columns, 1);
const afterRemove = JSON.parse(JSON.stringify(columns));
+// ⑤ Ctrl+휠 = 5° 돌리기(390-3) · 손가락 · 트랙패드 벌림 · 맨 휠 = 확대 · rotateWheel 없으면(추천 지도) 확대
+const input = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Input.ts"));
+const rotateMod = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Rotate.ts"));
+function wheelCase(withRotate, event) {
+ const handlers = {};
+ const canvas = {
+ addEventListener: (name, fn) => (handlers[name] = fn),
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
+ };
+ let wheelView = {
+ width: 400,
+ height: 300,
+ scale: 1,
+ offsetX: 0,
+ offsetY: 0,
+ mapRect: { width: 400 },
+ };
+ let changes = 0;
+ const rotation = rotateMod.createMapRotation({
+ overlay: { querySelector: () => null },
+ size: () => wheelView,
+ onChange: () => (changes += 1),
+ });
+ input.bindRouteEditNavigation({
+ canvas,
+ getView: () => wheelView,
+ setView: (next) => (wheelView = next),
+ getMeta: () => null,
+ rotateWheel: withRotate ? rotation.wheel : undefined,
+ draw: () => {},
+ });
+ handlers.wheel({ clientX: 200, clientY: 150, preventDefault: () => {}, ...event });
+ return { deg: (rotation.radians() * 180) / Math.PI, scale: wheelView.scale, changes };
+}
+const wheel = {
+ ctrlDown: wheelCase(true, { ctrlKey: true, deltaY: 100 }),
+ ctrlUp: wheelCase(true, { ctrlKey: true, deltaY: -100 }),
+ trackpad: wheelCase(true, { ctrlKey: true, deltaY: -4 }),
+ plain: wheelCase(true, { ctrlKey: false, deltaY: -100 }),
+ recommend: wheelCase(false, { ctrlKey: true, deltaY: 100 }),
+};
+
process.stdout.write(
JSON.stringify({
+ wheel,
saved,
again,
readBack,
diff --git a/resources/tester/test_366_3_station_fit.py b/resources/tester/test_366_3_station_fit.py
index d816fcbef..c8dedb95b 100644
--- a/resources/tester/test_366_3_station_fit.py
+++ b/resources/tester/test_366_3_station_fit.py
@@ -65,6 +65,21 @@ def test_맞춘_선은_다시_맞춰도_그대로_간격_0_은_안_맞춤(got) -
assert max(got["again"]) == 0 and max(got["noInterval"]) == 0
+@pytest.mark.parametrize("step", ["0.1", "1", "10"])
+def test_스냅_간격_배수에_맞춤(got, step) -> None:
+ """390-2 — MC 를 고른 간격 배수에 · 측점 밖 개수도 같은 간격 · 밀림은 간격 절반 안."""
+ size = float(step)
+ case = got["steps"][step]
+ assert case["offBefore"] == 5 and case["off"] == 0 and case["curves"] == 5
+ assert max(case["residues"]) <= min(TOL, size / 20)
+ assert max(case["moved"]) <= size / 2 + 1e-6
+ assert case["moved"][0] == 0 and case["moved"][-1] == 0
+
+
+def test_허용_오차는_간격_따라(got) -> None:
+ assert got["tol"] == pytest.approx([0.005, 0.05, 0.05, 0.05])
+
+
def test_시작_자리는_고친_꺾임점_앞(got) -> None:
start = got["start"]
assert start["moved4"] == 3 and start["radius2"] == 1
diff --git a/resources/tester/test_381_2_routeedit_marks.py b/resources/tester/test_381_2_routeedit_marks.py
index 0ed430cd4..e3b8b67c3 100644
--- a/resources/tester/test_381_2_routeedit_marks.py
+++ b/resources/tester/test_381_2_routeedit_marks.py
@@ -1,6 +1,6 @@
"""381-2 B05 평면 [측점] [기울기] 켬끔(요구 241-11).
-- 단추 = 절/성토선 왼쪽 · 켬끔 모듈(기본 켬 · 페이지 동안)
+- 단추 = 절/성토선 왼쪽 · 켬끔 모듈(기본 [측점] 켬 · [기울기] 끔(390-1 요구 242-17) · 페이지 동안)
- [측점] 끔 = 눈금 · 번호 안 그림 · 눈금 집기 안 함(라벨 0개 = 전부 집힘 함정) · 시점 · 종점 이름표 남김
- [기울기] 끔 = 기울기 라벨(높이 묻기) 안 부름
"""
@@ -14,9 +14,9 @@ def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
-def test_켬끔_모듈_기본_켬():
+def test_켬끔_모듈_기본_측점_켬_기울기_끔():
marks = _read("B05_Profile_UI_RouteEdit_Marks.ts")
- assert "{ stations: true, slopes: true }" in marks
+ assert "{ stations: true, slopes: false }" in marks
assert "scheduleRedraw();" in marks
assert "export function scheduleRedraw" in _read("B05_Profile_UI_RouteEdit_Footprint.ts")
diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py
index 9b1b1429a..9eb2fefb2 100644
--- a/resources/tester/test_b05_routeedit_initial.py
+++ b/resources/tester/test_b05_routeedit_initial.py
@@ -208,7 +208,7 @@ def test_노선_추천_다듬기_23_3():
return open(os.path.join(page, name), encoding="utf-8").read()
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
- # 366-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형(맨 오른쪽)
+ # 366-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 분석 지형(맨 오른쪽)
bar = chrome[
chrome.index("b05-routeedit__tools") : chrome.index('')
]
@@ -229,14 +229,20 @@ def test_노선_추천_다듬기_23_3():
'data-act="measure"',
"|",
'data-act="station-snap"',
- 'class="b05-routeedit__terrain"',
+ 'data-act="snap-step"', # 390-2 스냅 간격 = [측점 스냅] 오른쪽
+ "|", # 390-1 [측점 스냅] | 분석 지형
+ 'class="b05-routeedit__terrain" hidden',
]
at = 0
for mark in order:
key = "b05-route__view-separator" if mark == "|" else mark
at = bar.index(key, at) + 1
- assert bar.count("b05-route__view-separator") == 4
- assert chrome.count("b05-route__view-separator") == 4
+ assert bar.count("b05-route__view-separator") == 5
+ assert chrome.count("b05-route__view-separator") == 5
+ # 분석 지형이 숨으면 앞 구분선도 숨김 · 스냅 끔이면 간격 고르기 흐림
+ style = read("B05_Profile_UI_Style_RouteEdit.css")
+ assert ".b05-route__view-separator:has(+ .b05-routeedit__terrain[hidden])" in style
+ assert '[data-act="station-snap"][aria-pressed="false"] + .b05-routeedit__snap-step' in style
assert "b05-routeedit__spin" not in chrome # 회전 = 도구줄(추천 모달 CSS 는 남음)
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
diff --git a/resources/tester/test_b05_routeedit_view.py b/resources/tester/test_b05_routeedit_view.py
index b3ddbfc5b..289d64137 100644
--- a/resources/tester/test_b05_routeedit_view.py
+++ b/resources/tester/test_b05_routeedit_view.py
@@ -84,3 +84,13 @@ def test_숨김_크기_0_뒤_다시_보이면_그대로(results):
assert back["cx"] == pytest.approx(kept["cx"], abs=1e-6)
assert back["cy"] == pytest.approx(kept["cy"], abs=1e-6)
assert back["pxPerMeter"] == pytest.approx(kept["pxPerMeter"], rel=1e-9)
+
+
+def test_Ctrl_휠은_5도_돌리기_벌림과_맨_휠은_확대(results):
+ """390-3 — 마우스 Ctrl+휠 한 칸 = 5° · 트랙패드 벌림 · 맨 휠 = 확대 · 추천 지도(rotateWheel 없음) = 확대."""
+ w = results["wheel"]
+ assert w["ctrlDown"] == {"deg": pytest.approx(5), "scale": 1, "changes": 1}
+ assert w["ctrlUp"]["deg"] == pytest.approx(-5) and w["ctrlUp"]["scale"] == 1
+ for case in ("trackpad", "plain", "recommend"):
+ assert w[case]["deg"] == 0 and w[case]["changes"] == 0, case
+ assert w[case]["scale"] != 1, case