merge(dev): 254-1 여러 기준점 받기 — 255-1 시점 모달 km 시험과 충돌 풂(양쪽 살림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
This commit is contained in:
2026-10-11 14:01:29 +09:00
co-authored by Claude Opus 5.5
21 changed files with 694 additions and 263 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict
"id": f"h-{g['key']}"[:40],
"name": "",
"address": "",
"via_base": True,
"to": "site",
"linked": False,
"dir": None,
"haul": g["key"],
+59 -12
View File
@@ -4,14 +4,16 @@
거울 시험 `resources/tester/test_244_1_b08_milestone.py`.
조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈.
표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면
표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 ·
`base_chain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면
시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 ·
포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) ·
끝수 = 소수 셋째 자리(반올림).
구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7).
도로 구분(152) = 포장 종류는 선 자리로 정함(`road_of`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 =
1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 ·
포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리).
도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`road_of`) — 시점에 닿는 기준점 구간 = 1차포장 ·
그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는
구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리).
옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrate_milestone` 로 새 꼴(`bases[]` · `to`).
"""
from __future__ import annotations
@@ -21,8 +23,10 @@ from typing import Any
ROADS = ("2차포장", "1차포장", "포장", "비포장")
KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"}
# 구간 자리 → 포장 종류(152) — via = 거래처 → 기준점 · base = 기준점 → 시점 · direct = 거래처 → 시점 · mid = 시점 → 중점
# 구간 자리 → 포장 종류(152 · 254) — via = → 기준점 · base = 기준점 → 시점 ·
# direct = 거래처 → 시점 · mid = 시점 → 중점
PAVED = {"via": "2차포장", "base": "1차포장", "direct": "포장", "mid": "포장"}
SITE = "site" # 이음 대상 = 시점(그 밖 = 기준점 id)
def road_of(road: str | None, kind: str) -> str:
@@ -55,21 +59,64 @@ def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float:
return float(station or 0) / 1000
def base_chain(data: dict[str, Any], to: str) -> list[dict[str, Any]]:
"""기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) ·
안 이은 · 없는 기준점에서 멈춤."""
by_id = {b.get("id"): b for b in data.get("bases") or []}
out: list[dict[str, Any]] = []
seen: set[str] = set()
while to != SITE and to not in seen:
seen.add(to)
base = by_id.get(to)
if base is None or base.get("linked", True) is False:
break
out.append(base)
to = base.get("to") or SITE
return out
def migrate_milestone(data: dict[str, Any]) -> dict[str, Any]:
"""옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" ·
경로 `via_base` = to "b1" | "site" · 새 꼴은 그대로(사본)."""
rest = {k: v for k, v in data.items() if k != "base"}
old = data.get("base") or {}
fresh = isinstance(rest.get("bases"), list)
on = not fresh and bool(old.get("enabled"))
if fresh:
bases = rest["bases"]
elif on:
keep = ("name", "address", "dir", "lon", "lat", "pos", "manual", "legs")
bases = [{**{k: old[k] for k in keep if k in old}, "id": "b1", "to": SITE, "linked": True}]
else:
bases = []
routes = []
for route in rest.get("routes") or []:
if not isinstance(route.get("to"), str):
via = on and route.get("via_base", True) is not False
route = {k: v for k, v in route.items() if k != "via_base"}
route["to"] = "b1" if via else SITE
routes.append(route)
return {**rest, "bases": bases, "routes": routes}
def _kind(owner: dict[str, Any], to_site: str, to_base: str) -> str:
return to_site if (owner.get("to") or SITE) == SITE else to_base
def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]:
"""표 줄 — 경로 차례 · 경로 안 줄 차례."""
data = migrate_milestone(data)
site = data.get("site") or {}
base = data.get("base") or {}
mid = {"road": site.get("mid_road") or "비포장", "km": mid_km(data, mid_default_m)}
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, "pv": 0.0, "un": 0.0}
linked = route.get("linked", True) is not False # 안 이은 거래처 = 구간 km 없음(155)
via = linked and bool(base.get("enabled")) and route.get("via_base", True) is not False
if linked:
add_legs(sums, route.get("legs"), "via" if via else "direct")
if via:
add_legs(sums, base.get("legs"), "base")
# 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254)
if route.get("linked", True) is not False:
add_legs(sums, route.get("legs"), _kind(route, "direct", "via"))
for base in base_chain(data, route.get("to") or SITE):
add_legs(sums, base.get("legs"), _kind(base, "base", "via"))
if row.get("dest") == "mid":
add_legs(sums, [mid], "mid")
paved = sums["s2"] + sums["s1"] + sums["pv"]
+107 -35
View File
@@ -6,20 +6,23 @@
* 거울 시험 `resources/tester/test_244_1_b08_milestone.py`.
* 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈.
*
* 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면
* 표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 ·
* `baseChain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면
* 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 ·
* 끝수 = 소수 셋째 자리(반올림)
* 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7)
* 도로 구분(152) = 포장 종류는 선 자리로 정함(`roadOf`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 =
* 1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 ·
* 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리)
* 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`roadOf`) — 시점에 닿는 기준점 구간 = 1차포장 ·
* 그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는
* 구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리)
* 옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrateMilestone` 로 새 꼴(`bases[]` · `to`)
* ========================================================================== */
export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const;
export type Road = (typeof ROADS)[number];
/** 구간 자리(152) — 거래처 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · 시점 → 중점(mid) */
/** 구간 자리(152 · 254) — 거래처 · 기준점 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) ·
* 시점 → 중점(mid) */
export type SegKind = "via" | "base" | "direct" | "mid";
/** 포장 종류 = 선 자리로 정함(152) — 기준점이 생기면 거래처 → 기준점 = 2차 · 기준점 → 시점 = 1차 · 없으면 포장 */
/** 포장 종류 = 선 자리로 정함(152) — → 기준점 = 2차 · 기준점 → 시점 = 1차 · 거래처 → 시점 = 포장 */
export const PAVED: Record<SegKind, Road> = {
via: "2차포장",
base: "1차포장",
@@ -36,20 +39,81 @@ export function withRoads(data: MilestoneData): MilestoneData {
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"),
})),
bases: data.bases.map((b) => ({ ...b, legs: fix(b.legs, baseKind(b)) })),
routes: data.routes.map((r) => ({ ...r, legs: fix(r.legs, routeKind(r)) })),
};
}
/** 선 자리 — from = 거래처 id · "base" · "start"(시점 → 중점) */
/** 이음 대상 = 시점(254 · 그 밖 = 기준점 id) */
export const SITE_ID = "site";
const baseKind = (b: { to: string }): SegKind => (b.to === SITE_ID ? "base" : "via");
const routeKind = (r: { to: string }): SegKind => (r.to === SITE_ID ? "direct" : "via");
/** 선 자리 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */
export function segKind(data: MilestoneData, from: string): SegKind {
if (from === "start") return "mid";
if (from === "base") return "base";
const base = data.bases.find((b) => b.id === from);
if (base) return baseKind(base);
const route = data.routes.find((r) => r.id === from);
return data.base.enabled && route?.via_base !== false ? "via" : "direct";
return route ? routeKind(route) : "direct";
}
/** 기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · 안 이은 · 없는 기준점에서 멈춤 */
export function baseChain(data: MilestoneData, to: string): MilestoneBase[] {
const out: MilestoneBase[] = [];
const seen = new Set<string>();
let id = to;
while (id !== SITE_ID && !seen.has(id)) {
seen.add(id);
const base = data.bases.find((b) => b.id === id);
if (!base || base.linked === false) break;
out.push(base);
id = base.to;
}
return out;
}
type OldBase = Partial<MilestoneBase> & { enabled?: boolean };
type OldData = Partial<Omit<MilestoneData, "routes">> & {
base?: OldBase | null;
routes?: Array<Partial<MilestoneRoute> & { via_base?: boolean }>;
};
/** 옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · 경로 `via_base` = to "b1" | "site" ·
* 새 꼴은 그대로(사본) */
export function migrateMilestone(data: OldData | MilestoneData): MilestoneData {
const { base: old, ...rest } = data as OldData;
const fresh = Array.isArray(rest.bases);
const on = !fresh && !!old?.enabled;
const bases: MilestoneBase[] = fresh
? rest.bases!
: on
? [
{
id: "b1",
name: old!.name ?? "",
address: old!.address ?? "",
dir: old!.dir ?? null,
lon: old!.lon ?? null,
lat: old!.lat ?? null,
pos: old!.pos ?? null,
manual: !!old!.manual,
legs: old!.legs ?? [],
to: SITE_ID,
linked: true,
},
]
: [];
const routes = (rest.routes ?? []).map((r) => {
if (typeof r.to === "string") return r as MilestoneRoute;
const { via_base, ...keep } = r;
return { ...keep, to: on && via_base !== false ? "b1" : SITE_ID } as MilestoneRoute;
});
return {
site: rest.site ?? { mid_station_m: null, mid_road: "비포장" },
bases,
routes,
};
}
/** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */
export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const;
@@ -76,8 +140,8 @@ export interface MilestoneRoute {
/** 업체 이름 = 하위 탭 이름 */
name: string;
address: string;
/** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */
via_base: boolean;
/** 잇는 곳(254) — "site"(시점으로 바로) · 기준점 id */
to: string;
dir: Dir | null;
lon?: number | null;
lat?: number | null;
@@ -91,25 +155,32 @@ export interface MilestoneRoute {
haul?: string | null;
/** 단가연결(251 · dg · sg · logic 꼴 · 정본) */
ref?: Record<string, unknown> | null;
/** 업체 → 기준점(거침) · 시점 구간 */
/** 업체 → to(기준점 · 시점) 구간 */
legs: Leg[];
rows: MilestoneRow[];
}
/** 기준점(254 · 여럿) — 거래처가 거쳐 가는 자리 · to = 시점 또는 다른 기준점 */
export interface MilestoneBase {
id: string;
name: string;
address: string;
dir: Dir | null;
lon?: number | null;
lat?: number | null;
pos?: [number, number] | null;
manual?: boolean;
/** 이 기준점 → to 구간 */
legs: Leg[];
/** 잇는 곳 — "site"(시점) · 다른 기준점 id */
to: string;
/** false = 아직 선 안 이음(요소 추가 뒤) · 없음 = 이음 */
linked?: boolean;
}
export interface MilestoneData {
site: { mid_station_m: number | null; mid_road: Road };
base: {
enabled: boolean;
name: string;
address: string;
dir: Dir | null;
lon?: number | null;
lat?: number | null;
pos?: [number, number] | null;
manual?: boolean;
/** 기준점 → 시점 공통 구간 */
legs: Leg[];
};
bases: MilestoneBase[];
routes: MilestoneRoute[];
}
@@ -153,19 +224,20 @@ export function midKm(data: MilestoneData, midDefaultM: number | null): number {
/** 표 줄 — 경로 차례 · 경로 안 줄 차례 */
export function computeMilestone(
data: MilestoneData,
raw: MilestoneData,
midDefaultM: number | null,
): MilestoneTableRow[] {
const base = data.base;
const data = migrateMilestone(raw);
const mid: Leg = { road: data.site?.mid_road ?? "비포장", km: midKm(data, midDefaultM) };
const out: MilestoneTableRow[] = [];
for (const route of data.routes ?? []) {
(route.rows ?? []).forEach((row, index) => {
const sums = { s2: 0, s1: 0, pv: 0, un: 0 };
const linked = route.linked !== false; // 안 이은 거래처 = 구간 km 없음(155)
const via = linked && !!base?.enabled && route.via_base !== false;
if (linked) addLegs(sums, route.legs, via ? "via" : "direct");
if (via) addLegs(sums, base.legs, "base");
if (route.linked !== false) {
// 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254)
addLegs(sums, route.legs, routeKind(route));
for (const b of baseChain(data, route.to)) addLegs(sums, b.legs, baseKind(b));
}
if (row.dest === "mid") addLegs(sums, [mid], "mid");
const paved = sums.s2 + sums.s1 + sums.pv;
out.push({
+111 -75
View File
@@ -18,7 +18,7 @@
* 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표
* ========================================================================== */
import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc";
import type { Dir, Leg, MilestoneBase, MilestoneData } from "./B08_Quantity_Milestone_Calc";
export interface DiagramBox {
id: string;
@@ -201,7 +201,7 @@ export function setAutoKm(legs: Leg[], km: number): Leg[] {
}
export interface KmSegment {
/** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */
/** route = 거래처 구간 · base = 기준점 구간(→ 시점 · 다른 기준점) */
key: "route" | "base";
text: string;
from: [number, number] | null;
@@ -211,40 +211,64 @@ 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(시점 → 중점 = 손 입력 · 길찾기 안 함) */
/** 이음 대상 = 시점(Calc `SITE_ID` 와 같음 · 형 import 만) */
const TO_SITE = "site";
const baseOf = (data: MilestoneData, id: string): MilestoneBase | undefined =>
data.bases.find((b) => b.id === id);
/** 구간 주인 — 기준점 · 거래처(from id) */
export const ownerOf = (data: MilestoneData, from: string) =>
baseOf(data, from) ?? data.routes.find((r) => r.id === from);
/** 기준점 사슬(254 · Calc `baseChain` 과 같음) — to 부터 시점까지 · 돌기 · 안 이음 · 없음에서 멈춤 */
export function chainOf(data: MilestoneData, to: string): MilestoneBase[] {
const out: MilestoneBase[] = [];
const seen = new Set<string>();
for (let id = to; id !== TO_SITE && !seen.has(id);) {
seen.add(id);
const base = baseOf(data, id);
if (!base || base.linked === false) break;
out.push(base);
id = base.to;
}
return out;
}
/** 돌기(254) — 기준점 from 을 to 에 이으면 to 사슬이 from 으로 돌아옴(A → B → A · 이음 여부 무관) */
export function makesLoop(data: MilestoneData, from: string, to: string): boolean {
const seen = new Set<string>();
for (let id = to; id !== TO_SITE && !seen.has(id); id = baseOf(data, id)?.to ?? TO_SITE) {
if (id === from) return true;
seen.add(id);
}
return false;
}
/** 구간 한 줄(244-13 연결선) — from = 거래처 id · 기준점 id(→ to = 시점 · 기준점) ·
* 없는 구간 = 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 || route.linked === false) return null; // 155 안 이은 거래처 = 구간 없음
const viaBase = data.base.enabled && route.via_base;
const base = baseOf(data, from);
const owner = base ?? data.routes.find((r) => r.id === from);
if (!owner || owner.linked === false) return null; // 155 안 이은 상자 = 구간 없음
const target = owner.to === TO_SITE ? undefined : baseOf(data, owner.to);
if (owner.to !== TO_SITE && !target) return null;
return {
key: "route",
text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`,
from: pointOf(route),
to: viaBase ? pointOf(data.base) : (start ?? null),
key: base ? "base" : "route",
text: `${owner.name || (base ? "기준점" : "거래처")} → ${target ? target.name || "기준점" : "시점"}`,
from: pointOf(owner),
to: target ? pointOf(target) : (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;
}
/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점
/** [길찾기] 구간 — 거래처 → to · 그 뒤 기준점 사슬 구간들(254)
* (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */
export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] {
const first = segmentEnds(data, routeId, start);
if (!first) return [];
return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first];
const route = data.routes.find((r) => r.id === routeId);
if (!first || !route) return [];
return [first, ...chainOf(data, route.to).map((b) => segmentEnds(data, b.id, start)!)];
}
/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */
/** 카카오 지도 표지(244-9) — 거래처 · 기준점 사슬(좌표 있을 때) · 시점 */
export function mapMarks(
data: MilestoneData,
routeId: string,
@@ -257,19 +281,15 @@ export function mapMarks(
if (p) out.push({ name, lon: p[0], lat: p[1] });
};
add(route.name || "거래처", pointOf(route));
if (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base));
if (route.linked !== false)
for (const b of chainOf(data, route.to)) add(b.name || "기준점", pointOf(b));
add("시점", start ?? null);
return out;
}
/** 자동 km 를 그 구간에(사용자 km 는 그대로) */
export function putAutoKm(
data: MilestoneData,
routeId: string,
key: KmSegment["key"],
km: number,
): MilestoneData {
const legs = key === "base" ? data.base.legs : data.routes.find((r) => r.id === routeId)?.legs;
/** 자동 km 를 그 구간에(from = 거래처 · 기준점 id · 사용자 km 는 그대로) */
export function putAutoKm(data: MilestoneData, from: string, km: number): MilestoneData {
const legs = ownerOf(data, from)?.legs;
if (legs) setAutoKm(legs, km);
return data;
}
@@ -363,8 +383,7 @@ export function layoutMilestone(
};
/** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */
// 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리
const posOf = (id: string): [number, number] | null | undefined =>
id === "base" ? data.base.pos : data.routes.find((r) => r.id === id)?.pos;
const posOf = (id: string): [number, number] | null | undefined => ownerOf(data, id)?.pos;
const put = (box: DiagramBox, out?: [number, number]): void => {
const inner = box.w - 2 * INSET;
box.label = clip(box.label, inner, FONT.label);
@@ -411,31 +430,60 @@ export function layoutMilestone(
line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]);
const startLL = site?.start_lonlat ?? null;
const useBase = data.base.enabled;
const baseLL: LonLat =
data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null;
let baseDeg = 270;
if (useBase) {
baseDeg = placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270;
const [ux, uy] = unit(baseDeg);
const cx = ux * R_BASE;
const cy = uy * R_BASE;
const llOf = (p?: { lon?: number | null; lat?: number | null }): LonLat =>
p && p.lon != null && p.lat != null ? [p.lon, p.lat] : null;
const used = new Set<string>();
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
/** 상자 id → 잇는 곳에서 본 방위(기준점 둘레 빈 방위 = 그 방위에서 돌림 · 되돌아가는 쪽 뺌) */
const degOf = new Map<string, number>();
let auto = 0;
/** 방위 안 정한 상자 — 빈 8방 차례(기준점 둘레 = 잇는 곳 반대편부터 · 잇는 곳으로 향하는 쪽 뺌) */
const free = (anchor: string): number => {
const turn = anchor === "site" ? 0 : (degOf.get(anchor) ?? 0);
const blocked = anchor === "site" ? -1 : turn + 180;
const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter(
(c) => keyOf(anchor, c) !== keyOf(anchor, blocked),
);
return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length];
};
/** 잇는 곳 상자 — 놓인 기준점이면 그 id · 아니면 공사지 */
const anchorOf = (to: string): string => (to !== "site" && byId.has(to) ? to : "site");
// 기준점(254 · 여럿) — 잇는 곳(시점 · 다른 기준점) 기준 방위(좌표 → 방위각 · 없으면 서부터 빈 방위) ·
// 잇는 곳이 먼저 놓이게 사슬 차례 · 돌기 = 공사지 둘레
const waiting = [...data.bases];
while (waiting.length) {
let i = waiting.findIndex(
(b) => b.linked === false || b.to === "site" || byId.has(b.to) || !ownerOf(data, b.to),
);
if (i < 0) i = 0;
const [b] = waiting.splice(i, 1);
const anchor = b.linked === false ? "site" : anchorOf(b.to);
const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL);
const deg =
placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??
(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor));
used.add(keyOf(anchor, deg));
degOf.set(b.id, deg);
const [ax, ay] = center(anchor);
const [ux, uy] = unit(deg);
put(
{
id: "base",
id: b.id,
kind: "base",
x: cx - BOX.w / 2,
y: cy - BOX.h / 2,
x: ax + ux * R_BASE - BOX.w / 2,
y: ay + uy * R_BASE - BOX.h / 2,
w: BOX.w,
h: BOX.h,
label: data.base.name || "기준점",
label: b.name || "기준점",
},
[ux, uy],
);
line("base", "start", data.base.legs);
if (b.linked !== false && (b.to === "site" || byId.has(b.to)))
line(b.id, b.to === "site" ? "start" : b.to, b.legs);
}
// 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
// 거래처 — 잇는 곳(기준점 · 공사지) 기준 방위 · 같은 방위끼리 옆으로 나란히
const groups = new Map<
string,
Array<{
@@ -446,25 +494,15 @@ export function layoutMilestone(
anchor: string;
deg: number;
legs: Leg[];
linked: boolean;
}>
>();
const used = new Set<string>();
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
const items = data.routes.map((r) => {
const anchor = useBase && r.via_base ? "base" : "site";
return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) };
const anchor = anchorOf(r.to);
const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL);
return { r, anchor, deg: placeDeg(r, anchorLL) };
});
for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg));
let auto = 0;
/** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */
const free = (anchor: string): number => {
const turn = anchor === "base" ? baseDeg : 0;
const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1;
const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter(
(c) => keyOf(anchor, c) !== keyOf(anchor, blocked),
);
return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length];
};
for (const { r, anchor, deg } of items) {
const d = deg ?? free(anchor);
used.add(keyOf(anchor, d));
@@ -479,11 +517,13 @@ export function layoutMilestone(
anchor,
deg: d,
legs: r.legs,
// 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음
linked: r.linked !== false && (r.to === "site" || anchor === r.to),
});
}
for (const group of groups.values()) {
group.forEach((g, k) => {
const [ax, ay] = center(g.anchor === "base" ? "base" : "site");
const [ax, ay] = center(g.anchor);
const [ux, uy] = unit(g.deg);
// 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④)
const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP;
@@ -506,9 +546,7 @@ export function layoutMilestone(
},
[ux, uy],
);
// 155 안 이은 거래처 = 선 없음(연결 점으로 이음)
if (data.routes.find((r) => r.id === g.id)?.linked !== false)
line(g.id, g.anchor === "base" ? "base" : "start", g.legs);
if (g.linked) line(g.id, g.anchor === "site" ? "start" : g.anchor, g.legs);
});
}
@@ -530,11 +568,10 @@ export function layoutMilestone(
};
}
/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */
/** 선 글 한 줄 → 그 구간 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */
export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null {
if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 };
const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs;
return legs?.[i] ?? null;
return ownerOf(data, from)?.legs[i] ?? null;
}
/** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */
@@ -550,8 +587,7 @@ export function setLeg(
data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000;
return data;
}
const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs;
const leg = legs?.[i];
const leg = ownerOf(data, from)?.legs[i];
if (leg) Object.assign(leg, patch);
return data;
}
+21 -6
View File
@@ -27,6 +27,8 @@
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
기준점 = `bases[]`(254 · 여럿 · to = 시점 또는 다른 기준점) · 경로 `to` — 옛 `base` · `via_base` 는
읽을 때 · 받을 때 새 꼴로 옮김(`migrate_milestone` · 저장 = 새 꼴).
"""
from __future__ import annotations
@@ -50,7 +52,7 @@ from B01_Dashboard.B01_Dashboard_Map import reverse_geocode
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3
from B08_Quantity.B08_Quantity_Milestone_Calc import SITE, compute_milestone, migrate_milestone, r3
from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
@@ -71,6 +73,7 @@ DATA_FILE = "milestone.json"
AUTO = "auto" # 자동 그림 하위 폴더(244-11)
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀
Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13)
Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
@@ -102,7 +105,7 @@ class Place(BaseModel):
class Route(Place):
id: str = Field(pattern=ROUTE_ID)
via_base: bool = True
to: str = Field(default=SITE, pattern=ROUTE_ID) # 254 잇는 곳 = "site"(시점) · 기준점 id
# 251 운반 묶음 키(총괄자재 · B09 운반 줄과 이음) · 단가연결(dg · sg · logic 꼴 · 정본)
haul: str | None = Field(default=None, max_length=60, pattern=r"^[A-Za-z0-9_-]+$")
ref: dict[str, Any] | None = None
@@ -111,7 +114,11 @@ class Route(Place):
class Base(Place):
enabled: bool = False
"""기준점(254 · 여럿) — to = 시점 또는 다른 기준점 id(돌기는 화면이 막고 셈이 끊음)."""
id: str = Field(pattern=ROUTE_ID)
to: str = Field(default=SITE, pattern=ROUTE_ID)
linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤)
class Site(BaseModel):
@@ -121,14 +128,22 @@ class Site(BaseModel):
class Milestone(BaseModel):
site: Site = Field(default_factory=Site)
base: Base = Field(default_factory=Base)
bases: list[Base] = Field(default_factory=list, max_length=20)
routes: list[Route] = Field(default_factory=list, max_length=100)
@model_validator(mode="before")
@classmethod
def _migrate(cls, data: Any) -> Any:
"""옛 꼴(단일 base · via_base) → 새 꼴(254)."""
return migrate_milestone(data) if isinstance(data, dict) else data
@model_validator(mode="after")
def _unique_ids(self) -> Milestone:
ids = [route.id for route in self.routes]
ids = [b.id for b in self.bases] + [route.id for route in self.routes]
if len(ids) != len(set(ids)):
raise ValueError("경로 id 가 겹칩니다.")
raise ValueError("경로 · 기준점 id 가 겹칩니다.")
if BOX_IDS & set(ids):
raise ValueError("site · start · mid 는 id 로 못 씁니다.")
return self
+3 -3
View File
@@ -87,7 +87,7 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
id,
name: `거래처 ${data.routes.length + 1}`,
address: "",
via_base: true,
to: "site",
dir: null,
legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7)
rows: [{ item: "", dest: "start", note: "" }],
@@ -168,12 +168,12 @@ function tableView(projectId: string): MilestoneView {
const data = liveMilestone(projectId);
const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null);
// 기준점 생김 · 없앰 = 열 바뀜 → 표 새로
const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`;
const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`;
if (next !== mode) {
mode = next;
const fresh = makeTable(
milestoneColumns(
data.base.enabled,
data.bases.length > 0,
rows.some((r) => r.pv > 0),
),
);
@@ -28,6 +28,7 @@ import {
import {
layoutMilestone,
legAt,
ownerOf,
panContent,
panStart,
setLeg,
@@ -158,7 +159,7 @@ export function createDiagram(
site: () => openSiteInfo(projectId),
start: () => openSiteInfo(projectId),
mid: () => openSiteInfo(projectId),
base: () => openPlaceModal(projectId, { add: false, id: "base" }),
base: (id) => openPlaceModal(projectId, { add: false, id }),
};
/** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버)
@@ -189,7 +190,7 @@ export function createDiagram(
Math.round(box.y + (e.clientY - down.clientY) / k),
];
const fresh = liveMilestone(projectId);
const target = box.id === "base" ? fresh.base : fresh.routes.find((r) => r.id === box.id);
const target = ownerOf(fresh, box.id);
if (!target) return;
target.pos = pos;
userZoomed = true; // 옮긴 뒤 배율 그대로
@@ -200,8 +201,9 @@ export function createDiagram(
});
};
/** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 기준점 거침 · 공사지(시점 · 중점) = 시점으로 바로 ·
* 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + 길찾기 한 번(km · 경로 그림) */
/** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 그 기준점 거침(254 · to = 기준점 id) ·
* 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) +
* 길찾기 한 번(km · 경로 그림) */
const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => {
const cx = box.x + box.w;
const cy = box.y + box.h / 2;
@@ -221,12 +223,13 @@ export function createDiagram(
const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy });
shift.append(wire);
let over: Element | null = null;
const targetOf = (e: PointerEvent): "base" | "site" | null => {
/** 놓을 곳 — 기준점 id · "site"(공사지 · 시점 · 중점) · 없음 */
const targetOf = (e: PointerEvent): string | null => {
const hit = document.elementFromPoint(e.clientX, e.clientY);
const g = hit?.closest<SVGGElement>("[data-b08-milestone-box]");
const id = g?.dataset.b08MilestoneBox;
if (id === "base") return "base";
return id === "site" || id === "start" || id === "mid" ? "site" : null;
if (id === "site" || id === "start" || id === "mid") return "site";
return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null;
};
const move = (e: PointerEvent): void => {
const [x, y] = toLocal(e);
@@ -249,10 +252,8 @@ export function createDiagram(
const fresh = liveMilestone(projectId);
const route = fresh.routes.find((r) => r.id === box.id);
if (!t || !route) return;
const via = t === "base";
const now = route.linked === false ? null : fresh.base.enabled && route.via_base !== false;
if (via === now) return; // 같은 연결 = 그대로
route.via_base = via;
if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로
route.to = t;
route.linked = true; // 155 요소 추가 뒤 첫 이음
route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기
putMilestone(projectId, fresh);
@@ -270,7 +271,7 @@ export function createDiagram(
variant: "ghost",
onClick: () => {
const fresh = liveMilestone(projectId);
fresh.base.pos = null;
fresh.bases.forEach((b) => (b.pos = null));
fresh.routes.forEach((r) => (r.pos = null));
userZoomed = false;
putMilestone(projectId, fresh);
@@ -3,8 +3,8 @@
* 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름.
*
* 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7)
* 도로 구분(152) = 포장 종류는 선 자리로 정함 — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 ·
* 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`)
* 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 ·
* 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`)
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
@@ -22,7 +22,7 @@ import {
type Road,
type SegKind,
} from "./B08_Quantity_Milestone_Calc";
import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout";
import { ownerOf, 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";
@@ -144,7 +144,7 @@ function openMidLeg(projectId: string): void {
});
}
/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */
/** 연결선 누름 — from = 거래처 · 기준점 id(→ 기준점 · 시점 · 254) · "start"(시점 → 중점) */
export function openLegInfo(projectId: string, from: string): void {
if (from === "start") return openMidLeg(projectId);
const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null;
@@ -155,11 +155,8 @@ export function openLegInfo(projectId: string, from: string): void {
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);
const legsOf = (): Leg[] => ownerOf(liveMilestone(projectId), from)?.legs ?? [];
const isBase = liveMilestone(projectId).bases.some((b) => b.id === from);
const kind = segKind(liveMilestone(projectId), from);
let legs = legsEditor(legsOf(), kind);
const status = hint("");
@@ -167,7 +164,7 @@ export function openLegInfo(projectId: string, from: string): void {
/** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */
const apply = (): void => {
const fresh = liveMilestone(projectId);
const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from);
const target = ownerOf(fresh, from);
if (!target) return;
target.legs = legs.value();
putMilestone(projectId, fresh);
@@ -177,18 +174,20 @@ export function openLegInfo(projectId: string, from: string): void {
legs.root.replaceWith(next.root);
legs = next;
};
/** 표지 = 그 구간 두 끝(주인 · to = 기준점 또는 시점) */
const pins = (): MapPin[] => {
const d = liveMilestone(projectId);
const route = d.routes.find((r) => r.id === from);
const owner = ownerOf(d, 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);
if (!owner) return out;
add(owner.id, owner.name || (isBase ? "기준점" : "거래처"), owner.lon, owner.lat);
const target = d.bases.find((b) => b.id === owner.to);
if (target) add(target.id, target.name || "기준점", target.lon, target.lat);
else add("start", "시점", start()?.[0], start()?.[1]);
if (from === "base") add("start", "시점", start()?.[0], start()?.[1]);
return out;
};
const drawMap = (): void => {
@@ -214,7 +213,7 @@ export function openLegInfo(projectId: string, from: string): void {
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);
const target = ownerOf(fresh, key);
if (!target) return;
Object.assign(target, { lon, lat, address });
putMilestone(projectId, fresh);
@@ -242,14 +241,13 @@ export function openLegInfo(projectId: string, from: string): void {
save.dataset.b08MilestoneLegSave = "";
actions.append(find, save);
// 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기
const owner = (d = liveMilestone(projectId)) =>
from === "base" ? d.base : d.routes.find((r) => r.id === from);
const owner = (d = liveMilestone(projectId)) => ownerOf(d, from);
const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)");
manual.input.checked = !!owner()?.manual;
manual.input.dataset.b08MilestoneManual = "";
const auto = hint(
from === "base"
? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
isBase
? "기준점 구간 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
);
const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" });
+36 -22
View File
@@ -8,7 +8,8 @@
* 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달
* 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음)
* 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰)
* 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리
* 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) ·
* 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
* 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지)
* 조작 = 캐시 · [저장] 때 서버
@@ -88,20 +89,21 @@ function rowField(r: MilestoneRow, kind: "route" | "base") {
export type PlaceTarget = { add: true } | { add: false; id: string };
/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */
/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id = 거래처 · 기준점 id */
export function openPlaceModal(
projectId: string,
target: PlaceTarget,
addRoute?: (init: Partial<MilestoneRoute>) => string,
): void {
const data = liveMilestone(projectId);
const editBase = !target.add && target.id === "base";
const base = target.add ? undefined : data.bases.find((b) => b.id === target.id);
const editBase = !!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
? "요소 추가 — 기준점 · 거래처"
: editBase
? `기준점 — ${data.base.name || "기준점"}`
: base
? `기준점 — ${base.name || "기준점"}`
: `항목 정보 — ${route!.name || "거래처"}`;
openModal({
title,
@@ -119,20 +121,14 @@ export function openPlaceModal(
});
sort.select.value = kind;
sort.select.disabled = !target.add;
// 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침)
const baseOption = sort.select.querySelector<HTMLOptionElement>('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 = editBase ? data.base.name : (route?.name ?? "");
name.input.value = base ? base.name : (route?.name ?? "");
name.input.dataset.b08MilestoneAdd = "company";
const nameLabel = name.root.querySelector(".ui-field__label");
// 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠
const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind);
const old = editBase ? data.base : route;
const old = base ?? route;
const place = placePicker(
projectId,
{ address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null },
@@ -164,11 +160,24 @@ export function openPlaceModal(
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 }];
if (kind === "base" && base) {
const b = fresh.bases.find((x) => x.id === base.id);
if (!b) return close();
Object.assign(b, where, { name: text, dir: null });
putMilestone(projectId, fresh);
if (moved(where)) segment = b.id;
} else if (kind === "base") {
// 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기)
fresh.bases.push({
id: `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`,
name: text,
dir: null,
legs: [{ road: null, km: null }],
to: "site",
linked: false,
...where,
});
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();
@@ -181,9 +190,9 @@ export function openPlaceModal(
} else {
if (!text) return name.setError("거래처 이름을 넣으세요.");
// 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기)
const id = addRoute?.({
addRoute?.({
name: text,
via_base: true,
to: "site",
linked: false,
dir: null,
rows: rowValues(),
@@ -199,12 +208,17 @@ export function openPlaceModal(
label: "삭제",
variant: "danger",
onClick: async () => {
const label = editBase ? data.base.name || "기준점" : route?.name || "거래처";
const label = base ? base.name || "기준점" : route?.name || "거래처";
if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제")))
return;
const fresh = liveMilestone(projectId);
if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] };
else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이
if (base) {
fresh.bases = fresh.bases.filter((b) => b.id !== base.id);
// 254 그 기준점에 이은 상자 = 안 이음(옛 구간 km = 다른 길 · 연결 점으로 다시 이음)
for (const o of [...fresh.bases, ...fresh.routes])
if (o.to === base.id)
Object.assign(o, { to: "site", linked: false, legs: [{ road: null, km: null }] });
} else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이
putMilestone(projectId, fresh);
close();
},
@@ -14,6 +14,7 @@ import {
} from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout";
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
import {
@@ -131,28 +132,26 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
if (!r) return out;
add(r.id, r.name || "거래처", r.lon, r.lat);
if (r.linked === false) return out;
if (d.base.enabled && r.via_base) add("base", d.base.name || "기준점", d.base.lon, d.base.lat);
for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬
add("start", "시점", start()?.[0], start()?.[1]);
return out;
};
const lines = (): Array<Array<[number, number]>> => {
const d = liveMilestone(projectId);
const r = d.routes.find((x) => x.id === routeId);
const via = !!r && r.linked !== false && d.base.enabled && r.via_base;
return [segmentPath(routeId), ...(via ? [segmentPath("base")] : [])].filter(
(p) => p.length >= 2,
);
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2);
};
const map = editMap(projectId, {
hint: "",
onMove: (key, lon, lat) =>
void reverseAddress(projectId, lon, lat).then((addr) => {
const fresh = liveMilestone(projectId);
const target = key === "base" ? fresh.base : fresh.routes.find((x) => x.id === key);
const target = ownerOf(fresh, key);
if (!target) return;
Object.assign(target, { lon, lat, address: addr });
putMilestone(projectId, fresh);
void findSegmentKm(projectId, key === "base" ? "base" : routeId).then(() => drawMap());
void findSegmentKm(projectId, key).then(() => drawMap());
}),
});
const drawMap = (): void => map.set(pins(), lines());
+18 -11
View File
@@ -11,16 +11,22 @@
* 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐)
* 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) ·
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) ·
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
* 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`)
* ========================================================================== */
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 { PAVED, type MilestoneData, type SegKind } from "./B08_Quantity_Milestone_Calc";
import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout";
import {
PAVED,
migrateMilestone,
type MilestoneData,
type SegKind,
} from "./B08_Quantity_Milestone_Calc";
import { mapMarks, ownerOf, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout";
import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal";
export interface SiteInfo {
@@ -39,7 +45,7 @@ interface Payload {
export const EMPTY: MilestoneData = {
site: { mid_station_m: null, mid_road: "비포장" },
base: { enabled: false, name: "", address: "", dir: null, legs: [] },
bases: [],
routes: [],
};
@@ -97,9 +103,11 @@ export const routeImageKey = (routeId: string): string | null => {
export const hasMilestoneDraft = (projectId: string): boolean =>
readState<MilestoneData>("b08-milestone", projectId) !== null;
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본 · 옛 꼴 캐시 = 새 꼴 · 254) */
export function liveMilestone(projectId: string): MilestoneData {
return clone(readState<MilestoneData>("b08-milestone", projectId) ?? saved?.data ?? EMPTY);
return migrateMilestone(
clone(readState<MilestoneData>("b08-milestone", projectId) ?? saved?.data ?? EMPTY),
);
}
/** 고친 한 벌을 캐시에 · 화면에 알림 */
@@ -214,7 +222,7 @@ export function roadOptions(kind: SegKind): Array<{ value: string; text: string
return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r }));
}
/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */
/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(메모리만 · 저장 안 함) */
const paths = new Map<string, Array<[number, number]>>();
export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? [];
/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */
@@ -229,7 +237,7 @@ export async function findSegmentKm(
image = true,
): Promise<string> {
const data = liveMilestone(projectId);
const owner = from === "base" ? data.base : data.routes.find((r) => r.id === from);
const owner = ownerOf(data, from);
if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함
const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null);
if (!seg) return "";
@@ -253,9 +261,8 @@ export async function findSegmentKm(
if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!);
else paths.delete(from);
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
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);
if (ownerOf(fresh, from)?.legs[0]?.auto_km !== res.km) {
putAutoKm(fresh, from, res.km);
putMilestone(projectId, fresh);
} else changes.dispatchEvent(new Event("change"));
if (image && seg.key === "route" && paths.has(from))
@@ -16,6 +16,7 @@ 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 migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
LONG = "경상남도 거창군 북상면 병곡리 968-31"
@@ -58,6 +59,7 @@ def _clipped(note, box_w):
def _lay(data, site=None):
data = migrate_milestone(data) # 254 옛 꼴 → bases · to(옛 기준점 = b1)
return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)")
@@ -21,7 +21,10 @@ 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
from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402
compute_milestone,
migrate_milestone,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
B08 = "B08_Quantity/B08_Quantity_"
@@ -90,29 +93,29 @@ def test_포장_도로_구분_거울():
def test_도로_구분_갈래_연결선_구간_끝():
d = json.dumps(WITH_BASE)
off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}})
# 254 옛 꼴 → bases · to(옛 기준점 = b1)
d = json.dumps(migrate_milestone(WITH_BASE))
off = {**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}
off = json.dumps(migrate_milestone(off))
got = _node(
[CALC, LAYOUT],
f"""const D = {d}; const O = {off}; const S = [127.83, 35.64];
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),
console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D, f)]),
["g", "b1"].map((f) => segKind(O, f)),
segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "b1", S),
segmentEnds(D, "start", S), segmentEnds(O, "b1", S), segmentEnds(O, "g", S),
kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""",
)
# 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],
assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "direct"]
assert got[2] == {"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],
assert got[3]["text"] == "d → 시점" and got[3]["to"] == [127.83, 35.64]
assert got[4] == {"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"]
assert got[5] is None and got[6] is None # 시점 → 중점 = 손 입력 · 기준점 없음
assert got[7]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로
assert got[8] == ["route", "base"] and got[9] == ["route"]
def test_표_열_기준점_따라():
@@ -237,13 +240,13 @@ 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 b = await findSegmentKm("p1", "b1"); // 254 옛 꼴 캐시 → 기준점 id b1
const writes = WRITES.n;
await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀
await findSegmentKm("p1", "b1"); // 같은 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"),
route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("g"),
routeLines: routePaths("g"), baseLines: routePaths("b1"),
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"
@@ -290,7 +293,7 @@ def test_화면_등록_모달_연결선_모달():
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 "base: (id) => openPlaceModal(projectId, { add: false, id })," 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
@@ -14,10 +14,13 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
def _layout(data, site=None, mid_km=0.75):
data = migrate_milestone(data) # 254 옛 꼴(base · via_base) → bases · to(옛 기준점 = b1)
src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8")
body = (
f"\nconst d = {json.dumps(data, ensure_ascii=False)};"
@@ -95,17 +98,17 @@ def test_기준점_기준_방위_선_글():
), # fmt: skip
)
cx, cy = _center(lay, "site")
bx, by = _center(lay, "base")
bx, by = _center(lay, "b1")
assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽
gx, gy = _center(lay, "g")
assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽
kx, _ = _center(lay, "k")
assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽
labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]}
assert labels[("g", "base")] == ["2차포장 23.8km"]
assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"]
assert labels[("g", "b1")] == ["2차포장 23.8km"]
assert labels[("b1", "start")] == ["1차포장 0.6km", "비포장 0.8km"]
assert labels[("k", "start")] == []
assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31"
assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31"
def test_경위도_있으면_방위각이_8방보다_앞섬():
@@ -18,6 +18,7 @@ if str(ROOT) not in sys.path:
from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@@ -58,8 +59,9 @@ def test_방위_좌표_자동():
# 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽
"routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)],
}
lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "base")
# 254 옛 꼴 → bases · to(옛 기준점 = b1)
lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "b1")
sx, sy = _center(lay, "s")
assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래
cx, cy = _center(lay, "site")
@@ -68,8 +70,9 @@ def test_방위_좌표_자동():
# 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽)
data["base"] = {**base, "lon": 127.7, "lat": 35.7}
data["routes"] = [_route("n", 127.7, 35.8)]
lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "base")
# 254 옛 꼴 → bases · to(옛 기준점 = b1)
lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "b1")
nx, ny = _center(lay, "n")
assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표)
assert abs(nx - bx) < 1 and ny < by - 200
@@ -186,17 +189,17 @@ def test_선_글_바로_고침_모달과_한_벌():
"legs": [{"road": "1차포장", "km": 0.6}]},
"routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}],
} # fmt: skip
d = json.dumps(data)
d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1)
got = _node(
f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0),"
f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}}),"
f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]"
f" 'b1', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]"
)
assert got[0] == {"road": "2차포장", "km": None}
assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None
after = got[3]
assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}]
assert after["base"]["legs"] == [{"road": "1차포장", "km": None}]
assert after["bases"][0]["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)
@@ -5,9 +5,15 @@
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@@ -17,6 +23,7 @@ def _read(rel):
def _layout(base, site):
data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, "base": base, "routes": []}
data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1)
src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts"))
expr = f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)"
result = subprocess.run(
@@ -27,7 +34,7 @@ def _layout(base, site):
assert result.returncode == 0, result.stderr
lay = json.loads(result.stdout)
center = {b["id"]: (b["x"] + b["w"] / 2, b["y"] + b["h"] / 2) for b in lay["boxes"]}
return center["site"], center["base"]
return center["site"], center["b1"]
BASE = {"enabled": True, "name": "기준", "address": "", "legs": []}
@@ -60,6 +67,5 @@ def test_좌측_패널_방위_칸_없음_모달_안내():
]
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
)
assert "placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??" in layout
assert '(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor))' in layout
@@ -82,13 +82,13 @@ def test_실제_포인터_사건으로_팬():
def test_거래처_상자_항목_글():
data = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []},
"bases": [],
"routes": [
{"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None,
{"id": "g", "name": "거창군청", "address": "", "to": "site", "dir": None,
"legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""},
{"item": " 장비 ", "dest": "mid", "note": ""},
{"item": "골재", "dest": "mid", "note": ""}]},
{"id": "k", "name": "", "address": "", "via_base": True, "dir": None,
{"id": "k", "name": "", "address": "", "to": "site", "dir": None,
"legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]},
],
} # fmt: skip
@@ -118,12 +118,12 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음():
site = modal[modal.index("export function openSiteInfo") :]
assert 'label: "중점 측점 (km)"' in site and 'label: "시점 → 중점 도로"' in site
assert 'setLeg(fresh, "start", 0, {' in site and "putMilestone(" in site
# 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음
# 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 254 여럿 · 삭제 = 그 기준점만
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 "fresh.bases.push({" in base and 'label: "삭제"' in base
assert "fresh.bases = fresh.bases.filter((b) => b.id !== base.id);" 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
@@ -24,7 +24,10 @@ 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
from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402
compute_milestone,
migrate_milestone,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
FAKE_KEY = "fake-kakao-key-0123456789"
@@ -144,7 +147,8 @@ def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog):
def _data(base_enabled=True, base_ll=(127.85, 35.66)):
return {
"""254 옛 꼴로 적고 새 꼴(bases · to · 옛 기준점 = b1)로 옮겨 씀."""
return migrate_milestone({
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None,
"lon": base_ll[0], "lat": base_ll[1], "legs": []},
@@ -156,7 +160,7 @@ def _data(base_enabled=True, base_ll=(127.85, 35.66)):
"lon": None, "lat": None, "legs": [{"road": None, "km": None}],
"rows": [{"item": "철근", "dest": "start", "note": ""}]},
],
} # fmt: skip
}) # fmt: skip
LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts"
@@ -179,27 +183,27 @@ def test_구간_채움():
two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)")
assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}]
# 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로
put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 'route', 10.5), 'r1', 'base', 1.4)")
put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 10.5), 'b1', 1.4)")
assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}]
assert put["base"]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}]
assert put["bases"][0]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}]
# 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값
lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)")
line = {ln["from"]: ln for ln in lay["lines"]}
assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"]
assert line["base"]["labels"] == ["도로 구분 고르기 1.4km"] and line["base"]["tips"] == [""]
assert line["b1"]["labels"] == ["도로 구분 고르기 1.4km"] and line["b1"]["tips"] == [""]
assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로
def test_사용자_값_우선_고르기_표시():
data = _data()
data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}]
data["base"]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}]
data["bases"][0]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}]
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)
# 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0
assert (b["total"], b["pick"]) == (0, False)
data["base"]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리)
data["bases"][0]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리)
a2, _ = compute_milestone(data, 1500.0)
assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False)
# TS 짝 거울
@@ -210,7 +214,7 @@ def test_사용자_값_우선_고르기_표시():
# 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장
saved = ms.Milestone.model_validate(data).model_dump()
assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5
assert saved["base"]["legs"][0]["road"] is None
assert saved["bases"][0]["legs"][0]["road"] is None
assert ms.Leg.model_validate({"km": 1}).road == "2차포장"
@@ -19,6 +19,7 @@ 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 migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone"
@@ -118,14 +119,14 @@ def test_표지_셋():
"routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True},
{"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}],
} # fmt: skip
d = json.dumps(data)
d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1)
got = _node(
[LAYOUT],
f"""const D = {d};
console.log(JSON.stringify([
mapMarks(D, "r1", [127.83, 35.64]),
mapMarks(D, "r2", [127.83, 35.64]),
mapMarks({{ ...D, base: {{ ...D.base, enabled: false }} }}, "r1", null),
mapMarks({{ ...D, bases: [] }}, "r1", null),
]));""",
)
assert got[0] == [
@@ -9,7 +9,11 @@ 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
from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402
compute_milestone,
migrate_milestone,
road_of,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@@ -87,7 +91,9 @@ def test_153_지도_보기_유지_상자_색_끌어_옮김():
assert var in block[: block.index("}")], kind
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui
assert 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui # 159 이름
assert (
'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui
) # 159 이름
def test_153_끌어_놓은_자리_레이아웃():
@@ -96,23 +102,24 @@ def test_153_끌어_놓은_자리_레이아웃():
"base": {"enabled": True, "name": "기준", "address": "", "dir": "W", "legs": [], "pos": [500, -40]},
"routes": [{"id": "r", "name": "a", "address": "", "via_base": False, "dir": "N", "legs": [],
"rows": [], "pos": [-700, 300]}]} # fmt: skip
expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'base' || b.id === 'r').map((b) => [b.id, b.x, b.y])"
data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1)
expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'b1' || b.id === 'r').map((b) => [b.id, b.x, b.y])"
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
assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]]
assert sorted(json.loads(out.stdout)) == [["b1", 500, -40], ["r", -700, 300]]
def test_154_연결_점_끌어_잇기():
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui
assert 'const via = t === "base";' in ui and "route.via_base = via;" in ui
assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site"
assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움
assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번
assert "if (via === now) return;" in ui # 같은 연결 = 그대로
assert "if (route.linked !== false && route.to === t) return;" in ui # 같은 연결 = 그대로
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert (
'createCheckbox("기준점 거침")' not in modal and "via_base: via.input.checked" not in modal
@@ -145,7 +152,7 @@ def test_155_요소_추가_안_이은_거래처_수기_입력():
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함
layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
assert "if (!route || route.linked === false) return null;" in layout
assert "if (!owner || owner.linked === false) return null;" in layout # 254 거래처 · 기준점
def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화():
@@ -0,0 +1,213 @@
"""254-1 B08 거리이정표 여러 기준점(사용자 162-10) — `bases[]` · 경로 `to` · 옛 꼴 옮김 ·
기준점 사슬 셈(파이썬 · TS 거울) · 돌기 막음 ·
도로 구분(시점에 닿는 기준점 구간 = 1차 · 그 밖 = 2차).
견본 사슬 = 거창군청 → 기준점 A(병곡리) → 기준점 B(신원) → 시점 · 김천역 → B → 시점 ·
철근 상회 → 시점(직접).
"""
import json
import re
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
from B08_Quantity.B08_Quantity_Haul import with_haul_routes # noqa: E402
from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402
base_chain,
compute_milestone,
migrate_milestone,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
B08 = "B08_Quantity/B08_Quantity_"
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(files, expr):
src = "\n".join(IMPORTS.sub("", _read(f)) for f in files)
result = 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 result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _leg(road, km):
return {"road": road, "km": km}
def _base(bid, to, legs, linked=True):
return {"id": bid, "name": bid, "address": "", "dir": None, "legs": legs, "to": to,
"linked": linked} # fmt: skip
def _route(rid, to, legs, dest="start"):
return {"id": rid, "name": rid, "address": "", "dir": None, "to": to, "legs": legs,
"rows": [{"item": "장비", "dest": dest, "note": ""}]} # fmt: skip
CHAIN = {
"site": {"mid_station_m": 800, "mid_road": "비포장"},
"bases": [
_base("A", "B", [_leg(None, 4.5), _leg("비포장", 0.3)]),
_base("B", "site", [_leg(None, 0.6)]),
],
"routes": [
_route("g", "A", [_leg(None, 23.8)]),
_route("k", "B", [_leg(None, 61.2)], dest="mid"),
_route("c", "site", [_leg(None, 2.0)]),
],
}
OLD = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": True, "name": "병곡리", "address": "주소", "dir": "E", "lon": 127.85,
"lat": 35.66, "pos": [10, 20], "legs": [_leg("1차포장", 0.6)]},
"routes": [
{"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None,
"legs": [_leg("2차포장", 23.8)], "rows": [{"item": "장비", "dest": "start", "note": ""}]},
{"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None,
"legs": [_leg(None, 5)], "rows": [{"item": "철근", "dest": "start", "note": ""}]},
],
} # fmt: skip
def test_옛_꼴_옮김_거울():
new = migrate_milestone(OLD)
assert "base" not in new and [b["id"] for b in new["bases"]] == ["b1"]
b1 = new["bases"][0]
assert (b1["to"], b1["linked"], b1["name"], b1["pos"]) == ("site", True, "병곡리", [10, 20])
assert b1["legs"] == [_leg("1차포장", 0.6)] and b1["lon"] == 127.85
assert [(r["id"], r["to"], "via_base" in r) for r in new["routes"]] == [
("r1", "b1", False),
("r2", "site", False),
]
# 꺼진 기준점 = bases 없음 · 거침 경로도 시점으로
off = migrate_milestone({**OLD, "base": {**OLD["base"], "enabled": False}})
assert off["bases"] == [] and [r["to"] for r in off["routes"]] == ["site", "site"]
assert migrate_milestone(new) == new # 새 꼴 = 그대로
# TS 짝 — 옮긴 꼴 · 표 합이 같음
calc = [B08 + "Milestone_Calc.ts"]
ts = _node(calc, f"migrateMilestone({json.dumps(OLD)})")
assert ts["bases"][0]["id"] == "b1" and [r["to"] for r in ts["routes"]] == ["b1", "site"]
assert ts["bases"][0]["legs"] == b1["legs"] and ts["bases"][0]["pos"] == [10, 20]
assert _node(calc, f"computeMilestone({json.dumps(OLD)}, 1500)") == compute_milestone(
OLD, 1500.0
)
# 옛 표 합 그대로 — 2차 23.8 + 1차 0.6
(a, b) = compute_milestone(OLD, 1500.0)
assert (a["s2"], a["s1"], a["total"]) == (23.8, 0.6, 24.4) and (b["pv"], b["total"]) == (5, 5)
def test_서버_읽기_받기_새_꼴(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
# 옛 파일 읽기 = 새 꼴(지우지 않음 · 저장 때 새 꼴)
folder = tmp_path / "B08_Quantity" / "milestone"
folder.mkdir(parents=True)
(folder / "milestone.json").write_text(json.dumps(OLD, ensure_ascii=False), "utf-8")
data = ms.read_data(tmp_path)
assert [b["id"] for b in data["bases"]] == ["b1"] and data["routes"][0]["to"] == "b1"
assert "base" not in data and "via_base" not in data["routes"][0]
async def root(_pid):
return tmp_path
monkeypatch.setattr(ms, "_project_root", root)
app = FastAPI()
app.include_router(ms.router)
client = TestClient(app)
url = f"/api/projects/{uuid4()}/quantity/milestone"
saved = client.put(url, json=OLD).json() # 옛 꼴 받기 = 새 꼴로 저장
stored = json.loads((folder / "milestone.json").read_text("utf-8"))
assert "base" not in stored and stored["bases"][0]["id"] == "b1"
assert stored["routes"][0]["to"] == "b1" and "via_base" not in stored["routes"][0]
assert saved["table"] == compute_milestone(OLD, None)
# 새 꼴 사슬 왕복 · 겹친 id · 공사지 상자 id = 거절
assert client.put(url, json=CHAIN).json()["data"]["bases"][0]["to"] == "B"
twice = {**CHAIN, "bases": [*CHAIN["bases"], _base("g", "site", [])]}
assert client.put(url, json=twice).status_code == 422
box = {**CHAIN, "bases": [_base("site", "site", [])]}
assert client.put(url, json=box).status_code == 422
# 운반 자동 상자 = 시점으로(안 이음)
(auto,) = with_haul_routes({"routes": []}, [{"key": "1", "name": "골재"}])["routes"]
assert auto["to"] == "site" and auto["linked"] is False and "via_base" not in auto
def test_기준점_둘_사슬_셈_거울():
g, k, c = compute_milestone(CHAIN, None)
# 거래처 → A = 2차 · A → B(기준점 → 기준점) = 2차 · B → 시점 = 1차 · 비포장은 사용자 값
assert (g["s2"], g["s1"], g["pv"], g["un"]) == (28.3, 0.6, 0, 0.3)
assert (g["paved"], g["unpaved"], g["total"]) == (28.9, 0.3, 29.2)
# 김천역 → B = 2차 · B → 시점 = 1차 · 목적지 중점 = 시점 → 중점 0.8 비포장
assert (k["s2"], k["s1"], k["un"], k["total"]) == (61.2, 0.6, 0.8, 62.6)
# 시점으로 바로 = 포장
assert (c["s2"], c["s1"], c["pv"], c["total"]) == (0, 0, 2.0, 2.0)
calc = [B08 + "Milestone_Calc.ts"]
assert _node(calc, f"computeMilestone({json.dumps(CHAIN)}, null)") == [g, k, c]
# 안 이은 기준점에서 사슬 멈춤(그 구간 · 뒤 구간 안 셈)
cut = {**CHAIN, "bases": [CHAIN["bases"][0], {**CHAIN["bases"][1], "linked": False}]}
g2, k2, _ = compute_milestone(cut, None)
assert (g2["s2"], g2["s1"], g2["un"], g2["total"]) == (28.3, 0, 0.3, 28.6)
assert (k2["s2"], k2["total"]) == (61.2, 62.0)
assert _node(calc, f"computeMilestone({json.dumps(cut)}, null)") == compute_milestone(cut, None)
# 선 자리 — 시점에 닿는 기준점 = base(1차) · 기준점 → 기준점 · 거래처 → 기준점 = via(2차)
kinds = _node(
calc, f"['g', 'A', 'B', 'c', 'start'].map((f) => segKind({json.dumps(CHAIN)}, f))"
)
assert kinds == ["via", "via", "base", "direct", "mid"]
def test_돌기_막음():
loop = {**CHAIN, "bases": [_base("A", "B", [_leg(None, 1)]), _base("B", "A", [_leg(None, 2)])]}
# 셈 = 방문 집합으로 끊음(A → B → A 멈춤 · 끝없이 안 돎)
assert [b["id"] for b in base_chain(loop, "A")] == ["A", "B"]
(g, *_) = compute_milestone(loop, None)
assert g["total"] == 26.8
calc = [B08 + "Milestone_Calc.ts"]
assert _node(calc, f"computeMilestone({json.dumps(loop)}, null)") == compute_milestone(
loop, None
)
self_loop = {**CHAIN, "bases": [_base("A", "A", [_leg(None, 1)])]}
assert [b["id"] for b in base_chain(self_loop, "A")] == ["A"]
# 연결 놓을 때 = 돌기면 취소(makesLoop) — A → B 사슬에서 B 를 A 에 이으면 돎
layout = [B08 + "Milestone_Layout.ts"]
d = json.dumps(CHAIN)
got = _node(
layout,
f"[makesLoop({d}, 'B', 'A'), makesLoop({d}, 'A', 'B'), makesLoop({d}, 'B', 'site'),"
f" makesLoop({d}, 'A', 'A'), chainOf({d}, 'A').map((b) => b.id),"
f" kmSegments({d}, 'g', [127.8, 35.6]).map((s) => s.text)]",
)
assert got[:4] == [True, False, False, True]
assert got[4] == ["A", "B"] and got[5] == ["g → A", "A → B", "B → 시점"]
def test_화면_기준점_여럿_등록_삭제():
modal = _read(B08 + "UI_Milestone_Modal.ts")
assert "fresh.bases.push({" in modal and 'to: "site",' in modal
assert "기준위치(있음" not in modal # 254 기준점 여럿 — 고르기 막음 없음
# 지운 기준점에 이은 상자 = 안 이음
assert "if (o.to === base.id)" in modal
state = _read(B08 + "UI_Milestone_State.ts")
assert "migrateMilestone(" in state and "bases: []," in state
page = _read(B08 + "UI_Milestone.ts")
assert "data.bases.length > 0" in page and 'to: "site",' in page
for name in ("Milestone_Calc.ts", "Milestone_Layout.ts", "UI_Milestone_Diagram.ts",
"UI_Milestone_Modal.ts", "UI_Milestone_State.ts", "UI_Milestone_LegModal.ts",
"UI_Milestone_Route.ts", "UI_Milestone.ts"): # fmt: skip
text = _read(B08 + name)
assert "data.base." not in text and ".via_base" not in text and ".base.legs" not in text