From be9341029975da6ea662961a940af5b96061f718 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 19:45:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=E2=80=94=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A4=EC=8B=9C=ED=95=98=EA=B8=B0=EA=B0=80=20?= =?UTF-8?q?=E3=80=8C=ED=98=84=EC=9E=AC=20=EC=B6=94=EC=B2=9C=E3=80=8D=20?= =?UTF-8?q?=EA=B8=80=EC=9D=84=20=ED=95=A8=EA=BB=98=20=EB=90=98=EC=82=B4?= =?UTF-8?q?=EB=A6=BC(=EA=B1=B8=EC=9D=8C=EB=A7=88=EB=8B=A4=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=EC=9D=84=20=EC=8B=A4=EC=9D=8C)=20(PLAN=2023-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 18 +++++++++++++++--- .../B05_Profile_UI_RouteEdit_History.ts | 3 +++ .../B05_Profile_UI_RouteEdit_Initial.ts | 10 ++++++++++ .../tester/helper_b05_routeedit_initial.cjs | 18 ++++++++++++++++++ resources/tester/test_b05_routeedit_initial.py | 10 ++++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 90c2c7ce..6e77cbd6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -368,7 +368,16 @@ export async function openRouteEditModal( /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ function snapshotNow(): RouteEditSnapshot { - return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked }; + return { + planned, + curveOn, + curveRadius, + curveLock, + curveArc, + apexLock, + picked, + rec: initial.tag(), + }; } /** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */ @@ -380,7 +389,10 @@ export async function openRouteEditModal( const historyControls = bindHistoryControls({ overlay, getHistory: () => history, - restore: (snapshot, message) => restoreSnapshot(snapshot, message, false), + restore: (snapshot, message) => { + initial.restoreTag(snapshot.rec); // 「현재 추천」도 그 걸음 것으로 — 노선과 짝. + restoreSnapshot(snapshot, message, false); + }, }); // ── 조작 — 노드 끌기 · 집기는 `_Pointer` 몫 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── @@ -494,9 +506,9 @@ export async function openRouteEditModal( ({ nodes: nodeInfo, curves: curveInfo } = flat); picked = -1; // 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로. + initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null); history = createRouteEditHistory(snapshotNow()); historyControls.sync(); - initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index 6dfcfeb7..fda74f07 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -24,6 +24,8 @@ export interface RouteEditSnapshot { /** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */ apexLock: boolean[]; picked: number; + /** 이 걸음의 「현재 추천」 — `_Initial` 이 적고 되돌릴 때 되살린다(뜻은 그쪽만 안다). 노선과 짝. */ + rec?: unknown; } /** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */ @@ -54,6 +56,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { curveArc: [...snapshot.curveArc], apexLock: [...snapshot.apexLock], picked: snapshot.picked, + rec: snapshot.rec, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 2545db0e..eaa7f8e4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -57,6 +57,9 @@ export interface InitialPanel { weights: RouteInitialWeights | null; criteria: RouteInitialCriteria | null; }; + /** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */ + tag: () => InitialChoice | null; + restoreTag: (tag: unknown) => void; /** [취소] — 이 창에서 바꾼 추천을 창을 열 때 것으로 되돌린다. */ cancel: () => void; /** [확인] 뒤 결과 캐시를 비웠다 — 추천은 새 노선의 출발점이라 다시 적는다. */ @@ -199,6 +202,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { const criteria = choice ? choice.criteriaOverrides : base.criteria; return { mode: now.logic, weights: now.weights, criteria }; }, + tag: () => choice, + restoreTag(tag) { + if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다. + choice = tag as InitialChoice | null; + writeInitialChoice(projectId, choice); + render(); + }, cancel: () => writeInitialChoice(projectId, openingChoice), keep: () => writeInitialChoice(projectId, choice), }; diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index cb6ee6e3..f74dc95d 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -238,6 +238,23 @@ const mapLines = { oneDot: model.mapPaths([{ kind: "preview", points: [[5, 5]] }], 460, 340), }; +// ⑦ 되돌리기 · 다시하기가 걸음마다 실은 「현재 추천」을 되살린다(노선과 짝). +const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_History.ts")); +const step = (x, rec) => ({ + planned: [[x, 0], [x + 1, 1]], + curveOn: [true, true], + curveRadius: [null, null], + curveLock: [null, null], + curveArc: [null, null], + apexLock: [false, false], + picked: -1, + rec, +}); +const trail = history.createRouteEditHistory(step(0, { logic: "follow" })); +trail.commit(step(1, { logic: "earthwork" })); +trail.commit(step(2, null)); +const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map((one) => one.rec); + // ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다. model.writeInitialChoice("p-1", plain); const readBack = model.readInitialChoice("p-1"); @@ -278,5 +295,6 @@ process.stdout.write( sameAsServer, initialRoute, mapLines, + historyRecs, }), ); diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index 5b79e2e4..43a2adbd 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -182,3 +182,13 @@ def test_도구_막대는_노선_추천_하나만(): ).read() for name in ("follow", "earthwork", "gentle", "weighted"): assert f'"{name}"' in recommend + + +def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results): + """걸음마다 「현재 추천」이 노선과 짝 — 추천 없음(null)도 그대로 되돌아온다.""" + assert results["historyRecs"] == [ + {"logic": "earthwork"}, + {"logic": "follow"}, + {"logic": "earthwork"}, + None, + ] From ea4cb043cd2dff11fc2f3b4d54a2ea6c101c88f6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 19:46:46 +0900 Subject: [PATCH 2/3] =?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=EC=B0=BD=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EC=8B=9C=EC=A0=90=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EC=A0=90=20=EA=B3=A0=EC=A0=95(PLAN=2022-4=20=C2=B7=20?= =?UTF-8?q?22-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고정 한 벌 — 시점 · 종점(기본 고정) · 교각점을 한 칸 줄로 봄 · 저장 · 읽기는 _RouteEdit_Lock.ts(readRouteLocks = 초기노선 계산에 넘길 한 곳) - 곧은 선은 양 끝 · 호는 교각점과 앞뒤 점이 모두 고정일 때 고정 색(--map-route-locked · 밝음 #db2777 · 어두움 #f472b6) · 고정 점도 같은 색 - 시점 · 종점을 누르면 고정 단추만 뜸 · 풀면 끌어 옮김 · 고정이면 끌기 · 지우기 막힘 - 되돌리기 · [취소] · 다시 열기 · [확인] 뒤에도 고정 유지(pref 통 · 좌표로 되짚음) · [확인] 몸통은 그대로 - 시험 test_b05_routeedit_lock 5건 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- A00_Common/b_page_state.ts | 2 + B05_Profile/B05_Profile_UI_RouteEdit.ts | 23 +++- .../B05_Profile_UI_RouteEdit_CurveBar.ts | 45 ++++++-- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 19 +++- B05_Profile/B05_Profile_UI_RouteEdit_Lock.ts | 84 ++++++++++++++ .../B05_Profile_UI_RouteEdit_Render.ts | 64 ++++++++++- .../B05_Profile_UI_Style_RouteEdit.css | 5 + .../tester/helper_b05_routeedit_lock.cjs | 106 ++++++++++++++++++ resources/tester/test_b05_routeedit_lock.py | 56 +++++++++ ui_template/ui_template_theme.css | 3 + 10 files changed, 387 insertions(+), 20 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Lock.ts create mode 100644 resources/tester/helper_b05_routeedit_lock.cjs create mode 100644 resources/tester/test_b05_routeedit_lock.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index b7bbeb64..b00cea81 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -62,6 +62,8 @@ export const STATE_REGISTRY = { "m02-combo-panel-width": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, + /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ + "routeedit-locks": { bucket: "pref", scope: "project" }, "masshaul-open": { bucket: "pref", scope: "global", diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 90c2c7ce..2e4c96df 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -52,6 +52,12 @@ import { viewPxPerMeter, } from "./B05_Profile_UI_RouteEdit_View"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; +import { + lockColumn, + readRouteLocks, + routeLocksOf, + writeRouteLocks, +} from "./B05_Profile_UI_RouteEdit_Lock"; import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial"; import { applyArcLocks, @@ -118,8 +124,9 @@ export async function openRouteEditModal( * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ let curveLock: CurveLock[] = []; let curveArc: Array = []; - /** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도 - * 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */ + /** 자리를 못 박은 노드(2026-09-12 사용자 지시 ① · 계획서 22-5) — 0번 = 시점, 끝 칸 = 종점 + * (기본 고정), 사이 = 교각점. 켜진 자리는 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. + * 고정 한 벌의 저장 · 읽기는 `_Lock` 몫. */ let apexLock: boolean[] = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; @@ -275,6 +282,7 @@ export async function openRouteEditModal( nodeInfo, curveInfo, curveOn, + locked: apexLock, picked, stationIntervalM, }); @@ -322,7 +330,7 @@ export async function openRouteEditModal( curveOn, curveRadius, curveLock, - apexLock, + apexLock: apexLock.slice(1, -1), // 시점 · 종점은 기본 고정이라 「고정 N곳」에 안 센다. curveCount: curveInfo.length, violationCount: nodeInfo.filter((node) => node.violations.length).length, minRadiusM, @@ -359,6 +367,7 @@ export async function openRouteEditModal( /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ function applyEdit(message: string, record = true): void { markEdited(); + writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); status.textContent = `${routeHead()} — ${message} ${curveHint()}`; draw(); @@ -373,7 +382,10 @@ export async function openRouteEditModal( /** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */ function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void { + // 초기노선 싣기는 새 노선이다 — 고정은 좌표로 다시 짚는다. 되돌리기는 그 사진의 고정 그대로. + const locks = record ? routeLocksOf(planned, apexLock) : null; ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot); + if (locks) apexLock = lockColumn(planned, locks); applyEdit(message, record); } @@ -443,6 +455,8 @@ export async function openRouteEditModal( }, remove: (index) => { if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); + if (apexLock[index]) + return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error"); spliceNode(snapshotNow(), index); picked = -1; applyEdit("노드를 지웠습니다(직선 삭제)."); @@ -490,7 +504,8 @@ export async function openRouteEditModal( limitArcM = plan.limit_curve_length_m ?? 0; // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); - ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat); + ({ planned, curveOn, curveRadius, curveLock, curveArc } = flat); + apexLock = lockColumn(planned, readRouteLocks(projectId)); // 닫기 전 고정을 좌표로 되살린다. ({ nodes: nodeInfo, curves: curveInfo } = flat); picked = -1; // 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index bd0ba920..ccbcbead 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -100,11 +100,12 @@ export function createCurveBar(params: CurveBarParams): CurveBar { ); }, onApexLock: (locked) => { - const { picked, apexLock } = params.state(); + const { picked, apexLock, planned } = params.state(); if (picked < 0) return; apexLock[picked] = locked; + const name = picked === 0 ? "시점" : picked === planned.length - 1 ? "종점" : "교각점"; // 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다. - params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다."); + params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`); }, onCurveOn: (on) => { const { picked, curveOn } = params.state(); @@ -114,7 +115,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { }, }); - /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */ function sync(): void { const { picked, @@ -128,16 +129,43 @@ export function createCurveBar(params: CurveBarParams): CurveBar { limitRadiusM, limitArcM, } = params.state(); - if (!(picked > 0 && picked < planned.length - 1)) { + if (!(picked >= 0 && picked < planned.length)) { label.hide(); return; } + const rect = params.canvas.getBoundingClientRect(); + const bounds = { + left: rect.left + 8, + top: rect.top + 8, + right: rect.right - 8, + bottom: rect.bottom - 8, + }; + const last = planned.length - 1; + if (picked === 0 || picked === last) { + const [ex, ey] = params.toScreen(planned[picked]); + const [nx, ny] = params.toScreen(planned[picked === 0 ? 1 : last - 1]); + label.show({ + seat: picked, + endpoint: picked === 0 ? "start" : "end", + at: [ex + rect.left, ey + rect.top], + bounds, + // 노선에 직각으로 비켜 붙인다 — 노선 바깥쪽에는 시점 · 종점 이름표가 있다. + centerDirection: [-(ny - ey), nx - ex], + curveOn: false, + radiusShown: null, + arcLengthShown: null, + lock: null, + apexLocked: apexLock[picked] === true, + innerAngleDeg: null, + curveOptional: false, + }); + return; + } const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null; const optional = curveOptionalAt(innerAngle); const deflection = deflectionRad(innerAngle); - const rect = params.canvas.getBoundingClientRect(); const [screenX, screenY] = params.toScreen(planned[picked]); label.show({ seat: picked, @@ -145,12 +173,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { at: [screenX + rect.left, screenY + rect.top], // 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을 // 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨). - bounds: { - left: rect.left + 8, - top: rect.top + 8, - right: rect.right - 8, - bottom: rect.bottom - 8, - }, + bounds, centerDirection: pickedCurve ? centerDirectionOf( params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index c71990e8..60269cd9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -57,6 +57,8 @@ const COMPASS_STEPS = 16; export interface CurveLabelState { /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ seat: number; + /** 시점 · 종점을 골랐나 — 곡선 칸을 숨기고 **고정 단추만** 낸다(계획서 22-5). */ + endpoint?: "start" | "end" | null; /** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */ at: [number, number]; /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */ @@ -135,9 +137,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { -
교각점 - +
교각점 +
`; document.body.append(root); @@ -151,6 +152,9 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { const lockArc = root.querySelector('[data-act="lock-arc"]')!; const lockApex = root.querySelector('[data-act="lock-apex"]')!; const info = root.querySelector(".b05-routeedit__curve-info")!; + const lockName = root.querySelector(".b05-routeedit__lock-name")!; + /** 끝점에서는 숨길 곡선 칸 — 곡선 지우기 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */ + const curveOnly = [toggle, radius.closest("label")!, arc.closest("label")!, info]; // 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) { @@ -280,7 +284,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { radius.min = limitRadius > 0 ? String(limitRadius) : "1"; arc.min = limitArc > 0 ? String(limitArc) : "1"; root.hidden = false; - seatText.textContent = `${state.seat + 1}번째 꺾임점`; + const end = state.endpoint ?? null; + const name = end === "start" ? "시점" : end === "end" ? "종점" : "교각점"; + curveOnly.forEach((element) => (element.hidden = end !== null)); + seatText.textContent = end ? name : `${state.seat + 1}번째 꺾임점`; + lockName.textContent = name; + lockApex.title = end + ? `${name} 고정 — 켜면 끌어 옮길 수 없습니다. 풀면 끌어 옮깁니다` + : "교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다"; toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; radius.disabled = !state.curveOn; arc.disabled = !state.curveOn; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Lock.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Lock.ts new file mode 100644 index 00000000..6af49db9 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Lock.ts @@ -0,0 +1,84 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Lock.ts + * 계획노선 편집 창의 **고정 한 벌** — 시점 · 종점과 교각점 자리(계획서 22-4 · 22-5). + * + * 모달은 노드마다 한 칸씩 `apexLock` 을 쥔다 — 0번 = 시점, 끝 칸 = 종점, 사이 = 교각점. + * 시점 · 종점은 **기본 고정**이다. 켜진 자리는 고르기만 되고 안 끌린다(`_Pointer`). + * + * 창을 닫아도([취소] · [확인] 포함) 고정은 남는다 — 화면 상태 보관소의 pref 통(프로젝트 칸)에 + * 바로 적고, 다시 열 때 서버 노드에 **좌표로** 다시 짚는다(노드 번호는 편집마다 밀린다). + * draft 통에 두지 않는다 — [저장] 대상이 아닌데 미저장 표시가 켜지고 [저장] 뒤 지워진다. + * 초기노선 계산(POST /route/initial)에 넘길 때도 `readRouteLocks` 한 곳만 읽는다. + * DOM 을 안 만진다 — 시험이 Node 로 돈다. + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; + +type Vertex = [number, number]; + +/** 고정 한 벌. `apex` 는 고정한 교각점 좌표(사업지 좌표계 m). */ +export interface RouteLocks { + start: boolean; + end: boolean; + apex: Vertex[]; +} + +/** 같은 교각점으로 볼 거리(m) — 서버가 되돌려 준 좌표의 끝자리 어긋남만 흡수한다. */ +const SAME_POINT_M = 0.05; + +const isVertex = (value: unknown): value is Vertex => + Array.isArray(value) && value.length >= 2 && value.every((n) => Number.isFinite(n)); + +/** 저장된 고정 — 없거나 깨졌으면 시점 · 종점만 고정. */ +export function readRouteLocks(projectId: string): RouteLocks { + const saved = readState>("routeedit-locks", projectId); + return { + start: saved?.start !== false, + end: saved?.end !== false, + apex: Array.isArray(saved?.apex) ? saved.apex.filter(isVertex) : [], + }; +} + +/** 노드 칸 → 고정 한 벌. */ +export function routeLocksOf( + planned: ReadonlyArray, + locked: ReadonlyArray, +): RouteLocks { + const last = planned.length - 1; + return { + start: locked[0] === true, + end: locked[last] === true, + apex: planned + .filter((_, index) => index > 0 && index < last && locked[index] === true) + .map(([x, y]) => [x, y]), + }; +} + +export function writeRouteLocks( + projectId: string, + planned: ReadonlyArray, + locked: ReadonlyArray, +): void { + if (planned.length < 2) return; // 노선을 못 읽은 채 닫으면 적어 둔 고정을 지우지 않는다. + writeState("routeedit-locks", routeLocksOf(planned, locked), projectId); +} + +/** 고정 한 벌 → 노드 칸. 교각점은 좌표가 가까운 자리만 켠다. */ +export function lockColumn(planned: ReadonlyArray, locks: RouteLocks): boolean[] { + const last = planned.length - 1; + return planned.map(([x, y], index) => + index === 0 + ? locks.start + : index === last + ? locks.end + : locks.apex.some(([ax, ay]) => Math.hypot(ax - x, ay - y) <= SAME_POINT_M), + ); +} + +/** 곧은 선(노드 `index` → `index + 1`)은 **양 끝이 모두** 고정일 때 고정. */ +export const straightLocked = (locked: ReadonlyArray, index: number): boolean => + locked[index] === true && locked[index + 1] === true; + +/** 호(교각점 `index`)는 **그 교각점과 앞뒤 점이 모두** 고정일 때 고정. */ +export const arcLocked = (locked: ReadonlyArray, index: number): boolean => + locked[index - 1] === true && locked[index] === true && locked[index + 1] === true; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 7e603d50..9b4a1b0d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -27,9 +27,12 @@ import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross"; import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame"; import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure"; import { stationLabel as formatStation } from "../common_util/common_util_station"; +import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock"; /** 노드 반지름(px). */ const NODE_R = 4; +/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 조금 굵게 덧그려 밑의 주황이 비치지 않게. */ +const LOCKED_WIDTH = 3; /** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). * * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 @@ -82,6 +85,8 @@ export interface RouteEditScene { nodeInfo: ReadonlyArray; curveInfo: ReadonlyArray; curveOn: ReadonlyArray; + /** 자리를 못 박은 노드 — 0번 = 시점, 끝 칸 = 종점(계획서 22-4 · 22-5). */ + locked: ReadonlyArray; /** 지금 고른 꺾임점. 없으면 -1. */ picked: number; /** 규칙 측점 간격(m). */ @@ -203,6 +208,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou style.getPropertyValue("--map-route") || "#f97316", 2.4, ); + const lockedColor = style.getPropertyValue("--map-route-locked") || "#db2777"; + strokeLockedRoute(context, scene, line, lockedColor); context.save(); context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; @@ -212,9 +219,12 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou const [x, y] = toScreen(vertex); // 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정). const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0; + // 고정한 점은 고정 색 — 기준 미달(붉음)이 먼저다. context.fillStyle = bad ? style.getPropertyValue("--color-danger") || "#dc2626" - : style.getPropertyValue("--map-route") || "#f97316"; + : scene.locked[index] + ? lockedColor + : style.getPropertyValue("--map-route") || "#f97316"; context.beginPath(); context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); context.fill(); @@ -503,6 +513,58 @@ function endLabel( context.restore(); } +/** + * 고정한 선 · 호를 고정 색으로 덧그린다(계획서 22-4). 곧은 선은 양 끝 노드가, 호는 그 교각점과 + * 앞뒤 노드가 모두 고정일 때다. 곧은 선은 앞뒤 곡선의 접선점 사이, 호는 그려진 노선에서 + * 접선점 사이 정점을 잘라 쓴다 — 노드를 끄는 동안에도 네트워크 없이 따라간다. + */ +function strokeLockedRoute( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, + color: string, +): void { + const { planned, locked } = scene; + if (!locked.some(Boolean)) return; + const curveAt = new Map( + scene.curveInfo + .filter((curve) => scene.curveOn[curve.node_first] !== false) + .map((curve) => [curve.node_first, curve]), + ); + const pieces: Vertex[][] = []; + for (let index = 0; index + 1 < planned.length; index += 1) { + if (!straightLocked(locked, index)) continue; + pieces.push([ + curveAt.get(index)?.end ?? planned[index], + curveAt.get(index + 1)?.start ?? planned[index + 1], + ]); + } + curveAt.forEach((curve, index) => { + if (arcLocked(locked, index)) pieces.push(sliceBetween(line, curve.start, curve.end)); + }); + pieces.forEach((piece) => + strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH), + ); +} + +/** 노선에서 두 점(정점 위)에 가장 가까운 정점 사이를 잘라 낸다. */ +function sliceBetween(line: ReadonlyArray, from: Vertex, to: Vertex): Vertex[] { + const nearest = (point: Vertex): number => { + let best = 0; + line.forEach((vertex, index) => { + if ( + Math.hypot(vertex[0] - point[0], vertex[1] - point[1]) < + Math.hypot(line[best][0] - point[0], line[best][1] - point[1]) + ) { + best = index; + } + }); + return best; + }; + const [low, high] = [nearest(from), nearest(to)].sort((a, b) => a - b); + return line.slice(low, high + 1); +} + function strokePolyline( context: CanvasRenderingContext2D, toScreen: (vertex: Vertex) => [number, number], diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 6200db5e..63bfe297 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -435,6 +435,11 @@ font-size: var(--text-caption); } +/* 시점 · 종점을 고르면 곡선 칸을 숨긴다 — 줄마다 `display: flex` 라 `hidden` 이 안 먹는다. */ +.b05-routeedit__label [hidden] { + display: none; +} + /* 머리는 **잡아 옮기는 자리**다 — 패널이 곡선을 가리면 손으로 치울 수 있어야 한다. */ .b05-routeedit__label-head { display: flex; diff --git a/resources/tester/helper_b05_routeedit_lock.cjs b/resources/tester/helper_b05_routeedit_lock.cjs new file mode 100644 index 00000000..6df7fd97 --- /dev/null +++ b/resources/tester/helper_b05_routeedit_lock.cjs @@ -0,0 +1,106 @@ +/* 계획노선 편집 창 **고정 한 벌**(계획서 22-4 · 22-5) 단위검증 헬퍼 — + * `B05_Profile_UI_RouteEdit_Lock.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다. + * 브라우저 전용인 저장소·팔레트는 가짜로 채운다 — 셈만 본다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +// 브라우저 저장소 흉내 — `b_page_state` 가 읽고 쓰는 자리. +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + _map: map, + }; +} +const local = memoryStorage(); +globalThis.window = { localStorage: local, sessionStorage: memoryStorage() }; +globalThis.fetch = () => Promise.resolve({ ok: false }); + +const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const lock = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Lock.ts")); + +const planned = [ + [0, 0], + [100, 0], + [150, 80], + [260, 90], +]; + +// ① 저장된 것이 없으면 시점 · 종점만 고정. +const fresh = lock.readRouteLocks("p-1"); +const freshColumn = lock.lockColumn(planned, fresh); + +// ② 시점을 풀고 교각점 둘을 고정해 적는다 → 같은 프로젝트로 다시 읽는다. +const edited = [false, true, true, true]; +lock.writeRouteLocks("p-1", planned, edited); +const readBack = lock.readRouteLocks("p-1"); +const otherProject = lock.readRouteLocks("p-2"); +const storedKeys = [...local._map.keys()]; + +// ③ 서버가 끝자리만 조금 다른 좌표로 돌려줘도 같은 교각점 · 멀리 옮긴 점은 풀림. +const nudged = [ + [0, 0], + [100.03, -0.02], + [151, 80], + [260, 90], +]; +const nudgedColumn = lock.lockColumn(nudged, readBack); + +// ④ 선 · 호 고정 판정 — 곧은 선은 양 끝, 호는 교각점과 앞뒤. +const straight = [0, 1, 2].map((index) => lock.straightLocked(edited, index)); +const arc = [1, 2].map((index) => lock.arcLocked(edited, index)); +const arcAll = [1, 2].map((index) => lock.arcLocked([true, true, true, true], index)); + +// ⑤ 노선을 못 읽은(노드 1개 이하) 채 닫으면 적어 둔 고정을 안 지운다 · 깨진 값은 기본값. +lock.writeRouteLocks("p-1", [], []); +const keptAfterEmpty = lock.readRouteLocks("p-1"); +local.setItem("aislo:pref:routeedit-locks:p-3", "{\"start\":false,\"apex\":[[1,\"x\"],[2,3]]}"); +const broken = lock.readRouteLocks("p-3"); + +process.stdout.write( + JSON.stringify({ + fresh, + freshColumn, + readBack, + otherProject, + storedKeys, + nudgedColumn, + straight, + arc, + arcAll, + keptAfterEmpty, + broken, + }), +); diff --git a/resources/tester/test_b05_routeedit_lock.py b/resources/tester/test_b05_routeedit_lock.py new file mode 100644 index 00000000..66ae7528 --- /dev/null +++ b/resources/tester/test_b05_routeedit_lock.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""계획노선 편집 창 — **고정 한 벌**(계획서 22-4 · 22-5) 회귀검증. + +시점 · 종점은 기본 고정 · 교각점 고정은 좌표로 다시 짚음 · 곧은 선은 양 끝 · 호는 교각점과 +앞뒤 점이 모두 고정일 때 고정. 자리는 화면 상태 보관소 pref 통의 프로젝트 칸. + +Node 헬퍼(`helper_b05_routeedit_lock.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_routeedit_lock.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_저장된_것이_없으면_시점_종점만_고정(results): + assert results["fresh"] == {"start": True, "end": True, "apex": []} + assert results["freshColumn"] == [True, False, False, True] + + +def test_저장소_왕복은_프로젝트마다(results): + assert results["readBack"] == {"start": False, "end": True, "apex": [[100, 0], [150, 80]]} + assert results["otherProject"] == {"start": True, "end": True, "apex": []} + assert results["storedKeys"] == ["aislo:pref:routeedit-locks:p-1"] + + +def test_교각점은_좌표로_다시_짚음(results): + # 0.05m 안은 같은 점 · 1m 옮긴 점은 풀림 · 시점 · 종점은 자리와 무관하게 역할로. + assert results["nudgedColumn"] == [False, True, False, True] + + +def test_선과_호_고정_판정(results): + assert results["straight"] == [False, True, True] + assert results["arc"] == [False, True] + assert results["arcAll"] == [True, True] + + +def test_빈_노선은_안_지우고_깨진_값은_기본값(results): + assert results["keptAfterEmpty"] == results["readBack"] + assert results["broken"] == {"start": False, "end": True, "apex": [[2, 3]]} diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 225099e3..cdfd0f12 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -220,6 +220,7 @@ * 배경이 위성사진이라 라이트/다크 공통으로 같은 색을 쓴다. * ------------------------------------------------------------------------ */ --map-route: #f97316; /* 계획선 (B04 지도·B05 배수유역도 공통) */ + --map-route-locked: #db2777; /* 계획노선 편집 — 고정한 선 · 호 · 점 (주황 · 초록 · 연보라 · 하늘색과 구별) */ --map-cadastral: #f97316; --map-sigungu: #7c3aed; --map-eupmyeondong: #22c55e; @@ -320,6 +321,7 @@ --color-warning: #e0b64a; --color-danger: #e8695a; --panel-handle-border: #e0b64a; + --map-route-locked: #f472b6; --shadow-sm: rgba(0, 0, 0, 0.4) 0px 2px 4px 0px, rgba(0, 0, 0, 0.2) 0px 0px 0px 1px; --shadow-lg: rgba(0, 0, 0, 0.5) 0px 32px 24px -12px, rgba(0, 0, 0, 0.3) 0px 3px 3px 0px; @@ -357,6 +359,7 @@ --color-accent: var(--color-lavender-wash); --color-focus-ring: var(--color-lavender-wash); --panel-handle-border: #e0b64a; + --map-route-locked: #f472b6; } } From b492a9195ae04c0d01348bb25c730086071fb29c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 19:53:00 +0900 Subject: [PATCH 3/3] =?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=EC=B0=BD=20=E2=80=94=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=9D=BC=EB=B2=A8=20=EA=B8=80=EC=9E=90=20?= =?UTF-8?q?=C2=B7=20=ED=98=B8=EB=A5=BC=20=EB=88=8C=EB=9F=AC=EB=8F=84=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84(22-2=20=C2=B7=2022-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 누르기(Pointer)에 라벨 상자를 넘기고 눈금 다음에 라벨 · 호 갈래를 더함 - 호를 누르면 교각점을 누른 것과 같은 곡선 · 같은 패널(끌기 없음) - 우선순위 점 · 손잡이 > 눈금 > 라벨 > 호 > 등고선 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 173e4ec4..a3eb75eb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -17,10 +17,12 @@ import { type Vertex, } from "./B05_Profile_UI_RouteEdit_Curve"; import { + arcAtScreen, handleAtScreen, nodeAtScreen, segmentAtScreen, stationAtScreen, + stationLabelBoxes, } from "./B05_Profile_UI_RouteEdit_Input"; import { curveShortfalls, @@ -186,6 +188,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP px, py, STATION_HIT_PX, + stationLabelBoxes(canvas), ); if (chainage === null) return false; void params.crossPreview.open(chainage); @@ -193,6 +196,19 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP })() ) { /* 횡단 창이 떴다 — 더 집지 않는다. */ + } else if ( + // 호(곡선 부분)를 누르면 그 곡선을 고른다 — 교각점을 누른 것과 같다(끌기만 없다). 점·눈금 다음. + ((): boolean => { + const node = arcAtScreen(state.curveInfo, toScreen, px, py); + if (node < 0) return false; + params.setPicked(node); + params.measure.clear(); + params.syncCurveBar(); + params.draw(); + return true; + })() + ) { + /* 곡선을 골랐다 — 등고선은 안 집는다. */ } else if (state.contours) { // 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦). // 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.