diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py new file mode 100644 index 000000000..635bf9e88 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -0,0 +1,142 @@ +"""B08 거리이정표 경로 그림 자동 캡처(PLAN 244-11 · 사용자 139번) — 서버 헤드리스 Edge. + +[등록] · [길찾기] 뒤 화면이 그 경로의 길 선(마지막 길찾기 경로 좌표) · 표지를 보냄 → +서버가 헤드리스 Edge(Playwright channel msedge · 없으면 chrome · 번들 chromium 안 씀)로 +카카오 지도 JS + 길 선 + 표지 + 경로 맞춤 → tilesloaded 뒤 1200×800 PNG → `<폴더>/<경로 id>.png` +(사용자 그림이 있으면 화면은 사용자 그림 우선). +페이지 주소 = .env KAKAO_CAPTURE_ORIGIN(없으면 http://localhost:5173) + /__route_capture — +page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨). +경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 · +키 값은 돌려주지도 적지도 않음. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +import urllib.parse +from pathlib import Path +from typing import Any + +KAKAO_JS = "KAKAO_JS_KEY" +ORIGIN_KEY = "KAKAO_CAPTURE_ORIGIN" +DEFAULT_ORIGIN = "http://localhost:5173" +PAGE = "/__route_capture" +WIDTH, HEIGHT = 1200, 800 +TIMEOUT_S = 20 +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 +TAIL = " — 그림 올리기를 쓰세요." + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) + + +class CaptureError(Exception): + """까닭 한 줄(키 · 예외 원문 없음).""" + + +def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str: + """카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.""" + data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c") + sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false" + return f"""
+ + +""" + + +def shoot(html: str, origin: str) -> bytes: + """헤드리스 브라우저로 그 페이지 → 지도 칸 PNG. 실패 = CaptureError(까닭 한 줄).""" + try: + from playwright.sync_api import Error as PwError + from playwright.sync_api import TimeoutError as PwTimeout + from playwright.sync_api import sync_playwright + except ImportError as error: + raise CaptureError("서버에 Playwright 가 없습니다(pip install playwright)") from error + url = origin + PAGE + deadline = time.monotonic() + TIMEOUT_S + + def left() -> float: + return max(1.0, deadline - time.monotonic()) * 1000 + + try: + with sync_playwright() as p: + browser = None + for channel in CHANNELS: + try: + browser = p.chromium.launch(channel=channel, headless=True, timeout=left()) + break + except PwError: + continue + if browser is None: + raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") + try: + page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT}) + page.route( + url, + lambda route: route.fulfill( + status=200, content_type="text/html; charset=utf-8", body=html + ), + ) + page.goto(url, timeout=left()) + page.wait_for_function("window.__done || window.__fail", timeout=left()) + if page.evaluate("window.__fail"): + raise CaptureError( + f"카카오 지도 JS 가 캡처 주소({origin})를 받지 않음(키 도메인 등록 확인)" + ) + page.wait_for_timeout(500) # 마지막 타일 · 표지 그림 + return page.locator("#m").screenshot(timeout=left()) + finally: + browser.close() + except CaptureError: + raise + except PwTimeout as error: + raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") from error + + +def make_route_image( + out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]] +) -> dict[str, Any]: + """그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림.""" + key = os.environ.get(KAKAO_JS, "").strip() + if not key: + return { + "saved": False, + "reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}", + } + if not any(lines) and not marks: + return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"} + origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/") + with _lock: + try: + png = shoot(page_html(key, lines, marks), origin) + except CaptureError as error: + return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {error}{TAIL}"} + out_dir.mkdir(parents=True, exist_ok=True) + new = out_dir / f".{route_id}.png.new" + new.write_bytes(png) + new.replace(out_dir / f"{route_id}.png") + return {"saved": True, "reason": None} diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 348f331d2..bc1e83b47 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -152,25 +152,6 @@ export function mapMarks( return out; } -/** 캡처 자르기(244-9 · CropTarget 없을 때) — 요소 자리(CSS px) × (영상 폭 ÷ 창 폭 = 보통 - * devicePixelRatio) → 영상 안 자를 칸(영상 밖은 잘라 냄) */ -export function cropBox( - rect: { left: number; top: number; width: number; height: number }, - viewWidth: number, - videoWidth: number, - videoHeight: number, -): { sx: number; sy: number; sw: number; sh: number } { - const k = videoWidth / viewWidth; - const sx = Math.min(Math.max(0, Math.round(rect.left * k)), videoWidth); - const sy = Math.min(Math.max(0, Math.round(rect.top * k)), videoHeight); - return { - sx, - sy, - sw: Math.max(0, Math.min(videoWidth - sx, Math.round((rect.left + rect.width) * k) - sx)), - sh: Math.max(0, Math.min(videoHeight - sy, Math.round((rect.top + rect.height) * k) - sy)), - }; -} - /** 자동 km 를 그 구간에(사용자 km 는 그대로) */ export function putAutoKm( data: MilestoneData, diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index a1d1affae..43465fa21 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -4,7 +4,11 @@ PUT /api/projects/{id}/quantity/milestone [저장] — 입력만 · 표는 서버가 셈 POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체) GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기 - DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 + DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기(자동 그림까지) · + &user_only=true = 사용자 그림만(「자동 그림으로 되돌리기」 · 244-11) + POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 · + 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · + 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 @@ -13,8 +17,9 @@ GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 · 공개용 · .env KAKAO_JS_KEY · 없으면 null) -정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · -폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). +정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 사용자 +그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 +그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님). 그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). @@ -31,7 +36,7 @@ import urllib.error import urllib.parse import urllib.request from pathlib import Path -from typing import Any, Literal +from typing import Annotated, Any, Literal from uuid import UUID from fastapi import APIRouter, File, HTTPException, Query, UploadFile @@ -42,6 +47,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_Milestone_Calc import compute_milestone, r3 +from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_json import atomic_write_json @@ -57,6 +63,7 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"]) PATH = "/{project_id}/quantity/milestone" FOLDER = Path("B08_Quantity") / "milestone" 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}$" @@ -172,8 +179,8 @@ def site_info(root: str | Path, mid_station_m: float | None) -> dict[str, Any]: } -def route_images(root: str | Path, route_id: str) -> list[Path]: - path = folder(root) +def route_images(root: str | Path, route_id: str, sub: str = "") -> list[Path]: + path = folder(root) / sub if not path.is_dir(): return [] return sorted( @@ -181,13 +188,13 @@ def route_images(root: str | Path, route_id: str) -> list[Path]: ) -def images(root: str | Path) -> dict[str, str]: - """경로 id → 그림 파일 이름(폴더가 목록).""" - path = folder(root) +def images(root: str | Path, sub: str = "") -> dict[str, str]: + """경로 id → 그림 파일 이름(폴더가 목록 · sub = 하위 폴더 · 이름에 붙음).""" + path = folder(root) / sub if not path.is_dir(): return {} return { - f.stem: f.name + f.stem: f"{sub}/{f.name}" if sub else f.name for f in sorted(path.iterdir()) if f.is_file() and f.suffix.lower() in IMAGE_EXT and not f.name.startswith(".") } @@ -195,11 +202,13 @@ def images(root: str | Path) -> dict[str, str]: def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: site = site_info(root, data["site"]["mid_station_m"]) + auto = images(root, AUTO) return { "data": data, "site": site, "table": compute_milestone(data, site["mid_default_m"]), - "images": images(root), + "images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선 + "auto_images": auto, } @@ -209,9 +218,10 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: path.mkdir(parents=True, exist_ok=True) atomic_write_json(path / DATA_FILE, data) keep = {route["id"] for route in data["routes"]} - for stem, name in images(root).items(): - if stem not in keep: - (path / name).unlink(missing_ok=True) + for sub in ("", AUTO): + for stem, name in images(root, sub).items(): + if stem not in keep: + (path / name).unlink(missing_ok=True) return payload(root, data) @@ -280,19 +290,54 @@ async def milestone_image_upload( async def milestone_image(project_id: UUID, route: str = Query(...)): route_id = _route_id(route) root = await _project_root(project_id) - found = route_images(root, route_id) + found = route_images(root, route_id) + route_images(root, route_id, AUTO) if not found: raise HTTPException(status_code=404, detail="경로 그림이 없습니다.") - return FileResponse(found[0]) + return FileResponse(found[0]) # 사용자 그림 우선 @router.delete(PATH + "/image") -async def milestone_image_delete(project_id: UUID, route: str = Query(...)) -> dict[str, Any]: +async def milestone_image_delete( + project_id: UUID, route: str = Query(...), user_only: bool = False +) -> dict[str, Any]: route_id = _route_id(route) root = await _project_root(project_id) - for old in route_images(root, route_id): + auto = route_images(root, route_id, AUTO) + for old in route_images(root, route_id) + ([] if user_only else auto): old.unlink(missing_ok=True) - return {"name": None} + return {"name": f"{AUTO}/{auto[0].name}" if user_only and auto else None} + + +class Mark(BaseModel): + name: str = Field(default="", max_length=100) + lon: float = Field(ge=-180, le=180) + lat: float = Field(ge=-90, le=90) + + +Lat = Annotated[float, Field(ge=-90, le=90)] +Lon = Annotated[float, Field(ge=-180, le=180)] + + +class AutoImage(BaseModel): + """길 선 = 구간마다 [위도, 경도] 목록(마지막 [길찾기] 응답) · 표지 = 거래처 · 기준점 · 시점.""" + + lines: list[Annotated[list[tuple[Lat, Lon]], Field(max_length=20000)]] = Field( + default_factory=list, max_length=20 + ) + marks: list[Mark] = Field(default_factory=list, max_length=10) + + +@router.post(PATH + "/image/auto") +async def milestone_image_auto( + project_id: UUID, body: AutoImage, route: str = Query(...) +) -> dict[str, Any]: + """경로 그림 자동(244-11) — 서버 헤드리스 캡처 → auto/<경로 id>.png · 좌표는 저장 안 함.""" + route_id = _route_id(route) + root = await _project_root(project_id) + 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) + return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]} KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions" diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 196c5539b..f15eb4bae 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -1,30 +1,22 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_KakaoMap.ts - * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 + [캡처] → 그 경로 그림. + * 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 · 표지(보기만). * * SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key * (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기 * 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 · * 시점(`mapMarks`) · 경로 전체 맞춤 - * [지도 캡처] = getDisplayMedia(preferCurrentTab) → 지도 칸만(CropTarget · 없으면 `cropBox` 자리 수치) → - * 그 경로 그림(244-1 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤 - * (244-10) 보안 문맥 아닌 주소(테일스케일 http IP 등) = getDisplayMedia 없음 → [지도 캡처] 끔 + 까닭 한 줄 · - * SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 + * 경로 그림 = 서버 자동 캡처(244-11 · `…_State` autoRouteImage) — 브라우저 캡처 없음(접속 주소 무관) + * (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { cropBox, mapMarks } from "./B08_Quantity_Milestone_Layout"; +import { el } from "@ui/ui_template_elements"; +import { mapMarks } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; -import { - liveMilestone, - routePaths, - siteInfo, - uploadImage, -} from "./B08_Quantity_UI_Milestone_State"; +import { liveMilestone, routePaths, siteInfo } from "./B08_Quantity_UI_Milestone_State"; export const NO_MAP = "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; -export const NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요"; const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 @@ -88,74 +80,20 @@ export function loadKakaoMaps(projectId: string): Promise