feat(b08): 거리이정표 도구 왼쪽 아래 · 거래처 탭 = 길찾기 지도 + 그림 변경 · 초기화 (250-6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 00:41:01 +09:00
co-authored by Claude Opus 5.5
parent eaadf27782
commit e6be11cdd9
12 changed files with 154 additions and 90 deletions
@@ -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초)
+22 -2
View File
@@ -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);
}
@@ -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),
});
@@ -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<KakaoMaps> {
fail(
Object.assign(
new Error(
`카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`,
`카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요`,
),
{ name: SDK_FAIL },
),
+95 -56
View File
@@ -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<void> =>
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<Array<[number, number]>> => {
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 };
}
@@ -81,11 +81,10 @@ export function siteAddress(projectId: string, midM: number | null): SiteAddress
}
export const routeImages = (): Record<string, string> => 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<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;
@@ -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"));
}
@@ -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"]
@@ -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
@@ -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",
@@ -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"]
@@ -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")
@@ -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