refactor(b08): 거리이정표 거래처 탭 = 그림만 — 새 창 길찾기 · 탭 지도 칸 없앰 (250-5)

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:27:46 +09:00
co-authored by Claude Opus 5.5
parent edbce254ec
commit eaadf27782
6 changed files with 24 additions and 153 deletions
@@ -530,13 +530,6 @@ export function layoutMilestone(
};
}
/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */
export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string {
const part = (p: { name: string; lon: number; lat: number }): string =>
`${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`;
return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`;
}
/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */
export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null {
if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 };
@@ -1,19 +1,17 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_KakaoMap.ts
* 거리이정표 경로 지도(PLAN 244-9 · 사용자 138번) — 카카오 지도 JS 위 길 선 · 표지(보기만).
* 거리이정표 카카오 지도 칸(PLAN 244-9 · 사용자 138번) — 등록 모달(소재지 고르기) · 연결선 모달(길 선 · 표지 끌기)
* 한 벌(`editMap`) · 경로 하위 탭 지도 칸은 없앰(156 · 탭 = 그림만).
*
* SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key
* (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기
* 길 선 = 마지막 [길찾기] 응답 경로 좌표(`routePaths` · 메모리만 · 저장 안 함) · 표지 = 거래처 · 기준점 ·
* 시점(`mapMarks`) · 경로 전체 맞춤
* 길 선 = 마지막 [길찾기] 응답 경로 좌표(메모리만 · 저장 안 함) · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로)
* 경로 그림 = 서버 자동 캡처(244-11 · `…_State` autoRouteImage) — 브라우저 캡처 없음(접속 주소 무관)
* (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { mapMarks } from "./B08_Quantity_Milestone_Layout";
import { request } from "./B08_Quantity_UI_Hydro";
import { liveMilestone, routePaths, siteInfo } from "./B08_Quantity_UI_Milestone_State";
export const NO_MAP =
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요";
@@ -93,87 +91,6 @@ export function loadKakaoMaps(projectId: string): Promise<KakaoMaps> {
return sdk;
}
export interface RouteMapView {
root: HTMLElement;
/** 지금 값 · 마지막 [길찾기] 길 선으로 다시 그림 */
draw: () => void;
}
/** 경로 지도 칸 — 거래처 모달 · 경로 하위 탭 같은 한 벌 */
export function routeMapView(projectId: string, routeId: string): RouteMapView {
const box = el("div", { className: "b08-milestone__map" });
const hint = el("p", { className: "b08-options__hint" });
hint.dataset.b08MilestoneMapHint = "";
let map: KMap | null = null;
let drawn: KOverlay[] = [];
let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로)
const 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;
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
? "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦"
: "[길찾기]를 누르면 길 선 · 경로 그림이 생김";
}),
(error: unknown) => {
box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄(사진 올리기는 그대로)
hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP;
},
);
};
const root = el("div", {
className: "b08-milestone__map-wrap",
children: [hint, box],
});
draw();
return { root, draw };
}
/** 고르는 지도 표지(244-13) — key = 끌어 옮길 때 돌려받는 이름 */
export interface MapPin {
key: string;
@@ -5,9 +5,7 @@
* 그림 = [등록] · [길찾기] 때 서버 자동 캡처(244-11 · 카카오 지도 + 길 선 + 표지) · [그림 올리기] = 사용자
* 그림 우선 · [자동 그림으로 되돌리기] = 사용자 그림만 지움 · [그림 지우기] = 둘 다 · 즉시 서버
* 자동 그림 실패 = 까닭 한 줄(이 탭 안내 줄) · 사진 올리기
* [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · 경유 = 기준점 좌표(거침) ·
* 도착 = 목적지 좌표) — 보기만
* 카카오 지도(244-9) = `…_UI_Milestone_KakaoMap` — 길 선 · 표지(보기만) · JS 키 없음 = 안내 한 줄
* 길찾기 · 카카오 지도는 연결선 모달 몫(156 · 이 탭은 그림만 · 새 창 길찾기 · 지도 칸 없앰)
* ========================================================================== */
import {
@@ -17,7 +15,6 @@ 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 { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout";
import {
autoImageNote,
canRevertImage,
@@ -26,10 +23,8 @@ import {
liveMilestone,
putMilestone,
routeImageKey,
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;
@@ -70,26 +65,10 @@ export interface MilestoneView {
refresh: () => void;
}
/** 새 창 길찾기 지점 — 거래처 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */
export function routePoints(
d: MilestoneData,
r: MilestoneRoute,
): Array<{ name: string; lon: number; lat: number }> | null {
const site = siteInfo();
const dest = r.rows[0]?.dest ?? "start";
const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat;
if (r.lon == null || r.lat == null || !to) return null;
const points = [{ name: r.name || "거래처", lon: r.lon, lat: r.lat }];
if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null)
points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat });
points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] });
return points;
}
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 = "";
@@ -124,23 +103,6 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
if (picked) void put(picked);
});
const kakao = createButton({
label: "다음 지도에서 길찾기",
size: "sm",
variant: "filled",
onClick: () => {
const d = liveMilestone(projectId);
const r = d.routes.find((x) => x.id === routeId);
const points = r ? routePoints(d, r) : null;
if (!points)
return showToast(
"거래처 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.",
"info",
);
window.open(kakaoRouteUrl(points), "_blank", "noopener");
},
});
kakao.dataset.b08MilestoneKakao = "";
const upload = createButton({
label: "그림 올리기",
size: "sm",
@@ -167,7 +129,6 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
),
});
const mapView = routeMapView(projectId, routeId);
const root = el("section", {
className: "b08-milestone__route ui-card",
attrs: { "data-b08-milestone-route": routeId },
@@ -175,10 +136,9 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
title,
el("div", {
className: "b08-milestone__bar",
children: [kakao, upload, revert, removeImage, file],
children: [upload, revert, removeImage, file],
}),
hint,
mapView.root,
noImage,
img,
],
@@ -193,7 +153,6 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
loadImage();
revert.hidden = !canRevertImage(routeId);
hint.textContent = autoImageNote(routeId) ?? HINT;
mapView.draw();
};
refresh();
return { root, refresh };
@@ -38,7 +38,8 @@ const routePaths = () => [[[35.68, 127.9], [35.64, 127.83]]];
FAKE_KAKAO = """
class Fake { setMap() {} relayout() {} setBounds() {} extend() {} }
(globalThis as any).kakao = { maps: { load: (f: () => void) => f(), LatLng: Fake,
LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake } };
LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake,
event: { addListener: () => {} } } };
"""
@@ -48,7 +49,9 @@ def _view(setup):
+ setup
+ IMPORTS.sub("", _read(KMAP))
+ """
const view = routeMapView("p1", "r1");
// 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap)
const view = editMap("p1", { onMove: () => {}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" });
view.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }], routePaths());
await new Promise((r) => setTimeout(r, 30));
const [hint, box] = view.root.children;
console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden,
@@ -76,16 +76,10 @@ def test_방위_좌표_자동():
def test_다음_지도_길찾기_주소():
pts = [{"name": "거창군청", "lon": 127.909, "lat": 35.686},
{"name": "병곡리, 968/31", "lon": 127.75, "lat": 35.78},
{"name": "임도 시점", "lon": 127.74, "lat": 35.79}] # fmt: skip
url = _node(f"kakaoRouteUrl({json.dumps(pts, ensure_ascii=False)})")
# 공식 꼴 /link/by/car/이름,위도,경도/… · 이름의 쉼표 · 빗금은 빈칸
assert url == (
"https://map.kakao.com/link/by/car/%EA%B1%B0%EC%B0%BD%EA%B5%B0%EC%B2%AD,35.686,127.909/"
"%EB%B3%91%EA%B3%A1%EB%A6%AC%20%20968%2031,35.78,127.75/"
"%EC%9E%84%EB%8F%84%20%EC%8B%9C%EC%A0%90,35.79,127.74"
)
# 156 경로 탭 새 창 길찾기 없앰(길찾기 = 연결선 모달 · 카카오 길찾기 API) — 주소 만들기도 없음
assert "kakaoRouteUrl" not in _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "window.open(" not in route and "다음 지도에서 길찾기" not in route
def test_VWorld_좌표_주소(monkeypatch):
@@ -174,7 +168,6 @@ def test_화면_모달_팬줌_그림탭():
assert (
"createDataTable" not in route and "구간" not in route.split("export function routeView")[1]
)
assert "window.open(kakaoRouteUrl(points)" in route
assert "getDisplayMedia" not in route and "uploadImage(projectId, routeId, picked)" in route
assert 'label: "그림 올리기"' in route
page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
@@ -159,7 +159,11 @@ const PATHS = {json.dumps(paths)};
const asked: string[] = [];
const request = async (url: string) => (asked.push(url), {{ key: KEY }});
{extra}
const view = routeMapView("p1", "r1");
// 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap)
const view = editMap("p1", {{ onMove: () => {{}}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }});
view.set([{{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }},
{{ key: "base", name: "병곡리", lon: 127.85, lat: 35.66, movable: true }},
{{ key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }}], PATHS);
await new Promise((r) => setTimeout(r, 30));
const [hint, box] = view.root.children;
console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden: box.hidden,
@@ -193,6 +197,7 @@ const on = (kind: string, o: any) => ({ setMap: (m: any) => m && LOG.overlays.pu
Polyline: function (o: any) { return on("line", o); },
Marker: function (o: any) { return on("marker", o); },
CustomOverlay: function (o: any) { return on("label", o); },
event: { addListener: () => {} },
} };
"""
@@ -218,8 +223,9 @@ def test_화면_자리_캡처_키_서버만():
leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도
assert "mapHead, map.root" in leg and "map.set(pins()" in leg
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "routeMapView(projectId, routeId)" in route
assert "mapView.draw();" in route
# 156 경로 탭 = 그림만(새 창 길찾기 · 지도 칸 없앰 · 길찾기 · 지도 = 연결선 모달)
assert "routeMapView" not in route and "다음 지도에서 길찾기" not in route
assert "routeMapView" not in kmap
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감)
for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만