diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py index ca6a9cff1..7a3b50975 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Capture.py +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -36,7 +36,7 @@ CHILD = __file__ # 자식으로 돌릴 스크립트 NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색 -TAIL = " — 그림 올리기를 쓰세요." +TAIL = " — [그림 변경]으로 넣으세요." _lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 0953375dc..6f1c7fd0a 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -56,10 +56,10 @@ } /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ -/* 153 · 155 [자동 정렬](끌어 놓은 자리 있을 때만) · [요소 추가] — 다이어그램 왼쪽 위(오른쪽 위 = 진행단계 판 자리) */ +/* 153 · 155 · 157 [자동 정렬](끌어 놓은 자리 있을 때만) · [요소 추가] — 다이어그램 왼쪽 아래 · 단추 = 메인 탭 크기 */ .b08-milestone__tools { position: absolute; - top: 8px; + bottom: 8px; left: 8px; display: flex; gap: 6px; @@ -248,3 +248,23 @@ stroke-width: 3; stroke-dasharray: none; } + +/* 157 경로 탭 머리 줄 — 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 */ +.b08-milestone__route-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-16); +} + +.b08-milestone__route-title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8) var(--spacing-16); + font-size: var(--text-body-sm); +} + +.b08-milestone__route-addr { + color: var(--color-text-secondary); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 37080ea36..282856e60 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -267,7 +267,6 @@ export function createDiagram( // 153 [자동 정렬] — 끌어 놓은 자리 모두 지우고 방위 자동 자리로 const arrange = createButton({ label: "자동 정렬", - size: "sm", variant: "ghost", onClick: () => { const fresh = liveMilestone(projectId); @@ -281,7 +280,6 @@ export function createDiagram( // 155 [요소 추가] — 기준점 · 거래처 등록(공사지 · 기준점 [+] 없앰) · 등록 뒤 연결 점으로 선 이음 const addElem = createButton({ label: "요소 추가", - size: "sm", variant: "ghost", onClick: () => openPlaceModal(projectId, { add: true }, addRoute), }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 6a4980ccf..956d534ff 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -14,7 +14,7 @@ import { el } from "@ui/ui_template_elements"; import { request } from "./B08_Quantity_UI_Hydro"; export const NO_MAP = - "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요"; const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 @@ -74,7 +74,7 @@ export function loadKakaoMaps(projectId: string): Promise { fail( Object.assign( new Error( - `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`, + `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요`, ), { name: SDK_FAIL }, ), diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index 655e4724b..606fa3c38 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -1,11 +1,10 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_Route.ts - * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). + * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4 · 157) — 머리 줄(거래처 이름 · 주소 + [그림 변경] · [초기화]) + + * 길찾기 지도(연결선 모달과 같은 카카오 지도 · 표지 끌어 소재지 옮김) · 사용자 그림을 올리면 그 그림 · [초기화] = 지도로 * - * 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자 - * 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버 - * 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기 - * 길찾기 · 카카오 지도는 연결선 모달 몫(156 · 이 탭은 그림만 · 새 창 길찾기 · 지도 칸 없앰) + * [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버) + * 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157) * ========================================================================== */ import { @@ -15,14 +14,18 @@ import { } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { - autoImageNote, - canRevertImage, deleteImage, + findSegmentKm, + hasUserImage, imageBlobUrl, liveMilestone, putMilestone, routeImageKey, + segmentPath, + siteInfo, uploadImage, } from "./B08_Quantity_UI_Milestone_State"; @@ -66,94 +69,130 @@ export interface MilestoneView { } export function routeView(projectId: string, routeId: string): MilestoneView { - const title = el("h4"); - const HINT = - "경로 그림 = 연결선 길찾기 때 서버가 카카오 지도에 길 선을 그려 자동으로 만듦 · [그림 올리기] = 내 그림 우선"; - const hint = el("p", { className: "b08-options__hint", text: HINT }); - hint.dataset.b08MilestoneRouteHint = ""; + // 157 머리 줄 = 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 + const name = el("strong", { className: "b08-milestone__route-name" }); + const address = el("span", { className: "b08-milestone__route-addr" }); - // 그림 — 경로 하나 = 그림 하나 + // 몸 = 사용자 그림 있으면 그 그림 · 없으면 길찾기 지도(연결선 모달과 같은 지도 · 표지 끌어 소재지 옮김) 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 = routeImageKey(routeId); - if (!force && name === shown) return; - shown = name; - img.hidden = !name; - noImage.hidden = !!name; + const loadImage = (): void => { + const key = hasUserImage(routeId) ? routeImageKey(routeId) : null; + img.hidden = !key; + if (key === shown) return; + shown = key; if (img.src) URL.revokeObjectURL(img.src); img.removeAttribute("src"); - if (name) + if (key) imageBlobUrl(projectId, routeId).then( (url) => (img.src = url), (error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"), ); }; - const put = (picked: File): Promise => - uploadImage(projectId, routeId, picked).then( - () => loadImage(true), - (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), - ); const file = el("input", { attrs: { type: "file", accept: "image/*" } }); file.hidden = true; file.addEventListener("change", () => { const picked = file.files?.[0]; file.value = ""; - if (picked) void put(picked); + if (picked) + void uploadImage(projectId, routeId, picked).then( + () => refresh(), + (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), + ); }); - - const upload = createButton({ - label: "그림 올리기", - size: "sm", + const change = createButton({ + label: "그림 변경", variant: "ghost", onClick: () => file.click(), }); - const revert = createButton({ - label: "자동 그림으로 되돌리기", - size: "sm", + change.dataset.b08MilestoneImageChange = ""; + // 초기화 = 사용자 그림만 지우고 길찾기 지도로 되돌림 + const reset = createButton({ + label: "초기화", variant: "ghost", onClick: () => - void deleteImage(projectId, routeId, true).catch((error: unknown) => - showToast(`자동 그림으로 되돌리지 못했습니다 — ${String(error)}`, "error"), + void deleteImage(projectId, routeId, true).then( + () => refresh(), + (error: unknown) => showToast(`초기화하지 못했습니다 — ${String(error)}`, "error"), ), }); - revert.dataset.b08MilestoneRevert = ""; - const removeImage = createButton({ - label: "그림 지우기", - size: "sm", - variant: "ghost", - onClick: () => - void deleteImage(projectId, routeId).catch((error: unknown) => - showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"), - ), + reset.dataset.b08MilestoneRevert = ""; + + const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; + const pins = (): MapPin[] => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const out: MapPin[] = []; + const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => { + if (lon != null && lat != null) + out.push({ key, name: label, lon, lat, movable: key !== "start" }); + }; + if (!r) return out; + add(r.id, r.name || "거래처", r.lon, r.lat); + if (r.linked === false) return out; + if (d.base.enabled && r.via_base) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + add("start", "시점", start()?.[0], start()?.[1]); + return out; + }; + const lines = (): Array> => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + const via = !!r && r.linked !== false && d.base.enabled && r.via_base; + return [segmentPath(routeId), ...(via ? [segmentPath("base")] : [])].filter( + (p) => p.length >= 2, + ); + }; + const map = editMap(projectId, { + hint: "", + onMove: (key, lon, lat) => + void reverseAddress(projectId, lon, lat).then((addr) => { + const fresh = liveMilestone(projectId); + const target = key === "base" ? fresh.base : fresh.routes.find((x) => x.id === key); + if (!target) return; + Object.assign(target, { lon, lat, address: addr }); + putMilestone(projectId, fresh); + void findSegmentKm(projectId, key === "base" ? "base" : routeId).then(() => drawMap()); + }), }); + const drawMap = (): void => map.set(pins(), lines()); + let asked = false; // 길 선이 아직 없으면 열 때 한 번 길찾기(그림 · 알림 없음) + const ensurePath = (): void => { + if (asked || segmentPath(routeId).length) return; + asked = true; + void findSegmentKm(projectId, routeId, false).then(() => drawMap()); + }; const root = el("section", { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, children: [ - title, el("div", { - className: "b08-milestone__bar", - children: [upload, revert, removeImage, file], + className: "b08-milestone__route-head", + children: [ + el("div", { className: "b08-milestone__route-title", children: [name, address] }), + el("div", { className: "b08-milestone__bar", children: [change, reset, file] }), + ], }), - hint, - noImage, img, + map.root, ], }); - const refresh = (): void => { + function refresh(): void { const d = liveMilestone(projectId); const r = d.routes.find((x) => x.id === routeId); if (!r) return; - const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; - title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; + name.textContent = `${r.name || "거래처"} → ${DEST_TEXT[r.rows[0]?.dest ?? "start"]}`; + address.textContent = r.address || ""; loadImage(); - revert.hidden = !canRevertImage(routeId); - hint.textContent = autoImageNote(routeId) ?? HINT; - }; + const user = hasUserImage(routeId); + reset.hidden = !user; + map.root.hidden = user; + if (!user) { + drawMap(); + ensurePath(); + } + } 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 9459441fa..456a44b57 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -81,11 +81,10 @@ export function siteAddress(projectId: string, midM: number | null): SiteAddress } export const routeImages = (): Record => saved?.images ?? {}; -/** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */ -export const canRevertImage = (routeId: string): boolean => { +/** 사용자가 올린 그림이 있음(157 · 자동 그림과 다른 이름) — 경로 탭 = 그 그림 · 없으면 길찾기 지도 */ +export const hasUserImage = (routeId: string): boolean => { const name = saved?.images[routeId]; - const auto = saved?.auto_images?.[routeId]; - return !!name && !!auto && name !== auto; + return !!name && name !== saved?.auto_images?.[routeId]; }; /** 경로 그림 다시 읽을 표(이름 + 자동 그림 새로 만든 횟수 — 이름이 같아도 새 그림) */ @@ -95,10 +94,6 @@ export const routeImageKey = (routeId: string): string | null => { return name ? `${name}#${imageRev.get(routeId) ?? 0}` : null; }; -/** 경로 그림 자동 상태 한 줄(만드는 중 · 실패 까닭) — 없으면 null */ -const autoNotes = new Map(); -export const autoImageNote = (routeId: string): string | null => autoNotes.get(routeId) ?? null; - export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; @@ -189,8 +184,6 @@ export async function autoRouteImage( 0, 2, ); - autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)"); - changes.dispatchEvent(new Event("change")); let reason: string | null; try { const res = await request<{ name: string | null; reason: string | null }>( @@ -205,12 +198,9 @@ export async function autoRouteImage( imageRev.set(routeId, (imageRev.get(routeId) ?? 0) + 1); } } catch (error) { - reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — 그림 올리기를 쓰세요.`; + reason = `경로 그림 자동 캡처 요청 실패(${String(error)}) — [그림 변경]으로 넣으세요.`; } - if (reason) { - autoNotes.set(routeId, reason); - showToast(reason, "warning"); - } else autoNotes.delete(routeId); + if (reason) showToast(reason, "warning"); changes.dispatchEvent(new Event("change")); } diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index bed1745c6..9d4f13294 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -89,6 +89,6 @@ def test_SDK_401_도메인_등록_안내(): got = _view(NO_MEDIA + sdk_401) # window.kakao 없음 → script 넣음 → 401 = onerror assert len(got["injected"]) == 1 and "sdk.js?appkey=fake-js-key" in got["injected"][0] assert got["hint"] == ( - f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" ) assert got["boxHidden"] diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py index 25a591302..4efdac4de 100644 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -201,7 +201,7 @@ def test_사용자_그림_우선_되돌리기(tmp_path, monkeypatch): def test_실패_까닭_한_줄(tmp_path, monkeypatch): - tail = " — 그림 올리기를 쓰세요." + tail = " — [그림 변경]으로 넣으세요." cases = { "no_browser": "경로 그림 자동 캡처 실패 — 서버에서 브라우저(Edge · Chrome)를 못 띄움", "timeout": "경로 그림 자동 캡처 실패 — 지도 그림이 20초 안에 안 끝남", @@ -269,14 +269,11 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동(): 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 + # 157 [초기화] = 사용자 그림만 지우고 길찾기 지도로 · 쓸데없는 안내 줄 없음 + assert 'label: "초기화"' in route and "deleteImage(projectId, routeId, true)" in route + assert "reset.hidden = !user;" in route and "HINT" not in route kmap = read("B08_Quantity_UI_Milestone_KakaoMap.ts") - assert "등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" in kmap # SDK 도메인 안내 한 줄은 남김 + 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 diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 44cb13b38..613e5d68d 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -169,7 +169,7 @@ def test_화면_모달_팬줌_그림탭(): "createDataTable" not in route and "구간" not in route.split("export function routeView")[1] ) assert "getDisplayMedia" not in route and "uploadImage(projectId, routeId, picked)" in route - assert 'label: "그림 올리기"' in route + assert 'label: "그림 변경"' in route # 157 그림 올리기 → 그림 변경 page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page for rel in ("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts", diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index fc797d644..8f0393f93 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -177,7 +177,7 @@ def test_키_없음_안내_한_줄(): assert got["asked"] == ["/projects/p1/quantity/milestone/map-key"] assert got["injected"] == [] # SDK 안 부름 assert got["hint"] == ( - "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" ) assert got["boxHidden"] diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py index d567527cc..2e1948356 100644 --- a/resources/tester/test_249_1_b08_milestone_capture_child.py +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -81,7 +81,7 @@ def test_자식_프로세스로_PNG_생김(tmp_path, monkeypatch): def test_자식_시간_넘김_죽음_까닭(tmp_path, monkeypatch): monkeypatch.setenv(cap.KAKAO_JS, KEY) - tail = " — 그림 올리기를 쓰세요." + tail = " — [그림 변경]으로 넣으세요." head = "경로 그림 자동 캡처 실패 — " hang = tmp_path / "hang.py" hang.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index d368f1cea..9ff09d1d8 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -146,3 +146,23 @@ def test_155_요소_추가_안_이은_거래처_수기_입력(): assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") assert "if (!route || route.linked === false) return null;" in layout + + +def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + tools = css[css.index(".b08-milestone__tools {") :] + assert "bottom: 8px;" in tools[: tools.index("}")] and "left: 8px;" in tools[: tools.index("}")] + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert ( + 'label: "요소 추가",\n size: "sm"' not in ui + and 'label: "자동 정렬",\n size: "sm"' not in ui + ) + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert 'label: "그림 변경"' in route and 'label: "초기화"' in route + assert "그림 지우기" not in route and "자동 그림으로 되돌리기" not in route.split("*/", 1)[1] + assert ( + "map.root.hidden = user;" in route and "editMap(projectId" in route + ) # 그림 없으면 길찾기 지도 + assert ".b08-milestone__route-head {" in css and "justify-content: space-between;" in css + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "export const hasUserImage" in state and "canRevertImage" not in state