Files
Aislo/ui_template/ui_template_kakao_map.ts
T
eomsangdonandClaude Opus 5.5 c2876aef60 refactor(529-2): 카카오 지도 부품 B08 → ui_template 공용 이전 · 키 길 공용(/api/map/kakao-key)
- ui_template_kakao_map.ts(editMap · loadKakaoMaps) · 지도 CSS 공용 이름(ui-kakao-map*)
- B08 거리이정표 네 곳은 공용을 부름 · projectId 인자 걷음(키가 프로젝트 무관)
- B08 /quantity/milestone/map-key 걷음 → common_util_map_router 로 옮김
- B08 이정표 시험 파일 경로 · fetch 가짜 · 공용 CSS 로 고침(동작 판정 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:26:34 +09:00

215 lines
8.1 KiB
TypeScript

/* =============================================================================
* ui_template_kakao_map.ts
* 카카오 지도 칸 공용(PLAN 244-9 · 529-2) — 고르는 지도 한 벌(`editMap`) · B08 거리이정표 · D 예정노선이 씀.
*
* SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET /api/map/kakao-key
* (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기
* 길 선 = 부르는 쪽이 준 [위도, 경도] 목록 · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로)
* (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄
* ========================================================================== */
import "./ui_template_kakao_map.css";
import { API_BASE_URL } from "@config/config_frontend";
import { el } from "./ui_template_elements";
export const NO_MAP =
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요";
const SDK_FAIL = "KakaoSdkFail";
const SDK = "https://dapi.kakao.com/v2/maps/sdk.js";
const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선
interface LatLng {
getLat(): number;
getLng(): number;
}
interface KMap {
setBounds(bounds: object): void;
setCenter(p: LatLng): void;
setLevel(level: number): void;
relayout(): void;
getBounds(): { contain(p: LatLng): boolean };
panTo(p: LatLng): void;
}
interface KOverlay {
setMap(map: KMap | null): void;
}
interface KMarker extends KOverlay {
getPosition(): LatLng;
}
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; draggable?: boolean }) => KMarker;
event: {
addListener(target: object, type: string, fn: (e: { latLng: LatLng }) => void): void;
};
CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay;
}
let sdk: Promise<KakaoMaps> | null = null;
/** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */
export function loadKakaoMaps(): Promise<KakaoMaps> {
sdk ??= (async () => {
const response = await fetch(`${API_BASE_URL}/map/kakao-key`, { credentials: "include" });
const { key } = response.ok
? ((await response.json()) as { key: string | null })
: { key: null };
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(
Object.assign(
new Error(
`카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요`,
),
{ name: SDK_FAIL },
),
);
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;
}
/** 고르는 지도 표지(244-13) — key = 끌어 옮길 때 돌려받는 이름 */
export interface MapPin {
key: string;
name: string;
lon: number;
lat: number;
movable: boolean;
/** 262-2 고정 시점 표지 — 끌기 · 맞춤 · 보기 이동에 안 쓰임 · 고르는 표지와 모양이 다름 */
fixed?: boolean;
}
export interface EditMap {
root: HTMLElement;
/** 표지 · 길 선 다시 놓고 맞춤 */
set: (pins: MapPin[], lines: Array<Array<[number, number]>>) => void;
}
/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */
export function editMap(opts: {
onMove: (key: string, lon: number, lat: number) => void;
onClick?: (lon: number, lat: number) => void;
center?: () => [number, number] | null;
hint: string;
}): EditMap {
const box = el("div", { className: "ui-kakao-map__box" });
const hint = el("p", { className: "ui-kakao-map__hint", text: opts.hint });
hint.dataset.kakaoMapHint = "";
let map: KMap | null = null;
let drawn: KOverlay[] = [];
let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로
const set = (pins: MapPin[], lines: Array<Array<[number, number]>>): void => {
loadKakaoMaps().then(
(maps) =>
requestAnimationFrame(() => {
const moving = pins.filter((p) => !p.fixed);
const points = [
...moving.map((p) => new maps.LatLng(p.lat, p.lon)),
...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)),
];
const c = opts.center?.();
const first = points[0] ?? (c ? new maps.LatLng(c[1], c[0]) : null);
if (!first) return void (box.hidden = true);
box.hidden = false;
if (!map) {
map = new maps.Map(box, { center: first, level: 7 });
if (opts.onClick)
maps.event.addListener(map, "click", (e) =>
opts.onClick!(e.latLng.getLng(), e.latLng.getLat()),
);
}
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,
}),
),
...pins.flatMap((p) => {
const at = new maps.LatLng(p.lat, p.lon);
if (p.fixed)
return [
new maps.CustomOverlay({
position: at,
content: el("span", {
className: "ui-kakao-map__start",
text: p.name,
}),
yAnchor: 0.5,
}),
];
const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable });
if (p.movable)
maps.event.addListener(marker, "dragend", () => {
const to = marker.getPosition();
opts.onMove(p.key, to.getLng(), to.getLat());
});
return [
marker,
new maps.CustomOverlay({
position: at,
content: el("span", { className: "ui-kakao-map__label", text: p.name }),
yAnchor: 2.6,
}),
];
}),
];
drawn.forEach((o) => o.setMap(map));
if (fitted) {
// 보기 밖 표지만 그쪽으로 옮김(줌 그대로)
const view = map.getBounds();
const out = moving.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon)));
if (out) map.panTo(new maps.LatLng(out.lat, out.lon));
} else if (points.length > 1) {
const bounds = new maps.LatLngBounds();
points.forEach((p) => bounds.extend(p));
map.setBounds(bounds);
fitted = true;
} else {
map.setCenter(first);
map.setLevel(points.length ? 4 : 9);
fitted = points.length > 0;
}
}),
(error: unknown) => {
box.hidden = true;
hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP;
},
);
};
return {
root: el("div", { className: "ui-kakao-map", children: [hint, box] }),
set,
};
}