diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index a26bbab10..ece7882e5 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -179,7 +179,12 @@ export interface MilestoneBase { } export interface MilestoneData { - site: { mid_station_m: number | null; mid_road: Road }; + site: { + mid_station_m: number | null; + mid_road: Road; + /** 길찾기용 시점 덮개(255-2 · 경로 탭 시점 끌기 · 노선은 그대로) — 없음 = 노선 첫 점 */ + start_pin?: { lon: number; lat: number } | null; + }; bases: MilestoneBase[]; routes: MilestoneRoute[]; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 5ecf8d0de..e03790ba3 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -121,9 +121,15 @@ class Base(Place): linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤) +class LonLat(BaseModel): + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) + + class Site(BaseModel): mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000) mid_road: Road = "비포장" + start_pin: LonLat | None = None # 255-2 길찾기용 시점 덮개(노선은 그대로) class Milestone(BaseModel): @@ -394,11 +400,6 @@ KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" KAKAO_KEY = "KAKAO_REST_API_KEY" -class LonLat(BaseModel): - lon: float = Field(ge=-180, le=180) - lat: float = Field(ge=-90, le=90) - - class Directions(BaseModel): origin: LonLat destination: LonLat diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 0f556f114..089b7c485 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -33,6 +33,7 @@ import { roadOptions, segmentPath, siteInfo, + startPoint, } from "./B08_Quantity_UI_Milestone_State"; const km = (text: string): number | null => { @@ -147,7 +148,7 @@ function openMidLeg(projectId: string): void { /** 연결선 누름 — 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; + const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); // 255-2 덮개 const seg = segmentEnds(liveMilestone(projectId), from, start()); if (!seg) return; openModal({ diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index b3acd5fdd..8c632231c 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -5,6 +5,8 @@ * * [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버) * 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157) + * 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 · + * 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함 * ========================================================================== */ import { @@ -26,7 +28,8 @@ import { putMilestone, routeImageKey, segmentPath, - siteInfo, + startPoint, + touchingSegments, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; @@ -120,20 +123,19 @@ export function routeView(projectId: string, routeId: string): MilestoneView { }); reset.dataset.b08MilestoneRevert = ""; - const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; const pins = (): MapPin[] => { const d = liveMilestone(projectId); + const start = startPoint(d); const r = d.routes.find((x) => x.id === routeId); const out: MapPin[] = []; const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => { - if (lon != null && lat != null) - out.push({ key, name: label, lon, lat, movable: key !== "start" }); + if (lon != null && lat != null) out.push({ key, name: label, lon, lat, movable: true }); // 255-2 시점도 끌기(길찾기 덮개) }; if (!r) return out; add(r.id, r.name || "거래처", r.lon, r.lat); if (r.linked === false) return out; for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬 - add("start", "시점", start()?.[0], start()?.[1]); + add("start", "시점", start?.[0], start?.[1]); return out; }; const lines = (): Array> => { @@ -142,24 +144,45 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2); }; + // 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」) + const status = el("p", { className: "b08-options__hint" }); + status.dataset.b08MilestoneRouteStatus = ""; + status.hidden = true; + /** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */ + const refind = async (ids: string[]): Promise => { + const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false))); + const bad = texts.filter((t) => t.includes(" — ")); + status.textContent = bad.join(" · "); + status.hidden = !bad.length; + drawMap(); + }; + /** 표지 끌기 — 시점 = 덮개(site.start_pin · 노선 그대로) · 거래처 · 기준점 = 소재지 · 닿는 선 모두 다시 */ + const moveTo = async (key: string, lon: number, lat: number): Promise => { + const address = key === "start" ? "" : await reverseAddress(projectId, lon, lat); + const fresh = liveMilestone(projectId); + if (key === "start") fresh.site.start_pin = { lon, lat }; + else { + const target = ownerOf(fresh, key); + if (!target) return; + Object.assign(target, { lon, lat, address }); + } + putMilestone(projectId, fresh); + await refind(touchingSegments(fresh, key)); + }; const map = editMap(projectId, { hint: "", - onMove: (key, lon, lat) => - void reverseAddress(projectId, lon, lat).then((addr) => { - const fresh = liveMilestone(projectId); - const target = ownerOf(fresh, key); - if (!target) return; - Object.assign(target, { lon, lat, address: addr }); - putMilestone(projectId, fresh); - void findSegmentKm(projectId, key).then(() => drawMap()); - }), + onMove: (key, lon, lat) => void moveTo(key, lon, lat), }); const drawMap = (): void => map.set(pins(), lines()); - let asked = false; // 길 선이 아직 없으면 열 때 한 번 길찾기(그림 · 알림 없음) + let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음) const ensurePath = (): void => { - if (asked || segmentPath(routeId).length) return; + if (asked) return; asked = true; - void findSegmentKm(projectId, routeId, false).then(() => drawMap()); + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; + const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length); + if (missing.length) void refind(missing); }; const root = el("section", { @@ -173,6 +196,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { el("div", { className: "b08-milestone__bar", children: [change, reset, file] }), ], }), + status, img, map.root, ], diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 4f97cc489..2a9044cfb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -22,6 +22,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { PAVED, + SITE_ID, migrateMilestone, type MilestoneData, type SegKind, @@ -67,6 +68,19 @@ export async function loadMilestone(projectId: string, force = false): Promise

