feat(b08): 거리이정표 — 수정 글 · 상자 하나 = 항목 하나 · 폭 전체 · 처음 배율 맞춤 · 지도 높이 · 도로 구분 = 선 자리 (250)
- 등록 모달 [+ 줄] · 같은 이름 합침 없앰 · 기준위치 = 항목 · 목적지 없음 · 「고침」 → 「수정」 - 다이어그램 · 표 폭 전체 · 틀 높이 고정 · 틀 비율 그림 범위 · 처음 배율 = 상자 범위 맞춤 - 카카오 지도 높이 어디서나 480px - 도로 구분 = 선 자리(거래처 → 기준점 2차포장 · 기준점 → 시점 1차포장 · 그 밖 포장 · 비포장만 고름) · 파이썬 · TS 짝 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<SegKind, Road> = {
|
||||
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<Key, number>, 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<Key, number>, 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
|
||||
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();
|
||||
|
||||
@@ -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<DiagramBox["kind"], (id: string) => 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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<HTMLOptionElement>('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<typeof rowField>): 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);
|
||||
|
||||
@@ -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") → [위도, 경도] 목록(메모리만 · 저장 안 함) */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# 좌측 패널 카드 없음 · 공사지 · 기준점은 모달
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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_휠_진짜_사건_줌_스크롤_막음():
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user