From 0816595e2eb465f78be258592ec9747c97f09ea0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:34:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EA=B2=BD=EB=A1=9C=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=3D=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=A7=80=EB=8F=84=20JS=20?= =?UTF-8?q?=EC=9C=84=20=EA=B8=B8=20=EC=84=A0=20=C2=B7=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=EC=BA=A1=EC=B2=98=20(244-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 길찾기 응답에 경로 좌표 [위도, 경도] 목록(vertexes = 경도 · 위도 번갈아) · 저장 안 함 · km 만 저장 그대로 - 새 길 GET …/quantity/milestone/map-key — 카카오 지도 JavaScript 키(.env · 공개용) · 없으면 null - 거래처 모달 · 경로 하위 탭에 카카오 지도 — 길 선 · 거래처 · 기준점 · 시점 표지 · 경로 전체 맞춤 - [지도 캡처] = 지금 탭 캡처 → 지도 칸만 자름(CropTarget · 없으면 자리 수치) → 그 경로 그림 · 트랙 바로 멈춤 - JS 키 없음 · SDK 못 받음 = 안내 한 줄 · 사진 올리기 · 다음 지도 새 창 길은 그대로 - 새 시험 test_244_9 · test_244_7 응답 꼴 고침(path 더함) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 37 +++ B08_Quantity/B08_Quantity_Router_Milestone.py | 27 +- B08_Quantity/B08_Quantity_UI_Milestone.css | 16 ++ .../B08_Quantity_UI_Milestone_KakaoMap.ts | 225 ++++++++++++++++ .../B08_Quantity_UI_Milestone_Modal.ts | 8 + .../B08_Quantity_UI_Milestone_Route.ts | 6 + .../B08_Quantity_UI_Milestone_State.ts | 30 ++- .../tester/test_244_7_b08_milestone_kakao.py | 5 +- .../test_244_9_b08_milestone_route_map.py | 240 ++++++++++++++++++ 9 files changed, 579 insertions(+), 15 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts create mode 100644 resources/tester/test_244_9_b08_milestone_route_map.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index f687db05a..348f331d2 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -134,6 +134,43 @@ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): ]; } +/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ +export function mapMarks( + data: MilestoneData, + routeId: string, + start: LonLat, +): Array<{ name: string; lon: number; lat: number }> { + const route = data.routes.find((r) => r.id === routeId); + if (!route) return []; + const out: Array<{ name: string; lon: number; lat: number }> = []; + const add = (name: string, p: [number, number] | null): void => { + if (p) out.push({ name, lon: p[0], lat: p[1] }); + }; + add(route.name || "거래처", pointOf(route)); + if (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base)); + add("시점", start ?? null); + return out; +} + +/** 캡처 자르기(244-9 · CropTarget 없을 때) — 요소 자리(CSS px) × (영상 폭 ÷ 창 폭 = 보통 + * devicePixelRatio) → 영상 안 자를 칸(영상 밖은 잘라 냄) */ +export function cropBox( + rect: { left: number; top: number; width: number; height: number }, + viewWidth: number, + videoWidth: number, + videoHeight: number, +): { sx: number; sy: number; sw: number; sh: number } { + const k = videoWidth / viewWidth; + const sx = Math.min(Math.max(0, Math.round(rect.left * k)), videoWidth); + const sy = Math.min(Math.max(0, Math.round(rect.top * k)), videoHeight); + return { + sx, + sy, + sw: Math.max(0, Math.min(videoWidth - sx, Math.round((rect.left + rect.width) * k) - sx)), + sh: Math.max(0, Math.min(videoHeight - sy, Math.round((rect.top + rect.height) * k) - sy)), + }; +} + /** 자동 km 를 그 구간에(사용자 km 는 그대로) */ export function putAutoKm( data: MilestoneData, diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 9697f2997..a1d1affae 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -9,6 +9,9 @@ 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) + + 경로 좌표 [위도, 경도] 목록(244-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 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). @@ -312,8 +315,9 @@ def _kakao_open(request: urllib.request.Request) -> bytes: def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]: - """카카오 길찾기 → {km, reason} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄. - 키 · 응답 원문 · 경로 좌표는 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만).""" + """카카오 길찾기 → {km, reason, path} — km = 총 거리 ÷ 1000 소수 1자리 · 실패 = reason 한 줄 · + path = 경로 좌표 [위도, 경도] 목록(성공 때만 · 지도 길 선 · 저장 안 함). + 키 · 응답 원문은 돌려주지도 적지도 않음(까닭 = 고정 글 · 카카오 result_msg 만).""" key = os.environ.get(KAKAO_KEY, "").strip() if not key: return { @@ -348,10 +352,27 @@ def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]: if route.get("result_code") != 0 or not isinstance(distance, (int, float)): msg = str(route.get("result_msg") or "결과 없음")[:80] return {"km": None, "reason": f"길찾기 실패 — {msg} · km 를 손으로 넣으세요."} - return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None} + path: list[list[float]] = [] + try: # vertexes = 경도 · 위도 번갈아(도로마다) + for section in route.get("sections") or []: + for road in section.get("roads") or []: + v = [float(n) for n in road.get("vertexes") or []] + path += [[lat, lon] for lon, lat in zip(v[0::2], v[1::2])] + except (TypeError, ValueError, AttributeError): + path = [] # 깨진 좌표 = 길 선만 없음(km 는 그대로) + return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None, "path": path} @router.post(PATH + "/directions") async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, Any]: """[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음).""" return await asyncio.to_thread(directions_km, body.origin, body.destination) + + +KAKAO_JS = "KAKAO_JS_KEY" + + +@router.get(PATH + "/map-key") +async def milestone_map_key(project_id: UUID) -> dict[str, Any]: + """카카오 지도 JavaScript 키(공개용 · 도메인 등록으로 막힘) — 없으면 null = 화면 안내 한 줄.""" + return {"key": os.environ.get(KAKAO_JS, "").strip() or None} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 3e0baafe2..1cac2d074 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -164,3 +164,19 @@ .b08-milestone .is-pick-road { color: var(--color-danger); } + +/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 */ +.b08-milestone__map { + width: 100%; + height: 360px; + border: 1px solid var(--color-border); +} + +.b08-milestone__map-label { + padding: 2px 6px; + border: 1px solid #3c3c3c; + background: #fff; + color: #141414; + font-size: 12px; + white-space: nowrap; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts new file mode 100644 index 000000000..7bbdcb75e --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -0,0 +1,225 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_KakaoMap.ts + * 거리이정표 경로 지도(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 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { cropBox, mapMarks } from "./B08_Quantity_Milestone_Layout"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { + liveMilestone, + routePaths, + siteInfo, + uploadImage, +} from "./B08_Quantity_UI_Milestone_State"; + +export const NO_MAP = + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; +const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; +const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 + +type LatLng = object; +interface KMap { + setBounds(bounds: object): void; + relayout(): void; +} +interface KOverlay { + setMap(map: KMap | null): void; +} +interface KakaoMaps { + load(done: () => void): void; + LatLng: new (lat: number, lng: number) => LatLng; + LatLngBounds: new () => { extend(p: LatLng): void }; + Map: new (box: HTMLElement, o: { center: LatLng; level: number }) => KMap; + Polyline: new (o: { + path: LatLng[]; + strokeWeight: number; + strokeColor: string; + strokeOpacity: number; + }) => KOverlay; + Marker: new (o: { position: LatLng; title: string }) => KOverlay; + CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay; +} + +let sdk: Promise | null = null; + +/** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */ +export function loadKakaoMaps(projectId: string): Promise { + sdk ??= (async () => { + const { key } = await request<{ key: string | null }>( + `/projects/${projectId}/quantity/milestone/map-key`, + ); + if (!key) throw new Error(NO_MAP); + const w = window as unknown as { kakao?: { maps: KakaoMaps } }; + if (!w.kakao?.maps) { + await new Promise((done, fail) => { + const script = document.createElement("script"); + script.src = `${SDK}?appkey=${encodeURIComponent(key)}&autoload=false`; + script.onload = () => done(); + script.onerror = () => fail(new Error(NO_MAP)); + document.head.append(script); + }); + } + const maps = w.kakao?.maps; + if (!maps) throw new Error(NO_MAP); + await new Promise((done) => maps.load(done)); + return maps; + })(); + sdk.catch(() => (sdk = null)); + return sdk; +} + +/** 지도 칸만 화면 캡처(png) — 지금 탭 먼저 · 칸 자르기 · 스트림은 바로 끔 */ +export async function captureElement(target: HTMLElement): Promise { + 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 } } + ).CropTarget; + let cropped = false; + if (crop && track && "cropTo" in track) + try { + await (track as unknown as { cropTo(t: object): Promise }).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((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 { + 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 draw = (): void => { + const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); + const lines = routePaths(routeId); + const sig = JSON.stringify([marks, lines]); + if (map && sig === shown) return; + loadKakaoMaps(projectId).then( + (maps) => + requestAnimationFrame(() => { + const points = [ + ...marks.map((m) => new maps.LatLng(m.lat, m.lon)), + ...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)), + ]; + if (!points.length) { + hint.textContent = "거래처 주소 · 노선 시점이 있어야 지도에 놓임"; + 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)); + drawn = [ + ...lines.map( + (line) => + new maps.Polyline({ + path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)), + strokeWeight: 6, + strokeColor: LINE_COLOR, + strokeOpacity: 0.9, + }), + ), + ...marks.flatMap((m) => { + const at = new maps.LatLng(m.lat, m.lon); + return [ + new maps.Marker({ position: at, title: m.name }), + new maps.CustomOverlay({ + position: at, + content: el("span", { className: "b08-milestone__map-label", text: m.name }), + yAnchor: 2.6, + }), + ]; + }), + ]; + drawn.forEach((o) => o.setMap(map)); + const bounds = new maps.LatLngBounds(); + points.forEach((p) => bounds.extend(p)); + map.setBounds(bounds); + shown = sig; + hint.textContent = lines.length + ? "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림" + : "[길찾기]를 누르면 길 선이 그려짐 · [지도 캡처] = 이 지도 칸 → 경로 그림"; + }), + () => { + box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로) + capture.hidden = true; + hint.textContent = NO_MAP; + }, + ); + }; + + const capture = createButton({ + label: "지도 캡처", + size: "sm", + variant: "ghost", + 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 = ""; + const root = el("div", { + className: "b08-milestone__map-wrap", + children: [el("div", { className: "b08-milestone__bar", children: [capture] }), hint, box], + }); + draw(); + return { root, draw }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 8eb55eba1..f61314eb1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -10,6 +10,7 @@ * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 * [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 · * 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」) + * 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · [지도 캡처] → 그 경로 그림) * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; @@ -38,6 +39,7 @@ import { type Road, } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; +import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { findRouteKm, legRoadOptions, @@ -239,6 +241,9 @@ 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 apply = (): boolean => { const fresh = liveMilestone(projectId); @@ -276,6 +281,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { const next = legsEditor(r.legs); legs.root.replaceWith(next.root); legs = next; + mapView.draw(); // 길 선(그때그때 · 저장 안 함) }, }); find.dataset.b08MilestoneInfo = "find"; @@ -311,6 +317,8 @@ export function openRouteInfo(projectId: string, routeId: string): void { status, head("주소 · 지도"), address.root, + head("경로 지도 — 카카오"), + mapView.root, ); }, }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index eb09f50b5..ac799ae0a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -6,6 +6,8 @@ * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 * 그림 = 올리는 즉시 서버(교체 · 지우기) + * 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지 · [지도 캡처] → 이 탭 그림 · + * JS 키 없음 = 안내 한 줄(사진 올리기 · 다음 지도 새 창 길은 그대로) * ========================================================================== */ import { @@ -25,6 +27,7 @@ import { siteInfo, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; +import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap"; let menu: MapContextMenu | null = null; let lastCell: Element | null = null; @@ -185,6 +188,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { ), }); + const mapView = routeMapView(projectId, routeId, () => loadImage(true)); const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, @@ -195,6 +199,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { children: [kakao, capture, upload, removeImage, file], }), hint, + mapView.root, noImage, img, ], @@ -207,6 +212,7 @@ 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(); + mapView.draw(); }; refresh(); return { root, refresh }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 4ce05a5ca..89d6da7dd 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -8,6 +8,7 @@ * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). * [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 · * 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음) + * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -133,12 +134,18 @@ export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); /** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */ export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions]; +/** 마지막 [길찾기] 길 선 — 경로 id → 구간마다 [위도, 경도] 목록(메모리만 · 저장 안 함) */ +const paths = new Map>>(); +export const routePaths = (routeId: string): Array> => + paths.get(routeId) ?? []; + /** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 · * 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */ export async function findRouteKm(projectId: string, routeId: string): Promise { const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); const out: string[] = []; const got: Array<[KmSegment["key"], number]> = []; + const lines: Array> = []; for (const seg of segments) { if (!seg.from || !seg.to) { const missing = seg.text.split(" → ")[seg.from ? 1 : 0]; @@ -148,25 +155,28 @@ export async function findRouteKm(projectId: string, routeId: string): Promise( - `/projects/${projectId}/quantity/milestone/directions`, - { - method: "POST", - body: JSON.stringify({ - origin: { lon: seg.from[0], lat: seg.from[1] }, - destination: { lon: seg.to[0], lat: seg.to[1] }, - }), - }, - ); + const res = await request<{ + km: number | null; + reason: string | null; + path?: Array<[number, number]>; + }>(`/projects/${projectId}/quantity/milestone/directions`, { + method: "POST", + body: JSON.stringify({ + origin: { lon: seg.from[0], lat: seg.from[1] }, + destination: { lon: seg.to[0], lat: seg.to[1] }, + }), + }); if (res.km == null) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`); else { out.push(`${seg.text} ${res.km} km`); got.push([seg.key, res.km]); + if ((res.path?.length ?? 0) >= 2) lines.push(res.path!); } } catch (error) { out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`); } } + paths.set(routeId, lines); if (got.length) { const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 for (const [key, km] of got) putAutoKm(fresh, routeId, key, km); diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 2ea0966a8..75274d1c0 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -79,13 +79,14 @@ def test_가짜_응답_km_소수_한자리(monkeypatch): monkeypatch.setattr(ms, "_kakao_open", fake) res = _post(_client()) - assert res.status_code == 200 and res.json() == {"km": 10.5, "reason": None} + assert res.status_code == 200 + assert res.json() == {"km": 10.5, "reason": None, "path": [[35.6, 127.9]]} # 244-9 # 문서 꼴 — origin · destination = 경도,위도 · 머리 KakaoAK assert seen["url"].startswith(ms.KAKAO_URL + "?") assert "origin=127.9095%2C35.6866" in seen["url"] assert "destination=127.85%2C35.66" in seen["url"] and "priority=RECOMMEND" in seen["url"] assert seen["auth"] == f"KakaoAK {FAKE_KEY}" - # 경로 좌표 · 응답 원문은 안 돌려줌(km 만) + # 응답 원문은 안 돌려줌(km · 경로 좌표 [위도, 경도] 만 · 244-9) assert "vertexes" not in res.text and "sections" not in res.text # 끝수 = 소수 1자리 반올림 for meters, km in ((10450, 10.5), (10449, 10.4), (950, 1.0), (0, 0.0)): diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py new file mode 100644 index 000000000..3afc3857a --- /dev/null +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -0,0 +1,240 @@ +"""244-9 B08 거리이정표 경로 그림 = 카카오 지도 JS 위 경로 + [지도 캡처](사용자 138번). + +서버 = 길찾기 응답에 경로 좌표 [위도, 경도](vertexes = 경도 · 위도 번갈아) · 저장 안 함 · JS 키 길 · +화면 = 표지(거래처 · 기준점 · 시점) · 캡처 자르기 수치 · 키 없음 = 안내 한 줄 · 키 있음 = 길 선 · +표지 · 경로 전체 맞춤(가짜 kakao.maps 로 node 에서 돌림). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" +GEOCHANG = {"lon": 127.9095, "lat": 35.6866} +BYEONGGOK = {"lon": 127.85, "lat": 35.66} +BODY = { + "routes": [ + { + "result_code": 0, + "summary": {"distance": 10463}, + "sections": [ + {"roads": [{"vertexes": [127.9095, 35.6866, 127.89, 35.68]}]}, + {"roads": [{"vertexes": [127.87, 35.672, 127.85, 35.66]}]}, + ], + } + ] +} + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(rels, script): + src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + "\n" + script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _client(monkeypatch, body=BODY): + monkeypatch.setenv(ms.KAKAO_KEY, "fake-rest-key") + monkeypatch.setattr(ms, "_kakao_open", lambda r: json.dumps(body).encode()) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app) + + +def test_길찾기_경로_좌표_위도_경도(monkeypatch): + res = _client(monkeypatch).post( + BASE + "/directions", json={"origin": GEOCHANG, "destination": BYEONGGOK} + ) + assert res.json() == { + "km": 10.5, + "reason": None, + "path": [[35.6866, 127.9095], [35.68, 127.89], [35.672, 127.87], [35.66, 127.85]], + } + assert "vertexes" not in res.text and "sections" not in res.text # 원문 안 돌려줌 + + +def test_깨진_좌표_km_그대로_길_선만_없음(monkeypatch): + monkeypatch.delenv(ms.KAKAO_KEY, raising=False) + got = ms.directions_km(ms.LonLat(**GEOCHANG), ms.LonLat(**BYEONGGOK)) # 키 없음 = 길 선 없음 + assert got["km"] is None and "path" not in got + broken = json.loads(json.dumps(BODY)) + broken["routes"][0]["sections"][1]["roads"][0]["vertexes"] = ["x", None] + body = ( + _client(monkeypatch, broken) + .post(BASE + "/directions", json={"origin": GEOCHANG, "destination": BYEONGGOK}) + .json() + ) + assert body["km"] == 10.5 and body["path"] == [] + + +def test_좌표_저장_안_함(tmp_path): + route = {"id": "r1", "name": "거창군청", "legs": [{"road": None, "auto_km": 10.5}]} + route["path"] = [[35.6866, 127.9095], [35.66, 127.85]] # 화면이 잘못 실어 보내도 + ms.save_data(tmp_path, ms.Milestone.model_validate({"routes": [route]})) + files = [p for p in tmp_path.rglob("*") if p.is_file()] + assert [p.name for p in files] == ["milestone.json"] + text = files[0].read_text(encoding="utf-8") + assert '"path"' not in text and "35.6866" not in text and '"auto_km": 10.5' in text + + +def test_JS_키_길(monkeypatch): + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + monkeypatch.delenv(ms.KAKAO_JS, raising=False) + assert client.get(BASE + "/map-key").json() == {"key": None} + monkeypatch.setenv(ms.KAKAO_JS, " ") + assert client.get(BASE + "/map-key").json() == {"key": None} + monkeypatch.setenv(ms.KAKAO_JS, "fake-js-key") + assert client.get(BASE + "/map-key").json() == {"key": "fake-js-key"} + + +def test_표지_셋_캡처_자르기(): + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "lon": 127.85, "lat": 35.66, "legs": []}, + "routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True}, + {"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}], + } # fmt: skip + d = json.dumps(data) + got = _node( + [LAYOUT], + f"""const D = {d}; +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] == [ + {"name": "거창군청", "lon": 127.9, "lat": 35.68}, + {"name": "병곡리", "lon": 127.85, "lat": 35.66}, + {"name": "시점", "lon": 127.83, "lat": 35.64}, + ] + 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; +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] }); +const routePaths = () => PATHS; +""" + + +def _map(key, paths, extra=""): + script = f""" +const KEY = {json.dumps(key)}; +const PATHS = {json.dumps(paths)}; +const asked: string[] = []; +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; +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) + + +def test_키_없음_안내_한_줄(): + got = _map(None, []) + assert got["asked"] == ["/projects/p1/quantity/milestone/map-key"] + assert got["injected"] == [] # SDK 안 부름 + assert got["hint"] == ( + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + ) + assert got["boxHidden"] and got["captureHidden"] and got["label"] == "지도 캡처" + + +FAKE_KAKAO = """ +const LOG: any = { overlays: [], bounds: [] }; +(globalThis as any).LOG = LOG; +class LatLng { lat: number; lng: number; + constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; } } +const on = (kind: string, o: any) => ({ setMap: (m: any) => m && LOG.overlays.push([kind, o]) }); +(globalThis as any).kakao = { maps: { + load: (f: () => void) => f(), + LatLng, + LatLngBounds: class { extend(p: any) { LOG.bounds.push([p.lat, p.lng]); } }, + Map: class { constructor() { LOG.maps = (LOG.maps ?? 0) + 1; } + relayout() {} setBounds() { LOG.fit = true; } }, + Polyline: function (o: any) { return on("line", o); }, + Marker: function (o: any) { return on("marker", o); }, + CustomOverlay: function (o: any) { return on("label", o); }, +} }; +""" + + +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"] + 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"] + + +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 + 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 "mapView.draw();" in route + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감) + for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만 + text = rel.read_text(encoding="utf-8") + assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel + for rel in (KMAP, "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", LAYOUT, + "B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip + assert len(_read(rel).splitlines()) <= 700