From e012a09cf27d0fa5d36b553a9648e7fae1016e58 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:17:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EC=84=A0=20=EC=97=B0=EA=B2=B0=20=3D=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=A0=90=20=EB=81=8C=EA=B8=B0=20(250-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 거래처 상자 ● 를 끌어 기준점 = 기준점 거침 · 공사지 = 시점으로 바로 - 바뀌면 그 구간 km 비움 + 길찾기 한 번 · 등록 모달 「기준점 거침」 체크 없앰 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_UI_Milestone.css | 25 +++++++ .../B08_Quantity_UI_Milestone_Diagram.ts | 67 ++++++++++++++++++- .../B08_Quantity_UI_Milestone_Modal.ts | 9 +-- .../test_250_1_b08_milestone_roads_width.py | 15 +++++ 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index ac5998531..6592fabb7 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -232,3 +232,28 @@ pointer-events: stroke; cursor: pointer; } + +/* 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 · 공사지에 놓으면 연결 */ +.b08-milestone__connect { + fill: var(--color-success); + stroke: var(--color-surface); + stroke-width: 2; + cursor: crosshair; + opacity: 0.55; +} + +.b08-milestone__box:hover .b08-milestone__connect { + opacity: 1; +} + +.b08-milestone__wire { + stroke: var(--color-success); + stroke-width: 2; + stroke-dasharray: 6 4; + pointer-events: none; +} + +.b08-milestone__box.is-drop-target rect { + stroke-width: 3; + stroke-dasharray: none; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index e0370ed0e..0b755e5cb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -34,8 +34,9 @@ import { type DiagramBox, } from "./B08_Quantity_Milestone_Layout"; import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal"; -import { openPlaceModal, openSiteInfo } from "./B08_Quantity_UI_Milestone_Modal"; +import { openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { + findSegmentKm, liveMilestone, putMilestone, roadOptions, @@ -218,6 +219,69 @@ export function createDiagram( }); }; + /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 기준점 거침 · 공사지(시점 · 중점) = 시점으로 바로 · + * 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + 길찾기 한 번(km · 경로 그림) */ + const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => { + const cx = box.x + box.w; + const cy = box.y + box.h / 2; + const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 7 }); + dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결")); + dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸 + dot.addEventListener("pointerdown", (down) => { + if (down.button !== 0) return; + down.stopPropagation(); // 상자 끌기 · 팬 안 함 + down.preventDefault(); + const toLocal = (e: PointerEvent): [number, number] => { + const m = shift.getScreenCTM(); + if (!m) return [cx, cy]; + const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse()); + return [p.x, p.y]; + }; + const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy }); + shift.append(wire); + let over: Element | null = null; + const targetOf = (e: PointerEvent): "base" | "site" | null => { + const hit = document.elementFromPoint(e.clientX, e.clientY); + const g = hit?.closest("[data-b08-milestone-box]"); + const id = g?.dataset.b08MilestoneBox; + if (id === "base") return "base"; + return id === "site" || id === "start" || id === "mid" ? "site" : null; + }; + const move = (e: PointerEvent): void => { + const [x, y] = toLocal(e); + wire.setAttribute("x2", String(x)); + wire.setAttribute("y2", String(y)); + const t = targetOf(e); + const g = t ? shift.querySelector(`[data-b08-milestone-box="${t}"]`) : null; + if (g !== over) { + over?.classList.remove("is-drop-target"); + g?.classList.add("is-drop-target"); + over = g; + } + }; + const up = (e: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + wire.remove(); + over?.classList.remove("is-drop-target"); + const t = targetOf(e); + const fresh = liveMilestone(projectId); + const route = fresh.routes.find((r) => r.id === box.id); + if (!t || !route) return; + const via = t === "base"; + const now = fresh.base.enabled && route.via_base !== false; + if (via === now) return; // 같은 연결 = 그대로 + route.via_base = via; + route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 + putMilestone(projectId, fresh); + void findSegmentKm(projectId, route.id).then(toastKm); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + return dot; + }; + // 153 [자동 정렬] — 끌어 놓은 자리 모두 지우고 방위 자동 자리로 const arrange = createButton({ label: "자동 정렬", @@ -318,6 +382,7 @@ export function createDiagram( }); g.addEventListener("keydown", (e) => e.key === "Enter" && info()); if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); + if (box.kind === "route") g.append(connectDot(box, shift)); if (box.kind === "site") g.append(plus(box, false)); if (box.kind === "base") g.append(plus(box, true)); shift.append(g); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 5a8f3e891..9b8bdf6b9 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -6,6 +6,7 @@ * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제] * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 + * 연결(기준점 거침 · 시점으로 바로) = 처음 = [+] 누른 상자 쪽 · 그 뒤 = 다이어그램 연결 점 ● 끌기(154 · 체크 없앰) * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 @@ -13,7 +14,6 @@ * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; -import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, createInputField, @@ -130,9 +130,6 @@ export function openPlaceModal( const nameLabel = name.root.querySelector(".ui-field__label"); // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); - const via = createCheckbox("기준점 거침"); - via.input.checked = route?.via_base ?? true; - via.root.hidden = !route || !data.base.enabled; const old = editBase ? data.base : route; const place = placePicker( projectId, @@ -175,11 +172,10 @@ export function openPlaceModal( if (!r) return close(); Object.assign(r, where, { name: text || r.name, - via_base: via.input.checked, rows: rowValues(), }); putMilestone(projectId, fresh); - if (moved(where) || r.via_base !== route.via_base) segment = r.id; + if (moved(where)) segment = r.id; } else { if (!text) return name.setError("거래처 이름을 넣으세요."); const viaBase = target.add && target.viaBase; @@ -219,7 +215,6 @@ export function openPlaceModal( className: "b08-milestone__grid", children: [sort.root, first.item.root, name.root, first.dest.root], }), - via.root, head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), place.root, ); diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index 9a0151aca..e8fa770b8 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -104,3 +104,18 @@ def test_153_끌어_놓은_자리_레이아웃(): ) # fmt: skip assert out.returncode == 0, out.stderr assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]] + + +def test_154_연결_점_끌어_잇기(): + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui + assert 'const via = t === "base";' in ui and "route.via_base = via;" in ui + assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움 + assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번 + assert "if (via === now) return;" in ui # 같은 연결 = 그대로 + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert ( + 'createCheckbox("기준점 거침")' not in modal and "via_base: via.input.checked" not in modal + ) + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + assert ".b08-milestone__connect {" in css and ".b08-milestone__box.is-drop-target rect {" in css