feat(b08): 거리이정표 [요소 추가] · 선 없는 새 거래처 · 연결선 수기 입력 (250-4)

- 공사지 · 기준점 [+] 없앰 → [자동 정렬] 옆 [요소 추가]
- 등록 = 상자만(linked false) · 연결 점으로 이음 · 안 이은 거래처 = 구간 km 안 셈(파이썬 · TS 짝)
- 연결선 모달 [수기 입력] = 카카오 길찾기 연결 끊음(manual)

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:24:53 +09:00
co-authored by Claude Opus 5.5
parent 088bb1289c
commit 2d5d357bf3
13 changed files with 108 additions and 67 deletions
+4 -2
View File
@@ -64,8 +64,10 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list
for route in data.get("routes") or []:
for index, row in enumerate(route.get("rows") or []):
sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0}
via = bool(base.get("enabled")) and route.get("via_base", True) is not False
add_legs(sums, route.get("legs"), "via" if via else "direct")
linked = route.get("linked", True) is not False # 안 이은 거래처 = 구간 km 없음(155)
via = linked and bool(base.get("enabled")) and route.get("via_base", True) is not False
if linked:
add_legs(sums, route.get("legs"), "via" if via else "direct")
if via:
add_legs(sums, base.get("legs"), "base")
if row.get("dest") == "mid":
+8 -2
View File
@@ -83,6 +83,10 @@ export interface MilestoneRoute {
lat?: number | null;
/** 다이어그램에 끌어 놓은 자리(153 · 왼 위 · 그림 좌표) — 없으면 방위 자동 자리 */
pos?: [number, number] | null;
/** 선 이음(155) — false = 아직 안 이음(요소 추가 뒤 · 연결 점으로 이음 · 구간 km 안 셈) · 없음 = 이음(옛 자료) */
linked?: boolean;
/** 수기 입력(155) — 카카오 길찾기 안 함 · km 손 입력만 */
manual?: boolean;
/** 업체 → 기준점(거침) · 시점 구간 */
legs: Leg[];
rows: MilestoneRow[];
@@ -98,6 +102,7 @@ export interface MilestoneData {
lon?: number | null;
lat?: number | null;
pos?: [number, number] | null;
manual?: boolean;
/** 기준점 → 시점 공통 구간 */
legs: Leg[];
};
@@ -153,8 +158,9 @@ export function computeMilestone(
for (const route of data.routes ?? []) {
(route.rows ?? []).forEach((row, index) => {
const sums = { s2: 0, s1: 0, pv: 0, un: 0 };
const via = !!base?.enabled && route.via_base !== false;
addLegs(sums, route.legs, via ? "via" : "direct");
const linked = route.linked !== false; // 안 이은 거래처 = 구간 km 없음(155)
const via = linked && !!base?.enabled && route.via_base !== false;
if (linked) addLegs(sums, route.legs, via ? "via" : "direct");
if (via) addLegs(sums, base.legs, "base");
if (row.dest === "mid") addLegs(sums, [mid], "mid");
const paved = sums.s2 + sums.s1 + sums.pv;
@@ -220,7 +220,7 @@ export function segmentEnds(data: MilestoneData, from: string, start: LonLat): K
? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null }
: null;
const route = data.routes.find((r) => r.id === from);
if (!route) return null;
if (!route || route.linked === false) return null; // 155 안 이은 거래처 = 구간 없음
const viaBase = data.base.enabled && route.via_base;
return {
key: "route",
@@ -506,7 +506,9 @@ export function layoutMilestone(
},
[ux, uy],
);
line(g.id, g.anchor === "base" ? "base" : "start", g.legs);
// 155 안 이은 거래처 = 선 없음(연결 점으로 이음)
if (data.routes.find((r) => r.id === g.id)?.linked !== false)
line(g.id, g.anchor === "base" ? "base" : "start", g.legs);
});
}
@@ -94,12 +94,14 @@ class Place(BaseModel):
lat: float | None = Field(default=None, ge=-90, le=90)
# 153 다이어그램에 끌어 놓은 자리(왼 위 · 그림 좌표) — 없으면 방위 자동 자리
pos: tuple[float, float] | None = None
manual: bool = False # 155 수기 입력 — 카카오 길찾기 안 함
legs: list[Leg] = Field(default_factory=list, max_length=20)
class Route(Place):
id: str = Field(pattern=ROUTE_ID)
via_base: bool = True
linked: bool = True # 155 False = 아직 선 안 이음(요소 추가 뒤)
rows: list[Row] = Field(default_factory=list, max_length=50)
+5 -14
View File
@@ -56,11 +56,13 @@
}
/* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */
/* 153 [자동 정렬] — 다이어그램 왼쪽 위(끌어 놓은 자리가 있을 때만 · 오른쪽 위 = 진행단계 판 자리) */
.b08-milestone__arrange {
/* 153 · 155 [자동 정렬](끌어 놓은 자리 있을 때만) · [요소 추가] — 다이어그램 왼쪽 위(오른쪽 위 = 진행단계 판 자리) */
.b08-milestone__tools {
position: absolute;
top: 8px;
left: 8px;
display: flex;
gap: 6px;
}
.b08-milestone__diagram {
@@ -89,8 +91,7 @@
stroke-width: 1.2;
}
.b08-milestone__box text,
.b08-milestone__plus text {
.b08-milestone__box text {
fill: var(--color-text);
}
@@ -127,16 +128,6 @@
stroke-width: 2;
}
.b08-milestone__plus {
cursor: pointer;
}
.b08-milestone__plus circle {
fill: var(--color-surface);
stroke: var(--color-accent);
stroke-width: 1.5;
}
.b08-milestone__km {
fill: var(--color-text);
font-size: 12px;
@@ -161,25 +161,6 @@ export function createDiagram(
base: () => openPlaceModal(projectId, { add: false, id: "base" }),
};
const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => {
const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 });
const cx = box.x + box.w - 2;
const cy = box.y + 2;
g.append(
svg("circle", { cx, cy, r: 11 }),
svg("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "central" }, "+"),
svg("title", {}, "항목 추가"),
);
g.dataset.b08MilestonePlus = viaBase ? "base" : "site";
const go = (e: Event): void => {
e.stopPropagation();
openPlaceModal(projectId, { add: true, viaBase }, addRoute);
};
g.addEventListener("click", go);
g.addEventListener("keydown", (e) => e.key === "Enter" && go(e));
return g;
};
/** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버)
* 기준점을 옮기면 거친 거래처도 따라감(자리 안 정한 것만 · 레이아웃이 기준점 둘레로 놓음) */
const dragBox = (g: SVGGElement, box: DiagramBox, shift: SVGGElement): void => {
@@ -269,9 +250,10 @@ export function createDiagram(
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;
const now = route.linked === false ? null : fresh.base.enabled && route.via_base !== false;
if (via === now) return; // 같은 연결 = 그대로
route.via_base = via;
route.linked = true; // 155 요소 추가 뒤 첫 이음
route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기
putMilestone(projectId, fresh);
void findSegmentKm(projectId, route.id).then(toastKm);
@@ -295,8 +277,16 @@ export function createDiagram(
putMilestone(projectId, fresh);
},
});
arrange.classList.add("b08-milestone__arrange");
arrange.dataset.b08MilestoneArrange = "";
// 155 [요소 추가] — 기준점 · 거래처 등록(공사지 · 기준점 [+] 없앰) · 등록 뒤 연결 점으로 선 이음
const addElem = createButton({
label: "요소 추가",
size: "sm",
variant: "ghost",
onClick: () => openPlaceModal(projectId, { add: true }, addRoute),
});
addElem.dataset.b08MilestoneAdd = "open";
const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] });
const refresh = (): void => {
const data = liveMilestone(projectId);
@@ -383,8 +373,6 @@ 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);
if (box.kind === "site") shift.append(lineLayer);
}
@@ -421,7 +409,7 @@ export function createDiagram(
shift.append(text);
}
arrange.hidden = !data.base.pos && !data.routes.some((r) => r.pos);
host.replaceChildren(root, arrange);
host.replaceChildren(root, tools);
attachSvgZoomPan({
svg: root,
layer,
@@ -11,6 +11,7 @@
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
@@ -240,20 +241,39 @@ export function openLegInfo(projectId: string, from: string): void {
});
save.dataset.b08MilestoneLegSave = "";
actions.append(find, save);
body.append(
legs.root,
hint(
from === "base"
? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
),
status,
el("h4", { text: "경로 — 다음(카카오) 지도" }),
map.root,
// 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기
const owner = (d = liveMilestone(projectId)) =>
from === "base" ? d.base : d.routes.find((r) => r.id === from);
const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)");
manual.input.checked = !!owner()?.manual;
manual.input.dataset.b08MilestoneManual = "";
const auto = hint(
from === "base"
? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
);
const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" });
const showMode = (): void => {
const off = manual.input.checked;
find.hidden = auto.hidden = mapHead.hidden = map.root.hidden = off;
status.textContent = off ? "수기 입력 — 거리를 손으로 넣으세요" : status.textContent;
};
manual.input.addEventListener("change", () => {
const fresh = liveMilestone(projectId);
const target = owner(fresh);
if (!target) return;
target.legs = legs.value().map((l) => (manual.input.checked ? { ...l, auto_km: null } : l));
target.manual = manual.input.checked;
putMilestone(projectId, fresh);
redrawLegs();
showMode();
if (!manual.input.checked) void run(); // 다시 카카오 연결 = 길찾기 한 번
});
body.append(manual.root, legs.root, auto, status, mapHead, map.root);
showMode();
drawMap();
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음)
if (!segmentPath(from).length && seg.from && seg.to) void run(false);
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음 · 수기 입력이면 안 함)
if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to) void run(false);
},
});
}
@@ -6,7 +6,8 @@
* 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 ·
* 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제]
* 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달
* 연결(기준점 거침 · 시점으로 바로) = 처음 = [+] 누른 상자 쪽 · 그 뒤 = 다이어그램 연결 점 ● 끌기(154 · 체크 없앰)
* 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음)
* 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰)
* 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
@@ -84,7 +85,7 @@ function rowField(r: MilestoneRow, kind: "route" | "base") {
return { item, dest, note: r.note, setKind };
}
export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string };
export type PlaceTarget = { add: true } | { add: false; id: string };
/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */
export function openPlaceModal(
@@ -97,7 +98,7 @@ export function openPlaceModal(
const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined;
if (!target.add && !editBase && !route) return;
const title = target.add
? `항목 추가 — ${target.viaBase ? "기준점 거침" : "공사지로 바로"}`
? "요소 추가 — 기준점 · 거래처"
: editBase
? `기준점 — ${data.base.name || "기준점"}`
: `항목 정보 — ${route!.name || "거래처"}`;
@@ -178,15 +179,15 @@ export function openPlaceModal(
if (moved(where)) segment = r.id;
} else {
if (!text) return name.setError("거래처 이름을 넣으세요.");
const viaBase = target.add && target.viaBase;
// 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기)
const id = addRoute?.({
name: text,
via_base: viaBase,
via_base: true,
linked: false,
dir: null,
rows: rowValues(),
...where,
});
if (id && where.lon != null) segment = id;
}
close();
if (segment) void findSegmentKm(projectId, segment).then(toastKm);
@@ -239,6 +239,8 @@ export async function findSegmentKm(
image = true,
): Promise<string> {
const data = liveMilestone(projectId);
const owner = from === "base" ? data.base : data.routes.find((r) => r.id === from);
if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함
const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null);
if (!seg) return "";
if (!seg.from || !seg.to) {
@@ -299,7 +299,7 @@ def test_화면_등록_모달_연결선_모달():
assert "길찾기" not in mid.replace("길찾기 없음", "") and "editMap" not in mid
assert 'if (from === "start") return openMidLeg(projectId);' in leg
assert "void reverseAddress(projectId, lon, lat)" in leg and "void run();" in leg
assert "if (!segmentPath(from).length && seg.from && seg.to) void run(false);" in leg
assert "if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to)" in leg
for rel in (B08 + "UI_Milestone_Modal.ts", B08 + "UI_Milestone_Place.ts",
B08 + "UI_Milestone_LegModal.ts", KMAP, STATE, LAYOUT, CALC,
B08 + "UI_Milestone.ts", B08 + "UI_Milestone_Diagram.ts",
@@ -145,9 +145,9 @@ def test_화면_상자_누름_더하기_창():
"openTab" not in ui
and "route: (id) => openPlaceModal(projectId, { add: false, id })," in ui
)
assert 'if (box.kind === "site") g.append(plus(box, false));' in ui
assert 'if (box.kind === "base") g.append(plus(box, true));' in ui
assert "openPlaceModal(projectId, { add: true, viaBase }, addRoute)" in ui
# 155 공사지 · 기준점 [+] 없앰 → 다이어그램 [요소 추가]
assert "plus(box" not in ui and 'label: "요소 추가"' in ui
assert "openPlaceModal(projectId, { add: true }, addRoute)" in ui
modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8")
assert "openModal({" in modal
# 149 상자 하나 = 항목 하나 — [+ 줄] · 같은 이름 합침 없음
@@ -216,7 +216,7 @@ def test_화면_자리_캡처_키_서버만():
assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap
assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동
leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도
assert "map.root," in leg and "map.set(pins()" in leg
assert "mapHead, map.root" in leg and "map.set(pins()" in leg
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "routeMapView(projectId, routeId)" in route
assert "mapView.draw();" in route
@@ -119,3 +119,30 @@ def test_154_연결_점_끌어_잇기():
)
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
def test_155_요소_추가_안_이은_거래처_수기_입력():
leg = lambda road, km, auto=None: {"road": road, "km": km, "auto_km": auto} # noqa: E731
route = {"id": "r", "name": "a", "address": "", "via_base": True, "dir": None, "linked": False,
"legs": [leg(None, 5.0)], "rows": [{"item": "장비", "dest": "mid", "note": ""}]} # fmt: skip
data = {"site": {"mid_station_m": 700, "mid_road": "비포장"},
"base": {"enabled": True, "name": "b", "address": "", "dir": None, "legs": [leg(None, 0.6)]},
"routes": [route]} # fmt: skip
(a,) = compute_milestone(data, None) # 안 이음 = 구간 km 없음 · 중점 구간만
assert (a["s2"], a["s1"], a["un"], a["total"]) == (0, 0, 0.7, 0.7)
assert _ts(f"computeMilestone({json.dumps(data)}, null)") == [a]
route["linked"] = True
(b,) = compute_milestone(data, None)
assert (b["s2"], b["s1"], b["total"]) == (5.0, 0.6, 6.3)
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert "route.linked = true;" in ui and 'label: "요소 추가"' in ui and "plus(box" not in ui
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert "linked: false," in modal and "export type PlaceTarget = { add: true } |" in modal
leg_ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts")
assert (
'createCheckbox("수기 입력' in leg_ui and "target.manual = manual.input.checked;" in leg_ui
)
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함
layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
assert "if (!route || route.linked === false) return null;" in layout