feat(B05): 계획노선 편집 구간 재기 — 첫 점 노선 기억 · 둘째 점 · 커서 미리보기는 그 노선에만

- 첫 점을 찍은 노선(계획 · 예상)만 둘째 점 후보 — 겹친 다른 노선은 무시 · 첫 점이 노선 밖이면 전과 같음
- 첫 점 뒤 커서 미리보기(둘째 점 · 구간 덧그림) 추가 — 첫 점 노선 위에만
- 길이 = 노선 따라 잰 누가거리 차(전과 같음)
- 검증 시나리오 ui_checks/44-1 · 44-1b

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-05 11:28:10 +09:00
co-authored by Claude Opus 5.5
parent 80667d8555
commit 58a9b3e8b7
4 changed files with 205 additions and 10 deletions
@@ -4,7 +4,8 @@
*
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
* (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다.
* (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만
* 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차.
*
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
@@ -56,6 +57,8 @@ export interface MeasureTool {
hint: () => string;
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
active: () => boolean;
/** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */
preview: (px: number, py: number) => boolean;
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
@@ -109,6 +112,8 @@ export function bindMeasureControls(params: MeasureControlsParams): {
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
/** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */
let hover: MeasureMark | null = null;
const hint = (): string => {
if (picked.length === 0) return "";
@@ -133,10 +138,12 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
);
};
/** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */
/** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */
const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => {
const lines = params.lines();
const hits = (["planned", "expected"] as const)
const candidates: MeasureLine[] =
picked.length === 1 ? [picked[0].line] : ["planned", "expected"];
const hits = candidates
.map((line) => {
const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) return null;
@@ -145,30 +152,42 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
})
.filter((hit): hit is NonNullable<typeof hit> => hit !== null);
if (!hits.length) return null;
const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined;
const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a));
const best = hits.reduce((a, b) => (b.distance < a.distance ? b : a));
return { point: best.point, chainageM: best.chainageM, line: best.line };
};
return {
marks: () =>
picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })),
marks: () => [
...picked.map((entry) => ({
point: entry.point,
chainageM: entry.chainageM,
line: entry.line,
})),
...(picked.length === 1 && hover ? [hover] : []),
],
hint,
active: () => picked.length > 0,
preview(px, py) {
const next = picked.length === 1 ? hitAt(px, py) : null;
if (!next && !hover) return false;
hover = next;
return true;
},
clear() {
hover = null;
if (picked.length === 0) return;
picked = [];
params.onChange();
},
async pick(px, py) {
const hit = hitAt(px, py);
hover = null;
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line);
picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
@@ -71,7 +71,11 @@ export interface RouteEditPointerParams {
/** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */
unrotate: (x: number, y: number) => [number, number];
contourStepM: () => number;
measure: { pick: (px: number, py: number) => unknown; clear: () => void };
measure: {
pick: (px: number, py: number) => unknown;
preview: (px: number, py: number) => boolean;
clear: () => void;
};
/** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */
measureMode: () => boolean;
crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown };
@@ -287,6 +291,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
params.draw();
return;
}
// 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다.
if ((event.shiftKey || params.measureMode()) && params.measure.preview(px, py)) params.draw();
const overNode = nodeAt(px, py);
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
@@ -0,0 +1,82 @@
/* 44-1 · B05 계획노선 편집 구간 재기 — 첫 점 노선 기억(둘째 점 · 커서 미리보기는 그 노선에만)
* 시작 화면: 아무 화면(localhost vite 포트) — 실제 `_RouteEdit_Measure.ts` 를 vite 로 불러 가짜 두 노선에 돌림
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 가짜 노선(화면 = 미터): 계획 = (0,0)→(100,0)→(100,100) 꺾은선 · 예상 = (0,6)→(200,6) 곧은선(계획 위 6px 에 겹침)
* 기대 수치:
* - overlap.second = "planned" · overlap.len = "100.0"(계획 첫 점 (10,0) → 꺾임 지나 (100,10) · 노선 따라 잰 값)
* - otherOnly.marks = 0(계획 첫 점 뒤 예상노선만 닿는 자리 (150,6) = 빗나감 · 예상으로 새로 시작 안 함)
* - previewOther.marks = 1(예상만 닿는 자리 미리보기 없음) · previewOwn = [2, "planned", 50](계획 위 미리보기)
* - expectedFirst.second = "expected" · len = "60.0"(첫 점이 예상이면 계획이 더 가까워도 예상에 붙음)
* - firstMiss.marks = 0(첫 점이 노선 밖이면 지금처럼 아무것도 안 찍힘)
*/
(async () => {
// vite root = A00_Common — 저장소 뿌리는 main.ts 가 옮겨 적은 /@fs/ 주소에서 얻음
const repo = (await (await fetch("/main.ts")).text()).match(
/\/@fs\/[^"']*?\/(?=config\/config_frontend)/,
)[0];
const { createMeasureTool } = await import(
`${repo}B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts`
);
const lines = {
planned: [
[0, 0],
[100, 0],
[100, 100],
],
expected: [
[0, 6],
[200, 6],
],
};
const make = () =>
createMeasureTool({
projectId: "ui-check-none",
stationIntervalM: 20,
lines: () => lines,
toScreen: (v) => [v[0], v[1]],
isClosed: () => true, // 지반고 응답은 버림 — 길이만 본다
onChange: () => {},
});
const out = {};
const lenOf = (tool) => (tool.hint().match(/길이 ([\d.]+)m/) || [])[1] ?? null;
let tool = make();
await tool.pick(10, 1); // 계획 (1px) · 예상 (5px) 둘 다 닿음 → 가까운 계획
await tool.pick(100, 10); // 계획 꺾인 다리(0px) 위 · 예상도 4px
out.overlap = { second: tool.marks()[1]?.line, len: lenOf(tool) };
tool = make();
await tool.pick(10, 1);
await tool.pick(150, 6); // 예상만 닿음
out.otherOnly = { marks: tool.marks().length };
tool = make();
await tool.pick(10, 1);
tool.preview(150, 6);
out.previewOther = { marks: tool.marks().length };
tool.preview(50, 4); // 예상(2px)이 더 가까워도 계획(4px)에 붙어야 함
const m = tool.marks();
out.previewOwn = [m.length, m[1]?.line, m[1]?.point[0]];
tool = make();
await tool.pick(10, 7); // 예상(1px)이 가까움 → 예상
await tool.pick(70, 1); // 계획(1px)이 가까워도 예상(5px)
out.expectedFirst = { second: tool.marks()[1]?.line, len: lenOf(tool) };
tool = make();
await tool.pick(50, 60); // 노선 밖
out.firstMiss = { marks: tool.marks().length };
out.pass =
out.overlap.second === "planned" &&
out.overlap.len === "100.0" &&
out.otherOnly.marks === 0 &&
out.previewOther.marks === 1 &&
out.previewOwn[0] === 2 &&
out.previewOwn[1] === "planned" &&
out.previewOwn[2] === 50 &&
out.expectedFirst.second === "expected" &&
out.expectedFirst.len === "60.0" &&
out.firstMiss.marks === 0;
return JSON.stringify(out);
})();
@@ -0,0 +1,88 @@
/* 44-1b · B05 계획노선 편집 구간 재기 — 실제 화면 배선(찍기 · 커서 미리보기)
* 시작 화면: B05 종단설계 · [계획노선 편집] 창이 떠 있음(노선이 있는 프로젝트 · 저장 안 함)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: 격자(20px)로 찍어 계획 · 예상 노선 위 자리를 모은 뒤
* 계획 a 를 찍고 → 먼 계획 자리로 커서 이동(미리보기 초록 픽셀 늘어남)
* → 계획에서 먼 예상 자리로 커서 이동(미리보기 사라짐 = a 만 남은 초록 픽셀)
* → 그 예상 자리를 찍음(빗나감 또는 계획에 붙음 · 예상노선으로 안 바뀜) → 계획 a · b 를 찍음(「구간 … 길이」)
* 기대 수치: previewOn > previewBase · previewOff = previewBase · afterOther.text 가 「예상노선」으로 시작 안 함
* · final 에 「길이」 있고 「예상노선」 없음 · 끝나면 잰 것 지우고 재기 단추 끔
*/
(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();
// pointerType 이 비면 지도 끌기(pan)가 잡힘 — mouse 로 · 누르면 바로 뗌
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);
};
const green = () => {
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let n = 0;
for (let i = 0; i < d.length; i += 4)
if (d[i] === 34 && d[i + 1] === 197 && d[i + 2] === 94) n++;
return n;
};
if (!btn.classList.contains("is-active")) btn.click();
const hits = { planned: [], expected: [] };
for (let y = r.top + 20; y < r.bottom - 20; y += 20)
for (let x = r.left + 20; x < r.right - 20; x += 20) {
ev("pointerdown", x, y);
if (/시작/.test(text.textContent))
hits[/^예상노선/.test(text.textContent) ? "expected" : "planned"].push([x, y]);
if (!box.hidden) close.click(); // 닫기 = 지움 · 재기 모드도 꺼짐
if (!btn.classList.contains("is-active")) btn.click();
}
const far = (p, list, d) => list.every((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) > d);
const a = hits.planned[0];
const b = hits.planned.find((p) => far(p, [a], 150));
// 계획 자리에서 가장 먼 예상 자리 — 계획노선이 14px 밖이어야 「다른 노선만 닿는 자리」
const gap = (p) => Math.min(...hits.planned.map((q) => Math.hypot(p[0] - q[0], p[1] - q[1])));
const other = hits.expected.reduce((best, p) => (!best || gap(p) > gap(best) ? p : best), null);
const out = { found: { planned: hits.planned.length, expected: hits.expected.length } };
out.otherGapPx = other && Math.round(gap(other));
if (!a || !b || !other) return JSON.stringify({ ...out, error: "자리 못 찾음" });
ev("pointerdown", ...a);
await wait(100);
out.previewBase = green();
ev("pointermove", ...b);
await wait(100);
out.previewOn = green();
ev("pointermove", ...other);
await wait(100);
out.previewOff = green();
ev("pointerdown", ...other);
await wait(50);
out.afterOther = { hidden: box.hidden, text: text.textContent };
ev("pointerdown", ...a);
out.finalA = text.textContent;
ev("pointerdown", ...b);
await wait(50);
out.final = text.textContent;
if (!box.hidden) close.click();
if (btn.classList.contains("is-active")) btn.click();
out.pass =
out.previewOn > out.previewBase &&
out.previewOff === out.previewBase &&
!/^예상노선/.test(out.afterOther.text) &&
/길이/.test(out.final) &&
!/예상노선/.test(out.final);
return JSON.stringify(out);
})();