From 7be4feb1cf078ff880fb772258a67a4bb752d71a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:36:56 +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=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=EC=84=A0=20=EA=B8=80=20=EB=8D=94=EB=B8=94=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=3D=20km=20=C2=B7=20=EB=8F=84=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=20=EB=B0=94=EB=A1=9C=20=EA=B3=A0=EC=B9=A8=20(244-4=20?= =?UTF-8?q?=E2=91=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선 글 한 줄 = 그 구간(업체 · 공통 · 시점 → 중점) · 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소 - 값은 모달과 한 벌(legAt · setLeg) · 시점 → 중점 km = 중점 측점 · 표 셈 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 27 +++++++ B08_Quantity/B08_Quantity_UI_Milestone.css | 22 ++++++ .../B08_Quantity_UI_Milestone_Diagram.ts | 77 +++++++++++++++++-- .../tester/test_244_4_b08_milestone_modal.py | 42 +++++++++- 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index beeffbbc3..accdb206d 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -9,6 +9,7 @@ * 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서) * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) + * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * ========================================================================== */ import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; @@ -227,3 +228,29 @@ export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: nu `${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`; return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; } + +/** 선 글 한 줄 → 그 구간 — from = 업체 id(업체 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ +export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { + if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; + const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; + return legs?.[i] ?? null; +} + +/** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */ +export function setLeg( + data: MilestoneData, + from: string, + i: number, + patch: { road?: Leg["road"]; km?: number | null }, +): MilestoneData { + if (from === "start") { + if (patch.road) data.site.mid_road = patch.road; + if (patch.km !== undefined) + data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000; + return data; + } + const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; + const leg = legs?.[i]; + if (leg) Object.assign(leg, patch); + return data; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 6b8a9486d..2c55be329 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -125,3 +125,25 @@ .b08-milestone__diagram svg.is-panning { cursor: grabbing; } + +/* 선 글 바로 고침(244-4 ⑥) — 글 아래 작은 판 */ +.b08-milestone__km tspan { + cursor: text; +} + +.b08-milestone__leg-edit { + position: fixed; + z-index: 1000; + display: flex; + align-items: flex-end; + gap: 6px; + padding: 6px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.b08-milestone__leg-edit .ui-input { + width: 80px; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 5af9bc77a..ef4cbdbae 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -5,20 +5,26 @@ * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글 * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 · * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) - * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 더블클릭 = 원배율) + * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) + * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; -import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout"; +import { layoutMilestone, legAt, setLeg, type DiagramBox } from "./B08_Quantity_Milestone_Layout"; import { openAddItem, openBaseInfo, openRouteInfo, openSiteInfo, } from "./B08_Quantity_UI_Milestone_Modal"; -import { liveMilestone, siteInfo } from "./B08_Quantity_UI_Milestone_State"; +import { + liveMilestone, + putMilestone, + roadOptions, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; const NS = "http://www.w3.org/2000/svg"; @@ -33,6 +39,56 @@ function svg( return node; } +/** 선 글 바로 고침 — 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소 */ +function editLegAt( + projectId: string, + anchor: Element, + from: string, + i: number, + midKm: number, +): void { + const leg = legAt(liveMilestone(projectId), from, i, midKm); + if (!leg) return; + document.querySelector(".b08-milestone__leg-edit")?.remove(); + const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); + road.select.value = leg.road; + const km = el("input", { + className: "ui-input", + attrs: { type: "number", min: "0", step: "any" }, + }); + km.value = leg.km == null ? "" : String(leg.km); + km.setAttribute("aria-label", "km"); + const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] }); + box.dataset.b08MilestoneLegEdit = `${from}:${i}`; + const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로 + box.style.left = `${at.left}px`; + box.style.top = `${at.bottom + 4}px`; + let done = false; + const finish = (apply: boolean): void => { + if (done) return; + done = true; + box.remove(); + if (!apply) return; + const n = Number(km.value); + const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null; + const patch = { + road: road.select.value as typeof leg.road, + ...(km.value.trim() && value === null ? {} : { km: value }), + }; + putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch)); + }; + box.addEventListener("keydown", (e) => { + if (e.key === "Enter") finish(true); + if (e.key === "Escape") finish(false); + }); + box.addEventListener("focusout", (e) => { + if (!box.contains(e.relatedTarget as Node | null)) finish(true); + }); + document.body.append(box); + km.focus(); + km.select(); +} + /** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ export function createDiagram( projectId: string, @@ -71,7 +127,8 @@ export function createDiagram( const refresh = (): void => { const data = liveMilestone(projectId); const site = siteInfo(); - const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null)); + const mk = midKm(data, site?.mid_default_m ?? null); + const layout = layoutMilestone(data, site, mk); const [vx, vy, vw, vh] = layout.view; // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { @@ -126,7 +183,15 @@ export function createDiagram( const x = (ln.x1 + ln.x2) / 2; const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2; const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" }); - ln.labels.forEach((label, i) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label))); + ln.labels.forEach((label, i) => { + const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); + span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; + span.addEventListener("dblclick", (e) => { + e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 + editLegAt(projectId, span, ln.from, i, mk); + }); + text.append(span); + }); shift.append(text); } host.replaceChildren(root); diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 091a921ce..907c76e54 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -1,6 +1,8 @@ -"""244-4 B08 거리이정표 고침(사용자 130번) — [+] 모달(항목 · 업체 이름 · 목적지 + 주소 검색 재활용) · -방위 = 좌표 자동 · 시점 모달(등록 주소 · VWorld 주소 · 지도) · 다이어그램 팬 · 줌 공용 · -경로 하위 탭 = 그림만(다음 지도 길찾기 새 창 · 화면 캡처) · 상자 누름 = 항목 정보 모달. +"""244-4 B08 거리이정표 고침(사용자 130번) — [+] 모달(항목 · 업체 이름 · 목적지 + +주소 검색 재활용) · 방위 = 좌표 자동 · 시점 모달(등록 주소 · VWorld 주소 · 지도) · +다이어그램 팬 · 줌 공용 · +경로 하위 탭 = 그림만(다음 지도 길찾기 새 창 · 화면 캡처) · 상자 누름 = 항목 정보 모달 · +선 글 바로 고침(⑥). """ import json @@ -37,8 +39,9 @@ def _node(expr): def _route(rid, lon=None, lat=None, via=True): + row = {"item": "", "dest": "start", "note": ""} return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "lon": lon, - "lat": lat, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]} # fmt: skip + "lat": lat, "legs": [], "rows": [row]} # fmt: skip def _center(lay, bid): @@ -183,3 +186,34 @@ def test_화면_모달_팬줌_그림탭(): "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts", "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip assert len(_read(rel).splitlines()) <= 700 + + +def test_선_글_바로_고침_모달과_한_벌(): + from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone + + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "기준", "address": "", "dir": None, + "legs": [{"road": "1차포장", "km": 0.6}]}, + "routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}], + } # fmt: skip + d = json.dumps(data) + got = _node( + f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0)," + f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}})," + f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" + ) + assert got[0] == {"road": "2차포장", "km": None} + assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None + after = got[3] + assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}] + assert after["base"]["legs"] == [{"road": "1차포장", "km": None}] + assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} + # 표 셈 그대로 — 고친 값 = 모달 값과 같은 칸 + (row,) = compute_milestone(after, 1500.0) + assert (row["s2"], row["s1"], row["un"], row["total"]) == (0, 23.8, 0, 23.8) + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'span.addEventListener("dblclick"' in ui and "e.stopPropagation();" in ui + assert "editLegAt(projectId, span, ln.from, i, mk)" in ui + assert "anchor.getBoundingClientRect()" in ui # 팬 · 줌 뒤 글 화면 자리 + assert "putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch))" in ui