From 58a9b3e8b780888ac78434d724f6d3c1de75100b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:28:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EA=B5=AC=EA=B0=84=20=EC=9E=AC?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EC=B2=AB=20=EC=A0=90=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=B8=B0=EC=96=B5=20=C2=B7=20=EB=91=98=EC=A7=B8=20?= =?UTF-8?q?=EC=A0=90=20=C2=B7=20=EC=BB=A4=EC=84=9C=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EB=8A=94=20=EA=B7=B8=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=EC=97=90=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 점을 찍은 노선(계획 · 예상)만 둘째 점 후보 — 겹친 다른 노선은 무시 · 첫 점이 노선 밖이면 전과 같음 - 첫 점 뒤 커서 미리보기(둘째 점 · 구간 덧그림) 추가 — 첫 점 노선 위에만 - 길이 = 노선 따라 잰 누가거리 차(전과 같음) - 검증 시나리오 ui_checks/44-1 · 44-1b Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../B05_Profile_UI_RouteEdit_Measure.ts | 37 ++++++-- .../B05_Profile_UI_RouteEdit_Pointer.ts | 8 +- .../ui_checks/44-1_구간재기_노선기억.js | 82 +++++++++++++++++ .../tester/ui_checks/44-1b_구간재기_화면.js | 88 +++++++++++++++++++ 4 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 resources/tester/ui_checks/44-1_구간재기_노선기억.js create mode 100644 resources/tester/ui_checks/44-1b_구간재기_화면.js diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index b7cffb06d..33b9467c9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -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; /** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 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 => 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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index a3eb75eb0..ac87885c4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -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 = diff --git a/resources/tester/ui_checks/44-1_구간재기_노선기억.js b/resources/tester/ui_checks/44-1_구간재기_노선기억.js new file mode 100644 index 000000000..a0a9f779b --- /dev/null +++ b/resources/tester/ui_checks/44-1_구간재기_노선기억.js @@ -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); +})(); diff --git a/resources/tester/ui_checks/44-1b_구간재기_화면.js b/resources/tester/ui_checks/44-1b_구간재기_화면.js new file mode 100644 index 000000000..095380fc1 --- /dev/null +++ b/resources/tester/ui_checks/44-1b_구간재기_화면.js @@ -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); +})();