From 5f2e6e18b60abde3e0799f8b2484e800d4e75ff8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:33:53 +0900 Subject: [PATCH 1/2] =?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[+]=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EA=B2=80=EC=83=89=20=EC=9E=AC=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=20=C2=B7=20=EB=B0=A9=EC=9C=84=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=EC=A0=90=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20?= =?UTF-8?q?=ED=8C=AC=20=EC=A4=8C=20=C2=B7=20=EA=B2=BD=EB=A1=9C=20=ED=83=AD?= =?UTF-8?q?=20=3D=20=EA=B8=B8=EC=B0=BE=EA=B8=B0=20=EA=B7=B8=EB=A6=BC=20(24?= =?UTF-8?q?4-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리 - 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간) - 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan - 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰 - 서버 site-address(좌표 → 주소 · VWorld getAddress) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B01_Dashboard/B01_Dashboard_Map.py | 27 ++ .../B01_Dashboard_UI_AddressField.ts | 9 +- B01_Dashboard/B01_Dashboard_UI_MapPreview.ts | 10 +- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 14 +- B08_Quantity/B08_Quantity_Router_Milestone.py | 25 ++ B08_Quantity/B08_Quantity_UI_Milestone.css | 11 + B08_Quantity/B08_Quantity_UI_Milestone.ts | 50 +-- .../B08_Quantity_UI_Milestone_Diagram.ts | 124 +++---- .../B08_Quantity_UI_Milestone_Modal.ts | 336 ++++++++++++++++++ .../B08_Quantity_UI_Milestone_Route.ts | 316 +++++----------- .../tester/test_243_1_double_click_tables.py | 4 +- resources/tester/test_244_1_b08_milestone.py | 9 +- .../test_244_2_b08_milestone_diagram.py | 11 +- .../tester/test_244_4_b08_milestone_modal.py | 185 ++++++++++ 14 files changed, 774 insertions(+), 357 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts create mode 100644 resources/tester/test_244_4_b08_milestone_modal.py diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py index 0c26c5f33..5897561a3 100644 --- a/B01_Dashboard/B01_Dashboard_Map.py +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -114,3 +114,30 @@ def _search_sync(query: str, size: int) -> list[dict[str, Any]]: async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]: return await asyncio.to_thread(_search_sync, query.strip(), size) + + +def _reverse_sync(lon: float, lat: float) -> str | None: + """좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번).""" + query = urllib.parse.urlencode( + { + "service": "address", + "request": "getAddress", + "version": "2.0", + "crs": "epsg:4326", + "point": f"{lon},{lat}", + "type": "both", + "format": "json", + "key": VWORLD_API_KEY, + } + ) + try: + body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8")) + except Exception: + return None + items = (body.get("response") or {}).get("result") or [] + texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)} + return texts.get("road") or texts.get("parcel") or None + + +async def reverse_geocode(lon: float, lat: float) -> str | None: + return await asyncio.to_thread(_reverse_sync, lon, lat) diff --git a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts index 7a50998e4..595a03116 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts @@ -19,6 +19,8 @@ export interface AddressFieldHandle { root: HTMLElement; /** 저장할 한 줄 주소. 비면 null. */ value: () => string | null; + /** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */ + point: () => { lat: number; lon: number } | null; } /** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */ @@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { label: "지도 확인", variant: "ghost", onClick: async function onB01_Address_Map_Click() { - await map.show(base.input.value.trim()); + picked = await map.show(base.input.value.trim()); }, }); mapBtn.hidden = true; @@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root); let manual = false; + let picked: { lat: number; lon: number } | null = null; function pick(item: AddressCandidate): void { base.input.value = candidateLine(item); @@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { zip.hidden = !item.zipcode; list.hidden = true; list.innerHTML = ""; + picked = { lat: item.lat, lon: item.lon }; map.showPoint(item.lat, item.lon); detail.input.focus(); } @@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { }); // 이미 있는 주소는 열자마자 지도로 보여 준다. - if (initial && initial.trim()) void map.show(initial.trim()); + if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p)); return { root, @@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle { const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim(); return merged || null; }, + point: () => picked, }; } diff --git a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts index ed616c670..cf8c71eec 100644 --- a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts +++ b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts @@ -32,7 +32,8 @@ function tileIndex( export function buildAddressMap(): { root: HTMLElement; - show: (address: string) => Promise; + /** 찾은 좌표를 돌려줌 — 못 찾으면 null. */ + show: (address: string) => Promise<{ lat: number; lon: number } | null>; /** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */ showPoint: (lat: number, lon: number) => void; } { @@ -110,17 +111,18 @@ export function buildAddressMap(): { note.textContent = message; } - const show = async (address: string): Promise => { + const show = async (address: string): Promise<{ lat: number; lon: number } | null> => { if (!address.trim()) { fail("주소를 먼저 입력하십시오."); - return; + return null; } const point = await geocodeAddress(address).catch(() => null); if (!point) { fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오."); - return; + return null; } draw(point.lat, point.lon); + return { lat: point.lat, lon: point.lon }; }; return { root, show, showPoint: draw }; diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index d85c07088..beeffbbc3 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -3,8 +3,9 @@ * 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림). * * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들 - * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 둘 다 경위도가 있으면 방위각 · - * 없으면 사용자가 고른 8방 · 고르지 않았으면 빈 방위를 차례로 · 같은 방위 여럿 = 옆으로 나란히 + * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면 + * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · + * 같은 방위 여럿 = 옆으로 나란히 * 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서) * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) @@ -167,7 +168,7 @@ export function layoutMilestone( const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; const items = data.routes.map((r) => { const anchor = useBase && r.via_base ? "base" : "site"; - return { r, anchor, deg: placeDeg(r, anchor === "base" ? baseLL : startLL) }; + return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) }; }); for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); let auto = 0; @@ -219,3 +220,10 @@ export function layoutMilestone( view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY], }; } + +/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */ +export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string { + const part = (p: { name: string; lon: number; lat: number }): string => + `${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`; + return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; +} diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 18e2cd950..a9fd66fb7 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -5,6 +5,8 @@ POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체) GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기 DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 + GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · + 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) 정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). @@ -26,6 +28,8 @@ from fastapi import APIRouter, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, ValidationError, model_validator +from B01_Dashboard.B01_Dashboard_Map import reverse_geocode +from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 from B08_Quantity.B08_Quantity_Router_Hydro import _project_root @@ -219,6 +223,27 @@ async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: return await asyncio.to_thread(save_data, root, body) +@router.get(PATH + "/site-address") +async def milestone_site_address(project_id: UUID) -> dict[str, Any]: + """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소.""" + root = await _project_root(project_id) + data = await asyncio.to_thread(read_data, root) + site = await asyncio.to_thread(site_info, root, data["site"]["mid_station_m"]) + project = await get_project_by_id(str(project_id)) or {} + + async def where(lonlat: list[float] | None) -> str | None: + return await reverse_geocode(*lonlat) if lonlat else None + + start, mid = await asyncio.gather(where(site["start_lonlat"]), where(site["mid_lonlat"])) + return { + "region": project.get("region") or "", + "start_lonlat": site["start_lonlat"], + "mid_lonlat": site["mid_lonlat"], + "start_address": start, + "mid_address": mid, + } + + @router.post(PATH + "/image") async def milestone_image_upload( project_id: UUID, route: str = Query(...), file: UploadFile = File(...) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 01057d221..6b8a9486d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -114,3 +114,14 @@ stroke-width: 4px; stroke-linejoin: round; } + +/* 모달 구간 · 표 줄 — 칸 · [×] 한 줄(244-4) */ +.b08-milestone__line-edit { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.b08-milestone__diagram svg.is-panning { + cursor: grabbing; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index faa790110..aecaaa8b9 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -1,18 +1,18 @@ /* ============================================================================= * B08_Quantity_UI_Milestone.ts - * B08 메인 탭 「거리이정표」(PLAN 244-1) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름) + - * [+ 추가] · [삭제]. + * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름). + * 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음). * * [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 * 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도 - * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(244-2 · 상자 누름 = 경로 탭 · [+] = 항목 추가) - * 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄) + * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) + * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; -import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { @@ -64,8 +64,8 @@ export function addRoute(projectId: string, init: Partial = {}): } /** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */ -function tableView(projectId: string, open: (id: string) => void): MilestoneView { - const diagram = createDiagram(projectId, open, (init) => addRoute(projectId, init)); +function tableView(projectId: string): MilestoneView { + const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; const table = createDataTable({ columns: COLUMNS, @@ -100,7 +100,7 @@ function tableView(projectId: string, open: (id: string) => void): MilestoneView table.root.dataset.b08Milestone = "table"; const none = el("p", { className: "ui-general-block__empty", - text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+] 또는 하위 탭 줄 [+ 추가]로 업체를 넣으세요.", + text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.", }); const refresh = (): void => { const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); @@ -139,48 +139,16 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { button.dataset.b08MilestoneTab = id; return button; }; - const add = createButton({ - label: "+ 추가", - size: "sm", - variant: "ghost", - onClick: () => open(addRoute(projectId)), - }); - add.dataset.b08MilestoneAdd = ""; - const remove = createButton({ - label: "삭제", - size: "sm", - variant: "danger", - onClick: () => void removeCurrent(), - }); - remove.disabled = current === "table"; - remove.dataset.b08MilestoneDelete = ""; tabs.replaceChildren( tab("table", "거리이정표"), ...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)), - add, - remove, ); }; - const removeCurrent = async (): Promise => { - const data = liveMilestone(projectId); - const route = data.routes.find((r) => r.id === current); - if (!route) return; - if ( - !(await showConfirmDialog( - `「${route.name || "업체"}」 경로를 지울까요? — [저장] 때 반영`, - "삭제", - )) - ) - return; - data.routes = data.routes.filter((r) => r.id !== route.id); - putMilestone(projectId, data); // 알림이 없는 경로 탭을 보고 [거리이정표] 로 옮김 - }; - function open(id: string): void { current = id; drawTabs(); - body = id === "table" ? tableView(projectId, open) : routeView(projectId, id); + body = id === "table" ? tableView(projectId) : routeView(projectId, id); view.replaceChildren(body.root); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 3a54a3760..5af9bc77a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -3,20 +3,22 @@ * [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`. * * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글 - * 업체 상자 누름 = 그 경로 하위 탭으로 · 공사지 · 기준점 상자 [+] = 항목 추가(작은 창 → 경로 하위 탭 생김 · - * 같은 업체 이름이면 그 경로에 표 줄만 더함) · 조작 = 캐시(`…_UI_Milestone_State`) + * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 · + * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) + * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 더블클릭 = 원배율) * ========================================================================== */ -import { openModal } from "@ui/ui_template_modal"; -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import { midKm, type Dest, type Dir, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { 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 { - dirOptions, - liveMilestone, - putMilestone, - siteInfo, -} from "./B08_Quantity_UI_Milestone_State"; + openAddItem, + openBaseInfo, + openRouteInfo, + openSiteInfo, +} from "./B08_Quantity_UI_Milestone_Modal"; +import { liveMilestone, siteInfo } from "./B08_Quantity_UI_Milestone_State"; const NS = "http://www.w3.org/2000/svg"; @@ -31,69 +33,21 @@ function svg( return node; } -/** [+] 작은 창 — 항목 · 업체 이름 · 목적지 · 방위 → 새 경로(같은 이름 = 그 경로에 줄) */ -export function openAddItem( - projectId: string, - viaBase: boolean, - addRoute: (init: Partial) => string, -): void { - openModal({ - title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로", - closeLabel: "닫기", - mount: (body, close, actions) => { - const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); - const company = createInputField({ label: "업체 이름", compact: true }); - const dest = createSelectField({ - label: "목적지", - compact: true, - options: [ - { value: "start", text: "시점" }, - { value: "mid", text: "중점" }, - ], - }); - const dir = createSelectField({ - label: viaBase ? "방위(기준점에서 본)" : "방위(공사지에서 본)", - compact: true, - options: dirOptions, - }); - item.input.dataset.b08MilestoneAdd = "item"; - company.input.dataset.b08MilestoneAdd = "company"; - const ok = createButton({ - label: "추가", - variant: "filled", - onClick: () => { - const name = company.input.value.trim(); - if (!name) return company.setError("업체 이름을 넣으세요."); - const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; - const data = liveMilestone(projectId); - const same = data.routes.find((r) => r.name.trim() === name); - if (same) { - same.rows.push(row); - putMilestone(projectId, data); - } else - addRoute({ - name, - via_base: viaBase, - dir: (dir.select.value || null) as Dir | null, - rows: [row], - }); - close(); - }, - }); - actions.append(ok); - body.append(item.root, company.root, dest.root, dir.root); - }, - }); -} - /** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ export function createDiagram( projectId: string, - openTab: (routeId: string) => void, addRoute: (init: Partial) => string, ): { root: HTMLElement; refresh: () => void } { const host = el("div", { className: "b08-milestone__diagram" }); host.dataset.b08Milestone = "diagram"; + let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 + const INFO: Record void> = { + route: (id) => openRouteInfo(projectId, id), + site: () => openSiteInfo(projectId), + start: () => openSiteInfo(projectId), + mid: () => openSiteInfo(projectId), + base: () => openBaseInfo(projectId), + }; const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => { const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 }); @@ -119,8 +73,9 @@ export function createDiagram( const site = siteInfo(); const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null)); const [vx, vy, vw, vh] = layout.view; + // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { - viewBox: `${vx} ${vy} ${vw} ${vh}`, + viewBox: `0 0 ${vw} ${vh}`, width: vw, height: vh, role: "img", @@ -128,8 +83,12 @@ export function createDiagram( }); root.style.maxWidth = "100%"; root.style.height = "auto"; + const layer = svg("g", {}); + const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); + layer.append(shift); + root.append(layer); for (const ln of layout.lines) { - root.append( + shift.append( svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }), ); } @@ -149,15 +108,18 @@ export function createDiagram( box.label, ), ); - if (box.kind === "route") { - g.setAttribute("role", "button"); - g.setAttribute("tabindex", "0"); - g.addEventListener("click", () => openTab(box.id)); - g.addEventListener("keydown", (e) => e.key === "Enter" && openTab(box.id)); - } + // 상자 누름 = 모달(탭 이동 아님) + const info = (): void => INFO[box.kind](box.id); + g.setAttribute("role", "button"); + g.setAttribute("tabindex", "0"); + g.addEventListener("click", (e) => { + e.stopPropagation(); // 공사지 안 시점 · 중점 상자 = 한 번만 + info(); + }); + g.addEventListener("keydown", (e) => e.key === "Enter" && info()); if (box.kind === "site") g.append(plus(box, false)); if (box.kind === "base") g.append(plus(box, true)); - root.append(g); + shift.append(g); } // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 for (const ln of layout.lines) { @@ -165,9 +127,19 @@ export function createDiagram( 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))); - root.append(text); + shift.append(text); } host.replaceChildren(root); + attachSvgZoomPan({ + svg: root, + layer, + widthPx: vw, + heightPx: vh, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, + initial: zoomState, + onChange: (state) => (zoomState = state), + wheelZoom: true, + }); }; refresh(); return { root: host, refresh }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts new file mode 100644 index 000000000..c3b39e9ea --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -0,0 +1,336 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Modal.ts + * 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침. + * + * [+] 항목 추가 = 항목 · 업체 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = + * 경로(하위 탭) 생김 · 같은 업체 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 + * 업체 상자 = 그 항목 정보(업체 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] + * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 + * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) + * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 + * ========================================================================== */ + +import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { + createButton, + createInputField, + createSelectField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { + buildAddressField, + type AddressFieldHandle, +} from "../B01_Dashboard/B01_Dashboard_UI_AddressField"; +import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; +import { + ROADS, + type Dest, + type Leg, + type MilestoneData, + type MilestoneRoute, + type MilestoneRow, + type Road, +} from "./B08_Quantity_Milestone_Calc"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { liveMilestone, putMilestone, roadOptions } from "./B08_Quantity_UI_Milestone_State"; + +const DEST_OPTIONS = [ + { value: "start", text: "시점" }, + { value: "mid", text: "중점" }, +]; + +const line = (...children: HTMLElement[]): HTMLElement => + el("div", { className: "b08-milestone__line-edit", children }); + +/** 구간 줄 — 도로 구분 ▾ · km · [×] · [+ 구간] */ +function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () => Leg[] } { + const list = el("div"); + const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement }> = []; + const add = (leg: Leg): void => { + const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); + const km = createInputField({ label: "km", type: "number", min: 0, compact: true }); + road.select.value = leg.road; + km.input.value = leg.km == null ? "" : String(leg.km); + const ref = { road: road.select, km: km.input }; + rows.push(ref); + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(ref), 1); + row.remove(); + }, + }); + const row = line(road.root, km.root, drop); + row.dataset.b08MilestoneLeg = ""; + list.append(row); + }; + initial.forEach(add); + const more = createButton({ + label: "+ 구간", + size: "sm", + variant: "ghost", + onClick: () => add({ road: fresh, km: null }), + }); + return { + root: el("div", { children: [list, more] }), + value: () => + rows.map(({ road, km }) => { + const n = Number(km.value); + return { + road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : fresh, + km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null, + }; + }), + }; +} + +/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */ +function rowsEditor(initial: MilestoneRow[]): { + root: HTMLElement; + value: () => MilestoneRow[]; +} { + const list = el("div"); + const rows: Array<{ item: HTMLInputElement; dest: HTMLSelectElement; note: string }> = []; + const add = (r: MilestoneRow): void => { + const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); + const dest = createSelectField({ label: "목적지", options: DEST_OPTIONS, compact: true }); + item.input.value = r.item; + dest.select.value = r.dest; + const ref = { item: item.input, dest: dest.select, note: r.note }; + rows.push(ref); + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(ref), 1); + row.remove(); + }, + }); + const row = line(item.root, dest.root, drop); + list.append(row); + }; + initial.forEach(add); + const more = createButton({ + label: "+ 줄", + size: "sm", + variant: "ghost", + onClick: () => add({ item: "", dest: "start", note: "" }), + }); + return { + root: el("div", { children: [list, more] }), + value: () => + rows.map((r) => ({ item: r.item.value.trim(), dest: r.dest.value as Dest, note: r.note })), + }; +} + +/** 주소 칸 값 → 주소 글 · 좌표(못 찾았고 글이 그대로면 옛 좌표 · 바뀌었으면 비움) */ +function placeOf( + field: AddressFieldHandle, + old: { address: string; lon?: number | null; lat?: number | null }, +): { address: string; lon: number | null; lat: number | null } { + const address = field.value() ?? ""; + const point = field.point(); + if (point) return { address, lon: point.lon, lat: point.lat }; + return address === old.address + ? { address, lon: old.lon ?? null, lat: old.lat ?? null } + : { address, lon: null, lat: null }; +} + +const head = (text: string): HTMLElement => el("h4", { text }); + +/** [+] — 항목 · 업체 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ +export function openAddItem( + projectId: string, + viaBase: boolean, + addRoute: (init: Partial) => string, +): void { + openModal({ + title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로", + closeLabel: "닫기", + mount: (body, close, actions) => { + const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); + const company = createInputField({ label: "업체 이름", compact: true }); + const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); + const address = buildAddressField(null); + item.input.dataset.b08MilestoneAdd = "item"; + company.input.dataset.b08MilestoneAdd = "company"; + const ok = createButton({ + label: "등록", + variant: "filled", + onClick: () => { + const name = company.input.value.trim(); + if (!name) return company.setError("업체 이름을 넣으세요."); + const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; + const place = placeOf(address, { address: "" }); + const data = liveMilestone(projectId); + const same = data.routes.find((r) => r.name.trim() === name); + if (same) { + same.rows.push(row); + if (place.lon != null) Object.assign(same, place); + putMilestone(projectId, data); + } else addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); + close(); + }, + }); + actions.append(ok); + body.append( + item.root, + company.root, + dest.root, + head("업체 위치 — 주소 검색(방위는 이 자리로 자동)"), + address.root, + ); + }, + }); +} + +/** 업체 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */ +export function openRouteInfo(projectId: string, routeId: string): void { + const data = liveMilestone(projectId); + const route = data.routes.find((r) => r.id === routeId); + if (!route) return; + openModal({ + title: `항목 정보 — ${route.name || "업체"}`, + closeLabel: "닫기", + mount: (body, close, actions) => { + const name = createInputField({ label: "업체 이름", compact: true }); + name.input.value = route.name; + name.input.dataset.b08MilestoneInfo = "name"; + const via = createCheckbox("기준점 거침"); + via.input.checked = route.via_base; + via.root.hidden = !data.base.enabled; + const rows = rowsEditor(route.rows); + const address = buildAddressField(route.address || null); + const legs = legsEditor(route.legs, "2차포장"); + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh = liveMilestone(projectId); + const r = fresh.routes.find((x) => x.id === routeId); + if (!r) return close(); + Object.assign(r, placeOf(address, r), { + name: name.input.value.trim() || r.name, + via_base: via.input.checked, + rows: rows.value(), + legs: legs.value(), + }); + putMilestone(projectId, fresh); + close(); + }, + }); + const remove = createButton({ + label: "삭제", + variant: "danger", + onClick: async () => { + if ( + !(await showConfirmDialog( + `「${route.name || "업체"}」 항목을 지울까요? — [저장] 때 반영`, + "삭제", + )) + ) + return; + const fresh = liveMilestone(projectId); + fresh.routes = fresh.routes.filter((r) => r.id !== routeId); + putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐 + close(); + }, + }); + save.dataset.b08MilestoneInfo = "save"; + remove.dataset.b08MilestoneInfo = "delete"; + actions.append(remove, save); + body.append( + name.root, + via.root, + head("표 줄 — 항목 · 목적지"), + rows.root, + head( + `구간 — ${route.name || "업체"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, + ), + legs.root, + head("주소 · 지도"), + address.root, + ); + }, + }); +} + +/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */ +export function openBaseInfo(projectId: string): void { + const base = liveMilestone(projectId).base; + openModal({ + title: `기준점 — ${base.name || "기준점"}`, + closeLabel: "닫기", + mount: (body, close, actions) => { + const name = createInputField({ label: "기준점 이름", compact: true }); + name.input.value = base.name; + const address = buildAddressField(base.address || null); + const legs = legsEditor(base.legs, "1차포장"); + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh: MilestoneData = liveMilestone(projectId); + Object.assign(fresh.base, placeOf(address, fresh.base), { + name: name.input.value.trim(), + legs: legs.value(), + }); + putMilestone(projectId, fresh); + close(); + }, + }); + actions.append(save); + body.append( + name.root, + head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"), + legs.root, + head("주소 · 지도"), + address.root, + ); + }, + }); +} + +interface SiteAddress { + region: string; + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; + start_address: string | null; + mid_address: string | null; +} + +/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ +export function openSiteInfo(projectId: string): void { + openModal({ + title: "공사지 — 시점", + closeLabel: "닫기", + mount: (body) => { + const text = (label: string, value: string): HTMLElement => + el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); + const map = buildAddressMap(); + const info = el("div", { children: [text("주소", "불러오는 중…")] }); + body.append(info, map.root); + request(`/projects/${projectId}/quantity/milestone/site-address`).then( + (s) => { + const ll = (p: [number, number] | null): string => + p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : ""; + info.replaceChildren( + text("프로젝트 등록 주소(사업지역)", s.region || "없음"), + text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)), + text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)), + ); + if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]); + else if (s.region) void map.show(s.region); + }, + (error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"), + ); + }, + }); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index 9c3671293..df5c65bbb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -1,39 +1,23 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_Route.ts - * 거리이정표 경로 하위 탭(PLAN 244-1) — 컨테이너 하나 = 업체 한 곳 · 그림 하나. + * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). * - * 위 = 업체 이름(= 하위 탭 이름) · 주소 · 방위(기준점 · 없으면 공사지 기준 8방) · 기준점 거침 - * 구간 표 = 업체 → 기준점(또는 시점) · 기준점 → 시점(공통 · 모든 경로가 같이 씀) · 시점 → 중점(측점 km) · - * 구간마다 km(더블클릭 손 입력) · 도로 구분 ▾(2차포장 · 1차포장 · 비포장) - * 표 줄 = 항목 · 목적지 ▾ · 비고 — [거리이정표] 표에 이 업체 줄로 나감 - * 그림 = 올리는 즉시 서버(교체 · 지우기) · 나머지 조작 = 캐시(`…_UI_Milestone_State`) · [저장] 때 서버 + * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 업체 좌표 · + * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 + * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 + * 그림 = 올리는 즉시 서버(교체 · 지우기) * ========================================================================== */ -import { createCheckbox } from "@ui/ui_template_checkbox"; import { createMapContextMenu, type MapContextMenu, type MapContextMenuItem, } from "@ui/ui_template_context_menu"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { - ROADS, - midKm, - type Dest, - type Dir, - type MilestoneData, - type MilestoneRoute, -} from "./B08_Quantity_Milestone_Calc"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout"; import { deleteImage, - dirOptions, imageBlobUrl, liveMilestone, putMilestone, @@ -61,12 +45,6 @@ export function pickAtCell(items: MapContextMenuItem[]): void { menu.open(box.left, box.bottom, items); } -/** 고른 줄 강조(공용 표 is-focus · 다시 그리지 않음 — 더블클릭 그대로) */ -function markRow(root: HTMLElement, index: number | null): void { - root.querySelectorAll("tr.is-focus").forEach((tr) => tr.classList.remove("is-focus")); - if (index !== null) root.querySelector(`tr[data-i="${index}"]`)?.classList.add("is-focus"); -} - export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; /** 그 경로를 고쳐 캐시에 */ @@ -82,180 +60,59 @@ export function editRoute( putMilestone(projectId, data); } -const kmOf = (text: string): number | null | undefined => { - if (!text.trim()) return null; - const value = Number(text.replace(/,/g, "")); - return Number.isFinite(value) && value >= 0 ? value : undefined; // undefined = 그대로 -}; - -type LegRef = { group: "own" | "base" | "mid"; i: number }; - -const LEG_COLUMNS: DataColumn[] = [ - { key: "span", label: "구간", width: 200 }, - { key: "road", label: "도로 구분", width: 96 }, - { key: "km", label: "km", kind: "number", decimals: 3, trim: true, width: 80 }, -]; -const ROW_COLUMNS: DataColumn[] = [ - { key: "item", label: "항목", width: 140 }, - { key: "dest", label: "목적지", width: 80 }, - { key: "note", label: "비고", width: 180 }, -]; - export interface MilestoneView { root: HTMLElement; refresh: () => void; } +/** 새 창 길찾기 지점 — 업체 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */ +export function routePoints( + d: MilestoneData, + r: MilestoneRoute, +): Array<{ name: string; lon: number; lat: number }> | null { + const site = siteInfo(); + const dest = r.rows[0]?.dest ?? "start"; + const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat; + if (r.lon == null || r.lat == null || !to) return null; + const points = [{ name: r.name || "업체", lon: r.lon, lat: r.lat }]; + if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null) + points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat }); + points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] }); + return points; +} + +/** 화면 캡처 — 사용자가 고른 창 한 장(png) · 스트림은 바로 끔 */ +async function captureWindow(): Promise { + const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); + try { + const video = el("video"); + video.muted = true; + video.srcObject = stream; + await video.play(); + await new Promise((done) => requestAnimationFrame(done)); + const canvas = el("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext("2d")?.drawImage(video, 0, 0); + const blob = await new Promise((done, fail) => + canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"), + ); + return new File([blob], "capture.png", { type: "image/png" }); + } finally { + stream.getTracks().forEach((t) => t.stop()); + } +} + export function routeView(projectId: string, routeId: string): MilestoneView { - const edit = (fn: (data: MilestoneData, route: MilestoneRoute) => void): void => - editRoute(projectId, routeId, fn); - - // 위 — 업체 이름 · 주소 · 방위 · 기준점 거침 - const name = createInputField({ label: "업체 이름", compact: true }); - const address = createInputField({ label: "주소", compact: true }); - const dir = createSelectField({ label: "방위", options: dirOptions, compact: true }); - const via = createCheckbox("기준점 거침"); - name.input.dataset.b08Milestone = "route_name"; - name.input.addEventListener("input", () => edit((_, r) => (r.name = name.input.value))); - address.input.addEventListener("input", () => edit((_, r) => (r.address = address.input.value))); - dir.select.addEventListener("change", () => - edit((_, r) => (r.dir = (dir.select.value || null) as Dir | null)), - ); - via.input.addEventListener("change", () => edit((_, r) => (r.via_base = via.input.checked))); - const head = el("div", { - className: "b08-milestone__head", - children: [name.root, address.root, dir.root, via.root], - }); - - // 구간 표 - let legRefs: LegRef[] = []; - let pickedLeg: number | null = null; - const legRows = (d: MilestoneData, r: MilestoneRoute): DataRow[] => { - const useBase = d.base.enabled && r.via_base; - const baseName = d.base.name || "기준점"; - const who = r.name || "업체"; - const rows: Array<[LegRef, string, string, number | null]> = [ - ...r.legs.map((leg, i): [LegRef, string, string, number | null] => [ - { group: "own", i }, - `${who} → ${useBase ? baseName : "시점"}`, - leg.road, - leg.km, - ]), - ...(useBase ? d.base.legs : []).map((leg, i): [LegRef, string, string, number | null] => [ - { group: "base", i }, - `${baseName} → 시점 (공통)`, - leg.road, - leg.km, - ]), - [ - { group: "mid", i: 0 }, - "시점 → 중점 (측점)", - d.site.mid_road, - midKm(d, siteInfo()?.mid_default_m ?? null), - ], - ]; - legRefs = rows.map(([ref]) => ref); - return rows.map(([, span, road, km]) => ({ span, road: `${road} ▾`, km })); - }; - const setLeg = (ref: LegRef, fn: (leg: { road: string; km: number | null }) => void): void => - edit((d, r) => { - if (ref.group === "mid") { - const leg = { road: d.site.mid_road as string, km: null }; - fn(leg); - d.site.mid_road = leg.road as MilestoneData["site"]["mid_road"]; - } else fn((ref.group === "own" ? r.legs : d.base.legs)[ref.i]); - }); - const legs = createDataTable({ - columns: LEG_COLUMNS, - rows: [], - caption: "구간", - empty: "", - editOnDouble: true, - editable: (i, key) => key === "km" && legRefs[i]?.group !== "mid", - onEdit: (i, _key, text) => { - const km = kmOf(text); - if (km !== undefined) setLeg(legRefs[i], (leg) => (leg.km = km)); - else refresh(); - }, - onCellClick: (i, key) => { - if (key !== "road") return; - pickAtCell(ROADS.map((road) => [road, () => setLeg(legRefs[i], (leg) => (leg.road = road))])); - }, - onRowClick: (i) => markRow(legs.root, (pickedLeg = i)), - }); - trackCells(legs.root); - legs.root.dataset.b08Milestone = "legs"; - const addOwn = createButton({ - label: "+ 업체 구간", - size: "sm", - variant: "ghost", - onClick: () => edit((_, r) => r.legs.push({ road: "2차포장", km: null })), - }); - const addBase = createButton({ - label: "+ 공통 구간", - size: "sm", - variant: "ghost", - onClick: () => edit((d) => d.base.legs.push({ road: "1차포장", km: null })), - }); - const dropLeg = createButton({ - label: "고른 구간 지우기", - size: "sm", - variant: "ghost", - onClick: () => { - const ref = pickedLeg === null ? null : legRefs[pickedLeg]; - if (!ref || ref.group === "mid") return showToast("지울 구간 줄을 먼저 고르세요.", "info"); - pickedLeg = null; - edit((d, r) => (ref.group === "own" ? r.legs : d.base.legs).splice(ref.i, 1)); - }, - }); - - // 표 줄 - let pickedRow: number | null = null; - const rows = createDataTable({ - columns: ROW_COLUMNS, - rows: [], - caption: "표 줄", - empty: "", - editOnDouble: true, - editable: (_i, key) => key === "item" || key === "note", - onEdit: (i, key, text) => - edit((_, r) => { - if (r.rows[i]) r.rows[i][key as "item" | "note"] = text; - }), - onCellClick: (i, key) => { - if (key !== "dest") return; - pickAtCell( - (["start", "mid"] as Dest[]).map((dest) => [ - DEST_TEXT[dest], - () => edit((_, r) => r.rows[i] && (r.rows[i].dest = dest)), - ]), - ); - }, - onRowClick: (i) => markRow(rows.root, (pickedRow = i)), - }); - trackCells(rows.root); - rows.root.dataset.b08Milestone = "rows"; - const addRow = createButton({ - label: "+ 줄", - size: "sm", - variant: "ghost", - onClick: () => edit((_, r) => r.rows.push({ item: "", dest: "start", note: "" })), - }); - const dropRow = createButton({ - label: "고른 줄 지우기", - size: "sm", - variant: "ghost", - onClick: () => { - if (pickedRow === null) return showToast("지울 줄을 먼저 고르세요.", "info"); - const at = pickedRow; - pickedRow = null; - edit((_, r) => r.rows.splice(at, 1)); - }, + const title = el("h4"); + const hint = el("p", { + className: "b08-options__hint", + text: "[다음 지도에서 길찾기] → 새 창에서 길 확인 → [화면 캡처] → 그 창을 고름 = 이 탭 그림", }); // 그림 — 경로 하나 = 그림 하나 const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } }); - const noImage = el("p", { className: "ui-general-block__empty", text: "올린 그림 없음" }); + const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" }); let shown: string | null | undefined; const loadImage = (force = false): void => { const name = routeImages()[routeId] ?? null; @@ -271,21 +128,51 @@ export function routeView(projectId: string, routeId: string): MilestoneView { (error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"), ); }; + const put = (picked: File): Promise => + uploadImage(projectId, routeId, picked).then( + () => loadImage(true), + (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), + ); const file = el("input", { attrs: { type: "file", accept: "image/*" } }); file.hidden = true; file.addEventListener("change", () => { const picked = file.files?.[0]; file.value = ""; - if (picked) - uploadImage(projectId, routeId, picked).then( - () => loadImage(true), - (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), - ); + if (picked) void put(picked); }); + + const kakao = createButton({ + label: "다음 지도에서 길찾기", + size: "sm", + variant: "filled", + onClick: () => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const points = r ? routePoints(d, r) : null; + if (!points) + return showToast( + "업체 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.", + "info", + ); + window.open(kakaoRouteUrl(points), "_blank", "noopener"); + }, + }); + kakao.dataset.b08MilestoneKakao = ""; + const capture = createButton({ + label: "화면 캡처", + size: "sm", + variant: "ghost", + onClick: () => + void captureWindow().then(put, (error: unknown) => { + if ((error as Error)?.name !== "NotAllowedError") + showToast(`화면을 캡처하지 못했습니다 — ${String(error)}`, "error"); + }), + }); + capture.dataset.b08MilestoneCapture = ""; const upload = createButton({ label: "그림 올리기", size: "sm", - variant: "filled", + variant: "ghost", onClick: () => file.click(), }); const removeImage = createButton({ @@ -298,21 +185,16 @@ export function routeView(projectId: string, routeId: string): MilestoneView { ), }); - const bar = (...items: HTMLElement[]): HTMLElement => - el("div", { className: "b08-milestone__bar", children: items }); const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, children: [ - head, - el("h4", { text: "구간 (km 더블클릭 · 도로 구분 ▾)" }), - legs.root, - bar(addOwn, addBase, dropLeg), - el("h4", { text: "표 줄" }), - rows.root, - bar(addRow, dropRow), - el("h4", { text: "경로 그림" }), - bar(upload, removeImage, file), + title, + el("div", { + className: "b08-milestone__bar", + children: [kakao, capture, upload, removeImage, file], + }), + hint, noImage, img, ], @@ -322,16 +204,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const d = liveMilestone(projectId); const r = d.routes.find((x) => x.id === routeId); if (!r) return; - if (document.activeElement !== name.input) name.input.value = r.name; - if (document.activeElement !== address.input) address.input.value = r.address; - dir.select.value = r.dir ?? ""; - via.input.checked = r.via_base; - via.root.hidden = !d.base.enabled; - addBase.hidden = !(d.base.enabled && r.via_base); - legs.setRows(legRows(d, r)); - rows.setRows(r.rows.map((row) => ({ ...row, dest: `${DEST_TEXT[row.dest]} ▾` }))); - markRow(legs.root, pickedLeg); - markRow(rows.root, pickedRow); + const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; + title.textContent = `${r.name || "업체"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; loadImage(); }; refresh(); diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 61be0ddae..a44d0be7e 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -127,8 +127,8 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble(): if "editable:" in block: seen += 1 assert "editOnDouble: true" in block, p.name - # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 셋(표 · 구간 · 표 줄 · 244-1) - assert seen == 4 + # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 표(구간 · 표 줄 표는 244-4 에서 모달로) + assert seen == 2 def test_고르기_메뉴_이동_그대로(): diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index a4e3141aa..f7bed025c 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -159,12 +159,13 @@ def test_메인_탭_하위_탭_추가_삭제(): ) ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui - assert 'label: "+ 추가"' in ui and "open(addRoute(projectId))" in ui - assert 'label: "삭제"' in ui and 'remove.disabled = current === "table"' in ui - assert "data.routes.filter((r) => r.id !== route.id)" in ui + # 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만) + assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert "fresh.routes = fresh.routes.filter((r) => r.id !== routeId);" in modal assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") - assert route.count("editOnDouble: true") == 2 and "uploadImage(projectId, routeId" in route + assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index aa4944a42..4c5663804 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -140,13 +140,16 @@ def test_빈_방위_차례_같은_방위_나란히(): def test_화면_상자_누름_더하기_창(): ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8") - assert 'g.addEventListener("click", () => openTab(box.id));' in ui + # 244-4 — 상자 누름 = 모달(탭 이동 아님) + assert "openTab" not in ui and "route: (id) => openRouteInfo(projectId, 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 "openAddItem(projectId, viaBase, addRoute)" in ui and "openModal({" in ui - assert "same.rows.push(row)" in ui # 같은 업체 이름 = 그 경로에 표 줄 + assert "openAddItem(projectId, viaBase, addRoute)" in ui + modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8") + assert "openModal({" in modal + assert "same.rows.push(row)" in modal # 같은 업체 이름 = 그 경로에 표 줄 page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") - assert "createDiagram(projectId, open, (init) => addRoute(projectId, init))" in page + assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"): assert ( diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py new file mode 100644 index 000000000..091a921ce --- /dev/null +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -0,0 +1,185 @@ +"""244-4 B08 거리이정표 고침(사용자 130번) — [+] 모달(항목 · 업체 이름 · 목적지 + 주소 검색 재활용) · +방위 = 좌표 자동 · 시점 모달(등록 주소 · VWorld 주소 · 지도) · 다이어그램 팬 · 줌 공용 · +경로 하위 탭 = 그림만(다음 지도 길찾기 새 창 · 화면 캡처) · 상자 누름 = 항목 정보 모달. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _route(rid, lon=None, lat=None, via=True): + return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "lon": lon, + "lat": lat, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]} # fmt: skip + + +def _center(lay, bid): + box = next(b for b in lay["boxes"] if b["id"] == bid) + return box["x"] + box["w"] / 2, box["y"] + box["h"] / 2 + + +def test_방위_좌표_자동(): + site = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None} + base = {"enabled": True, "name": "기준", "address": "", "dir": None, "legs": []} + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": base, + # 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽 + "routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)], + } + lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "base") + sx, sy = _center(lay, "s") + assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 + cx, cy = _center(lay, "site") + ex, ey = _center(lay, "e") + assert ex > cx + 200 and abs(ey - cy) < 1 + # 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽) + data["base"] = {**base, "lon": 127.7, "lat": 35.7} + data["routes"] = [_route("n", 127.7, 35.8)] + lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "base") + nx, ny = _center(lay, "n") + assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) + assert abs(nx - bx) < 1 and ny < by - 200 + + +def test_다음_지도_길찾기_주소(): + pts = [{"name": "거창군청", "lon": 127.909, "lat": 35.686}, + {"name": "병곡리, 968/31", "lon": 127.75, "lat": 35.78}, + {"name": "임도 시점", "lon": 127.74, "lat": 35.79}] # fmt: skip + url = _node(f"kakaoRouteUrl({json.dumps(pts, ensure_ascii=False)})") + # 공식 꼴 /link/by/car/이름,위도,경도/… · 이름의 쉼표 · 빗금은 빈칸 + assert url == ( + "https://map.kakao.com/link/by/car/%EA%B1%B0%EC%B0%BD%EA%B5%B0%EC%B2%AD,35.686,127.909/" + "%EB%B3%91%EA%B3%A1%EB%A6%AC%20%20968%2031,35.78,127.75/" + "%EC%9E%84%EB%8F%84%20%EC%8B%9C%EC%A0%90,35.79,127.74" + ) + + +def test_VWorld_좌표_주소(monkeypatch): + body = {"response": {"status": "OK", "result": [ + {"type": "parcel", "text": "경남 거창군 북상면 병곡리 968-31"}, + {"type": "road", "text": "경남 거창군 북상면 덕유월성로 1"}]}} # fmt: skip + seen = [] + + def fetch(url, timeout=5): + seen.append(url) + return json.dumps(body).encode() + + monkeypatch.setattr(vmap, "_fetch", fetch) + assert vmap._reverse_sync(127.75, 35.78) == "경남 거창군 북상면 덕유월성로 1" + assert "request=getAddress" in seen[0] and "point=127.75%2C35.78" in seen[0] + body["response"]["result"] = body["response"]["result"][:1] + assert vmap._reverse_sync(127.75, 35.78) == "경남 거창군 북상면 병곡리 968-31" + monkeypatch.setattr(vmap, "_fetch", lambda url, timeout=5: b"not json") + assert vmap._reverse_sync(127.75, 35.78) is None + + +def test_시점_모달_주소_길(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_pid): + return tmp_path + + async def project(_pid): + return {"region": "경남 거창군 북상면"} + + async def where(lon, lat): + return f"주소 {lon:.3f}" + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.setattr(ms, "get_project_by_id", project) + monkeypatch.setattr(ms, "reverse_geocode", where) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + url = f"/api/projects/{uuid4()}/quantity/milestone/site-address" + empty = client.get(url).json() + assert empty["region"] == "경남 거창군 북상면" and empty["start_address"] is None + path = tmp_path / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8") + got = client.get(url).json() + lon, lat = got["start_lonlat"] + assert 124 < lon < 132 and 33 < lat < 39 + assert got["start_address"] == f"주소 {lon:.3f}" and got["mid_address"].startswith("주소 ") + + +def test_화면_모달_팬줌_그림탭(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + # ① [+] 모달 — 항목 · 업체 이름 · 목적지 + 주소 검색(B01 재활용) · 방위 고르기 없음 · [등록] + add = modal[ + modal.index("export function openAddItem") : modal.index("export function openRouteInfo") + ] + for text in ('label: "항목"', 'label: "업체 이름"', 'label: "목적지"', 'label: "등록"', + "buildAddressField(null)", "placeOf(address,"): # fmt: skip + assert text in add + assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal + assert 'from "../B01_Dashboard/B01_Dashboard_UI_AddressField"' in modal + assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal + field = _read("B01_Dashboard/B01_Dashboard_UI_AddressField.ts") + assert "point: () => picked," in field and "searchAddress(text)" in field + # ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도 + assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal + assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal + # ⑤ 업체 상자 = 항목 정보(구간 km · 도로 구분 · 고침 · 삭제) + info = modal[ + modal.index("export function openRouteInfo") : modal.index("export function openBaseInfo") + ] + for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs, "2차포장")', + "rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip + assert text in info + diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert "route: (id) => openRouteInfo(projectId, id)," in diagram + assert "start: () => openSiteInfo(projectId)," in diagram + # ③ 팬 · 줌 = 공용 + assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram + assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram + # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 화면 캡처 → 그림 올리기 길 + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert ( + "createDataTable" not in route and "구간" not in route.split("export function routeView")[1] + ) + assert "window.open(kakaoRouteUrl(points)" in route + assert "getDisplayMedia({ video: true, audio: false })" in route + assert ( + "captureWindow().then(put," in route and "uploadImage(projectId, routeId, picked)" in route + ) + assert 'label: "그림 올리기"' in route + page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page + for rel in ("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", + "B08_Quantity/B08_Quantity_UI_Milestone_Route.ts", + "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts", + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700 From 7be4feb1cf078ff880fb772258a67a4bb752d71a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 21:36:56 +0900 Subject: [PATCH 2/2] =?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