From d70b66f57ee4980d2cbf22e35753b1ce1410576d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 00:08:57 +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=E2=80=94=20=EC=88=98=EC=A0=95=20=EA=B8=80?= =?UTF-8?q?=20=C2=B7=20=EC=83=81=EC=9E=90=20=ED=95=98=EB=82=98=20=3D=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=ED=95=98=EB=82=98=20=C2=B7=20=ED=8F=AD=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=C2=B7=20=EC=B2=98=EC=9D=8C=20=EB=B0=B0?= =?UTF-8?q?=EC=9C=A8=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=C2=B7=20=EB=8F=84=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=20=3D=20=EC=84=A0=20=EC=9E=90=EB=A6=AC=20(250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록 모달 [+ 줄] · 같은 이름 합침 없앰 · 기준위치 = 항목 · 목적지 없음 · 「고침」 → 「수정」 - 다이어그램 · 표 폭 전체 · 틀 높이 고정 · 틀 비율 그림 범위 · 처음 배율 = 상자 범위 맞춤 - 카카오 지도 높이 어디서나 480px - 도로 구분 = 선 자리(거래처 → 기준점 2차포장 · 기준점 → 시점 1차포장 · 그 밖 포장 · 비포장만 고름) · 파이썬 · TS 짝 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B08_Quantity/B08_Quantity_Milestone_Calc.py | 39 ++++---- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 66 +++++++++---- B08_Quantity/B08_Quantity_UI_Milestone.css | 26 ++--- B08_Quantity/B08_Quantity_UI_Milestone.ts | 12 +-- .../B08_Quantity_UI_Milestone_Diagram.ts | 77 ++++++++++++--- .../B08_Quantity_UI_Milestone_LegModal.ts | 32 ++++--- .../B08_Quantity_UI_Milestone_Modal.ts | 94 +++++++------------ .../B08_Quantity_UI_Milestone_State.ts | 18 +--- .../test_244_13_b08_milestone_place_leg.py | 6 +- .../test_244_14_b08_milestone_item_input.py | 2 +- resources/tester/test_244_1_b08_milestone.py | 9 +- .../test_244_2_b08_milestone_diagram.py | 3 +- .../tester/test_244_4_b08_milestone_modal.py | 11 ++- .../test_244_6_b08_milestone_pan_client.py | 6 +- .../tester/test_244_7_b08_milestone_kakao.py | 10 +- .../test_244_8_b08_milestone_no_scroll.py | 8 +- .../test_250_1_b08_milestone_roads_width.py | 72 ++++++++++++++ 17 files changed, 305 insertions(+), 186 deletions(-) create mode 100644 resources/tester/test_250_1_b08_milestone_roads_width.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index f77d85f6a..3030ee999 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -8,11 +8,10 @@ 시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · 포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · 끝수 = 소수 셋째 자리(반올림). -구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 -pick = 참(화면 「도로 구분 고르기」). -도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 · -그 밖 = 포장 · 비포장 · -포장 = 2차 + 1차 + 포장. +구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7). +도로 구분(152) = 포장 종류는 선 자리로 정함(`road_of`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = +1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · +포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). """ from __future__ import annotations @@ -22,6 +21,13 @@ from typing import Any ROADS = ("2차포장", "1차포장", "포장", "비포장") KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} +# 구간 자리 → 포장 종류(152) — via = 거래처 → 기준점 · base = 기준점 → 시점 · direct = 거래처 → 시점 · mid = 시점 → 중점 +PAVED = {"via": "2차포장", "base": "1차포장", "direct": "포장", "mid": "포장"} + + +def road_of(road: str | None, kind: str) -> str: + """그 구간 도로 구분 — 비포장만 사용자 값 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류.""" + return "비포장" if road == "비포장" else PAVED[kind] def r3(value: float) -> float: @@ -35,16 +41,10 @@ def leg_km(leg: dict[str, Any]) -> float | None: return leg.get("auto_km") if km is None else km -def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> bool: - """도로 구분마다 더함 — 안 고른 구간이 있으면 참.""" - pick = False +def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None, kind: str) -> None: + """도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음).""" for leg in legs or []: - key = KEYS.get(leg.get("road")) - if key: - sums[key] += float(leg_km(leg) or 0) - else: - pick = True - return pick + sums[KEYS[road_of(leg.get("road"), kind)]] += float(leg_km(leg) or 0) def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: @@ -64,11 +64,12 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list for route in data.get("routes") or []: for index, row in enumerate(route.get("rows") or []): sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0} - 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 + via = bool(base.get("enabled")) and route.get("via_base", True) is not False + add_legs(sums, route.get("legs"), "via" if via else "direct") + if via: + add_legs(sums, base.get("legs"), "base") if row.get("dest") == "mid": - add_legs(sums, [mid]) + add_legs(sums, [mid], "mid") paved = sums["s2"] + sums["s1"] + sums["pv"] out.append( { @@ -85,7 +86,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list "unpaved": r3(sums["un"]), "total": r3(paved + sums["un"]), "note": row.get("note") or "", - "pick": pick, + "pick": False, } ) return out diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 50aa3ae78..7fe174baf 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -9,17 +9,48 @@ * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) - * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고 - * pick = 참(화면 「도로 구분 고르기」) - * 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · - * 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장 + * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) + * 도로 구분(152) = 포장 종류는 선 자리로 정함(`roadOf`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = + * 1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · + * 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) * ========================================================================== */ 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차포장", "비포장"] : ["포장", "비포장"]; +/** 구간 자리(152) — 거래처 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · 시점 → 중점(mid) */ +export type SegKind = "via" | "base" | "direct" | "mid"; +/** 포장 종류 = 선 자리로 정함(152) — 기준점이 생기면 거래처 → 기준점 = 2차 · 기준점 → 시점 = 1차 · 없으면 포장 */ +export const PAVED: Record = { + via: "2차포장", + base: "1차포장", + direct: "포장", + mid: "포장", +}; +/** 그 구간 도로 구분 — 사용자는 비포장만 고름 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류 */ +export const roadOf = (road: Road | null | undefined, kind: SegKind): Road => + road === "비포장" ? "비포장" : PAVED[kind]; +/** 도로 구분을 선 자리 포장 종류로 바꾼 사본(152) — 다이어그램 선 글 · 그림용(저장 값은 그대로) */ +export function withRoads(data: MilestoneData): MilestoneData { + const fix = (legs: Leg[], kind: SegKind): Leg[] => + legs.map((leg) => ({ ...leg, road: roadOf(leg.road, kind) })); + return { + ...data, + site: { ...data.site, mid_road: roadOf(data.site.mid_road, "mid") }, + base: { ...data.base, legs: fix(data.base.legs, "base") }, + routes: data.routes.map((r) => ({ + ...r, + legs: fix(r.legs, data.base.enabled && r.via_base !== false ? "via" : "direct"), + })), + }; +} + +/** 선 자리 — from = 거래처 id · "base" · "start"(시점 → 중점) */ +export function segKind(data: MilestoneData, from: string): SegKind { + if (from === "start") return "mid"; + if (from === "base") return "base"; + const route = data.routes.find((r) => r.id === from); + return data.base.enabled && route?.via_base !== false ? "via" : "direct"; +} /** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; export type Dir = (typeof DIRS)[number]; @@ -97,15 +128,9 @@ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 10 /** 구간 km — 사용자 값 우선 · 없으면 자동 값 */ export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; -/** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */ -function addLegs(sums: Record, legs: Leg[] | undefined): boolean { - let pick = false; - for (const leg of legs ?? []) { - const key = leg.road ? KEYS[leg.road] : undefined; - if (key) sums[key] += Number(legKm(leg) ?? 0); - else pick = true; - } - return pick; +/** 도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음) */ +function addLegs(sums: Record, legs: Leg[] | undefined, kind: SegKind): void { + for (const leg of legs ?? []) sums[KEYS[roadOf(leg.road, kind)]] += Number(legKm(leg) ?? 0); } /** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */ @@ -125,9 +150,10 @@ export function computeMilestone( for (const route of data.routes ?? []) { (route.rows ?? []).forEach((row, index) => { 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 via = !!base?.enabled && route.via_base !== false; + addLegs(sums, route.legs, via ? "via" : "direct"); + if (via) addLegs(sums, base.legs, "base"); + if (row.dest === "mid") addLegs(sums, [mid], "mid"); const paved = sums.s2 + sums.s1 + sums.pv; out.push({ route: route.id, @@ -143,7 +169,7 @@ export function computeMilestone( unpaved: r3(sums.un), total: r3(paved + sums.un), note: row.note ?? "", - pick, + pick: false, }); }); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 90ff5b010..e48adb616 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -4,13 +4,17 @@ display: flex; flex-direction: column; gap: var(--spacing-8); - /* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */ - width: fit-content; - max-width: 100%; + /* 149 — 페이지 폭 전체(다이어그램 · 표 같은 폭) */ + width: 100%; } -.b08-milestone > .b08-milestone__diagram { - contain: inline-size; +/* 149 표도 폭 전체 — 열 너비는 비율대로 늘음 */ +.b08-milestone > .ui-data-table { + width: 100%; +} + +.b08-milestone > .ui-data-table .ui-data-table__table { + width: 100%; } .b08-milestone__route { @@ -53,6 +57,8 @@ /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ .b08-milestone__diagram { + /* 149 높이 고정 · 그림은 틀 비율로 넓힌 범위에 맞춤(스크롤 없음 · 244-8) */ + height: clamp(420px, 58vh, 760px); overflow: hidden; border: 1px solid var(--color-border); border-radius: 6px; @@ -61,7 +67,6 @@ .b08-milestone__diagram svg { display: block; - margin: 0 auto; font-size: 13px; } @@ -166,15 +171,10 @@ font-size: 10px; } -/* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */ -.b08-milestone .is-pick-road { - color: var(--color-danger); -} - -/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */ +/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */ .b08-milestone__map { width: 100%; - height: 360px; + height: 480px; border: 1px solid var(--color-border); } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index df187f338..05058419a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -84,7 +84,6 @@ export function addRoute(projectId: string, init: Partial = {}): function tableView(projectId: string): MilestoneView { const diagram = createDiagram(projectId, (init) => addRoute(projectId, init)); let keys: Array<{ route: string; index: number }> = []; - let pickRows: boolean[] = []; const makeTable = (columns: DataColumn[]) => { const t = createDataTable({ columns, @@ -92,7 +91,6 @@ function tableView(projectId: string): MilestoneView { 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]; @@ -143,16 +141,8 @@ function tableView(projectId: string): MilestoneView { table = fresh; } keys = rows.map(({ route, index }) => ({ route, index })); - pickRows = rows.map((r) => r.pick); none.hidden = rows.length > 0; - // 도로 구분 안 고른 구간 = 합계 칸에 「도로 구분 고르기」(그 km 는 합에 안 듦 · 244-7) - table.setRows( - rows.map((r) => ({ - ...r, - dest: `${DEST_TEXT[r.dest]} ▾`, - total: r.pick ? "도로 구분 고르기" : r.total, - })), - ); + table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); diagram.refresh(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 91cb5e2d6..abc0811a4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -7,7 +7,7 @@ * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰) - * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달 + * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달 * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) @@ -17,9 +17,15 @@ 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, + midKm, + roadOf, + segKind, + withRoads, + type MilestoneRoute, + type Road, +} from "./B08_Quantity_Milestone_Calc"; +import { layoutMilestone, legAt, panContent, @@ -62,12 +68,13 @@ function editLegAt( const leg = legAt(data, from, i, midKm); if (!leg) return; document.querySelector(".b08-milestone__leg-edit")?.remove(); + const kind = segKind(data, from); const road = createSelectField({ label: "도로 구분", - options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름 + options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름 compact: true, }); - road.select.value = leg.road ?? ""; + road.select.value = roadOf(leg.road, kind); const km = el("input", { className: "ui-input", attrs: { type: "number", min: "0", step: "any" }, @@ -109,6 +116,27 @@ function editLegAt( km.select(); } +/** 처음 배율(149) — 상자 범위가 틀에 차도록 당김(1 ~ 1.8배 · 가운데 맞춤) · 좌표 = viewBox(바탕 이동 vx · vy 뺌) */ +function fitBoxes( + boxes: DiagramBox[], + vx: number, + vy: number, + vw: number, + vh: number, +): ZoomPanState | undefined { + if (!boxes.length) return undefined; + const pad = 36; + const x0 = Math.min(...boxes.map((b) => b.x)) - pad - vx; + const y0 = Math.min(...boxes.map((b) => b.y)) - pad - vy; + const x1 = Math.max(...boxes.map((b) => b.x + b.w)) + pad - vx; + const y1 = Math.max(...boxes.map((b) => b.y + b.h)) + pad - vy; + const scale = Math.min(1.8, Math.max(1, Math.min(vw / (x1 - x0), vh / (y1 - y0)))); + if (scale <= 1.001) return undefined; + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + return { scale, tx: vw / 2 - cx * scale, ty: vh / 2 - cy * scale }; +} + /** 다이어그램 — refresh() 로 캐시 값 다시 그림 */ export function createDiagram( projectId: string, @@ -118,7 +146,12 @@ export function createDiagram( host.dataset.b08Milestone = "diagram"; // 틀 위 휠 = 줌만(244-8) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 새지 않음 host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); - let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로 + let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로(사용자가 줌 · 팬 했을 때만) + let userZoomed = false; + // 사용자 줌 · 팬 = 그 배율 지킴 · 그 전엔 그릴 때마다 상자 범위에 맞춰 당김(149 · 글씨 읽힘 · 새 상자도 보임) + host.addEventListener("wheel", () => (userZoomed = true)); + host.addEventListener("pointermove", (e) => e.buttons && (userZoomed = true)); + host.addEventListener("dblclick", () => (userZoomed = false)); // 공용 더블클릭 = 원배율 const INFO: Record void> = { route: (id) => openPlaceModal(projectId, { add: false, id }), site: () => openSiteInfo(projectId), @@ -153,7 +186,7 @@ export function createDiagram( // 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림) const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null); const layout = layoutMilestone( - data, + withRoads(data), // 선 글 도로 구분 = 선 자리 포장 종류(152) { start_lonlat: addr?.start_lonlat ?? site?.start_lonlat, mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat, @@ -162,19 +195,24 @@ export function createDiagram( }, mk, ); - const [vx, vy, vw, vh] = layout.view; + // 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠 + // (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음) + const fw = Math.max(host.clientWidth, 320); + const fh = Math.max(host.clientHeight, 240); + const [lx, ly, lw, lh] = layout.view; + const scale = Math.max(lw / fw, lh / fh); + const vw = fw * scale; + const vh = fh * scale; + const vx = lx - (vw - lw) / 2; + const vy = ly - (vh - lh) / 2; // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { viewBox: `0 0 ${vw} ${vh}`, - width: vw, - height: vh, + width: fw, + height: fh, role: "img", "aria-label": "거리이정표 다이어그램", }); - // 틀 너비 = 아래 표 너비(244-6) · 높이는 비율대로(팬 · 줌 화면 px ↔ 그림 좌표가 어긋나지 않게) - // 높이 48vh 넘으면 그만큼 좁혀 틀 안에 다 들어감(244-8 · 스크롤 없음) - root.style.width = `min(100%, calc(48vh * ${vw / vh}))`; - root.style.height = "auto"; const layer = svg("g", {}); const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); layer.append(shift); @@ -268,7 +306,7 @@ export function createDiagram( widthPx: vw, heightPx: vh, pad: { left: 0, right: 0, top: 0, bottom: 0 }, - initial: zoomState, + initial: userZoomed ? zoomState : fitBoxes(layout.boxes, vx, vy, vw, vh), onChange: (state) => (zoomState = state), wheelZoom: true, content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임) @@ -276,5 +314,14 @@ export function createDiagram( }); }; refresh(); + // 틀 크기가 바뀌면(창 · 패널 접기) 그 비율로 다시 그림 + let lastW = 0; + new ResizeObserver(() => { + const w = host.clientWidth; + if (w && Math.abs(w - lastW) > 1) { + lastW = w; + refresh(); + } + }).observe(host); return { root: host, refresh }; } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index f8f7cd3e6..98d50bffa 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -3,8 +3,8 @@ * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. * * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) - * 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 · - * 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`) + * 도로 구분(152) = 포장 종류는 선 자리로 정함 — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · + * 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) @@ -13,7 +13,14 @@ 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 { + ROADS, + roadOf, + segKind, + type Leg, + type Road, + type SegKind, +} 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"; @@ -35,18 +42,18 @@ const km = (text: string): number | null => { /** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */ export function legsEditor( initial: Leg[], - via: boolean, + kind: SegKind, ): { 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), + options: roadOptions(kind), // 포장 종류 = 선 자리(152) · 비포장만 고름 compact: true, }); const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true }); - road.select.value = leg.road ?? ""; + road.select.value = roadOf(leg.road, kind); dist.input.value = leg.km == null ? "" : String(leg.km); const auto = leg.auto_km ?? null; if (auto != null) { @@ -102,17 +109,17 @@ function openMidLeg(projectId: string): void { mount: (body, close, actions) => { const road = createSelectField({ label: "도로 구분", - options: roadOptions(false, false, data.site.mid_road), + options: roadOptions("mid"), compact: true, }); - road.select.value = data.site.mid_road; + road.select.value = roadOf(data.site.mid_road, "mid"); 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: "고침", + label: "수정", variant: "filled", onClick: () => { const fresh = liveMilestone(projectId); @@ -152,7 +159,8 @@ export function openLegInfo(projectId: string, from: string): void { 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 kind = segKind(liveMilestone(projectId), from); + let legs = legsEditor(legsOf(), kind); const status = hint(""); status.dataset.b08MilestoneLegStatus = ""; /** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */ @@ -164,7 +172,7 @@ export function openLegInfo(projectId: string, from: string): void { putMilestone(projectId, fresh); }; const redrawLegs = (): void => { - const next = legsEditor(legsOf(), via); + const next = legsEditor(legsOf(), kind); legs.root.replaceWith(next.root); legs = next; }; @@ -223,7 +231,7 @@ export function openLegInfo(projectId: string, from: string): void { }); find.dataset.b08MilestoneLegFind = ""; const save = createButton({ - label: "고침", + label: "수정", variant: "filled", onClick: () => { apply(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index f0454ab76..5a8f3e891 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -4,9 +4,9 @@ * * 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 · * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · - * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제] - * 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정 - * 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리 + * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제] + * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 + * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 조작 = 캐시 · [저장] 때 서버 @@ -24,7 +24,13 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; -import type { Dest, MilestoneRoute, MilestoneRow, Road } from "./B08_Quantity_Milestone_Calc"; +import { + roadOf, + type Dest, + type MilestoneRoute, + type MilestoneRow, + type Road, +} from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -69,7 +75,8 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { const setKind = (k: "route" | "base"): void => { list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } }))); item.input.placeholder = `예 ${ITEMS[k][0]}`; - dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정 + // 기준점 = 거쳐 가는 자리 — 항목 · 목적지 없음(149) + item.root.style.visibility = dest.root.style.visibility = k === "base" ? "hidden" : ""; }; setKind(kind); dest.select.value = r.dest; @@ -114,43 +121,15 @@ export function openPlaceModal( const baseOption = sort.select.querySelector('option[value="base"]'); if (target.add && data.base.enabled && baseOption) { baseOption.disabled = true; - baseOption.textContent = "기준위치(있음 — 기준점 상자에서 고침)"; + baseOption.textContent = "기준위치(있음 — 기준점 상자에서 수정)"; } sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); 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(); - }, - }); - const row = line(field.item.root, field.dest.root, drop); - more.append(row); - }; - 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); - }, - }); + // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 + const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); const via = createCheckbox("기준점 거침"); via.input.checked = route?.via_base ?? true; via.root.hidden = !route || !data.base.enabled; @@ -163,22 +142,23 @@ export function openPlaceModal( 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"; + first.setKind(k); }; sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base")); setKind(kind); - const rowValues = (): MilestoneRow[] => - rows.map((r) => ({ - item: r.item.input.value.trim(), - dest: r.dest.select.value as Dest, - note: r.note, - })); + const rowValues = (): MilestoneRow[] => [ + { + item: first.item.input.value.trim(), + dest: first.dest.select.value as Dest, + note: first.note, + }, + ...(route?.rows.slice(1) ?? []), + ]; 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 ? "등록" : "고침", + label: target.add ? "등록" : "수정", variant: "filled", onClick: () => { const text = name.input.value.trim(); @@ -202,16 +182,14 @@ export function openPlaceModal( 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 }); + const id = addRoute?.({ + name: text, + via_base: viaBase, + dir: null, + rows: rowValues(), + ...where, + }); if (id && where.lon != null) segment = id; } close(); @@ -241,8 +219,6 @@ export function openPlaceModal( className: "b08-milestone__grid", children: [sort.root, first.item.root, name.root, first.dest.root], }), - more, - plusRow, via.root, head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), place.root, @@ -283,12 +259,12 @@ export function openSiteInfo(projectId: string): void { mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", - options: roadOptions(false, false, data.site.mid_road), + options: roadOptions("mid"), compact: true, }); - road.select.value = data.site.mid_road; + road.select.value = roadOf(data.site.mid_road, "mid"); const save = createButton({ - label: "고침", + label: "수정", variant: "filled", onClick: () => { const fresh = liveMilestone(projectId); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 301afa94e..b14239be1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -19,7 +19,7 @@ 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 { roadsFor, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import { PAVED, type MilestoneData, type SegKind } from "./B08_Quantity_Milestone_Calc"; import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; @@ -219,19 +219,9 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); } -/** 도로 구분 ▾ — 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 })), - ]; +/** 도로 구분 ▾(152) — 그 자리 포장 종류 · 비포장 둘(포장 종류는 선 자리로 정함 · `roadOf`) */ +export function roadOptions(kind: SegKind): Array<{ value: string; text: string }> { + return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r })); } /** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index e409a1606..5234c4374 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -95,13 +95,15 @@ def test_도로_구분_갈래_연결선_구간_끝(): 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), +console.log(JSON.stringify([["g", "d", "base", "start"].map((f) => PAVED[segKind(D, f)]), + ["g", "base"].map((f) => segKind(O, f)), ["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] == ["포장", "비포장"] + # 152 포장 종류 = 선 자리 — 기준점 거침 거래처 2차 · 안 거침 포장 · 기준점 → 시점 1차 · 시점 → 중점 포장 + assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "base"] 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 diff --git a/resources/tester/test_244_14_b08_milestone_item_input.py b/resources/tester/test_244_14_b08_milestone_item_input.py index f13a81e1a..b4fe3871b 100644 --- a/resources/tester/test_244_14_b08_milestone_item_input.py +++ b/resources/tester/test_244_14_b08_milestone_item_input.py @@ -78,6 +78,6 @@ def test_항목_손_입력_제안_분류_바꿔도_글_유지(): def test_저장_값_손_글_그대로(): modal = MODAL.read_text(encoding="utf-8") - assert "item: r.item.input.value.trim()," in modal # 목록 밖 글도 받음(앞뒤 빈칸만 뺌) + assert "item: first.item.input.value.trim()," in modal # 목록 밖 글도 받음(앞뒤 빈칸만 뺌) assert 'createSelectField({ label: "항목"' not in modal assert len(modal.splitlines()) <= 700 diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index ef1e7b574..d7013901c 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -64,13 +64,16 @@ def test_견본_표_합(): assert (a["s2"], a["s1"], a["un"]) == (23.8, 0.6, 0.8) assert (a["paved"], a["unpaved"], a["total"]) == (24.4, 0.8, 25.2) assert b["item"] == "장비(8톤)" and b["total"] == 25.2 and b["company"] == "거창군청" - # 기준점 안 거침 · 목적지 중점 = 시점 → 중점(기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 - assert (c["s2"], c["s1"], c["un"], c["total"]) == (61.2, 2.05, 1.5, 64.75) + # 기준점 안 거침 = 거래처 → 시점 = 포장(152 · 옛 2차 · 1차 값도 자리로) · 목적지 중점 = 시점 → 중점 + # (기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 + assert (c["s2"], c["s1"], c["pv"], c["un"], c["total"]) == (0, 0, 63.25, 1.5, 64.75) # 사용자 중점 측점 · 기준점 없음 = 공통 구간 안 셈 no_base = {**SAMPLE, "base": {**SAMPLE["base"], "enabled": False}, "site": {"mid_station_m": 420, "mid_road": "1차포장"}} # fmt: skip a2, _, c2 = compute_milestone(no_base, 1500.0) - assert a2["total"] == 23.8 and (c2["s1"], c2["un"]) == (2.47, 0) + # 기준점 없음 = 2차 · 1차 없이 포장(152) · 시점 → 중점 1차포장 값 = 포장 + assert a2["total"] == 23.8 and (a2["s2"], a2["pv"]) == (0, 23.8) + assert (c2["s1"], c2["pv"], c2["un"]) == (0, 63.67, 0) def test_거울_TS(): diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index a8dc4adb0..d07f91343 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -150,7 +150,8 @@ def test_화면_상자_누름_더하기_창(): 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(...rowValues())" in modal # 같은 업체 이름 = 그 경로에 표 줄 + # 149 상자 하나 = 항목 하나 — [+ 줄] · 같은 이름 합침 없음 + assert "same.rows.push" not in modal and '"+ 줄"' not 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 8d533d060..5f8db74f8 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -158,16 +158,17 @@ def test_화면_모달_팬줌_그림탭(): assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 - for text in ('"고침"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): + 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 + assert "legsEditor(legsOf(), kind)" in leg diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") 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 - assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram + # 149 처음 배율 = 상자 범위 맞춤(사용자 줌 · 팬 뒤엔 그 배율) + assert "wheelZoom: true" in diagram and "initial: userZoomed ? zoomState : fitBoxes(" in diagram # ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 그림 올리기 길(화면 캡처는 244-11 에서 서버 자동으로) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert ( @@ -206,9 +207,9 @@ def test_선_글_바로_고침_모달과_한_벌(): assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}] assert after["base"]["legs"] == [{"road": "1차포장", "km": None}] assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} - # 표 셈 그대로 — 고친 값 = 모달 값과 같은 칸 + # 표 셈 — 포장 종류 = 선 자리(152 · 기준점 거침 거래처 = 2차포장 · 저장 값 1차포장 이어도) (row,) = compute_milestone(after, 1500.0) - assert (row["s2"], row["s1"], row["un"], row["total"]) == (0, 23.8, 0, 23.8) + assert (row["s2"], row["s1"], row["un"], row["total"]) == (23.8, 0, 0, 23.8) ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'span.addEventListener("dblclick"' in ui and "e.stopPropagation();" in ui assert "editLegAt(projectId, span, ln.from, i, mk)" in ui 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 9b5248ef8..9e609a8ef 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -104,10 +104,10 @@ def test_거래처_상자_항목_글(): def test_표_폭_다이어그램_폭_좌측_카드_없음(): css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") block = css[css.index(".b08-milestone {") :] - assert "width: fit-content;" in block[: block.index("}")] # 틀 = 표 너비 - assert ".b08-milestone > .b08-milestone__diagram {\n contain: inline-size;" in css + assert "width: 100%;" in block[: block.index("}")] # 149 틀 = 페이지 폭 전체 + assert ".b08-milestone > .ui-data-table {\n width: 100%;" in css # 149 표도 폭 전체 ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui # 다이어그램 = 틀 너비 + assert "const fw = Math.max(host.clientWidth, 320);" in ui # 149 다이어그램 = 틀 폭 · 틀 비율 page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert "children: [diagram.root, none, table.root]" in page # 좌측 패널 카드 없음 · 공사지 · 기준점은 모달 diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 0392f8230..30f715237 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -197,11 +197,11 @@ def test_사용자_값_우선_고르기_표시(): a, b = compute_milestone(data, 1500.0) # 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장 assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False) - # 도로 구분 안 고름 = 합에 안 듦 · pick - assert (b["total"], b["pick"]) == (0, True) - data["base"]["legs"][0]["road"] = None + # 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0 + assert (b["total"], b["pick"]) == (0, False) + data["base"]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) a2, _ = compute_milestone(data, 1500.0) - assert (a2["s1"], a2["total"], a2["pick"]) == (0, 12.0, True) + assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False) # TS 짝 거울 for d in (data, _data()): ts = _node("B08_Quantity/B08_Quantity_Milestone_Calc.ts", @@ -223,7 +223,7 @@ def test_화면_길찾기_단추_키_화면에_없음(): 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 '"도로 구분 고르기"' not in table and "r.pick" not in table # 152 자리로 정함 assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름 # 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음 for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): diff --git a/resources/tester/test_244_8_b08_milestone_no_scroll.py b/resources/tester/test_244_8_b08_milestone_no_scroll.py index cbf8855aa..17c980d2a 100644 --- a/resources/tester/test_244_8_b08_milestone_no_scroll.py +++ b/resources/tester/test_244_8_b08_milestone_no_scroll.py @@ -37,9 +37,11 @@ def test_틀_스크롤_없음_그림_맞춤(): assert "overflow: hidden;" in block and "overflow: auto" not in block assert "max-height" not in block # 높이는 그림이 맞춤 — 틀이 자르지 않음 ui = _read(DIAGRAM) - # 너비 = 틀 · 높이 48vh 넘으면 비율대로 좁힘(height auto 라 화면 px ↔ 그림 좌표 그대로) - assert "root.style.width = `min(100%, calc(48vh * ${vw / vh}))`;" in ui - assert 'root.style.height = "auto";' in ui + # 149 틀 = 폭 전체 · 높이 고정(CSS) · 그림 범위를 틀 비율로 넓힘(화면 px ↔ 그림 좌표 가로세로 같음) + assert "const scale = Math.max(lw / fw, lh / fh);" in ui + assert "height: clamp(420px, 58vh, 760px);" in _read( + "B08_Quantity/B08_Quantity_UI_Milestone.css" + ) def test_휠_진짜_사건_줌_스크롤_막음(): diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py new file mode 100644 index 000000000..fa9bfb2dc --- /dev/null +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -0,0 +1,72 @@ +"""PLAN 250(사용자 149 · 150 · 152) — 거리이정표 도로 구분 = 선 자리 · 모달 정리 · 폭 · 지도 높이.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, road_of # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _ts(expr: str): + src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts")) + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", + "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_도로_구분_선_자리_짝(): + cases = [ + (r, k) + for r in (None, "2차포장", "1차포장", "포장", "비포장") + for k in ("via", "base", "direct", "mid") + ] + py = [road_of(r, k) for r, k in cases] + assert py == _ts(f"{json.dumps(cases)}.map(([r, k]) => roadOf(r, k))") + assert road_of(None, "via") == "2차포장" and road_of("포장", "base") == "1차포장" + assert road_of("2차포장", "direct") == "포장" and road_of("비포장", "via") == "비포장" + + +def test_기준점_생기면_2차_1차_없으면_포장(): + leg = lambda road, km: {"road": road, "km": km} # noqa: E731 + route = {"id": "r", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [leg(None, 23.8)], "rows": [{"item": "장비", "dest": "mid", "note": ""}]} # fmt: skip + data = {"site": {"mid_station_m": 800, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "", "dir": None, + "legs": [leg(None, 0.6)]}, + "routes": [route]} # fmt: skip + (a,) = compute_milestone(data, None) + assert (a["s2"], a["s1"], a["pv"], a["un"], a["total"]) == (23.8, 0.6, 0, 0.8, 25.2) + data["base"]["enabled"] = False + (b,) = compute_milestone(data, None) + assert (b["s2"], b["s1"], b["pv"], b["un"], b["total"]) == (0, 0, 23.8, 0.8, 24.6) + + +def test_모달_상자_하나_항목_하나_수정_글(): + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") + assert '"+ 줄"' not in modal and "same.rows" not in modal + assert 'label: "고침"' not in modal + leg and modal.count('"수정"') >= 2 + assert 'item.root.style.visibility = dest.root.style.visibility = k === "base"' in modal + + +def test_폭_전체_지도_높이_한_값(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + block = css[css.index(".b08-milestone {") :] + assert "width: 100%;" in block[: block.index("}")] + assert ".b08-milestone > .ui-data-table .ui-data-table__table {\n width: 100%;" in css + assert css.count("height: 480px;") == 1 and ".b08-milestone__map {" in css