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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 # 메모리만(캐시 · 서버 안 감)
|
||||
|
||||
Reference in New Issue
Block a user