saved?.site ?? null; +/** 길찾기 시점 = 경로 탭에서 끌어 놓은 덮개(255-2 · site.start_pin) · 없으면 노선 첫 점 */ +export const startPoint = (data: MilestoneData): [number, number] | null => { + const pin = data.site.start_pin; + return pin ? [pin.lon, pin.lat] : (siteInfo()?.start_lonlat ?? null); +}; + +/** 그 자리(시점 "start" · 기준점 · 거래처 id)에 닿는 구간(255-2) — 나가는 구간(자기) + 들어오는 구간(to = 그 자리) */ +export function touchingSegments(data: MilestoneData, key: string): string[] { + const to = key === "start" ? SITE_ID : key; + const incoming = [...data.bases, ...data.routes].filter((o) => o.to === to && o.linked !== false); + return [...(key === "start" ? [] : [key]), ...incoming.map((o) => o.id)]; +} + /** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 · * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */ const siteAddresses = new Map(); @@ -188,10 +202,11 @@ export async function autoRouteImage( lines: Array>, ): Promise { // 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘) - const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice( - 0, - 2, - ); + const marks = mapMarks( + liveMilestone(projectId), + routeId, + startPoint(liveMilestone(projectId)), + ).slice(0, 2); let reason: string | null; try { const res = await request<{ name: string | null; reason: string | null }>( @@ -239,7 +254,7 @@ export async function findSegmentKm( const data = liveMilestone(projectId); const owner = ownerOf(data, from); if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함 - const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); + const seg = segmentEnds(data, from, startPoint(data)); if (!seg) return ""; if (!seg.from || !seg.to) { const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; diff --git a/resources/tester/test_255_2_b08_milestone_start_pin.py b/resources/tester/test_255_2_b08_milestone_start_pin.py new file mode 100644 index 000000000..bf149c7e4 --- /dev/null +++ b/resources/tester/test_255_2_b08_milestone_start_pin.py @@ -0,0 +1,119 @@ +"""255-2 거리이정표 경로 탭 — 시점 끌기 = 길찾기용 덮개(site.start_pin · 노선 그대로) · +표지 옮기면 그 자리에 닿는 구간(나가는 · 들어오는) 모두 다시 길찾기 · 열 때 사슬 구간 모두 선 · +실패 안내 · 끌기는 그림 캡처 안 부름.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from pydantic import ValidationError + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +STUBS = """ +let cache = null; +const sent = []; +const API_BASE_URL = ""; +const showToast = () => undefined; +const readState = () => cache; +const writeState = (_key, d) => { cache = d; }; +const clearState = () => { cache = null; }; +const request = async (url, opts) => { + if (url.endsWith("/quantity/milestone")) + return { data: DATA, site: { length_m: 1000, mid_default_m: 500, start_lonlat: [127, 35], + mid_lonlat: null }, images: {}, auto_images: {} }; + sent.push(JSON.parse(opts.body)); + return { km: 1.5, reason: null, path: [] }; +}; +""" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _place(pid, to, lon, lat, **more): + return {"id": pid, "name": pid, "address": "", "dir": None, "lon": lon, "lat": lat, + "legs": [], "to": to, **more} # fmt: skip + + +DATA = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "bases": [_place("b1", "site", 127.5, 35.5), _place("b2", "b1", 127.6, 35.6)], + "routes": [ + _place("r1", "site", 127.2, 35.2, rows=[]), + _place("r2", "b1", 127.3, 35.3, rows=[]), + _place("r3", "b2", 127.4, 35.4, rows=[]), + _place("r4", "b1", 127.1, 35.1, rows=[], linked=False), + ], +} + + +def _run(script): + src = "".join( + IMPORTS.sub("", _read(f"B08_Quantity/B08_Quantity_{n}.ts")) + for n in ("Milestone_Calc", "Milestone_Layout", "UI_Milestone_State") + ) + code = f"const DATA = {json.dumps(DATA)};\n{STUBS}\n{src}\n{script}" + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=code, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_시점_덮개_길찾기(): + got = _run(""" +await loadMilestone("p"); +await findSegmentKm("p", "r1", false); +const d = liveMilestone("p"); +d.site.start_pin = { lon: 128.25, lat: 36.5 }; +putMilestone("p", d); +await findSegmentKm("p", "r1", false); +await findSegmentKm("p", "b1", false); +console.log(JSON.stringify({ sent, start: startPoint(liveMilestone("p")) })); +""") + dest = [s["destination"] for s in got["sent"]] + assert dest[0] == {"lon": 127, "lat": 35} # 덮개 없음 = 노선 첫 점 + assert dest[1] == dest[2] == {"lon": 128.25, "lat": 36.5} # 거래처 · 기준점 → 시점 = 덮개 + assert got["start"] == [128.25, 36.5] + + +def test_닿는_구간_모두(): + got = _run(""" +console.log(JSON.stringify(["start", "b1", "b2", "r1", "r3"].map((k) => touchingSegments(DATA, k)))); +""") + start, b1, b2, r1, r3 = got + assert start == ["b1", "r1"] # 시점에 들어오는 구간 + assert b1 == ["b1", "b2", "r2"] # 나가는 구간 + 들어오는 경로들(안 이은 r4 빠짐) + assert b2 == ["b2", "r3"] + assert r1 == ["r1"] and r3 == ["r3"] + + +def test_서버_모델_시점_덮개(): + got = ms.Milestone.model_validate({"site": {"start_pin": {"lon": 127.5, "lat": 35.5}}}) + assert got.model_dump()["site"]["start_pin"] == {"lon": 127.5, "lat": 35.5} + assert ms.Milestone().model_dump()["site"]["start_pin"] is None + with pytest.raises(ValidationError): + ms.Milestone.model_validate({"site": {"start_pin": {"lon": 500, "lat": 35}}}) + + +def test_경로_탭_끌기_다시_길찾기(): + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert "movable: true" in route and 'movable: key !== "start"' not in route + assert "fresh.site.start_pin = { lon, lat };" in route + assert "await refind(touchingSegments(fresh, key));" in route + assert "findSegmentKm(projectId, id, false)" in route # 끌기 · 열기 = 그림 캡처 없음 + assert "[routeId, ...chain].filter((id) => !segmentPath(id).length)" in route # 열 때 모든 구간 + assert "status.hidden = !bad.length;" in route # 실패 안내 + calc = _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts") + assert "start_pin?: { lon: number; lat: number } | null;" in calc