feat(B05): 계획노선 편집 구간 재기 — 첫 점 뒤 커서 자리 길이 · 지반고 · 종단기울기 실시간 표시

- 첫 점을 찍을 때 그 노선 땅 높이를 2m 간격(최대 3000점)으로 한 번에 받아 둠 — 커서 이동 중 서버 부르지 않음
- 둘째 점을 누르면 전처럼 두 점만 다시 물어 고정 · 묻는 동안 「지반고 읽는 중」
- 노선을 고치면 높이 표를 다시 받음
- 검증 시나리오 ui_checks/48-1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-05 13:57:30 +09:00
co-authored by Claude Opus 5.5
parent bad2e6cb6d
commit 2faec5fb14
3 changed files with 246 additions and 16 deletions
@@ -0,0 +1,89 @@
/* 48-1 · B05 계획노선 편집 구간 재기 — 첫 점 뒤 커서 자리 값 실시간 표시
* 시작 화면: B05 종단설계 · [계획노선 편집] 창이 떠 있음(노선이 있는 프로젝트 · 저장 안 함)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: 격자(40px)로 계획노선 자리 둘(a · b)을 찾고 → a 를 찍음 → b 로 커서 이동 → 높이 표가
* 올 때까지 기다림 → 커서를 b 근처에서 여러 번 움직이며 서버 부름 수를 셈 → b 를 찍어 고정 값과 비교
* 기대 수치: hover 글에 「길이」·「종단기울기」·「누르면 고정」 · 표가 온 뒤 커서 이동 중 /route/elevations
* 부름 0 · 고정 글의 길이 = 미리보기 길이 · 기울기 차 ≤ 0.5%p(보간 vs 두 점 정밀 조회)
* · 끝나면 잰 것 지우고 재기 단추 끔
*/
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const o = document.querySelector(".b05-routeedit");
const c = o.querySelector("canvas");
const box = o.querySelector(".b05-routeedit__measure");
const text = o.querySelector(".b05-routeedit__measure-text");
const btn = o.querySelector('[data-act="measure"]');
const close = o.querySelector(".b05-routeedit__measure-close");
const r = c.getBoundingClientRect();
const fire = (type, x, y) =>
c.dispatchEvent(
new PointerEvent(type, {
button: 0,
clientX: x,
clientY: y,
pointerId: 1,
pointerType: "mouse",
bubbles: true,
}),
);
const ev = (type, x, y) => {
fire(type, x, y);
if (type === "pointerdown") fire("pointerup", x, y);
};
let calls = 0;
const realFetch = window.fetch;
window.fetch = (...args) => {
if (String(args[0]).includes("/route/elevations")) calls += 1;
return realFetch(...args);
};
const out = {};
try {
if (!btn.classList.contains("is-active")) btn.click();
const planned = [];
for (let y = r.top + 20; y < r.bottom - 20 && planned.length < 40; y += 40)
for (let x = r.left + 20; x < r.right - 20; x += 40) {
ev("pointerdown", x, y);
if (/시작/.test(text.textContent) && !/^예상노선/.test(text.textContent))
planned.push([x, y]);
if (!box.hidden) close.click();
if (!btn.classList.contains("is-active")) btn.click();
}
const a = planned[0];
const b = planned.find((p) => Math.hypot(p[0] - a[0], p[1] - a[1]) > 200);
out.found = planned.length;
if (!a || !b) return JSON.stringify({ ...out, error: "자리 못 찾음" });
ev("pointerdown", ...a);
ev("pointermove", ...b);
out.hoverFirst = text.textContent;
for (let i = 0; i < 150 && !/종단기울기|못 냅니다/.test(text.textContent); i += 1)
await wait(100);
out.hover = text.textContent;
const before = calls;
for (let i = 0; i < 10; i += 1) {
ev("pointermove", b[0] + (i % 3) - 1, b[1] + (i % 2));
await wait(30);
}
ev("pointermove", ...b);
out.hoverCallsWhileMoving = calls - before;
out.hoverAgain = text.textContent;
ev("pointerdown", ...b);
for (let i = 0; i < 150 && !/종단기울기|못 냅니다/.test(text.textContent); i += 1)
await wait(100);
out.final = text.textContent;
const len = (s) => (s.match(/길이 ([\d.]+)m/) || [])[1];
const grade = (s) => Number((s.match(/종단기울기 (-?[\d.]+)%/) || [])[1]);
out.pass =
/길이/.test(out.hoverAgain) &&
/종단기울기/.test(out.hoverAgain) &&
/누르면 고정/.test(out.hoverAgain) &&
out.hoverCallsWhileMoving === 0 &&
len(out.hoverAgain) === len(out.final) &&
Math.abs(grade(out.hoverAgain) - grade(out.final)) <= 0.5;
} finally {
window.fetch = realFetch;
if (!box.hidden) close.click();
if (btn.classList.contains("is-active")) btn.click();
}
return JSON.stringify(out);
})();