feat(b08): 거리이정표 경로 그림 = 서버 자동 캡처 · 브라우저 캡처 없앰 (244-11)

- [등록] · [길찾기] 뒤 길 선 · 표지 → 서버 헤드리스 Edge(없으면 chrome)로 카카오 지도 1200×800 PNG → auto/<경로 id>.png
- 페이지 주소 = KAKAO_CAPTURE_ORIGIN(기본 http://localhost:5173) + /__route_capture page.route 채움
- 사용자 그림 우선 · 자동 그림으로 되돌리기 · 실패 = 까닭 한 줄 · 동시 요청 잠금 · 좌표 저장 없음
- [지도 캡처] · [화면 캡처] · getDisplayMedia · 244-10 캡처 안내 없앰(지도 SDK 도메인 안내는 그대로)
- 새 시험 test_244_11 · 244_4 · 244_9 · 244_10 기대값 고침 · requirements playwright

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 22:59:25 +09:00
co-authored by Claude Opus 5.5
parent a48c1a3b9b
commit 8f96b16380
12 changed files with 615 additions and 236 deletions
@@ -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"""<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">
<div id="m" style="width:{WIDTH}px;height:{HEIGHT}px"></div>
<script src="{sdk}" onerror="window.__fail=1"></script>
<script>window.kakao && kakao.maps.load(() => {{
const D = {data}, K = kakao.maps, at = (p) => new K.LatLng(p[0], p[1]);
const pts = [...D.marks.map((m) => [m.lat, m.lon]), ...D.lines.flat()].map(at);
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: 7 }});
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
D.marks.forEach((m) => {{
const p = at([m.lat, m.lon]);
new K.Marker({{ map, position: p, title: m.name }});
const s = document.createElement("span");
s.textContent = m.name;
s.style.cssText = "background:#fff;border:1px solid #333;border-radius:4px;padding:2px 6px;"
+ "font:600 13px sans-serif;white-space:nowrap";
new K.CustomOverlay({{ map, position: p, content: s, yAnchor: 2.6 }});
}});
K.event.addListener(map, "tilesloaded", () => (window.__done = true));
const b = new K.LatLngBounds();
pts.forEach((p) => b.extend(p));
map.setBounds(b, 60, 60, 60, 60);
}});</script></body></html>"""
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}
@@ -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,
+64 -19
View File
@@ -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"
@@ -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<KakaoMaps> {
return sdk;
}
/** 지도 칸만 화면 캡처(png) — 지금 탭 먼저 · 칸 자르기 · 스트림은 바로 끔 */
export async function captureElement(target: HTMLElement): Promise<File> {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
preferCurrentTab: true,
} as DisplayMediaStreamOptions);
try {
const [track] = stream.getVideoTracks();
const crop = (
window as unknown as { CropTarget?: { fromElement(e: Element): Promise<object> } }
).CropTarget;
let cropped = false;
if (crop && track && "cropTo" in track)
try {
await (track as unknown as { cropTo(t: object): Promise<void> }).cropTo(
await crop.fromElement(target),
);
cropped = true;
} catch {
cropped = false; // 다른 창을 골랐음 등 — 자리 수치로 자름
}
const video = el("video");
video.muted = true;
video.srcObject = stream;
await video.play();
await new Promise((done) => requestAnimationFrame(done));
const box = cropped
? { sx: 0, sy: 0, sw: video.videoWidth, sh: video.videoHeight }
: cropBox(
target.getBoundingClientRect(),
window.innerWidth,
video.videoWidth,
video.videoHeight,
);
const canvas = el("canvas");
canvas.width = box.sw;
canvas.height = box.sh;
canvas.getContext("2d")?.drawImage(video, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
const blob = await new Promise<Blob>((done, fail) =>
canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"),
);
return new File([blob], "capture.png", { type: "image/png" });
} finally {
stream.getTracks().forEach((t) => t.stop());
}
}
export interface RouteMapView {
root: HTMLElement;
/** 지금 값 · 마지막 [길찾기] 길 선으로 다시 그림 */
draw: () => void;
}
/** 경로 지도 칸 + [캡처] — 거래처 모달 · 경로 하위 탭 같은 한 벌 */
export function routeMapView(
projectId: string,
routeId: string,
onCaptured: () => void = () => {},
): RouteMapView {
/** 경로 지도 칸 — 거래처 모달 · 경로 하위 탭 같은 한 벌 */
export function routeMapView(projectId: string, routeId: string): RouteMapView {
const box = el("div", { className: "b08-milestone__map" });
const hint = el("p", { className: "b08-options__hint" });
hint.dataset.b08MilestoneMapHint = "";
let map: KMap | null = null;
let drawn: KOverlay[] = [];
let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로)
const canCapture = Boolean(navigator.mediaDevices?.getDisplayMedia); // http IP 주소 = 없음
const shot = canCapture ? "[지도 캡처] = 이 지도 칸 → 경로 그림" : NO_CAPTURE;
const draw = (): void => {
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
@@ -174,7 +112,6 @@ export function routeMapView(
return;
}
box.hidden = false;
capture.hidden = false;
map ??= new maps.Map(box, { center: points[0], level: 7 });
map.relayout();
drawn.forEach((o) => o.setMap(null));
@@ -206,35 +143,19 @@ export function routeMapView(
map.setBounds(bounds);
shown = sig;
hint.textContent = lines.length
? `길 선 = 카카오 길찾기 · ${shot}`
: `[길찾기]를 누르면 길 선이 그려짐 · ${shot}`;
? "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦"
: "[길찾기]를 누르면 길 선 · 경로 그림이 생김";
}),
(error: unknown) => {
box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로)
capture.hidden = true;
box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄(사진 올리기는 그대로)
hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP;
},
);
};
const capture = createButton({
label: "지도 캡처",
size: "sm",
variant: "ghost",
disabled: !canCapture,
onClick: () =>
void captureElement(box)
.then((file) => uploadImage(projectId, routeId, file))
.then(onCaptured, (error: unknown) => {
if ((error as Error)?.name !== "NotAllowedError")
showToast(`지도를 캡처하지 못했습니다 — ${String(error)}`, "error");
}),
});
capture.dataset.b08MilestoneCapture = "";
if (!canCapture) capture.title = NO_CAPTURE;
const root = el("div", {
className: "b08-milestone__map-wrap",
children: [el("div", { className: "b08-milestone__bar", children: [capture] }), hint, box],
children: [hint, box],
});
draw();
return { root, draw };
@@ -10,7 +10,8 @@
* 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버
* [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 ·
* 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」)
* 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · [지도 캡처] → 그 경로 그림)
* 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · 보기만) · 경로 그림 = [등록] · [길찾기] 뒤
* 서버 자동 캡처(244-11)
* ========================================================================== */
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
@@ -241,9 +242,7 @@ export function openRouteInfo(projectId: string, routeId: string): void {
let legs = legsEditor(route.legs);
const status = el("p", { className: "b08-options__hint" });
status.dataset.b08MilestoneInfo = "status";
const mapView = routeMapView(projectId, routeId, () =>
showToast("지도 캡처 = 이 거래처 경로 그림", "success"),
);
const mapView = routeMapView(projectId, routeId);
/** 모달 값 → 캐시(고침 · 길찾기 앞) */
const apply = (): boolean => {
const fresh = liveMilestone(projectId);
+28 -47
View File
@@ -2,12 +2,12 @@
* B08_Quantity_UI_Milestone_Route.ts
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달).
*
* 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 ·
* 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로
* 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에
* 그림 = 올리는 즉시 서버(교체 · 지우기)
* 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지 · [지도 캡처] → 이 탭 그림 ·
* JS 키 없음 = 안내 한 줄(사진 올리기 · 다음 지도 새 창 길은 그대로)
* 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자
* 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버
* 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기
* [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) ·
* 도착 = 목적지 좌표) — 보기만
* 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄
* ========================================================================== */
import {
@@ -19,11 +19,13 @@ import { createButton, el, showToast } from "@ui/ui_template_elements";
import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout";
import {
autoImageNote,
canRevertImage,
deleteImage,
imageBlobUrl,
liveMilestone,
putMilestone,
routeImages,
routeImageKey,
siteInfo,
uploadImage,
} from "./B08_Quantity_UI_Milestone_State";
@@ -84,41 +86,19 @@ export function routePoints(
return points;
}
/** 화면 캡처 — 사용자가 고른 창 한 장(png) · 스트림은 바로 끔 */
async function captureWindow(): Promise<File> {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
try {
const video = el("video");
video.muted = true;
video.srcObject = stream;
await video.play();
await new Promise((done) => requestAnimationFrame(done));
const canvas = el("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
const blob = await new Promise<Blob>((done, fail) =>
canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"),
);
return new File([blob], "capture.png", { type: "image/png" });
} finally {
stream.getTracks().forEach((t) => t.stop());
}
}
export function routeView(projectId: string, routeId: string): MilestoneView {
const title = el("h4");
const hint = el("p", {
className: "b08-options__hint",
text: "[다음 지도에서 길찾기] → 새 창에서 길 확인 → [화면 캡처] → 그 창을 고름 = 이 탭 그림",
});
const HINT =
"경로 그림 = [길찾기] 때 서버가 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선";
const hint = el("p", { className: "b08-options__hint", text: HINT });
hint.dataset.b08MilestoneRouteHint = "";
// 그림 — 경로 하나 = 그림 하나
const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } });
const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" });
let shown: string | null | undefined;
const loadImage = (force = false): void => {
const name = routeImages()[routeId] ?? null;
const name = routeImageKey(routeId);
if (!force && name === shown) return;
shown = name;
img.hidden = !name;
@@ -161,23 +141,22 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
},
});
kakao.dataset.b08MilestoneKakao = "";
const capture = createButton({
label: "화면 캡처",
size: "sm",
variant: "ghost",
onClick: () =>
void captureWindow().then(put, (error: unknown) => {
if ((error as Error)?.name !== "NotAllowedError")
showToast(`화면을 캡처하지 못했습니다 — ${String(error)}`, "error");
}),
});
capture.dataset.b08MilestoneCapture = "";
const upload = createButton({
label: "그림 올리기",
size: "sm",
variant: "ghost",
onClick: () => file.click(),
});
const revert = createButton({
label: "자동 그림으로 되돌리기",
size: "sm",
variant: "ghost",
onClick: () =>
void deleteImage(projectId, routeId, true).catch((error: unknown) =>
showToast(`자동 그림으로 되돌리지 못했습니다 — ${String(error)}`, "error"),
),
});
revert.dataset.b08MilestoneRevert = "";
const removeImage = createButton({
label: "그림 지우기",
size: "sm",
@@ -188,7 +167,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
),
});
const mapView = routeMapView(projectId, routeId, () => loadImage(true));
const mapView = routeMapView(projectId, routeId);
const root = el("section", {
className: "b08-milestone__route ui-card",
attrs: { "data-b08-milestone-route": routeId },
@@ -196,7 +175,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
title,
el("div", {
className: "b08-milestone__bar",
children: [kakao, capture, upload, removeImage, file],
children: [kakao, upload, revert, removeImage, file],
}),
hint,
mapView.root,
@@ -212,6 +191,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"];
title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`;
loadImage();
revert.hidden = !canRevertImage(routeId);
hint.textContent = autoImageNote(routeId) ?? HINT;
mapView.draw();
};
refresh();
@@ -9,13 +9,16 @@
* [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 ·
* 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음)
* 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐)
* 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* ========================================================================== */
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 { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc";
import { kmSegments, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout";
import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout";
export interface SiteInfo {
length_m: number | null;
@@ -28,6 +31,7 @@ interface Payload {
data: MilestoneData;
site: SiteInfo;
images: Record<string, string>;
auto_images: Record<string, string>;
}
export const EMPTY: MilestoneData = {
@@ -55,6 +59,24 @@ export async function loadMilestone(projectId: string, force = false): Promise<P
export const siteInfo = (): SiteInfo | null => saved?.site ?? null;
export const routeImages = (): Record<string, string> => saved?.images ?? {};
/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */
export const canRevertImage = (routeId: string): boolean => {
const name = saved?.images[routeId];
const auto = saved?.auto_images?.[routeId];
return !!name && !!auto && name !== auto;
};
/** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */
const imageRev = new Map<string, number>();
export const routeImageKey = (routeId: string): string | null => {
const name = saved?.images[routeId];
return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null;
};
/** 경로 그림 자동 상태 한 줄(만드는 중 · 실패 까닭) — 없으면 null */
const autoNotes = new Map<string, string>();
export const autoImageNote = (routeId: string): string | null => autoNotes.get(routeId) ?? null;
export const hasMilestoneDraft = (projectId: string): boolean =>
readState<MilestoneData>("b08-milestone", projectId) !== null;
@@ -114,16 +136,58 @@ export async function uploadImage(projectId: string, routeId: string, file: File
changes.dispatchEvent(new Event("change"));
}
export async function deleteImage(projectId: string, routeId: string): Promise<void> {
await call(imageUrl(projectId, routeId), { method: "DELETE" });
/** 그림 지우기(자동 그림까지) · userOnly = 사용자 그림만 = 자동 그림으로 되돌리기 */
export async function deleteImage(
projectId: string,
routeId: string,
userOnly = false,
): Promise<void> {
const url = imageUrl(projectId, routeId) + (userOnly ? "&user_only=true" : "");
const body = (await (await call(url, { method: "DELETE" })).json()) as { name: string | null };
if (saved) {
const rest = { ...saved.images };
const auto = { ...saved.auto_images };
delete rest[routeId];
if (body.name) rest[routeId] = body.name;
else delete auto[routeId];
saved.images = rest;
saved.auto_images = auto;
}
changes.dispatchEvent(new Event("change"));
}
/** 경로 그림 자동(244-11) — 길 선 · 표지만 보냄 → 서버 헤드리스 캡처 → 자동 그림(사용자 그림 우선) */
export async function autoRouteImage(
projectId: string,
routeId: string,
lines: Array<Array<[number, number]>>,
): Promise<void> {
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)");
changes.dispatchEvent(new Event("change"));
let reason: string | null;
try {
const res = await request<{ name: string | null; reason: string | null }>(
`/projects/${projectId}/quantity/milestone/image/auto?route=${encodeURIComponent(routeId)}`,
{ method: "POST", body: JSON.stringify({ lines, marks }) },
);
reason = res.reason;
if (res.name && saved) {
const user = saved.images[routeId] && saved.images[routeId] !== saved.auto_images?.[routeId];
saved.auto_images = { ...saved.auto_images, [routeId]: res.name };
if (!user) saved.images = { ...saved.images, [routeId]: res.name };
imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1);
}
} catch (error) {
reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`;
}
if (reason) {
autoNotes.set(routeId, reason);
showToast(reason, "warning");
} else autoNotes.delete(routeId);
changes.dispatchEvent(new Event("change"));
}
/** 그림 → 화면 주소(blob · 쿠키 포함) */
export async function imageBlobUrl(projectId: string, routeId: string): Promise<string> {
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
@@ -177,6 +241,7 @@ export async function findRouteKm(projectId: string, routeId: string): Promise<s
}
}
paths.set(routeId, lines);
if (lines.length) void autoRouteImage(projectId, routeId, lines); // 뒤에서 · km 알림 안 기다림
if (got.length) {
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
for (const [key, km] of got) putAutoKm(fresh, routeId, key, km);
+1
View File
@@ -28,6 +28,7 @@ openpyxl==3.1.2
packaging==26.2
pandas==3.0.3
pillow==12.3.0
playwright==1.62.0
psutil>=6.1,<7
pydantic==2.5.0
pydantic_core==2.14.1
@@ -1,7 +1,7 @@
"""244-10 B08 거리이정표 경로 지도 — 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님).
① getDisplayMedia 없음 = [지도 캡처] 끔 + 까닭 한 줄(지도는 그대로)
② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 · 캡처 숨김
① getDisplayMedia 없음 = 지도 그대로 · 캡처 안내 없음([지도 캡처]는 244-11 에서 없앰 · 그림 = 서버 자동)
② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 숨김
(가짜 kakao.maps · document 로 node 에서 돌림)
"""
@@ -15,7 +15,6 @@ IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts"
KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts"
ORIGIN = "http://100.72.32.27:5173"
NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요"
def _read(rel):
@@ -25,10 +24,6 @@ def _read(rel):
STUBS = f"""
const el = (tag: string, o: any = {{}}) => ({{ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {{}}, children: o.children ?? [] }});
const createButton = (o: any) => ({{ ...el("button", {{ text: o.label }}), disabled: !!o.disabled,
title: "", onClick: o.onClick }});
const showToast = () => {{}};
const uploadImage = async () => {{}};
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
const request = async () => ({{ key: "fake-js-key" }});
const injected: string[] = [];
@@ -55,10 +50,9 @@ def _view(setup):
+ """
const view = routeMapView("p1", "r1");
await new Promise((r) => setTimeout(r, 30));
const [bar, hint, box] = view.root.children;
const cap = bar.children[0];
const [hint, box] = view.root.children;
console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden,
capHidden: cap.hidden, capDisabled: cap.disabled, capTitle: cap.title }));
children: view.root.children.length }));
"""
)
src = IMPORTS.sub("", _read(LAYOUT)) + script
@@ -77,23 +71,11 @@ Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} }
"""
def test_보안_문맥_아님_캡처_끔_까닭_한_줄():
def test_보안_문맥_아님_지도_그대로_캡처_안내_없음():
got = _view(NO_MEDIA + FAKE_KAKAO)
assert not got["boxHidden"] and not got["capHidden"] # 지도는 그대로 뜸
assert got["capDisabled"] and got["capTitle"] == NO_CAPTURE
assert got["hint"] == f"길 선 = 카카오 길찾기 · {NO_CAPTURE}"
assert "[지도 캡처] =" not in got["hint"]
def test_보안_문맥_캡처_켜짐():
media = """
Object.defineProperty(globalThis, "navigator", { configurable: true,
value: { mediaDevices: { getDisplayMedia: async () => ({}) } } });
(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } };
"""
got = _view(media + FAKE_KAKAO)
assert not got["capDisabled"] and got["capTitle"] == ""
assert got["hint"] == "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림"
assert not got["boxHidden"] and got["children"] == 2 # 지도는 그대로 · 캡처 단추 없음
assert got["hint"] == "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦"
assert "캡처는 localhost" not in got["hint"]
def test_SDK_401_도메인_등록_안내():
@@ -106,4 +88,4 @@ def test_SDK_401_도메인_등록_안내():
assert got["hint"] == (
f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"
)
assert got["boxHidden"] and got["capHidden"]
assert got["boxHidden"]
@@ -0,0 +1,277 @@
"""244-11 B08 거리이정표 경로 그림 = 서버 자동 캡처(사용자 139번).
가짜 브라우저(playwright.sync_api 자리에 가짜 모듈)로:
① [길찾기] 길 선 · 표지 → 서버 캡처 → auto/<경로 id>.png · 페이지 주소 = KAKAO_CAPTURE_ORIGIN +
/__route_capture(page.route 로 채움) · 좌표 저장 0 · 키 값 응답 0
② 사용자 그림 우선 · 「자동 그림으로 되돌리기」(user_only) · 그림 지우기 = 둘 다 ·
경로 지우고 [저장] = 자동 그림도
③ 실패 까닭 한 줄 — 키 없음 · Playwright 없음 · 브라우저 못 띄움 · 20초 넘김 · 도메인 불일치
④ 동시 요청 = 하나씩(잠금) · 화면 = 브라우저 캡처 길 없음 · [길찾기] 뒤 자동 · 되돌리기 단추
"""
import json
import sys
import threading
import time
import types
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_Milestone_Capture as cap # noqa: E402
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
PNG = b"\x89PNG\r\n\x1a\nfake-map"
KEY = "fake-js-key-123"
LINES = [[[35.6866, 127.9095], [35.68, 127.89]], [[35.672, 127.87], [35.66, 127.85]]]
MARKS = [
{"name": "거창군청", "lon": 127.9095, "lat": 35.6866},
{"name": "시점", "lon": 127.85, "lat": 35.66},
]
class Box:
"""가짜 브라우저 기록 · 동작 모드."""
def __init__(self, mode="ok", delay=0.0):
self.mode, self.delay = mode, delay
self.channels, self.routed, self.goto, self.html = [], [], [], []
self.active = self.peak = 0
self.lock = threading.Lock()
def fake_playwright(box):
class Error(Exception):
pass
class TimeoutError(Error): # noqa: A001 — playwright 이름 그대로
pass
class Page:
def route(self, url, handler):
box.routed.append(url)
self.handler = handler
def goto(self, url, timeout):
box.goto.append(url)
fulfil = types.SimpleNamespace(fulfill=lambda **kw: box.html.append(kw["body"]))
self.handler(fulfil)
def wait_for_function(self, expr, timeout):
assert timeout <= cap.TIMEOUT_S * 1000
if box.mode == "timeout":
raise TimeoutError("Timeout 20000ms exceeded")
def evaluate(self, expr):
return 1 if box.mode == "sdk_fail" else None
def wait_for_timeout(self, ms):
pass
def locator(self, selector):
assert selector == "#m"
return self
def screenshot(self, timeout):
with box.lock:
box.active += 1
box.peak = max(box.peak, box.active)
time.sleep(box.delay)
with box.lock:
box.active -= 1
return PNG
class Browser:
def new_page(self, viewport):
assert viewport == {"width": 1200, "height": 800}
return Page()
def close(self):
pass
class Chromium:
def launch(self, channel, headless, timeout):
box.channels.append(channel)
assert headless
if box.mode == "no_browser" or (box.mode == "no_edge" and channel == "msedge"):
raise Error(f"Chromium distribution '{channel}' is not found")
return Browser()
class Ctx:
def __enter__(self):
return types.SimpleNamespace(chromium=Chromium())
def __exit__(self, *a):
return False
return types.SimpleNamespace(Error=Error, TimeoutError=TimeoutError, sync_playwright=Ctx)
def _setup(tmp_path, monkeypatch, box, origin=None):
monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_playwright(box))
monkeypatch.setenv(cap.KAKAO_JS, KEY)
if origin is None:
monkeypatch.delenv(cap.ORIGIN_KEY, raising=False)
else:
monkeypatch.setenv(cap.ORIGIN_KEY, origin)
async def root(project_id):
return tmp_path
monkeypatch.setattr(ms, "_project_root", root)
app = FastAPI()
app.include_router(ms.router)
return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone"
def _auto(client, base, route="r1"):
return client.post(
base + "/image/auto", params={"route": route}, json={"lines": LINES, "marks": MARKS}
)
def test_자동_그림_저장_좌표_0(tmp_path, monkeypatch):
box = Box()
client, base = _setup(tmp_path, monkeypatch, box)
res = _auto(client, base)
assert res.json() == {"name": "auto/r1.png", "reason": None}
assert KEY not in res.text
auto = tmp_path / "B08_Quantity/milestone/auto/r1.png"
assert auto.read_bytes() == PNG
# 페이지 = 기본 주소 + /__route_capture 를 page.route 로 채움 · Edge 먼저
assert box.routed == box.goto == ["http://localhost:5173/__route_capture"]
assert box.channels == ["msedge"]
html = box.html[0]
assert f"appkey={KEY}&autoload=false" in html and 'onerror="window.__fail=1"' in html
assert json.dumps(LINES) in html and "거창군청" in html and "tilesloaded" in html
assert "#E60046" in html and "1200px" in html and "800px" in html
# 좌표 저장 0 — 폴더엔 그림만
files = sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file())
assert files == ["B08_Quantity/milestone/auto/r1.png"]
got = client.get(base).json()
assert got["images"] == {"r1": "auto/r1.png"} and got["auto_images"] == {"r1": "auto/r1.png"}
assert client.get(base + "/image", params={"route": "r1"}).content == PNG
# 나쁜 좌표 · id = 거절
bad = {"lines": [[[95, 127.9]]], "marks": []}
assert client.post(base + "/image/auto", params={"route": "r1"}, json=bad).status_code == 422
assert _auto(client, base, "../x").status_code == 400
def test_주소_바꿈_Edge_없으면_Chrome(tmp_path, monkeypatch):
box = Box("no_edge")
client, base = _setup(tmp_path, monkeypatch, box, origin=" http://100.72.32.27:5173/ ")
assert _auto(client, base).json()["name"] == "auto/r1.png"
assert box.channels == ["msedge", "chrome"]
assert box.goto == ["http://100.72.32.27:5173/__route_capture"]
def test_사용자_그림_우선_되돌리기(tmp_path, monkeypatch):
client, base = _setup(tmp_path, monkeypatch, Box())
img = base + "/image"
client.post(img, params={"route": "r1"}, files={"file": ("u.jpg", b"user", "image/jpeg")})
assert _auto(client, base).json()["name"] == "auto/r1.png" # 자동 그림은 늘 만듦
got = client.get(base).json()
assert got["images"] == {"r1": "r1.jpg"} and got["auto_images"] == {"r1": "auto/r1.png"}
assert client.get(img, params={"route": "r1"}).content == b"user" # 사용자 그림 우선
# 자동 그림으로 되돌리기 = 사용자 그림만 지움
back = client.delete(img, params={"route": "r1", "user_only": "true"}).json()
assert back == {"name": "auto/r1.png"}
assert client.get(img, params={"route": "r1"}).content == PNG
# 그림 지우기 = 둘 다
assert client.delete(img, params={"route": "r1"}).json() == {"name": None}
assert client.get(img, params={"route": "r1"}).status_code == 404
assert client.get(base).json()["auto_images"] == {}
# 경로 지우고 [저장] = 자동 그림도 지움 · 남은 경로 것은 그대로
_auto(client, base, "r1")
_auto(client, base, "r2")
client.post(img, params={"route": "r2"}, files={"file": ("u.png", b"u2", "image/png")})
keep = {"routes": [{"id": "r2", "name": "김천역"}]}
saved = client.put(base, json=keep).json()
assert saved["images"] == {"r2": "r2.png"} and saved["auto_images"] == {"r2": "auto/r2.png"}
assert not (tmp_path / "B08_Quantity/milestone/auto/r1.png").exists()
def test_실패_까닭_한_줄(tmp_path, monkeypatch):
tail = " — 그림 올리기를 쓰세요."
cases = {
"no_browser": "경로 그림 자동 캡처 실패 — 서버에서 브라우저(Edge · Chrome)를 못 띄움",
"timeout": "경로 그림 자동 캡처 실패 — 지도 그림이 20초 안에 안 끝남",
"sdk_fail": "경로 그림 자동 캡처 실패 — 카카오 지도 JS 가 캡처 주소(http://localhost:5173)"
"를 받지 않음(키 도메인 등록 확인)",
}
for mode, reason in cases.items():
box = Box(mode)
client, base = _setup(tmp_path, monkeypatch, box)
res = _auto(client, base)
assert res.json() == {"name": None, "reason": reason + tail}, mode
assert KEY not in res.text
assert not (tmp_path / "B08_Quantity/milestone/auto").exists() # 실패 = 그림 없음
# Playwright 없음
monkeypatch.setitem(sys.modules, "playwright.sync_api", None)
assert _auto(client, base).json()["reason"] == (
"경로 그림 자동 캡처 실패 — 서버에 Playwright 가 없습니다(pip install playwright)" + tail
)
# 키 없음 = 브라우저 안 띄움
box = Box()
client, base = _setup(tmp_path, monkeypatch, box)
monkeypatch.setenv(cap.KAKAO_JS, " ")
assert _auto(client, base).json() == {
"name": None,
"reason": "카카오 지도 JavaScript 키가 없습니다(.env KAKAO_JS_KEY)" + tail,
}
assert box.channels == []
# 길 선 · 표지 없음
monkeypatch.setenv(cap.KAKAO_JS, KEY)
empty = client.post(base + "/image/auto", params={"route": "r1"}, json={}).json()
assert empty["reason"] == "길 선 · 표지가 없어 지도 그림을 못 만듦" + tail
def test_동시_요청_하나씩(tmp_path, monkeypatch):
box = Box(delay=0.15)
_setup(tmp_path, monkeypatch, box)
out = tmp_path / "auto"
got = []
def one(i):
got.append(cap.make_route_image(out, f"r{i}", LINES, MARKS))
jobs = [threading.Thread(target=one, args=(i,)) for i in range(3)]
for job in jobs:
job.start()
for job in jobs:
job.join()
assert [g["saved"] for g in got] == [True] * 3 and box.peak == 1
assert sorted(p.name for p in out.iterdir()) == ["r0.png", "r1.png", "r2.png"]
def test_화면_브라우저_캡처_없음_길찾기_뒤_자동():
def read(name):
return (ROOT / "B08_Quantity" / name).read_text(encoding="utf-8")
ui = ("UI_Milestone_KakaoMap", "UI_Milestone_Route", "UI_Milestone_Modal", "Milestone_Layout")
for name in (f"B08_Quantity_{n}.ts" for n in ui):
text = read(name)
assert "getDisplayMedia" not in text and "지도 캡처" not in text, name
assert "캡처는 localhost" not in text, name
state = read("B08_Quantity_UI_Milestone_State.ts")
assert "if (lines.length) void autoRouteImage(projectId, routeId, lines);" in state
assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state
assert "user_only=true" in state and "showToast(reason," in state
route = read("B08_Quantity_UI_Milestone_Route.ts")
assert (
'label: "자동 그림으로 되돌리기"' in route
and "deleteImage(projectId, routeId, true)" in route
)
assert "revert.hidden = !canRevertImage(routeId);" in route
assert "hint.textContent = autoImageNote(routeId) ?? HINT;" in route
kmap = read("B08_Quantity_UI_Milestone_KakaoMap.ts")
assert "등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김
big = ("Milestone_Capture.py", "Router_Milestone.py", "UI_Milestone_State.ts")
for name in (f"B08_Quantity_{n}" for n in big):
assert len(read(name).splitlines()) <= 700
@@ -168,16 +168,13 @@ def test_화면_모달_팬줌_그림탭():
# ③ 팬 · 줌 = 공용
assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram
assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram
# ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 화면 캡처 → 그림 올리기 길
# ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 그림 올리기 길(화면 캡처는 244-11 에서 서버 자동으로)
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert (
"createDataTable" not in route and "구간" not in route.split("export function routeView")[1]
)
assert "window.open(kakaoRouteUrl(points)" in route
assert "getDisplayMedia({ video: true, audio: false })" in route
assert (
"captureWindow().then(put," in route and "uploadImage(projectId, routeId, picked)" in route
)
assert "getDisplayMedia" not in route and "uploadImage(projectId, routeId, picked)" in route
assert 'label: "그림 올리기"' in route
page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page
@@ -1,7 +1,7 @@
"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로 + [지도 캡처](사용자 138번).
"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로(사용자 138번 · [지도 캡처]는 244-11 에서 없앰).
서버 = 길찾기 응답에 경로 좌표 [위도, 경도](vertexes = 경도 · 위도 번갈아) · 저장 안 함 · JS 키 길 ·
화면 = 표지(거래처 · 기준점 · 시점) · 캡처 자르기 수치 · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 ·
화면 = 표지(거래처 · 기준점 · 시점) · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 ·
표지 · 경로 전체 맞춤(가짜 kakao.maps 로 node 에서 돌림).
"""
@@ -111,7 +111,7 @@ def test_JS_키_길(monkeypatch):
assert client.get(BASE + "/map-key").json() == {"key": "fake-js-key"}
def test_표지_셋_캡처_자르기():
def test_표지_셋():
data = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": True, "name": "병곡리", "lon": 127.85, "lat": 35.66, "legs": []},
@@ -126,8 +126,6 @@ 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),
cropBox({{ left: 100, top: 50, width: 400, height: 300 }}, 1000, 2000, 1200),
cropBox({{ left: -20, top: 500, width: 1100, height: 300 }}, 1000, 1000, 600),
]));""",
)
assert got[0] == [
@@ -137,23 +135,16 @@ console.log(JSON.stringify([
]
assert got[1] == [{"name": "시점", "lon": 127.83, "lat": 35.64}] # 좌표 없는 거래처 = 표지 없음
assert got[2] == [{"name": "거창군청", "lon": 127.9, "lat": 35.68}]
assert got[3] == {"sx": 200, "sy": 100, "sw": 800, "sh": 600} # × 2(devicePixelRatio)
assert got[4] == {"sx": 0, "sy": 500, "sw": 1000, "sh": 100} # 영상 밖은 잘라 냄
STUBS = """
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [] });
const createButton = (o: any) => ({ ...el("button", { text: o.label }), onClick: o.onClick });
const showToast = () => {};
const uploadImage = async () => {};
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
const injected: string[] = [];
(globalThis as any).document = { createElement: () => ({}),
head: { append: (s: any) => injected.push(s.src) } };
(globalThis as any).window = globalThis;
Object.defineProperty(globalThis, "navigator", { configurable: true,
value: { mediaDevices: { getDisplayMedia: async () => ({}) } } }); // localhost = 보안 문맥
const liveMilestone = () => ({ site: {}, base: { enabled: true, name: "병곡리", lon: 127.85,
lat: 35.66 }, routes: [{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: true }] });
const siteInfo = () => ({ start_lonlat: [127.83, 35.64] });
@@ -170,9 +161,8 @@ const request = async (url: string) => (asked.push(url), {{ key: KEY }});
{extra}
const view = routeMapView("p1", "r1");
await new Promise((r) => setTimeout(r, 30));
const [bar, hint, box] = view.root.children;
const [hint, box] = view.root.children;
console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden,
captureHidden: bar.children[0].hidden, label: bar.children[0].textContent,
log: (globalThis as any).LOG ?? null }}));
"""
return _node([LAYOUT], STUBS + IMPORTS.sub("", _read(KMAP)) + script)
@@ -185,7 +175,7 @@ def test_키_없음_안내_한_줄():
assert got["hint"] == (
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"
)
assert got["boxHidden"] and got["captureHidden"] and got["label"] == "지도 캡처"
assert got["boxHidden"]
FAKE_KAKAO = """
@@ -211,26 +201,24 @@ def test_키_있음_길_선_표지_전체_맞춤():
paths = [[[35.68, 127.9], [35.67, 127.87], [35.66, 127.85]], [[35.66, 127.85], [35.64, 127.83]]]
got = _map("fake-js-key", paths, FAKE_KAKAO)
log = got["log"]
assert not got["boxHidden"] and not got["captureHidden"] and log["maps"] == 1 and log["fit"]
assert not got["boxHidden"] and log["maps"] == 1 and log["fit"]
lines = [o for kind, o in log["overlays"] if kind == "line"]
assert [[[p["lat"], p["lng"]] for p in o["path"]] for o in lines] == paths
assert lines[0]["strokeColor"] == "#E60046" and lines[0]["strokeWeight"] >= 5
titles = [o["title"] for kind, o in log["overlays"] if kind == "marker"]
assert titles == ["거창군청", "병곡리", "시점"]
assert len(log["bounds"]) == 3 + 5 # 표지 셋 + 길 선 점 다섯 = 경로 전체 맞춤
assert "[지도 캡처]" in got["hint"]
assert got["hint"] == "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦"
def test_화면_자리_캡처_키_서버만():
kmap = _read(KMAP)
assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap
assert "preferCurrentTab: true" in kmap and "crop.fromElement(target)" in kmap
assert "cropTo(" in kmap and "stream.getTracks().forEach((t) => t.stop())" in kmap
assert "uploadImage(projectId, routeId, file)" in kmap
assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert "mapView.root" in modal and "mapView.draw();" in modal
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "routeMapView(projectId, routeId, () => loadImage(true))" in route
assert "routeMapView(projectId, routeId)" in route
assert "mapView.draw();" in route
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감)