From 2afb1af1ca045dd0d30e26b6eed52cf3b79c0197 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:36:39 +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=93=B1=EB=A1=9D=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?2=EC=97=B4=20=C2=B7=20=EC=86=8C=EC=9E=AC=EC=A7=80=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=EC=A7=80=EB=8F=84=20=C2=B7=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=84=A0=20=EB=AA=A8=EB=8B=AC=20=EA=B8=B8=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EB=A1=9C=20=EA=B5=AC=EB=B6=84=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=EC=97=B4(244-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록 모달 한 벌(추가 · 상자 고침 · 삭제) — 분류(기준위치 · 거래처) · 항목 고르기 · 거래처 이름 · 목적지 2열 · 큰 창 - 소재지 = 주소 찾기 · 다음 지도 누름 · 표지 끌기(직접 입력 없음) · 좌표 → VWorld 주소 서버 길 - 다이어그램 [기준점 +] 없앰 · 연결선 누름 = 도로 구분 · 거리 · 다음 지도 경로 · 끝점 끌기 = 다시 길찾기 - 시점 → 중점 = 손 입력만 · 자동 그림 = 거래처 쪽 구간 - 도로 구분 = 기준점 거침 2차 · 1차 · 비포장 · 그 밖 포장 · 비포장(파이썬 · TS 짝) · 표 열 기준점 따라 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Calc.py | 12 +- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 18 +- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 48 +- B08_Quantity/B08_Quantity_Router_Milestone.py | 14 +- B08_Quantity/B08_Quantity_UI_Milestone.css | 28 +- B08_Quantity/B08_Quantity_UI_Milestone.ts | 128 +++-- .../B08_Quantity_UI_Milestone_Diagram.ts | 57 +- .../B08_Quantity_UI_Milestone_KakaoMap.ts | 118 ++++- .../B08_Quantity_UI_Milestone_LegModal.ts | 251 +++++++++ .../B08_Quantity_UI_Milestone_Modal.ts | 494 +++++++----------- .../B08_Quantity_UI_Milestone_Place.ts | 144 +++++ .../B08_Quantity_UI_Milestone_State.ts | 118 +++-- .../test_244_11_b08_milestone_auto_capture.py | 4 +- .../test_244_13_b08_milestone_place_leg.py | 305 +++++++++++ resources/tester/test_244_1_b08_milestone.py | 4 +- .../test_244_2_b08_milestone_diagram.py | 9 +- .../tester/test_244_4_b08_milestone_modal.py | 32 +- .../test_244_5_b08_milestone_base_dir.py | 6 +- .../test_244_6_b08_milestone_pan_client.py | 12 +- .../tester/test_244_7_b08_milestone_kakao.py | 6 +- .../test_244_9_b08_milestone_route_map.py | 6 +- 21 files changed, 1315 insertions(+), 499 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_Place.ts create mode 100644 resources/tester/test_244_13_b08_milestone_place_leg.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index 58cc57df4..f77d85f6a 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -10,6 +10,9 @@ 끝수 = 소수 셋째 자리(반올림). 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 pick = 참(화면 「도로 구분 고르기」). +도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 · +그 밖 = 포장 · 비포장 · +포장 = 2차 + 1차 + 포장. """ from __future__ import annotations @@ -17,8 +20,8 @@ from __future__ import annotations import math from typing import Any -ROADS = ("2차포장", "1차포장", "비포장") -KEYS = {"2차포장": "s2", "1차포장": "s1", "비포장": "un"} +ROADS = ("2차포장", "1차포장", "포장", "비포장") +KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} def r3(value: float) -> float: @@ -60,13 +63,13 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list out: list[dict[str, Any]] = [] for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): - sums = {"s2": 0.0, "s1": 0.0, "un": 0.0} + sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0} pick = add_legs(sums, route.get("legs")) if base.get("enabled") and route.get("via_base", True): pick = add_legs(sums, base.get("legs")) or pick if row.get("dest") == "mid": add_legs(sums, [mid]) - paved = sums["s2"] + sums["s1"] + paved = sums["s2"] + sums["s1"] + sums["pv"] out.append( { "route": route.get("id"), @@ -76,6 +79,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list "dest": row.get("dest") or "start", "s2": r3(sums["s2"]), "s1": r3(sums["s1"]), + "pv": r3(sums["pv"]), "un": r3(sums["un"]), "paved": r3(paved), "unpaved": r3(sums["un"]), diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 481d56391..50aa3ae78 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -11,10 +11,15 @@ * 끝수 = 소수 셋째 자리(반올림) * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 * pick = 참(화면 「도로 구분 고르기」) + * 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · + * 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장 * ========================================================================== */ -export const ROADS = ["2차포장", "1차포장", "비포장"] as const; +export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const; export type Road = (typeof ROADS)[number]; +/** 구간 도로 구분 고르기 — 기준점 거침 = 2차 · 1차 · 비포장 · 아니면 포장 · 비포장(244-13) */ +export const roadsFor = (viaBase: boolean): Road[] => + viaBase ? ["2차포장", "1차포장", "비포장"] : ["포장", "비포장"]; /** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; export type Dir = (typeof DIRS)[number]; @@ -73,6 +78,7 @@ export interface MilestoneTableRow { dest: Dest; s2: number; s1: number; + pv: number; un: number; paved: number; unpaved: number; @@ -82,7 +88,8 @@ export interface MilestoneTableRow { pick: boolean; } -const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" }; +type Key = "s2" | "s1" | "pv" | "un"; +const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 포장: "pv", 비포장: "un" }; /** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000; @@ -91,7 +98,7 @@ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 10 export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; /** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */ -function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): boolean { +function addLegs(sums: Record, legs: Leg[] | undefined): boolean { let pick = false; for (const leg of legs ?? []) { const key = leg.road ? KEYS[leg.road] : undefined; @@ -117,11 +124,11 @@ export function computeMilestone( const out: MilestoneTableRow[] = []; for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { - const sums = { s2: 0, s1: 0, un: 0 }; + const sums = { s2: 0, s1: 0, pv: 0, un: 0 }; let pick = addLegs(sums, route.legs); if (base?.enabled && route.via_base !== false) pick = addLegs(sums, base.legs) || pick; if (row.dest === "mid") addLegs(sums, [mid]); - const paved = sums.s2 + sums.s1; + const paved = sums.s2 + sums.s1 + sums.pv; out.push({ route: route.id, index, @@ -130,6 +137,7 @@ export function computeMilestone( dest: row.dest ?? "start", s2: r3(sums.s2), s1: r3(sums.s1), + pv: r3(sums.pv), un: r3(sums.un), paved: r3(paved), unpaved: r3(sums.un), diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 6c193c89b..12f3c648b 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -11,6 +11,7 @@ * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 + * 연결선 구간(244-13) = `segmentEnds` · 도로 구분 갈래 = `chainVia`(기준점 거침 = 2차 · 1차 · 비포장) * 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘 * 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버 * 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · @@ -210,25 +211,37 @@ export interface KmSegment { const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null => p.lon != null && p.lat != null ? [p.lon, p.lat] : null; +/** 구간 한 줄(244-13 연결선) — from = 거래처 id(거래처 → 기준점(거침) · 시점) · "base"(기준점 → 시점) · + * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함) */ +export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null { + const baseName = data.base.name || "기준점"; + if (from === "base") + return data.base.enabled + ? { 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; + const viaBase = data.base.enabled && route.via_base; + return { + key: "route", + text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, + from: pointOf(route), + to: viaBase ? pointOf(data.base) : (start ?? null), + }; +} + +/** 기준점 거쳐 시점 들어가는 구간(244-13 · 도로 구분 2차 · 1차 · 비포장) — 기준점 → 시점 · 거침 거래처 */ +export function chainVia(data: MilestoneData, from: string): boolean { + if (!data.base.enabled || from === "start") return false; + return from === "base" || !!data.routes.find((r) => r.id === from)?.via_base; +} + /** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] { - const route = data.routes.find((r) => r.id === routeId); - if (!route) return []; - const viaBase = data.base.enabled && route.via_base; - const baseName = data.base.name || "기준점"; - const from = pointOf(route); - const first: KmSegment = { - key: "route", - text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, - from, - to: viaBase ? pointOf(data.base) : (start ?? null), - }; - if (!viaBase) return [first]; - return [ - first, - { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null }, - ]; + const first = segmentEnds(data, routeId, start); + if (!first) return []; + return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first]; } /** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ @@ -544,7 +557,8 @@ export function setLeg( } /** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */ -export const PAN_SKIP = ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus"; +export const PAN_SKIP = + ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus, .b08-milestone__line-hit"; export function panStart(e: { button: number; target: EventTarget | null }): boolean { if (e.button === 1) return true; const target = e.target as { closest?: (selector: string) => unknown } | null; diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 6f962871b..01f281924 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -15,6 +15,8 @@ POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) + 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함) + GET /api/projects/{id}/quantity/milestone/reverse?lon=&lat= 지도에서 고른 소재지(244-13) → + VWorld 주소 한 줄(서버 단독 · 저장 안 함 · 못 찾음 = null) GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 · 공개용 · .env KAKAO_JS_KEY · 없으면 null) @@ -68,7 +70,7 @@ AUTO = "auto" # 자동 그림 하위 폴더(244-11) IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" -Road = Literal["2차포장", "1차포장", "비포장"] +Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13) Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -270,6 +272,16 @@ async def milestone_site_address( } +@router.get(PATH + "/reverse") +async def milestone_reverse( + project_id: UUID, + lon: Annotated[float, Query(ge=-180, le=180)], + lat: Annotated[float, Query(ge=-90, le=90)], +) -> dict[str, Any]: + """지도에서 고른 소재지(244-13) — 좌표 → VWorld 주소(못 찾음 = null).""" + return {"address": await reverse_geocode(lon, lat)} + + @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 1581b8005..90ff5b010 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -134,7 +134,7 @@ /* 선 글 바로 고침(244-4 ⑥) — 글 아래 작은 판 */ .b08-milestone__km tspan { - cursor: text; + cursor: pointer; } .b08-milestone__leg-edit { @@ -186,3 +186,29 @@ font-size: 12px; white-space: nowrap; } + +/* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */ +.ui-modal.b08-milestone__modal { + width: min(960px, 94vw); + --modal-max: 90vh; +} + +.b08-milestone__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; + align-items: end; +} + +.b08-milestone__place { + margin: 6px 0; + font-weight: 600; +} + +/* 연결선 누름 자리 — 투명한 굵은 선(244-13) */ +.b08-milestone__line-hit { + stroke: transparent; + stroke-width: 14; + pointer-events: stroke; + cursor: pointer; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 2a9aaea35..df187f338 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -3,7 +3,8 @@ * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름). * 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음). * - * [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) + * [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 도로 구분 · 계 · 비고) — 열(244-13) = 기준점 있음 = + * 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계 * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 * 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도 * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) @@ -32,18 +33,34 @@ import { } from "./B08_Quantity_UI_Milestone_Route"; const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; -const COLUMNS: DataColumn[] = [ - { key: "item", label: "항목", width: 120 }, - { key: "company", label: "거래처 이름", width: 120 }, - { key: "dest", label: "목적지", width: 72 }, - { key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM }, - { key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM }, - { key: "un", label: "비포장", group: ["도로 구분 (km)"], ...KM }, - { key: "paved", label: "포장", group: ["계 (km)"], ...KM }, - { key: "unpaved", label: "비포장", group: ["계 (km)"], ...KM }, - { key: "total", label: "합계", group: ["계 (km)"], ...KM, width: 108 }, - { key: "note", label: "비고", width: 160 }, -]; +const ROAD = ["도로 구분 (km)"]; +const SUM = ["계 (km)"]; + +/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · + * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ +export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { + const roads: DataColumn[] = withBase + ? [ + { key: "s2", label: "2차포장", group: ROAD, ...KM }, + { key: "s1", label: "1차포장", group: ROAD, ...KM }, + ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), + { key: "un", label: "비포장", group: ROAD, ...KM }, + { key: "paved", label: "포장", group: SUM, ...KM }, + { key: "unpaved", label: "비포장", group: SUM, ...KM }, + ] + : [ + { key: "paved", label: "포장", group: ROAD, ...KM }, + { key: "un", label: "비포장", group: ROAD, ...KM }, + ]; + return [ + { key: "item", label: "항목", width: 120 }, + { key: "company", label: "거래처 이름", width: 120 }, + { key: "dest", label: "목적지", width: 72 }, + ...roads, + { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, + { key: "note", label: "비고", width: 160 }, + ]; +} /** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */ export function addRoute(projectId: string, init: Partial = {}): string { @@ -68,44 +85,63 @@ function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; let pickRows: boolean[] = []; - const table = createDataTable({ - columns: COLUMNS, - rows: [], - caption: "거리이정표", - empty: "", - editOnDouble: true, - cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), - editable: (_i, key) => key === "item" || key === "company" || key === "note", - onEdit: (i, key, text) => { - const at = keys[i]; - if (!at) return; - editRoute(projectId, at.route, (_, r) => { - if (key === "company") r.name = text; - else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text; - }); - }, - onCellClick: (i, key) => { - const at = keys[i]; - if (key !== "dest" || !at) return; - pickAtCell( - (["start", "mid"] as const).map((dest) => [ - DEST_TEXT[dest], - () => - editRoute(projectId, at.route, (_, r) => { - if (r.rows[at.index]) r.rows[at.index].dest = dest; - }), - ]), - ); - }, - }); - trackCells(table.root); - table.root.dataset.b08Milestone = "table"; + const makeTable = (columns: DataColumn[]) => { + const t = createDataTable({ + columns, + rows: [], + caption: "거리이정표", + empty: "", + editOnDouble: true, + cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null), + editable: (_i, key) => key === "item" || key === "company" || key === "note", + onEdit: (i, key, text) => { + const at = keys[i]; + if (!at) return; + editRoute(projectId, at.route, (_, r) => { + if (key === "company") r.name = text; + else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text; + }); + }, + onCellClick: (i, key) => { + const at = keys[i]; + if (key !== "dest" || !at) return; + pickAtCell( + (["start", "mid"] as const).map((dest) => [ + DEST_TEXT[dest], + () => + editRoute(projectId, at.route, (_, r) => { + if (r.rows[at.index]) r.rows[at.index].dest = dest; + }), + ]), + ); + }, + }); + trackCells(t.root); + t.root.dataset.b08Milestone = "table"; + return t; + }; + let mode = "false:false"; + let table = makeTable(milestoneColumns(false, false)); const none = el("p", { className: "ui-general-block__empty", text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.", }); const refresh = (): void => { - const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); + const data = liveMilestone(projectId); + const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); + // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 + const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`; + if (next !== mode) { + mode = next; + const fresh = makeTable( + milestoneColumns( + data.base.enabled, + rows.some((r) => r.pv > 0), + ), + ); + table.root.replaceWith(fresh.root); + table = fresh; + } keys = rows.map(({ route, index }) => ({ route, index })); pickRows = rows.map((r) => r.pick); none.hidden = rows.length > 0; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 55bf4e8a9..91cb5e2d6 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -6,17 +6,20 @@ * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 거래처 = 항목 정보 · 공사지 = 시점 주소 · * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) - * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6) + * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰) + * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달 + * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ -import { createButton, createSelectField, 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, type Road } from "./B08_Quantity_Milestone_Calc"; import { + chainVia, layoutMilestone, legAt, panContent, @@ -24,14 +27,9 @@ import { setLeg, type DiagramBox, } from "./B08_Quantity_Milestone_Layout"; +import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal"; +import { openPlaceModal, openSiteInfo } from "./B08_Quantity_UI_Milestone_Modal"; import { - openAddItem, - openBaseInfo, - openRouteInfo, - openSiteInfo, -} from "./B08_Quantity_UI_Milestone_Modal"; -import { - legRoadOptions, liveMilestone, putMilestone, roadOptions, @@ -60,12 +58,13 @@ function editLegAt( i: number, midKm: number, ): void { - const leg = legAt(liveMilestone(projectId), from, i, midKm); + const data = liveMilestone(projectId); + const leg = legAt(data, from, i, midKm); if (!leg) return; document.querySelector(".b08-milestone__leg-edit")?.remove(); const road = createSelectField({ label: "도로 구분", - options: from === "start" ? roadOptions : legRoadOptions, // 시점 → 중점 = 늘 고름 + options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름 compact: true, }); road.select.value = leg.road ?? ""; @@ -121,11 +120,11 @@ export function createDiagram( host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 const INFO: Record void> = { - route: (id) => openRouteInfo(projectId, id), + route: (id) => openPlaceModal(projectId, { add: false, id }), site: () => openSiteInfo(projectId), start: () => openSiteInfo(projectId), mid: () => openSiteInfo(projectId), - base: () => openBaseInfo(projectId), + base: () => openPlaceModal(projectId, { add: false, id: "base" }), }; const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => { @@ -140,7 +139,7 @@ export function createDiagram( g.dataset.b08MilestonePlus = viaBase ? "base" : "site"; const go = (e: Event): void => { e.stopPropagation(); - openAddItem(projectId, viaBase, addRoute); + openPlaceModal(projectId, { add: true, viaBase }, addRoute); }; g.addEventListener("click", go); g.addEventListener("keydown", (e) => e.key === "Enter" && go(e)); @@ -183,9 +182,15 @@ export function createDiagram( // 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림) const lineLayer = svg("g", {}); for (const ln of layout.lines) { - lineLayer.append( - svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }), - ); + const ends = { x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }; + // 누르는 자리 = 투명한 굵은 선(244-13 · 연결선 모달) + const hit = svg("line", { class: "b08-milestone__line-hit", ...ends }); + hit.dataset.b08MilestoneLine = ln.from; + hit.addEventListener("click", (e) => { + e.stopPropagation(); + openLegInfo(projectId, ln.from); + }); + lineLayer.append(svg("line", { class: "b08-milestone__line", ...ends }), hit); } for (const box of layout.boxes) { const g = svg("g", { class: `b08-milestone__box is-${box.kind}` }); @@ -240,23 +245,23 @@ export function createDiagram( const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); + // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 + let wait = 0; + span.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(wait); + wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); + }); span.addEventListener("dblclick", (e) => { e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 + clearTimeout(wait); editLegAt(projectId, span, ln.from, i, mk); }); text.append(span); }); shift.append(text); } - const addBase = createButton({ - label: "기준점 +", - size: "sm", - variant: "ghost", - onClick: () => openBaseInfo(projectId, true), - }); - addBase.dataset.b08MilestoneAddBase = ""; - addBase.hidden = data.base.enabled; - host.replaceChildren(el("div", { className: "b08-milestone__bar", children: [addBase] }), root); + host.replaceChildren(root); attachSvgZoomPan({ svg: root, layer, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index f15eb4bae..af216b5cb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -21,14 +21,22 @@ const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 -type LatLng = object; +interface LatLng { + getLat(): number; + getLng(): number; +} interface KMap { setBounds(bounds: object): void; + setCenter(p: LatLng): void; + setLevel(level: number): void; relayout(): void; } interface KOverlay { setMap(map: KMap | null): void; } +interface KMarker extends KOverlay { + getPosition(): LatLng; +} interface KakaoMaps { load(done: () => void): void; LatLng: new (lat: number, lng: number) => LatLng; @@ -40,7 +48,10 @@ interface KakaoMaps { strokeColor: string; strokeOpacity: number; }) => KOverlay; - Marker: new (o: { position: LatLng; title: string }) => KOverlay; + Marker: new (o: { position: LatLng; title: string; draggable?: boolean }) => KMarker; + event: { + addListener(target: object, type: string, fn: (e: { latLng: LatLng }) => void): void; + }; CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay; } @@ -160,3 +171,106 @@ export function routeMapView(projectId: string, routeId: string): RouteMapView { draw(); return { root, draw }; } + +/** 고르는 지도 표지(244-13) — key = 끌어 옮길 때 돌려받는 이름 */ +export interface MapPin { + key: string; + name: string; + lon: number; + lat: number; + movable: boolean; +} + +export interface EditMap { + root: HTMLElement; + /** 표지 · 길 선 다시 놓고 맞춤 */ + set: (pins: MapPin[], lines: Array>) => void; +} + +/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */ +export function editMap( + projectId: string, + opts: { + onMove: (key: string, lon: number, lat: number) => void; + onClick?: (lon: number, lat: number) => void; + center?: () => [number, number] | null; + hint: string; + }, +): EditMap { + const box = el("div", { className: "b08-milestone__map" }); + const hint = el("p", { className: "b08-options__hint", text: opts.hint }); + hint.dataset.b08MilestoneMapHint = ""; + let map: KMap | null = null; + let drawn: KOverlay[] = []; + + const set = (pins: MapPin[], lines: Array>): void => { + loadKakaoMaps(projectId).then( + (maps) => + requestAnimationFrame(() => { + const points = [ + ...pins.map((p) => new maps.LatLng(p.lat, p.lon)), + ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), + ]; + const c = opts.center?.(); + const first = points[0] ?? (c ? new maps.LatLng(c[1], c[0]) : null); + if (!first) return void (box.hidden = true); + box.hidden = false; + if (!map) { + map = new maps.Map(box, { center: first, level: 7 }); + if (opts.onClick) + maps.event.addListener(map, "click", (e) => + opts.onClick!(e.latLng.getLng(), e.latLng.getLat()), + ); + } + map.relayout(); + drawn.forEach((o) => o.setMap(null)); + drawn = [ + ...lines.map( + (line) => + new maps.Polyline({ + path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)), + strokeWeight: 6, + strokeColor: LINE_COLOR, + strokeOpacity: 0.9, + }), + ), + ...pins.flatMap((p) => { + const at = new maps.LatLng(p.lat, p.lon); + const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable }); + if (p.movable) + maps.event.addListener(marker, "dragend", () => { + const to = marker.getPosition(); + opts.onMove(p.key, to.getLng(), to.getLat()); + }); + return [ + marker, + new maps.CustomOverlay({ + position: at, + content: el("span", { className: "b08-milestone__map-label", text: p.name }), + yAnchor: 2.6, + }), + ]; + }), + ]; + drawn.forEach((o) => o.setMap(map)); + if (points.length > 1) { + const bounds = new maps.LatLngBounds(); + points.forEach((p) => bounds.extend(p)); + map.setBounds(bounds); + } else { + map.setCenter(first); + map.setLevel(points.length ? 4 : 9); + } + }), + (error: unknown) => { + box.hidden = true; + hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; + }, + ); + }; + + return { + root: el("div", { className: "b08-milestone__map-wrap", children: [hint, box] }), + set, + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts new file mode 100644 index 000000000..f8f7cd3e6 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -0,0 +1,251 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_LegModal.ts + * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. + * + * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) + * 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 · + * 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`) + * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) + * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) + * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) + * 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`) + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { ROADS, type Leg, type Road } from "./B08_Quantity_Milestone_Calc"; +import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; +import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; +import { + findSegmentKm, + liveMilestone, + putMilestone, + roadOptions, + segmentPath, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; + +const km = (text: string): number | null => { + const n = Number(text); + return text.trim() && Number.isFinite(n) && n >= 0 ? n : null; +}; + +/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */ +export function legsEditor( + initial: Leg[], + via: boolean, +): { root: HTMLElement; value: () => Leg[] } { + const list = el("div"); + const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; + const add = (leg: Leg): void => { + const road = createSelectField({ + label: "도로 구분", + options: roadOptions(via, true, leg.road), + compact: true, + }); + const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); + road.select.value = leg.road ?? ""; + dist.input.value = leg.km == null ? "" : String(leg.km); + const auto = leg.auto_km ?? null; + if (auto != null) { + dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 + dist.input.title = `자동 값 ${auto} km`; + } + const ref = { road: road.select, km: dist.input, auto }; + rows.push(ref); + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(ref), 1); + row.remove(); + }, + }); + const row = el("div", { + className: "b08-milestone__line-edit", + children: [road.root, dist.root, drop], + }); + row.dataset.b08MilestoneLeg = ""; + list.append(row); + }; + (initial.length ? initial : [{ road: null, km: null }]).forEach(add); + const more = createButton({ + label: "+ 구간", + size: "sm", + variant: "ghost", + onClick: () => add({ road: null, km: null }), + }); + return { + root: el("div", { children: [list, more] }), + value: () => + rows.map(({ road, km: dist, auto }) => ({ + road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, + km: km(dist.value), + auto_km: auto, + })), + }; +} + +const hint = (text: string): HTMLElement => el("p", { className: "b08-options__hint", text }); + +/** 시점 → 중점 — 도로 구분 · 거리 늘 손 입력(빈칸 = 노선 ÷ 2) */ +function openMidLeg(projectId: string): void { + const data = liveMilestone(projectId); + const site = siteInfo(); + openModal({ + title: "구간 — 시점 → 중점", + closeLabel: "닫기", + dialogClass: "b08-milestone__modal", + mount: (body, close, actions) => { + const road = createSelectField({ + label: "도로 구분", + options: roadOptions(false, false, data.site.mid_road), + compact: true, + }); + road.select.value = data.site.mid_road; + const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); + dist.input.value = + data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); + dist.input.placeholder = site?.mid_default_m == null ? "" : String(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2 + dist.input.dataset.b08MilestoneLegKm = "start"; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh = liveMilestone(projectId); + putMilestone( + projectId, + setLeg(fresh, "start", 0, { + road: road.select.value as Road, + km: km(dist.input.value), + }), + ); + close(); + }, + }); + save.dataset.b08MilestoneLegSave = ""; + actions.append(save); + body.append( + el("div", { className: "b08-milestone__grid", children: [road.root, dist.root] }), + hint("시점 → 중점 = 늘 손 입력(길찾기 없음) · 빈칸 = 노선 ÷ 2"), + ); + }, + }); +} + +/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */ +export function openLegInfo(projectId: string, from: string): void { + if (from === "start") return openMidLeg(projectId); + const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; + const seg = segmentEnds(liveMilestone(projectId), from, start()); + if (!seg) return; + openModal({ + title: `구간 — ${seg.text}`, + closeLabel: "닫기", + dialogClass: "b08-milestone__modal", + mount: (body, close, actions) => { + const legsOf = (): Leg[] => { + const d = liveMilestone(projectId); + return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? []; + }; + const via = chainVia(liveMilestone(projectId), from); + let legs = legsEditor(legsOf(), via); + const status = hint(""); + status.dataset.b08MilestoneLegStatus = ""; + /** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */ + const apply = (): void => { + const fresh = liveMilestone(projectId); + const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from); + if (!target) return; + target.legs = legs.value(); + putMilestone(projectId, fresh); + }; + const redrawLegs = (): void => { + const next = legsEditor(legsOf(), via); + legs.root.replaceWith(next.root); + legs = next; + }; + const pins = (): MapPin[] => { + const d = liveMilestone(projectId); + const route = d.routes.find((r) => r.id === from); + const out: MapPin[] = []; + const add = (key: string, name: string, lon?: number | null, lat?: number | null): void => { + if (lon != null && lat != null) + out.push({ key, name, lon, lat, movable: key !== "start" }); + }; + if (route) add(route.id, route.name || "거래처", route.lon, route.lat); + if (from === "base" || via) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + else add("start", "시점", start()?.[0], start()?.[1]); + if (from === "base") add("start", "시점", start()?.[0], start()?.[1]); + return out; + }; + const drawMap = (): void => { + const path = segmentPath(from); + map.set(pins(), path.length >= 2 ? [path] : []); + }; + const run = async (image = true): Promise => { + find.disabled = true; + status.textContent = "길찾기 중…"; + const typed = JSON.stringify(legs.value()); // 기다리는 동안 손댄 칸은 그대로 + const text = await findSegmentKm(projectId, from, image).finally( + () => (find.disabled = false), + ); + status.textContent = text; + if (image) toastKm(text); + if (JSON.stringify(legs.value()) === typed) redrawLegs(); + drawMap(); + }; + // 끝점 끌어 옮김 = 그 소재지 고침(주소 다시) + 다시 길찾기 + const map = editMap(projectId, { + hint: "경로 = 카카오 길찾기 · 거래처 · 기준점 표지를 끌어 옮기면 다시 길찾기", + onMove: (key, lon, lat) => { + apply(); + void reverseAddress(projectId, lon, lat).then((address) => { + const fresh = liveMilestone(projectId); + const target = key === "base" ? fresh.base : fresh.routes.find((r) => r.id === key); + if (!target) return; + Object.assign(target, { lon, lat, address }); + putMilestone(projectId, fresh); + void run(); + }); + }, + }); + const find = createButton({ + label: "길찾기", + variant: "ghost", + onClick: () => { + apply(); + void run(); + }, + }); + find.dataset.b08MilestoneLegFind = ""; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + apply(); + close(); + }, + }); + save.dataset.b08MilestoneLegSave = ""; + actions.append(find, save); + body.append( + legs.root, + hint( + from === "base" + ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" + : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", + ), + status, + el("h4", { text: "경로 — 다음(카카오) 지도" }), + map.root, + ); + drawMap(); + // 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음) + if (!segmentPath(from).length && seg.from && seg.to) void run(false); + }, + }); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index fc0ed923c..74bc8a704 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -1,17 +1,15 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_Modal.ts - * 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침. + * 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침. * - * [+] 항목 추가 = 항목 · 거래처 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = - * 경로(하위 탭) 생김 · 같은 거래처 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 - * 거래처 상자 = 그 항목 정보(거래처 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] + * 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 · + * 2열(분류(기준위치 · 거래처) · 항목(분류 따라 고르기 목록) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · + * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제] + * 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정 + * 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리 + * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 - * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) - * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 - * [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 · - * 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」) - * 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · 보기만) · 경로 그림 = [등록] · [길찾기] 뒤 - * 서버 자동 캡처(244-11) + * 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; @@ -25,25 +23,12 @@ import { 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 type { Dest, MilestoneRoute, MilestoneRow, Road } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; -import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { - findRouteKm, - legRoadOptions, + findSegmentKm, liveMilestone, putMilestone, roadOptions, @@ -55,324 +40,211 @@ const DEST_OPTIONS = [ { value: "mid", text: "중점" }, ]; +/** 분류 → 항목 고르기 목록(거래처 = 견본 「거리이정표」 시트 다섯) */ +export const ITEMS: Record<"route" | "base", string[]> = { + route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"], + base: ["기준점"], +}; + const line = (...children: HTMLElement[]): HTMLElement => el("div", { className: "b08-milestone__line-edit", children }); -/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · km(빈칸 = 자동 값) · [×] · [+ 구간] */ -function legsEditor(initial: Leg[]): { root: HTMLElement; value: () => Leg[] } { - const list = el("div"); - const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = []; - const add = (leg: Leg): void => { - const road = createSelectField({ label: "도로 구분", options: legRoadOptions, 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 auto = leg.auto_km ?? null; - if (auto != null) { - km.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선 - km.input.title = `자동 값 ${auto} km`; - } - const ref = { road: road.select, km: km.input, auto }; - 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: null, km: null }), - }); - return { - root: el("div", { children: [list, more] }), - value: () => - rows.map(({ road, km, auto }) => { - const n = Number(km.value); - return { - road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null, - km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null, - auto_km: auto, - }; - }), - }; -} - -/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */ -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 }); -/** [길찾기] 한 번 — 구간마다 결과 한 줄(알림) · 실패 줄이 있으면 경고 */ -async function routeKm(projectId: string, routeId: string): Promise { - const lines = await findRouteKm(projectId, routeId); - if (lines.length) - showToast( - `길찾기 — ${lines.join(" / ")}`, - lines.some((l) => l.includes(" — ")) ? "warning" : "success", +/** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */ +export function toastKm(text: string): void { + if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success"); +} + +/** 항목 ▾ · 목적지 ▾ 한 줄 — 목록 밖 옛 항목 글도 보임 */ +function rowField(r: MilestoneRow, kind: "route" | "base") { + const item = createSelectField({ label: "항목", compact: true, options: [] }); + const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); + const setKind = (k: "route" | "base"): void => { + const list = [...ITEMS[k]]; + if (r.item && !list.includes(r.item)) list.unshift(r.item); + const keep = list.includes(item.select.value) ? item.select.value : r.item || list[0]; + item.setOptions( + list.map((t) => ({ value: t, text: t })), + keep, ); - return lines; + dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정 + }; + setKind(kind); + dest.select.value = r.dest; + item.select.dataset.b08MilestoneAdd = "item"; + return { item, dest, note: r.note, setKind }; } -/** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ -export function openAddItem( +export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string }; + +/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ +export function openPlaceModal( projectId: string, - viaBase: boolean, - addRoute: (init: Partial) => string, + target: PlaceTarget, + 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); - let id = same?.id; - if (same) { - same.rows.push(row); - if (place.lon != null) Object.assign(same, place); - putMilestone(projectId, data); - } else id = addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); - close(); - if (id && place.lon != null) void routeKm(projectId, id); // [등록] 뒤 한 번 - }, - }); - 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; + const editBase = !target.add && target.id === "base"; + 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 || "거래처"}`; openModal({ - title: `항목 정보 — ${route.name || "거래처"}`, + title, closeLabel: "닫기", + dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { + let kind: "route" | "base" = editBase ? "base" : "route"; + const sort = createSelectField({ + label: "분류", + compact: true, + options: [ + { value: "base", text: "기준위치" }, + { value: "route", text: "거래처" }, + ], + }); + sort.select.value = kind; + sort.select.disabled = !target.add; + // 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침) + const baseOption = sort.select.querySelector('option[value="base"]'); + if (target.add && data.base.enabled && baseOption) { + baseOption.disabled = true; + baseOption.textContent = "기준위치(있음 — 기준점 상자에서 고침)"; + } + sort.select.dataset.b08MilestoneAdd = "sort"; 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); - let legs = legsEditor(route.legs); - const status = el("p", { className: "b08-options__hint" }); - status.dataset.b08MilestoneInfo = "status"; - const mapView = routeMapView(projectId, routeId); - /** 모달 값 → 캐시(고침 · 길찾기 앞) */ - const apply = (): boolean => { - const fresh = liveMilestone(projectId); - const r = fresh.routes.find((x) => x.id === routeId); - if (!r) return false; - Object.assign(r, placeOf(address, r), { - name: name.input.value.trim() || r.name, - via_base: via.input.checked, - rows: rows.value(), - legs: legs.value(), + name.input.value = editBase ? data.base.name : (route?.name ?? ""); + name.input.dataset.b08MilestoneAdd = "company"; + const nameLabel = name.root.querySelector(".ui-field__label"); + const initRows: MilestoneRow[] = route?.rows.length + ? route.rows + : [{ item: "", dest: "start", note: "" }]; + const rows = initRows.map((r) => rowField(r, kind)); + const [first] = rows; + const more = el("div"); + const addRow = (field: ReturnType): void => { + const drop = createButton({ + label: "×", + size: "sm", + variant: "ghost", + onClick: () => { + rows.splice(rows.indexOf(field), 1); + row.remove(); + }, }); - putMilestone(projectId, fresh); - return true; + const row = line(field.item.root, field.dest.root, drop); + more.append(row); }; - const save = createButton({ - label: "고침", + rows.slice(1).forEach(addRow); + const plusRow = createButton({ + label: "+ 줄", + size: "sm", + variant: "ghost", + onClick: () => { + const field = rowField({ item: "", dest: "start", note: "" }, kind); + rows.push(field); + addRow(field); + }, + }); + const via = createCheckbox("기준점 거침"); + via.input.checked = route?.via_base ?? true; + via.root.hidden = !route || !data.base.enabled; + const old = editBase ? data.base : route; + const place = placePicker( + projectId, + { address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null }, + () => name.input.value.trim() || (kind === "base" ? "기준점" : "거래처"), + ); + const setKind = (k: "route" | "base"): void => { + kind = k; + if (nameLabel) nameLabel.textContent = k === "base" ? "기준점 이름" : "거래처 이름"; + rows.forEach((r) => r.setKind(k)); + more.hidden = plusRow.hidden = k === "base"; + }; + sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base")); + setKind(kind); + const rowValues = (): MilestoneRow[] => + rows.map((r) => ({ + item: r.item.select.value, + dest: r.dest.select.value as Dest, + note: r.note, + })); + const moved = (p: { lon: number | null; lat: number | null }): boolean => + p.lon != null && (p.lon !== old?.lon || p.lat !== old?.lat); + + const ok = createButton({ + label: target.add ? "등록" : "고침", variant: "filled", onClick: () => { - apply(); + const text = name.input.value.trim(); + const where = place.value(); + const fresh = liveMilestone(projectId); + let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번 + if (kind === "base") { + Object.assign(fresh.base, where, { enabled: true, name: text, dir: null }); + if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }]; + putMilestone(projectId, fresh); + if (moved(where)) segment = "base"; + } else if (route) { + const r = fresh.routes.find((x) => x.id === route.id); + if (!r) return close(); + Object.assign(r, where, { + name: text || r.name, + via_base: via.input.checked, + rows: rowValues(), + }); + putMilestone(projectId, fresh); + if (moved(where) || r.via_base !== route.via_base) segment = r.id; + } else { + if (!text) return name.setError("거래처 이름을 넣으세요."); + const same = fresh.routes.find((r) => r.name.trim() === text); + if (same) { + same.rows.push(...rowValues()); + if (where.lon != null) Object.assign(same, where); + putMilestone(projectId, fresh); + } + const viaBase = target.add && target.viaBase; + const id = + same?.id ?? + addRoute?.({ name: text, via_base: viaBase, dir: null, rows: rowValues(), ...where }); + if (id && where.lon != null) segment = id; + } close(); + if (segment) void findSegmentKm(projectId, segment).then(toastKm); }, }); - // [길찾기] = 지금 모달 값을 캐시에 넣고 구간마다 카카오 km(서버) → 구간 칸 다시 그림 - const find = createButton({ - label: "길찾기", - variant: "ghost", - onClick: async () => { - if (!apply()) return close(); - find.disabled = true; - status.textContent = "길찾기 중…"; - const lines = await routeKm(projectId, routeId).finally(() => (find.disabled = false)); - status.textContent = lines.join(" / "); - const r = liveMilestone(projectId).routes.find((x) => x.id === routeId); - if (!r) return; - const next = legsEditor(r.legs); - legs.root.replaceWith(next.root); - legs = next; - mapView.draw(); // 길 선(그때그때 · 저장 안 함) - }, - }); - find.dataset.b08MilestoneInfo = "find"; + ok.dataset.b08MilestoneInfo = "save"; const remove = createButton({ label: "삭제", variant: "danger", onClick: async () => { - if ( - !(await showConfirmDialog( - `「${route.name || "거래처"}」 항목을 지울까요? — [저장] 때 반영`, - "삭제", - )) - ) + const label = editBase ? data.base.name || "기준점" : route?.name || "거래처"; + if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제"))) return; const fresh = liveMilestone(projectId); - fresh.routes = fresh.routes.filter((r) => r.id !== routeId); - putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐 + if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] }; + else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 + putMilestone(projectId, fresh); close(); }, }); - save.dataset.b08MilestoneInfo = "save"; remove.dataset.b08MilestoneInfo = "delete"; - actions.append(remove, find, save); + remove.hidden = target.add; + actions.append(remove, ok); body.append( - name.root, - via.root, - head("표 줄 — 항목 · 목적지"), - rows.root, - head( - `구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, - ), - legs.root, - status, - head("주소 · 지도"), - address.root, - head("경로 지도 — 카카오"), - mapView.root, - ); - }, - }); -} - -/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */ -export function openBaseInfo(projectId: string, create = false): void { - const base = liveMilestone(projectId).base; - openModal({ - title: create ? "기준점 추가" : `기준점 — ${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.length ? base.legs : [{ road: null, km: null }]); - const save = createButton({ - label: create ? "등록" : "고침", - variant: "filled", - onClick: () => { - const fresh: MilestoneData = liveMilestone(projectId); - Object.assign(fresh.base, placeOf(address, fresh.base), { - enabled: true, - name: name.input.value.trim(), - legs: legs.value(), - }); - putMilestone(projectId, fresh); - close(); - }, - }); - // 기준점 없앰 = 꺼 둠(값은 남김 · 거래처는 시점으로 바로) - const off = createButton({ - label: "기준점 없앰", - variant: "danger", - onClick: () => { - const fresh: MilestoneData = liveMilestone(projectId); - fresh.base.enabled = false; - putMilestone(projectId, fresh); - close(); - }, - }); - off.hidden = create; - save.dataset.b08MilestoneBase = "save"; - off.dataset.b08MilestoneBase = "off"; - actions.append(off, save); - body.append( - name.root, - head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"), - legs.root, - head("주소 · 지도"), - el("p", { - className: "b08-options__hint", - text: "주소를 고르면 다이어그램에 실제 방위로 놓임 — 안 고르면 공사지 서쪽(왼쪽) 기본 자리", + el("div", { + className: "b08-milestone__grid", + children: [sort.root, first.item.root, name.root, first.dest.root], }), - address.root, + more, + plusRow, + via.root, + head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), + place.root, ); }, }); @@ -410,7 +282,7 @@ export function openSiteInfo(projectId: string): void { mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", - options: roadOptions, + options: roadOptions(false, false, data.site.mid_road), compact: true, }); road.select.value = data.site.mid_road; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts new file mode 100644 index 000000000..aa65196e9 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Place.ts + * 거리이정표 등록 모달 「소재지」 칸(PLAN 244-13 · 사용자 147번 5) — 주소 찾기 · 지도에서 고르기만(직접 입력 없음). + * + * 주소 찾기 = B01 회사 주소와 같은 서버 후보 검색(VWorld 대리 · `searchAddress`) → 후보 고름 = 소재지 · 표지 + * 지도 = 다음(카카오) 지도 JS(`…_KakaoMap` editMap) — 지도 누름 = 그 자리 소재지 · 표지 끌어 옮김 = 다시 고름 · + * 고른 좌표 → 서버 GET …/milestone/reverse(VWorld 주소 한 줄 · 못 찾음 = 위도, 경도 글) + * 값 = 주소 글 · 경도 · 위도(캐시 · [저장] 때 서버) + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { siteInfo } from "./B08_Quantity_UI_Milestone_State"; + +export interface Place { + address: string; + lon: number | null; + lat: number | null; +} + +export interface PlaceHandle { + root: HTMLElement; + value: () => Place; +} + +/** 지번 후보 = 지번 줄이 시 · 도까지 갖춤(B01 주소 칸과 같은 고름) */ +const lineOf = (c: AddressCandidate): string => + (c.category === "parcel" ? c.parcel : c.road) || c.road || c.parcel; + +/** 좌표 → 주소 한 줄(서버 VWorld) · 못 찾음 = 「위도, 경도」 글 */ +export async function reverseAddress(projectId: string, lon: number, lat: number): Promise { + const got = await request<{ address: string | null }>( + `/projects/${projectId}/quantity/milestone/reverse?lon=${lon}&lat=${lat}`, + ).catch(() => null); + return got?.address || `${lat.toFixed(5)}, ${lon.toFixed(5)}`; +} + +/** 소재지 칸 — name() = 지도 표지 이름(거래처 · 기준점 이름) */ +export function placePicker(projectId: string, initial: Place, name: () => string): PlaceHandle { + let place: Place = { ...initial }; + let seq = 0; // 늦게 온 주소가 새 자리를 덮지 않게 + const query = createInputField({ + label: "주소 찾기", + compact: true, + placeholder: "도로명 · 지번 · 건물명 일부 (예 병곡리 968-31)", + }); + const find = createButton({ label: "주소 찾기", variant: "ghost", onClick: () => void search() }); + find.dataset.b08MilestonePlace = "find"; + const list = el("div", { className: "b01-dashboard__address-list" }); + list.hidden = true; + const where = el("p", { className: "b08-milestone__place" }); + where.dataset.b08MilestonePlace = "value"; + const map = editMap(projectId, { + hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김", + onClick: (lon, lat) => void moveTo(lon, lat), + onMove: (_key, lon, lat) => void moveTo(lon, lat), + center: () => siteInfo()?.start_lonlat ?? null, + }); + + const draw = (): void => { + where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`; + map.set( + place.lon != null && place.lat != null + ? [ + { + key: "place", + name: name() || "소재지", + lon: place.lon, + lat: place.lat, + movable: true, + }, + ] + : [], + [], + ); + }; + + async function moveTo(lon: number, lat: number): Promise { + const mine = ++seq; + place = { ...place, lon, lat }; + draw(); + const address = await reverseAddress(projectId, lon, lat); + if (mine !== seq) return; + place.address = address; + draw(); + } + + async function search(): Promise { + const text = query.input.value.trim(); + if (text.length < 2) return showToast("주소를 두 글자 이상 넣으세요.", "error"); + find.disabled = true; + try { + const items = await searchAddress(text); + list.hidden = false; + list.replaceChildren( + ...(items.length + ? items.map((item) => { + const option = el("button", { + className: "b01-dashboard__address-item", + attrs: { type: "button" }, + children: [ + el("strong", { text: lineOf(item) }), + el("small", { text: [item.building, item.zipcode].filter(Boolean).join(" · ") }), + ], + }); + option.addEventListener("click", () => { + seq++; + place = { address: lineOf(item), lon: item.lon, lat: item.lat }; + list.hidden = true; + draw(); + }); + return option; + }) + : [el("p", { text: "찾은 주소가 없습니다 — 다른 낱말로 찾거나 지도에서 고르세요." })]), + ); + } catch { + showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하세요.", "error"); + } finally { + find.disabled = false; + } + } + // 검색 칸 Enter = 찾기만(모달 안 닫힘) + query.input.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + void search(); + }); + + draw(); + return { + root: el("div", { + className: "b08-milestone__place-field", + children: [ + el("div", { className: "b08-milestone__line-edit", children: [query.root, find] }), + list, + where, + map.root, + ], + }), + value: () => ({ ...place }), + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index bdb3d9188..301afa94e 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -11,14 +11,16 @@ * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 + * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · + * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; -import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; +import { roadsFor, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { @@ -182,7 +184,11 @@ export async function autoRouteImage( routeId: string, lines: Array>, ): Promise { - const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); + // 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘) + const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice( + 0, + 2, + ); autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)"); changes.dispatchEvent(new Event("change")); let reason: string | null; @@ -213,59 +219,67 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); } -export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); +/** 도로 구분 ▾ — via = 기준점 거쳐 시점 들어가는 구간(2차 · 1차 · 비포장) · 아니면 포장 · 비포장(244-13) · + * blank = 「도로 구분 고르기」 빈 값(자동 판정 없음 · 244-7) · 목록 밖 옛 값도 보임 */ +export function roadOptions( + via: boolean, + blank: boolean, + current?: string | null, +): Array<{ value: string; text: string }> { + const roads: string[] = roadsFor(via); + if (current && !roads.includes(current)) roads.push(current); + return [ + ...(blank ? [{ value: "", text: "도로 구분 고르기" }] : []), + ...roads.map((r) => ({ value: r, text: r })), + ]; +} -/** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */ -export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions]; - -/** 마지막 [길찾기] 길 선 — 경로 id → 구간마다 [위도, 경도] 목록(메모리만 · 저장 안 함) */ -const paths = new Map>>(); +/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ +const paths = new Map>(); +export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; +/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ export const routePaths = (routeId: string): Array> => - paths.get(routeId) ?? []; + paths.has(routeId) ? [paths.get(routeId)!] : []; -/** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 · - * 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */ -export async function findRouteKm(projectId: string, routeId: string): Promise { - const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); - const out: string[] = []; - const got: Array<[KmSegment["key"], number]> = []; - const lines: Array> = []; - for (const seg of segments) { - if (!seg.from || !seg.to) { - const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; - out.push( - `${seg.text} — ${missing} 좌표가 없습니다(주소 검색 · 노선) · km 를 손으로 넣으세요.`, - ); - continue; - } - try { - const res = await request<{ - km: number | null; - reason: string | null; - path?: Array<[number, number]>; - }>(`/projects/${projectId}/quantity/milestone/directions`, { - method: "POST", - body: JSON.stringify({ - origin: { lon: seg.from[0], lat: seg.from[1] }, - destination: { lon: seg.to[0], lat: seg.to[1] }, - }), - }); - if (res.km == null) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`); - else { - out.push(`${seg.text} ${res.km} km`); - got.push([seg.key, res.km]); - if ((res.path?.length ?? 0) >= 2) lines.push(res.path!); - } - } catch (error) { - out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`); - } +/** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) · + * 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */ +export async function findSegmentKm( + projectId: string, + from: string, + image = true, +): Promise { + const data = liveMilestone(projectId); + const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); + if (!seg) return ""; + if (!seg.from || !seg.to) { + const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; + return `${seg.text} — ${missing} 좌표가 없습니다(소재지 · 노선) · km 를 손으로 넣으세요.`; } - paths.set(routeId, lines); - if (lines.length) void autoRouteImage(projectId, routeId, lines); // 뒤에서 · km 알림 안 기다림 - if (got.length) { + try { + const res = await request<{ + km: number | null; + reason: string | null; + path?: Array<[number, number]>; + }>(`/projects/${projectId}/quantity/milestone/directions`, { + method: "POST", + body: JSON.stringify({ + origin: { lon: seg.from[0], lat: seg.from[1] }, + destination: { lon: seg.to[0], lat: seg.to[1] }, + }), + }); + if (res.km == null) return `${seg.text} — ${res.reason ?? "길찾기 실패"}`; + if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!); + else paths.delete(from); const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 - for (const [key, km] of got) putAutoKm(fresh, routeId, key, km); - putMilestone(projectId, fresh); + const legs = from === "base" ? fresh.base.legs : fresh.routes.find((r) => r.id === from)?.legs; + if (legs?.[0]?.auto_km !== res.km) { + putAutoKm(fresh, from, seg.key, res.km); + putMilestone(projectId, fresh); + } else changes.dispatchEvent(new Event("change")); + if (image && seg.key === "route" && paths.has(from)) + void autoRouteImage(projectId, from, routePaths(from)); // 뒤에서 · km 알림 안 기다림 + return `${seg.text} ${res.km} km`; + } catch (error) { + return `${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`; } - return out; } diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py index 055f0c979..33e0396fa 100644 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -260,7 +260,9 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): assert "getDisplayMedia" not in text and "지도 캡처" not in text, name assert "캡처는 localhost" not in text, name state = read("B08_Quantity_UI_Milestone_State.ts") - assert "if (lines.length) void autoRouteImage(projectId, routeId, lines);" in state + # 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림 + assert 'if (image && seg.key === "route" && paths.has(from))' in state + assert "void autoRouteImage(projectId, from, routePaths(from));" in state assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state assert "user_only=true" in state and "showToast(reason," in state route = read("B08_Quantity_UI_Milestone_Route.ts") diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py new file mode 100644 index 000000000..e409a1606 --- /dev/null +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -0,0 +1,305 @@ +"""244-13 B08 거리이정표 등록 모달 · 연결선 길찾기 · 도로 구분 열(사용자 147번 5 · 8). + +도로 구분 = 기준점 거쳐 시점 들어가는 구간 2차포장 · 1차포장 · 비포장 · 그 밖 포장 · 비포장 +(파이썬 · TS 거울) · 연결선 구간 끝 · 표 열(2차포장 = 기준점 있을 때만) · 소재지 역지오코딩 길 · +고르는 지도(누름 · 끌기) · 연결선 한 구간 [길찾기](거래처 쪽만 자동 그림) — node 로 실제 TS 를 돌림. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import ValidationError + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" +CALC = B08 + "Milestone_Calc.ts" +LAYOUT = B08 + "Milestone_Layout.ts" +KMAP = B08 + "UI_Milestone_KakaoMap.ts" +STATE = B08 + "UI_Milestone_State.ts" +BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(rels, script, pre=""): + src = pre + "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + result = subprocess.run( # 원본이 길어 명령 줄 대신 표준 입력 + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=src + "\n" + script, 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, legs, via=True, rows=None, **kw): + return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "legs": legs, + "rows": rows or [{"item": "철근", "dest": "start", "note": ""}], **kw} # fmt: skip + + +NO_BASE = { + "site": {"mid_station_m": 800, "mid_road": "비포장"}, + "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "routes": [ + _route("g", [{"road": "포장", "km": 10.4}, {"road": "비포장", "km": 0.5}], rows=[ + {"item": "철근", "dest": "start", "note": ""}, + {"item": "혼합골재", "dest": "mid", "note": ""}]), + ], +} # fmt: skip +WITH_BASE = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "", "dir": None, "lon": 127.85, + "lat": 35.66, "legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]}, + "routes": [ + _route("g", [{"road": "2차포장", "km": 23.8}], lon=127.9, lat=35.68), + _route("d", [{"road": "포장", "km": 5}], via=False, lon=127.8, lat=35.6), + ], +} # fmt: skip + + +def test_포장_도로_구분_거울(): + a = compute_milestone(NO_BASE, 1500.0) + assert [(r["pv"], r["un"], r["paved"], r["total"]) for r in a] == [ + (10.4, 0.5, 10.4, 10.9), + (10.4, 1.3, 10.4, 11.7), # 목적지 중점 = 시점 → 중점 0.8 km 비포장 더함 + ] + b = compute_milestone(WITH_BASE, 1500.0) + assert (b[0]["s2"], b[0]["s1"], b[0]["pv"], b[0]["un"]) == (23.8, 0.6, 0, 0.8) + assert (b[0]["paved"], b[0]["total"]) == (24.4, 25.2) # 견본 수식 = 포장 2차 + 1차 + assert (b[1]["s2"], b[1]["pv"], b[1]["paved"], b[1]["total"]) == (0, 5, 5, 5) # 거침 안 함 + ts = _node([CALC], f"console.log(JSON.stringify([computeMilestone({json.dumps(NO_BASE)}, " + f"1500), computeMilestone({json.dumps(WITH_BASE)}, 1500)]));") # fmt: skip + assert ts == [a, b] + assert ms.Leg.model_validate({"road": "포장", "km": 1}).road == "포장" + with pytest.raises(ValidationError): + ms.Leg.model_validate({"road": "3차포장"}) + + +def test_도로_구분_갈래_연결선_구간_끝(): + d = json.dumps(WITH_BASE) + off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}) + got = _node( + [CALC, LAYOUT], + f"""const D = {d}; const O = {off}; const S = [127.83, 35.64]; +console.log(JSON.stringify([roadsFor(true), roadsFor(false), + ["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)), + segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S), + segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S), + kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""", + ) + assert got[0] == ["2차포장", "1차포장", "비포장"] and got[1] == ["포장", "비포장"] + assert got[2] == [True, False, True, False] and got[3] == [False, False] + assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], + "to": [127.85, 35.66]} # fmt: skip + assert got[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64] + assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], + "to": [127.83, 35.64]} # fmt: skip + assert got[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음 + assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 + assert got[10] == ["route", "base"] and got[11] == ["route"] + + +def test_표_열_기준점_따라(): + got = _node( + [B08 + "UI_Milestone.ts"], + "console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>" + " milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));", + ) + assert got[0] == ["|항목|item", "|거래처 이름|company", "|목적지|dest", + "도로 구분 (km)|포장|paved", "도로 구분 (km)|비포장|un", + "계 (km)|합계|total", "|비고|note"] # fmt: skip + roads = [c for c in got[1] if c.startswith("도로 구분")] + assert roads == ["도로 구분 (km)|2차포장|s2", "도로 구분 (km)|1차포장|s1", + "도로 구분 (km)|비포장|un"] # fmt: skip + assert "계 (km)|포장|paved" in got[1] and "계 (km)|비포장|unpaved" in got[1] + assert "도로 구분 (km)|포장|pv" in got[2] # 기준점 있어도 거침 안 한 「포장」 값 = 포장 열 + assert not any("2차포장" in c for c in got[0]) # 2차포장 열 = 기준점 있을 때만 + + +def test_소재지_역지오코딩(monkeypatch): + asked = [] + + async def fake(lon, lat): + asked.append((lon, lat)) + return "경남 거창군 북상면 병곡리 968-31" + + monkeypatch.setattr(ms, "reverse_geocode", fake) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + res = client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 35.66}) + assert res.json() == {"address": "경남 거창군 북상면 병곡리 968-31"} + assert asked == [(127.85, 35.66)] + assert client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 91}).status_code == 422 + + +MAP_STUBS = """ +const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "", + className: o.className, dataset: {}, children: o.children ?? [] }); +const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); +(globalThis as any).window = globalThis; +(globalThis as any).document = { createElement: () => ({}), head: { append: () => undefined } }; +const request = async () => ({ key: "fake-js-key" }); +const LOG: any = { markers: [], handlers: [], lines: 0 }; +class LatLng { lat: number; lng: number; + constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; } + getLat() { return this.lat; } getLng() { return this.lng; } } +const overlay = () => ({ setMap: () => undefined }); +(globalThis as any).kakao = { maps: { + load: (f: () => void) => f(), LatLng, + LatLngBounds: class { extend() {} }, + Map: class { relayout() {} setBounds() { LOG.fit = true; } + setCenter(p: any) { LOG.center = [p.lng, p.lat]; } setLevel(l: number) { LOG.level = l; } }, + Polyline: function () { LOG.lines++; return overlay(); }, + Marker: function (o: any) { const m = { ...overlay(), o, at: o.position, + getPosition: () => m.at }; LOG.markers.push(m); return m; }, + CustomOverlay: function () { return overlay(); }, + event: { addListener: (t: any, type: string, fn: any) => LOG.handlers.push([t, type, fn]) }, +} }; +""" + + +def test_고르는_지도_누름_끌기(): + got = _node( + [KMAP], + """ +const moved: any[] = []; const clicked: any[] = []; +const m = editMap("p1", { hint: "h", onMove: (k, lon, lat) => moved.push([k, lon, lat]), + onClick: (lon, lat) => clicked.push([lon, lat]), center: () => [127.83, 35.64] }); +m.set([], []); +await new Promise((r) => setTimeout(r, 20)); +const empty = { center: LOG.center, level: LOG.level, hidden: m.root.children[1].hidden }; +m.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }, + { key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }], + [[[35.68, 127.9], [35.64, 127.83]]]); +await new Promise((r) => setTimeout(r, 20)); +const click = LOG.handlers.find(([, type]) => type === "click"); +click[2]({ latLng: new LatLng(35.7, 127.95) }); +const drags = LOG.handlers.filter(([, type]) => type === "dragend"); +const [r1] = LOG.markers.slice(-2); +r1.at = new LatLng(35.69, 127.91); +drags[0][2](); +console.log(JSON.stringify({ empty, moved, clicked, drags: drags.length, fit: !!LOG.fit, + lines: LOG.lines, draggable: LOG.markers.slice(-2).map((x: any) => !!x.o.draggable), + clicks: LOG.handlers.filter(([, t]) => t === "click").length })); +""", + MAP_STUBS, + ) + assert got["empty"] == { + "center": [127.83, 35.64], + "level": 9, + "hidden": False, + } # 점 없음 = 시점 + assert got["draggable"] == [True, False] and got["drags"] == 1 # 시점 = 고정 + assert got["moved"] == [["r1", 127.91, 35.69]] and got["clicked"] == [[127.95, 35.7]] + assert got["fit"] and got["lines"] == 1 and got["clicks"] == 1 # 지도 누름 사건 한 번만 + + +STATE_STUBS = """ +const API_BASE_URL = ""; +const showToast = () => undefined; +const store = new Map(); +const readState = (k: string, p: string) => store.get(k + p) ?? null; +const writeState = (k: string, v: any, p: string) => (store.set(k + p, v), WRITES.n++); +const clearState = (k: string, p: string) => store.delete(k + p); +const WRITES = { n: 0 }; +const CALLS: any[] = []; +const request = async (url: string, init: any = {}) => { + CALLS.push([url, init.body ? JSON.parse(init.body) : null]); + if (url.endsWith("/milestone")) return { data: DATA, site: { start_lonlat: [127.83, 35.64], + mid_default_m: 1500 }, images: {}, auto_images: {} }; + if (url.includes("/directions")) return { km: 10.5, reason: null, + path: [[35.68, 127.9], [35.66, 127.85]] }; + return { name: "auto/g.png", reason: null }; +}; +""" + + +def test_연결선_한_구간_길찾기_거래처_쪽만_그림(): + script = f"""const DATA = {json.dumps(WITH_BASE)}; +await loadMilestone("p1"); +const a = await findSegmentKm("p1", "g"); +await new Promise((r) => setTimeout(r, 10)); +const afterRoute = CALLS.length; +const b = await findSegmentKm("p1", "base"); +const writes = WRITES.n; +await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀 +const live = liveMilestone("p1"); +console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n, + route: live.routes[0].legs, base: live.base.legs, path: segmentPath("g"), + routeLines: routePaths("g"), baseLines: routePaths("base"), + start: await findSegmentKm("p1", "start") }}));""" + got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS) + assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km" + (url, body), (img, payload), (url2, body2) = got["calls"][:3] + assert url.endswith("/directions") and body == { + "origin": {"lon": 127.9, "lat": 35.68}, + "destination": {"lon": 127.85, "lat": 35.66}, + } + assert img.endswith("/image/auto?route=g") # 거래처 쪽 구간 = 자동 그림 + assert payload["lines"] == [[[35.68, 127.9], [35.66, 127.85]]] + assert [m["name"] for m in payload["marks"]] == ["g", "병곡리"] # 두 끝만(시점 없음) + assert body2["origin"] == {"lon": 127.85, "lat": 35.66} + assert body2["destination"] == {"lon": 127.83, "lat": 35.64} + assert not any( + "/image/auto" in c[0] for c in got["calls"][got["afterRoute"] :] + ) # 기준점 = 그림 없음 + assert got["route"][0]["km"] == 23.8 and got["route"][0]["auto_km"] == 10.5 # 사용자 값 그대로 + assert got["base"][0]["auto_km"] == 10.5 and got["again"] == got["writes"] + assert got["routeLines"] == [got["path"]] and got["baseLines"] == [ + got["path"] + ] # 구간마다 길 선 + assert got["start"] == "" # 시점 → 중점 = 길찾기 없음 + + +def test_화면_등록_모달_연결선_모달(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + place = _read(B08 + "UI_Milestone_Place.ts") + leg = _read(B08 + "UI_Milestone_LegModal.ts") + diagram = _read(B08 + "UI_Milestone_Diagram.ts") + css = _read(B08 + "UI_Milestone.css") + # ① 큰 창 · 2열 · 분류 · 항목 목록 · 소재지 · 직접 입력 없음 · 다음 지도 + assert 'dialogClass: "b08-milestone__modal"' in modal + assert ".ui-modal.b08-milestone__modal {\n width: min(960px, 94vw);" in css + assert "grid-template-columns: 1fr 1fr;" in css + assert "children: [sort.root, first.item.root, name.root, first.dest.root]" in modal + assert ( + '{ value: "base", text: "기준위치" }' in modal + and '{ value: "route", text: "거래처" }' in modal + ) + assert 'route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"]' in modal + assert "소재지" in modal and "거래처 위치" not in modal + for text in (modal, place): + assert 'label: "직접 입력"' not in text and "buildAddressField" not in text + assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place + assert "/quantity/milestone/reverse?lon=" in place + # ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음 + assert 'base: () => openPlaceModal(projectId, { add: false, id: "base" }),' in diagram + assert 'label: "기준점 +"' not in diagram and "addBase" not in diagram + # ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만 + assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram + assert "clearTimeout(wait);" in diagram # 선 글 더블클릭 = 바로 고침 그대로 + mid = leg[leg.index("function openMidLeg") : leg.index("export function openLegInfo")] + 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 + 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", + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700, rel diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index c69fd4724..ef1e7b574 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -162,8 +162,8 @@ def test_메인_탭_하위_탭_추가_삭제(): # 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 + assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal + assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index 4c5663804..a8dc4adb0 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -141,13 +141,16 @@ def test_빈_방위_차례_같은_방위_나란히(): def test_화면_상자_누름_더하기_창(): ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8") # 244-4 — 상자 누름 = 모달(탭 이동 아님) - assert "openTab" not in ui and "route: (id) => openRouteInfo(projectId, id)," in ui + assert ( + "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 "openAddItem(projectId, viaBase, addRoute)" in ui + assert "openPlaceModal(projectId, { add: true, 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 # 같은 업체 이름 = 그 경로에 표 줄 + assert "same.rows.push(...rowValues())" in modal # 같은 업체 이름 = 그 경로에 표 줄 page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8") assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 91f57ffe6..8d533d060 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -140,30 +140,30 @@ def test_시점_모달_주소_길(tmp_path, monkeypatch): def test_화면_모달_팬줌_그림탭(): modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") - # ① [+] 모달 — 항목 · 업체 이름 · 목적지 + 주소 검색(B01 재활용) · 방위 고르기 없음 · [등록] + # ① [+] 모달 — 항목 · 거래처 이름 · 목적지 + 소재지(244-13 · 주소 찾기 = B01 후보 검색 재활용 · + # 지도 = 다음 지도) · 방위 고르기 없음 · [등록] add = modal[ - modal.index("export function openAddItem") : modal.index("export function openRouteInfo") + modal.index("export function openPlaceModal") : modal.index("export interface SiteAddress") ] - for text in ('label: "항목"', 'label: "거래처 이름"', 'label: "목적지"', 'label: "등록"', - "buildAddressField(null)", "placeOf(address,"): # fmt: skip + for text in ('label: "항목"', 'label: "목적지"'): # 항목 · 목적지 줄 = rowField + assert text in modal + for text in ('label: "거래처 이름"', '"등록"', "placePicker(", "place.value()"): 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 + place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts") + assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place + assert "searchAddress(text)" in place + assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도 # ② 시점 모달 — 등록 주소 · 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)', - "rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip - assert text in info + # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 + for text in ('"고침"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): + assert text in add + leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert "legsEditor(legsOf(), via)" in leg diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert "route: (id) => openRouteInfo(projectId, id)," in diagram + assert "route: (id) => openPlaceModal(projectId, { add: false, id })," in diagram assert "start: () => openSiteInfo(projectId)," in diagram # ③ 팬 · 줌 = 공용 assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index d5c4ac8cd..0b1b161e1 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -55,8 +55,10 @@ def test_좌측_패널_방위_칸_없음_모달_안내(): assert "공사지에서 본 방위" not in state and "dirOptions" not in state assert "d.base.dir" not in state # 244-6 — 기준점 칸은 다이어그램 기준점 모달로 modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") - base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] - assert "주소를 고르면 다이어그램에 실제 방위로 놓임" in base + base = modal[ + modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") + ] + assert "방위는 이 자리로 자동" in base # 244-13 — 기준점 = 등록 모달 분류 기준위치 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") assert ( "placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270" in layout diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 341e66d4b..9b5248ef8 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -118,11 +118,13 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): site = modal[modal.index("export function openSiteInfo") :] assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site - base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] - assert "enabled: true," in base and 'label: "기준점 없앰"' in base - assert "fresh.base.enabled = false;" in base - assert 'label: "기준점 +"' in ui and "openBaseInfo(projectId, true)" in ui - assert "addBase.hidden = data.base.enabled;" in ui + # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음 + base = modal[ + modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") + ] + assert "enabled: true," in base and 'label: "삭제"' in base + assert "fresh.base = { enabled: false," in base + assert 'label: "기준점 +"' not in ui and "openBaseInfo" not in ui and "addBase" not in ui assert "content: panContent(vw, vh)" in ui and "panStart," in ui diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 75274d1c0..0392f8230 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -219,8 +219,10 @@ def test_화면_길찾기_단추_키_화면에_없음(): modal = read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") state = read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") table = read("B08_Quantity/B08_Quantity_UI_Milestone.ts") - assert 'label: "길찾기"' in modal and "routeKm(projectId, id)" in modal # 단추 · [등록] 뒤 - assert "/quantity/milestone/directions" in state and "kmSegments(" in state + # 244-13 — [길찾기] = 연결선 모달 단추 · [등록] 뒤 그 구간 한 번 + leg = read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert 'label: "길찾기"' in leg and "findSegmentKm(projectId, segment)" in modal + assert "/quantity/milestone/directions" in state and "segmentEnds(" in state assert '"도로 구분 고르기"' in table and "r.pick" in table assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름 # 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음 diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 5ee1d655f..1d25f62a8 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -215,13 +215,13 @@ def test_화면_자리_캡처_키_서버만(): kmap = _read(KMAP) assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동 - modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") - assert "mapView.root" in modal and "mapView.draw();" in modal + leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도 + assert "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 state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") - assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감) + assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감) for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만 text = rel.read_text(encoding="utf-8") assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel