feat(b08): 거리이정표 선 연결 = 연결 점 끌기 (250-3)

- 거래처 상자 ● 를 끌어 기준점 = 기준점 거침 · 공사지 = 시점으로 바로
- 바뀌면 그 구간 km 비움 + 길찾기 한 번 · 등록 모달 「기준점 거침」 체크 없앰

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 00:17:46 +09:00
co-authored by Claude Opus 5.5
parent 46db8e56c3
commit e012a09cf2
4 changed files with 108 additions and 8 deletions
@@ -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;
}
@@ -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<SVGGElement>("[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);
@@ -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,
);
@@ -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