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:
@@ -166,6 +166,7 @@ export async function openRouteEditModal(
|
||||
measureControls.sync();
|
||||
draw();
|
||||
},
|
||||
onHint: () => measureControls.sync(),
|
||||
});
|
||||
/** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */
|
||||
const measureControls = bindMeasureControls({
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
|
||||
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
|
||||
* 끄는 동안에는 한 번도 부르지 않는다.
|
||||
*
|
||||
* 첫 점을 찍으면 그 노선 땅 높이를 일정 간격으로 **한 번에** 받아 둔다(계획서 48-1). 커서를
|
||||
* 움직이는 동안 길이 · 지반고 · 기울기는 이 표에서 보간해 바로 보이고 서버는 안 부른다.
|
||||
* 둘째 점을 누르면 전처럼 두 점만 다시 물어 그 값으로 고정한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
|
||||
@@ -23,6 +27,10 @@ export type MeasureLine = "planned" | "expected";
|
||||
|
||||
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
|
||||
const MEASURE_HIT_PX = 14;
|
||||
/** 커서 미리보기용 땅 높이 표 간격(m). */
|
||||
const PROFILE_STEP_M = 2;
|
||||
/** 한 번에 묻는 점 수 상한 — 긴 노선은 간격을 넓힌다. */
|
||||
const PROFILE_MAX_POINTS = 3000;
|
||||
|
||||
interface MeasurePoint extends RoutePointHit {
|
||||
line: MeasureLine;
|
||||
@@ -41,6 +49,69 @@ export interface MeasureToolParams {
|
||||
isClosed: () => boolean;
|
||||
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
|
||||
onChange: () => void;
|
||||
/** 커서 미리보기 값이 바뀌었다 — 작은 창 글만 다시 적는다(그리기는 포인터 몫). */
|
||||
onHint?: () => void;
|
||||
}
|
||||
|
||||
/** 첫 점 노선의 땅 높이 표 — 누가거리 `chainages[i]` 자리의 지반고 `z[i]`. */
|
||||
interface LineProfile {
|
||||
/** 표를 만든 노선 — 편집으로 노선이 바뀌면 배열이 새로 생겨 낡은 표를 알아챈다. */
|
||||
vertices: Vertex[];
|
||||
stepM: number;
|
||||
chainages: number[];
|
||||
/** 받는 중이면 null. */
|
||||
z: Array<number | null> | null;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** 노선 전체 길이(m). */
|
||||
function lineLength(vertices: Vertex[]): number {
|
||||
let total = 0;
|
||||
for (let index = 0; index < vertices.length - 1; index += 1) {
|
||||
total += Math.hypot(
|
||||
vertices[index + 1][0] - vertices[index][0],
|
||||
vertices[index + 1][1] - vertices[index][1],
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 노선을 따라 `stepM` 마다 한 점(끝점 포함) — 점과 그 누가거리. */
|
||||
function sampleAlong(vertices: Vertex[], stepM: number): { points: Vertex[]; chainages: number[] } {
|
||||
const points: Vertex[] = [];
|
||||
const chainages: number[] = [];
|
||||
let travelled = 0;
|
||||
let next = 0;
|
||||
for (let index = 0; index < vertices.length - 1; index += 1) {
|
||||
const [ax, ay] = vertices[index];
|
||||
const [bx, by] = vertices[index + 1];
|
||||
const segmentM = Math.hypot(bx - ax, by - ay);
|
||||
while (segmentM > 0 && next <= travelled + segmentM) {
|
||||
const t = (next - travelled) / segmentM;
|
||||
points.push([ax + (bx - ax) * t, ay + (by - ay) * t]);
|
||||
chainages.push(next);
|
||||
next += stepM;
|
||||
}
|
||||
travelled += segmentM;
|
||||
}
|
||||
if (points.length && travelled - chainages[chainages.length - 1] > 1e-6) {
|
||||
points.push(vertices[vertices.length - 1]);
|
||||
chainages.push(travelled);
|
||||
}
|
||||
return { points, chainages };
|
||||
}
|
||||
|
||||
/** 표에서 누가거리 자리의 지반고를 선형 보간 — 양옆 중 하나라도 모르면 null. */
|
||||
function profileZ(profile: LineProfile, chainageM: number): number | null {
|
||||
const { chainages, z } = profile;
|
||||
if (!z || chainages.length === 0) return null;
|
||||
if (chainages.length === 1) return z[0];
|
||||
const index = Math.max(0, Math.min(chainages.length - 2, Math.floor(chainageM / profile.stepM)));
|
||||
const [c0, c1] = [chainages[index], chainages[index + 1]];
|
||||
const [z0, z1] = [z[index], z[index + 1]];
|
||||
if (z0 === null || z1 === null) return null;
|
||||
const t = c1 > c0 ? Math.max(0, Math.min(1, (chainageM - c0) / (c1 - c0))) : 0;
|
||||
return z0 + (z1 - z0) * t;
|
||||
}
|
||||
|
||||
export interface MeasureMark {
|
||||
@@ -114,27 +185,86 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
let picked: MeasurePoint[] = [];
|
||||
/** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */
|
||||
let hover: MeasureMark | null = null;
|
||||
/** 첫 점 노선의 땅 높이 표 — 커서 미리보기 값의 바탕. */
|
||||
let profile: LineProfile | null = null;
|
||||
/** 두 점을 찍고 그 자리 지반고를 묻는 중 — 답이 오기 전에 「못 읽음」으로 보이지 않게. */
|
||||
let fixing = false;
|
||||
|
||||
/** 첫 점 노선 땅 높이를 한 번에 받아 둔다. 같은 노선 표가 살아 있으면 다시 안 묻는다. */
|
||||
const loadProfile = (line: MeasureLine): void => {
|
||||
const vertices = params.lines()[line];
|
||||
if (profile && profile.vertices === vertices) return;
|
||||
const stepM = Math.max(PROFILE_STEP_M, lineLength(vertices) / PROFILE_MAX_POINTS);
|
||||
const { points, chainages } = sampleAlong(vertices, stepM);
|
||||
const asked: LineProfile = { vertices, stepM, chainages, z: null, failed: !points.length };
|
||||
profile = asked;
|
||||
if (!points.length) return;
|
||||
fetchRouteElevations(params.projectId, points)
|
||||
.then((heights) => {
|
||||
asked.z = chainages.map((_, index) => heights[index] ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
asked.failed = true;
|
||||
})
|
||||
.finally(() => {
|
||||
if (params.isClosed() || profile !== asked) return; // 그 사이 다른 노선을 짚었으면 버린다.
|
||||
params.onHint?.();
|
||||
});
|
||||
};
|
||||
|
||||
/** 구간 한 줄 — 두 점(누가거리 · 지반고)에서 길이 · 지반고 · 종단기울기. */
|
||||
const spanText = (
|
||||
name: string,
|
||||
a: { chainageM: number; z: number | null },
|
||||
b: { chainageM: number; z: number | null },
|
||||
missing: string,
|
||||
): string => {
|
||||
const span = Math.abs(b.chainageM - a.chainageM);
|
||||
const head =
|
||||
`${name}구간 ${formatStation(a.chainageM, params.stationIntervalM)} → ` +
|
||||
`${formatStation(b.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
|
||||
if (a.z === null || b.z === null || span <= 1e-6) return `${head} · ${missing}`;
|
||||
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
|
||||
const rise = b.z - a.z;
|
||||
return (
|
||||
`${head} · 지반고 ${a.z.toFixed(1)} → ${b.z.toFixed(1)}m` +
|
||||
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
|
||||
);
|
||||
};
|
||||
|
||||
/** 커서 미리보기 한 줄 — 받아 둔 높이 표에서 보간한 값(누르면 두 점만 다시 물어 고정). */
|
||||
const previewText = (first: MeasurePoint, at: MeasureMark): string => {
|
||||
const table =
|
||||
profile !== null && profile.vertices === params.lines()[first.line] ? profile : null;
|
||||
const zOf = (chainageM: number): number | null => (table ? profileZ(table, chainageM) : null);
|
||||
const missing = !table
|
||||
? "지반고 읽는 중…"
|
||||
: table.failed
|
||||
? "지반고를 못 읽어 기울기는 못 냅니다."
|
||||
: table.z
|
||||
? "지표면 밖이라 기울기는 못 냅니다."
|
||||
: "지반고 읽는 중…";
|
||||
const text = spanText(
|
||||
LINE_NAME[first.line],
|
||||
{ chainageM: first.chainageM, z: zOf(first.chainageM) },
|
||||
{ chainageM: at.chainageM, z: zOf(at.chainageM) },
|
||||
missing,
|
||||
);
|
||||
return `${text} · 누르면 고정`;
|
||||
};
|
||||
|
||||
const hint = (): string => {
|
||||
if (picked.length === 0) return "";
|
||||
const first = picked[0];
|
||||
const name = LINE_NAME[first.line];
|
||||
if (picked.length === 1) {
|
||||
return `${name}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
if (hover) return previewText(first, hover);
|
||||
return `${LINE_NAME[first.line]}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
}
|
||||
const second = picked[1];
|
||||
const span = Math.abs(second.chainageM - first.chainageM);
|
||||
const head =
|
||||
`${name}구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
|
||||
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
|
||||
if (first.z === null || second.z === null || span <= 1e-6) {
|
||||
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
|
||||
}
|
||||
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
|
||||
const rise = second.z - first.z;
|
||||
return (
|
||||
`${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` +
|
||||
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
|
||||
return spanText(
|
||||
LINE_NAME[first.line],
|
||||
first,
|
||||
picked[1],
|
||||
fixing ? "지반고 읽는 중…" : "지반고를 못 읽어 기울기는 못 냅니다.",
|
||||
);
|
||||
};
|
||||
|
||||
@@ -171,10 +301,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
const next = picked.length === 1 ? hitAt(px, py) : null;
|
||||
if (!next && !hover) return false;
|
||||
hover = next;
|
||||
// 노선을 고친 뒤면 표가 낡았다 — 다시 받는다.
|
||||
if (next) loadProfile(picked[0].line);
|
||||
params.onHint?.();
|
||||
return true;
|
||||
},
|
||||
clear() {
|
||||
hover = null;
|
||||
profile = null;
|
||||
if (picked.length === 0) return;
|
||||
picked = [];
|
||||
params.onChange();
|
||||
@@ -188,8 +322,12 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
return;
|
||||
}
|
||||
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
|
||||
fixing = picked.length === 2;
|
||||
params.onChange();
|
||||
if (picked.length < 2) return;
|
||||
if (picked.length < 2) {
|
||||
loadProfile(hit.line);
|
||||
return;
|
||||
}
|
||||
const asked = picked;
|
||||
try {
|
||||
const heights = await fetchRouteElevations(
|
||||
@@ -202,7 +340,9 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
});
|
||||
} catch {
|
||||
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
|
||||
if (picked !== asked) return;
|
||||
}
|
||||
fixing = false;
|
||||
params.onChange();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user