From 2c79ff90464942d3abdef1868e5da9d4627fb43d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:11:39 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EA=B2=BD=EB=A1=9C=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=3D=20=EC=A0=84=EC=B2=B4=20=EA=B2=BD=EB=A1=9C(=EA=B1=B0?= =?UTF-8?q?=EB=9E=98=EC=B2=98=20=E2=86=92=20=EA=B8=B0=EC=A4=80=EC=A0=90=20?= =?UTF-8?q?=EC=82=AC=EC=8A=AC=20=E2=86=92=20=EC=8B=9C=EC=A0=90)=20=C2=B7?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20=EC=A7=80=EB=AC=B8=20?= =?UTF-8?q?=EB=B0=94=EB=80=90=20=EA=B2=BD=EB=A1=9C=EB=A7=8C=20=EC=BA=A1?= =?UTF-8?q?=EC=B2=98=20(256-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- B08_Quantity/B08_Quantity_Router_Milestone.py | 58 +++++++- .../test_256_1_b08_milestone_place_image.py | 2 + .../test_256_2_b08_milestone_route_image.py | 140 ++++++++++++++++++ 3 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_256_2_b08_milestone_route_image.py diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 677de2218..5f33376bf 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -32,6 +32,10 @@ 다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 · GET 답 `place_images` {상자 id: "place/.png"} · `place_times` {상자 id: 바뀐 시각 ms} (캐시 깨기). +경로 그림(256-2) = [저장] 뒤 같은 길로 경로 탭과 같은 전체 경로(거래처 → 기준점 사슬 → 시점 · +구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · 표지 = `mapMarks` 차례) → `auto/<경로 id>.png` +· 지문(표지 · 수기) 바뀐 경로만 · 화면 [길찾기] 그림(거래처 구간만)이 들어오면 지문을 지워 다음 +[저장]에 전체로. 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). 표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). @@ -62,7 +66,13 @@ 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 SITE, compute_milestone, migrate_milestone, r3 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( + SITE, + base_chain, + compute_milestone, + migrate_milestone, + r3, +) from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets @@ -314,6 +324,36 @@ def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: flo return got +def route_chain(data: dict[str, Any], route: dict[str, Any], start: list[float] | None) -> list: + """경로 그림 사슬 — [(이름, 경도, 위도, 수기)] · 화면 `mapMarks` 와 같은 차례 · + 좌표 없는 상자 뺌.""" + owners = [(route.get("name") or "거래처", route)] + if route.get("linked", True) is not False: + owners += [ + (b.get("name") or "기준점", b) for b in base_chain(data, route.get("to") or SITE) + ] + out = [(n, o.get("lon"), o.get("lat"), bool(o.get("manual"))) for n, o in owners] + if start: + out.append(("시점", start[0], start[1], False)) + return [c for c in out if c[1] is not None and c[2] is not None] + + +def route_image(root: str | Path, route_id: str, chain: list) -> dict[str, Any]: + """경로 그림 한 장 — 구간마다 카카오 길 선(수기 · 실패 = 곧은 선) → 캡처 → 지문 적음.""" + lines = [] + for (_, lon1, lat1, manual), (_, lon2, lat2, _) in zip(chain, chain[1:]): + path = [] + if not manual: + got = directions_km(LonLat(lon=lon1, lat=lat1), LonLat(lon=lon2, lat=lat2)) + path = got.get("path") or [] + lines.append(path or [[lat1, lon1], [lat2, lon2]]) + marks = [{"name": n, "lon": lon, "lat": lat} for n, lon, lat, _ in chain] + got = make_route_image(folder(root) / AUTO, route_id, lines, marks) + if got["saved"]: + _set_print(root, f"{AUTO}/{route_id}", _print(chain)) + return got + + def refresh_images(root: str | Path) -> None: """[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 · 실패 = 다음 [저장]에 다시.""" @@ -323,6 +363,15 @@ def refresh_images(root: str | Path) -> None: for place_id, name, lon, lat in places(data): if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have: place_image(root, place_id, name, lon, lat) + start = site_info(root, None)["start_lonlat"] + have = images(root, AUTO) + for route in data["routes"]: + chain = route_chain(data, route, start) + key = f"{AUTO}/{route['id']}" + if route.get("linked", True) is False or len(chain) < 2: + continue # 안 이은 거래처 = 경로 그림 없음 + if prints.get(key) != _print(chain) or route["id"] not in have: + route_image(root, route["id"], chain) def _route_id(route: str) -> str: @@ -353,7 +402,10 @@ async def milestone_save( ) -> dict[str, Any]: root = await _project_root(project_id) out = await asyncio.to_thread(save_data, root, body) - tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임 + if not os.environ.get( + "PYTEST_CURRENT_TEST" + ): # 남의 시험 [저장] = 실제 카카오 · 브라우저 안 부름 + tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임 return out @@ -464,6 +516,8 @@ async def milestone_image_auto( lines = [[list(p) for p in line] for line in body.lines] marks = [m.model_dump() for m in body.marks] got = await asyncio.to_thread(make_route_image, folder(root) / AUTO, route_id, lines, marks) + if got["saved"]: # 거래처 구간만 그림 — 다음 [저장]에 전체 경로로 다시(256-2) + await asyncio.to_thread(_set_print, root, f"{AUTO}/{route_id}", None) return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} diff --git a/resources/tester/test_256_1_b08_milestone_place_image.py b/resources/tester/test_256_1_b08_milestone_place_image.py index 316947fd8..63553beab 100644 --- a/resources/tester/test_256_1_b08_milestone_place_image.py +++ b/resources/tester/test_256_1_b08_milestone_place_image.py @@ -55,6 +55,7 @@ def _save(root, data): def _fake(monkeypatch): fake = Fake() monkeypatch.setattr(ms, "make_place_image", fake) + monkeypatch.setattr(ms, "route_image", lambda *a: {"saved": False}) # 경로 그림 = 256-2 시험 return fake @@ -121,6 +122,7 @@ def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch): return tmp_path monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠) app = FastAPI() app.include_router(ms.router) client = TestClient(app) diff --git a/resources/tester/test_256_2_b08_milestone_route_image.py b/resources/tester/test_256_2_b08_milestone_route_image.py new file mode 100644 index 000000000..50dd56306 --- /dev/null +++ b/resources/tester/test_256_2_b08_milestone_route_image.py @@ -0,0 +1,140 @@ +"""256-2 B08 거리이정표 경로 그림 = 전체 경로(사용자 162-7). + +캡처 · 길찾기는 가짜로: +① 사슬 = 거래처 → 기준점 사슬(254 bases · to) → 시점 · 화면 `mapMarks` 차례 · 좌표 없는 상자 뺌 +② 구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · auto/<경로 id>.png +③ [저장] 뒤 지문 비교 — 안 바뀜 = 0 · 기준점 · 시점 옮김 = 그 사슬 경로만 · 안 이은 거래처 = 없음 · + 화면 [길찾기] 그림(거래처 구간만)이 들어오면 다음 [저장]에 전체로 +""" + +import sys +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +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 + +PNG = b"\x89PNG\r\n\x1a\nfake-route" +START = [127.5, 35.4] +BASES = [ + {"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"}, + {"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52, "manual": True}, +] +ROUTES = [ + {"id": "r1", "name": "거창레미콘", "lon": 127.95, "lat": 35.70, "to": "b1"}, + {"id": "r2", "name": "직송", "lon": 127.60, "lat": 35.45}, + {"id": "r3", "name": "안 이음", "lon": 127.61, "lat": 35.46, "linked": False}, + {"id": "r4", "name": "좌표 없음", "to": "b2"}, +] +DATA = {"bases": BASES, "routes": ROUTES} + + +class Fake: + def __init__(self): + self.shots = [] # (경로 id, lines, marks) + self.asked = [] # 길찾기 구간 + + def shoot(self, out_dir, route_id, lines, marks): + self.shots.append((route_id, lines, marks)) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / f"{route_id}.png").write_bytes(PNG) + return {"saved": True, "reason": None} + + def directions(self, origin, destination): + self.asked.append((origin.lon, destination.lon)) + if origin.lon == 127.60: # r2 → 시점 = 길찾기 실패 + return {"km": None, "reason": "실패"} + return {"km": 1.0, "reason": None, "path": [[origin.lat, origin.lon], [0, 0]]} + + +def _setup(tmp_path, monkeypatch, start=START): + fake = Fake() + box = {"start": start} + monkeypatch.setattr(ms, "make_route_image", fake.shoot) + monkeypatch.setattr(ms, "directions_km", fake.directions) + monkeypatch.setattr(ms, "make_place_image", lambda *a: {"saved": False}) # 256-1 시험 + monkeypatch.setattr( + ms, "site_info", lambda root, mid: {"start_lonlat": box["start"], "mid_default_m": None} + ) + return fake, box + + +def _save(root, data): + ms.save_data(root, ms.Milestone.model_validate(data)) + ms.refresh_images(root) + + +def test_사슬_화면_차례(): + data = ms.Milestone.model_validate(DATA).model_dump() + chain = ms.route_chain(data, data["routes"][0], START) + assert chain == [ + ("거창레미콘", 127.95, 35.70, False), + ("거창IC", 127.91, 35.68, False), + ("함양JC", 127.72, 35.52, True), + ("시점", 127.5, 35.4, False), + ] + assert [c[0] for c in ms.route_chain(data, data["routes"][3], START)] == ["함양JC", "시점"] + assert [c[0] for c in ms.route_chain(data, data["routes"][1], None)] == ["직송"] + + +def test_전체_경로_그림_구간_선(tmp_path, monkeypatch): + fake, _ = _setup(tmp_path, monkeypatch) + _save(tmp_path, DATA) + shots = {rid: (lines, marks) for rid, lines, marks in fake.shots} + assert sorted(shots) == ["r1", "r2", "r4"] # r3 안 이음 = 없음 + lines, marks = shots["r1"] + assert [m["name"] for m in marks] == ["거창레미콘", "거창IC", "함양JC", "시점"] + assert len(lines) == 3 + assert lines[0] == [[35.70, 127.95], [0, 0]] # 카카오 길 선 + assert lines[2] == [[35.52, 127.72], [35.4, 127.5]] # 수기 기준점 = 곧은 선 · 길찾기 안 함 + assert (127.72, 127.5) not in fake.asked + assert shots["r2"][0] == [[[35.45, 127.60], [35.4, 127.5]]] # 길찾기 실패 = 곧은 선 + assert (tmp_path / "B08_Quantity/milestone/auto/r1.png").read_bytes() == PNG + + +def test_바뀐_경로만_다시(tmp_path, monkeypatch): + fake, box = _setup(tmp_path, monkeypatch) + _save(tmp_path, DATA) + fake.shots.clear() + ms.refresh_images(tmp_path) + assert fake.shots == [] # 안 바뀜 + moved = {**DATA, "bases": [BASES[0], {**BASES[1], "lat": 35.55}]} + _save(tmp_path, moved) + assert sorted(s[0] for s in fake.shots) == ["r1", "r4"] # b2 를 지나는 경로만 + fake.shots.clear() + box["start"] = [127.4, 35.3] # 시점 옮김(노선 수정) = 모두 + ms.refresh_images(tmp_path) + assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"] + fake.shots.clear() + (tmp_path / "B08_Quantity/milestone/auto/r2.png").unlink() # 그림 없음 = 다시 + ms.refresh_images(tmp_path) + assert [s[0] for s in fake.shots] == ["r2"] + + +def test_길찾기_그림_뒤엔_다음_저장에_전체로(tmp_path, monkeypatch): + fake, _ = _setup(tmp_path, monkeypatch) + + async def root(project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + base = f"/api/projects/{uuid4()}/quantity/milestone" + assert client.put(base, json=DATA).status_code == 200 # 답 뒤 캡처 + assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"] + fake.shots.clear() + part = {"lines": [[[35.70, 127.95], [35.68, 127.91]]], "marks": []} + assert client.post(base + "/image/auto", params={"route": "r1"}, json=part).status_code == 200 + assert [s[0] for s in fake.shots] == ["r1"] # 화면 길찾기 그림(거래처 구간만) + fake.shots.clear() + ms.refresh_images(tmp_path) + assert [(s[0], len(s[2])) for s in fake.shots] == [("r1", 4)] # 전체 경로로 다시 From 41f68b8178df11f57f4bca7d9e45773fc9767617 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:12:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EC=83=81=EC=9E=90=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=C2=B7=20=EC=83=81=EC=9E=90=20=EC=95=88=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EA=B7=B8=EB=A6=BC(254-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 항목 정보 없음) - 장소 그림(256-1 place_images) = 상자 글 아래 132 × 88 · 그림 없으면 자리 없음 · 키 바뀔 때만 다시 받음 - 시험 = test_254_3 새로 · 244_6 · 244_10 node 입력 = stdin(명령 줄 길이) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 13 ++- B08_Quantity/B08_Quantity_UI_Milestone.css | 5 ++ .../B08_Quantity_UI_Milestone_Diagram.ts | 37 ++++++++- .../B08_Quantity_UI_Milestone_Modal.ts | 2 +- .../test_244_10_b08_milestone_tailscale.py | 3 +- .../test_244_6_b08_milestone_pan_client.py | 3 +- .../test_254_3_b08_milestone_box_modal_pic.py | 81 +++++++++++++++++++ 7 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_254_3_b08_milestone_box_modal_pic.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 2c99a0062..a07c8f8c5 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -38,6 +38,8 @@ export interface DiagramBox { notes?: string[]; /** 호버 = 줄이지 않은 주소 */ tip?: string; + /** 장소 그림 자리(254-3 · 글 아래 · 그림 없으면 없음) */ + pic?: Rect; } export interface DiagramLine { @@ -101,6 +103,8 @@ const KM_LINE = 16; /** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */ export const ROW = 15; const ROW_PAD = 13; +/** 장소 그림(254-3) — 상자 안쪽 폭 · 서버 그림 480 × 320 과 같은 3:2 */ +export const PIC = { w: 132, h: 88 }; const NOTE_LINES = 3; /** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */ @@ -421,8 +425,10 @@ export function layoutMilestone( mid_address?: string | null; } | null, midKm: number, + pics: ReadonlySet = new Set(), // 254-3 장소 그림 있는 상자 id ): DiagramLayout { const boxes: DiagramBox[] = []; + const picH = (id: string): number => (pics.has(id) ? PIC.h + INSET : 0); const pending: Array<[string, string, Leg[]]> = []; const byId = new Map(); const center = (id: string): [number, number] => { @@ -523,7 +529,7 @@ export function layoutMilestone( const [ax, ay] = center(anchor); const [ux, uy] = unit(deg); const notes = b.address ? wrap(b.address, BOX.w - 2 * INSET, FONT.note) : []; - const h = boxH(1 + notes.length); + const h = boxH(1 + notes.length) + picH(b.id); const reach = reachOf(anchor, h, ux, uy, b.legs); put( { @@ -583,7 +589,7 @@ export function layoutMilestone( // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음 linked: r.linked !== false && (r.to === "site" || anchor === r.to), notes, - h: boxH((items ? 1 : 0) + 1 + notes.length), + h: boxH((items ? 1 : 0) + 1 + notes.length) + picH(r.id), }); } for (const group of groups.values()) { @@ -616,6 +622,9 @@ export function layoutMilestone( }); } + // 254-3 장소 그림 = 글 아래 · 안쪽 여백 안(그림 없으면 자리 없음) + for (const b of boxes) + if (pics.has(b.id)) b.pic = { x: b.x + INSET, y: b.y + b.h - INSET - PIC.h, ...PIC }; const lines = pending.map(([from, to, legs]) => edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!), ); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 2c7509eae..80cc552d4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -183,6 +183,11 @@ font-size: 10px; } +/* 254-3 상자 안 장소 그림 — 누름 · 끌기는 상자 몫 */ +.b08-milestone__pic { + pointer-events: none; +} + /* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */ .b08-milestone__map { width: 100%; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 548c2b7f4..a6178f573 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -13,6 +13,7 @@ * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) · * 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km) * 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소) + * 장소 그림(254-3) = 서버 `place/<상자 id>.png`(256-1) 을 상자 글 아래 작게 · 그림 없으면 자리 없음 * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ @@ -42,6 +43,8 @@ import { openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milesto import { findSegmentKm, liveMilestone, + placeImageBlobUrl, + placeImageKey, putMilestone, roadOptions, siteAddress, @@ -298,6 +301,18 @@ export function createDiagram( addElem.dataset.b08MilestoneAdd = "open"; const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] }); + /** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */ + const picUrls = new Map }>(); + const picUrl = (id: string, key: string): Promise => { + let got = picUrls.get(id); + if (!got || got.key !== key) { + got?.url.then(URL.revokeObjectURL, () => undefined); + got = { key, url: placeImageBlobUrl(projectId, id) }; + picUrls.set(id, got); + } + return got.url; + }; + const refresh = (): void => { const data = liveMilestone(projectId); const site = siteInfo(); @@ -313,6 +328,7 @@ export function createDiagram( mid_address: addr?.mid_address, }, mk, + new Set([...data.bases, ...data.routes].map((o) => o.id).filter((id) => placeImageKey(id))), ); // 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠 // (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음) @@ -361,9 +377,10 @@ export function createDiagram( ...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]), ]; const x = box.x + box.w / 2; + const th = box.pic ? box.pic.y - box.y : box.h; // 글 자리 = 그림 위 const text = svg("text", { x, - y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2, + y: box.kind === "site" ? box.y + 18 : box.y + th / 2 - ((rows.length - 1) * 15) / 2, "text-anchor": "middle", "dominant-baseline": "central", }); @@ -371,6 +388,24 @@ export function createDiagram( text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)), ); g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text); + // 254-3 장소 그림 — 글 아래 작게(그림 없으면 자리 없음 · 못 읽으면 지움) + const key = box.pic && placeImageKey(box.id); + if (box.pic && key) { + const { x: px, y: py, w: pw, h: ph } = box.pic; + const pic = svg("image", { + class: "b08-milestone__pic", + x: px, + y: py, + width: pw, + height: ph, + }); + pic.setAttribute("preserveAspectRatio", "xMidYMid slice"); + g.append(pic); + picUrl(box.id, key).then( + (url) => pic.setAttribute("href", url), + () => pic.remove(), + ); + } if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소 // 상자 누름 = 모달(탭 이동 아님) const info = (): void => INFO[box.kind](box.id); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 476ede05d..a072c9878 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -104,7 +104,7 @@ export function openPlaceModal( ? "요소 추가 — 기준점 · 거래처" : base ? `기준점 — ${base.name || "기준점"}` - : `항목 정보 — ${route!.name || "거래처"}`; + : `거래처 — ${route!.name || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음 openModal({ title, closeLabel: "닫기", diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index 9d4f13294..c4bbfbc45 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -59,9 +59,10 @@ console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hi """ ) src = IMPORTS.sub("", _read(LAYOUT)) + script + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src], + "--input-type=module-typescript", "-"], input=src, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 93228518e..cef51aaa2 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -27,9 +27,10 @@ def _node(script): IMPORTS.sub("", _read(rel)) for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts") ) + # 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", src + "\n" + script], + "--input-type=module-typescript", "-"], input=src + "\n" + script, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr diff --git a/resources/tester/test_254_3_b08_milestone_box_modal_pic.py b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py new file mode 100644 index 000000000..aeec089ba --- /dev/null +++ b/resources/tester/test_254_3_b08_milestone_box_modal_pic.py @@ -0,0 +1,81 @@ +"""254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8). + +① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음) +② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +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(expr): + src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts")) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-"], + input=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) + + +DATA = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "bases": [{"id": "B", "name": "병곡리", "address": "거창읍", "dir": None, "to": "site", + "legs": [{"road": None, "km": 0.6}], "linked": True}], + "routes": [ + {"id": "g", "name": "거창군청", "address": "거창읍", "dir": "N", "to": "B", + "legs": [{"road": None, "km": 23.8}], + "rows": [{"item": "장비", "dest": "start", "note": ""}]}, + {"id": "k", "name": "김천역", "address": "", "dir": "E", "to": "site", + "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, + ], +} # fmt: skip + + +def test_장소_그림_상자_안_글_아래(): + d = json.dumps(DATA, ensure_ascii=False) + plain, with_pic = _node( + f"[layoutMilestone({d}, null, 0.5), layoutMilestone({d}, null, 0.5, new Set(['B', 'g']))]" + ) + a = {b["id"]: b for b in plain["boxes"]} + b = {b["id"]: b for b in with_pic["boxes"]} + pic = _node("PIC") + assert pic == {"w": 132, "h": 88} # 서버 480 × 320 과 같은 3:2 · 상자 안쪽 폭 + # 그림 없음 = 자리 없음(높이 그대로 · pic 없음) + assert all("pic" not in box for box in a.values()) + assert "pic" not in b["k"] and b["k"]["h"] == a["k"]["h"] + for bid in ("B", "g"): + box, before = b[bid], a[bid] + assert box["h"] == before["h"] + pic["h"] + 8 # 그림 + 아래 여백만큼 늘림 + p = box["pic"] + # 안쪽 여백 안 · 글 아래(글 자리 = 그림 위 높이 = 그림 없을 때 높이) + assert p["x"] == box["x"] + 8 and p["x"] + p["w"] == box["x"] + box["w"] - 8 + assert p["y"] - box["y"] == before["h"] and p["y"] + p["h"] == box["y"] + box["h"] - 8 + + +def test_모달_제목_화면_그림(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal + assert "항목 정보 —" not in modal + ui = _read(B08 + "UI_Milestone_Diagram.ts") + assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui + assert 'pic.setAttribute("href", url)' in ui and "() => pic.remove()" in ui + assert "if (!got || got.key !== key)" in ui # 키(이름#시각) 바뀔 때만 다시 받음 + assert "const th = box.pic ? box.pic.y - box.y : box.h;" in ui # 글 = 그림 위 가운데 + css = _read(B08 + "UI_Milestone.css") + pic = css[css.index(".b08-milestone__pic {") :] + assert "pointer-events: none;" in pic[: pic.index("}")] # 누름 · 끌기 = 상자 몫 + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts"): + assert len(_read(B08 + name).splitlines()) <= 700, name