feat(b08): 거리이정표 경로 지도 = 카카오 지도 JS 위 길 선 · 지도 캡처 (244-9)
- 서버 길찾기 응답에 경로 좌표 [위도, 경도] 목록(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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<KakaoMaps> | null = null;
|
||||
|
||||
/** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */
|
||||
export function loadKakaoMaps(projectId: string): Promise<KakaoMaps> {
|
||||
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<void>((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<void>((done) => maps.load(done));
|
||||
return maps;
|
||||
})();
|
||||
sdk.catch(() => (sdk = null));
|
||||
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 {
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string, Array<Array<[number, number]>>>();
|
||||
export const routePaths = (routeId: string): Array<Array<[number, number]>> =>
|
||||
paths.get(routeId) ?? [];
|
||||
|
||||
/** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 ·
|
||||
* 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */
|
||||
export async function findRouteKm(projectId: string, routeId: string): Promise<string[]> {
|
||||
const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
|
||||
const out: string[] = [];
|
||||
const got: Array<[KmSegment["key"], number]> = [];
|
||||
const lines: Array<Array<[number, number]>> = [];
|
||||
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<s
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const res = await request<{ km: number | null; reason: string | null }>(
|
||||
`/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);
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user