/* ============================================================================= * 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) 등록 안내 한 줄 * (560-3) 바탕 종류 = `setType("ROADMAP" | "HYBRID")`(위성 + 지명) · 단추는 부르는 쪽 부품 * (585-1) 마우스 = ☰ [설정] 따름 — 휠 = 설정 확대 방향(카카오 제 휠은 잡는 단계에서 막음 · 한 단씩) · * 옮기기 단추(588-5) = 설정(가운데 늘 · 오른쪽 고르면 오른쪽도) 끌기 = 옮김 · 왼쪽 끌기(표지 누름 포함) * · 손가락 = 카카오 제 것 · 표지 색 = `MapPin.color`(이름표 바탕) * ========================================================================== */ import "./ui_template_kakao_map.css"; import { API_BASE_URL } from "@config/config_frontend"; import { el } from "./ui_template_elements"; import { buttonMask, guardMenuAfterDrag, isAuxPanButton, wheelEventIn, } from "../A00_Common/input_prefs"; export const NO_MAP = "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요"; const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 /** 휠 한 단 사이 쉼(ms) — 카카오 확대는 단(두 배)만 되어 잘게 오는 휠(트랙패드)을 한 단으로 묶음 */ const WHEEL_GAP_MS = 180; interface LatLng { getLat(): number; getLng(): number; } interface KPoint { x: number; y: number; } interface KMap { setBounds(bounds: object): void; setCenter(p: LatLng): void; setLevel(level: number, opts?: { anchor?: LatLng }): void; getLevel(): number; getProjection(): { coordsFromContainerPoint(p: KPoint): LatLng }; relayout(): void; getBounds(): { contain(p: LatLng): boolean }; panTo(p: LatLng): void; setMapTypeId(id: number): 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; Point: new (x: number, y: number) => KPoint; 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; MapTypeId: Record; } /** 바탕 지도 종류 — 일반 · 위성 + 지명 */ export type MapType = "ROADMAP" | "HYBRID"; let sdk: Promise | null = null; /** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */ export function loadKakaoMaps(): Promise { 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((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((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; /** 585-1 이름표 바탕 색(없으면 흰 바탕) */ color?: string; } export interface EditMap { root: HTMLElement; /** 표지 · 길 선 다시 놓고 맞춤 · colors = 선마다 색 · focus = 고친 표지 key(529-3 · 없으면 지금 규칙) */ set: ( pins: MapPin[], lines: Array>, colors?: string[], focus?: string, ) => void; /** 바탕 종류(지도가 아직 없으면 만들 때 씀) */ setType: (type: MapType) => void; } /** 이름표 — 색이 있으면 그 바탕 · 흰 글 */ function label(p: MapPin): HTMLElement { const tag = el("span", { className: "ui-kakao-map__label", text: p.name }); if (p.color) { tag.classList.add("is-colored"); tag.style.background = p.color; } return tag; } /** 585-1 설정 따름 — 휠(잡는 단계 · 카카오에 안 넘김) = 설정 방향 한 단 · 마우스 자리 고정 · * 설정 옮기기 단추(가운데 · 오른쪽) 끌기 = 옮김. 왼쪽 끌기 · 손가락은 카카오 제 것(왼쪽 옮김도 같음). */ function followMouse(box: HTMLElement, map: KMap, maps: KakaoMaps): void { const pointAt = (x: number, y: number): LatLng => map.getProjection().coordsFromContainerPoint(new maps.Point(x, y)); let lastWheel = 0; box.addEventListener( "wheel", (event) => { event.preventDefault(); event.stopPropagation(); if (event.timeStamp - lastWheel < WHEEL_GAP_MS) return; lastWheel = event.timeStamp; const rect = box.getBoundingClientRect(); const anchor = pointAt(event.clientX - rect.left, event.clientY - rect.top); map.setLevel(map.getLevel() + (wheelEventIn(event) ? -1 : 1), { anchor }); }, { capture: true, passive: false }, ); let drag: { id: number; x: number; y: number; button: number } | null = null; box.addEventListener( "pointerdown", (event) => { if (event.pointerType !== "mouse" || !isAuxPanButton(event.button)) return; event.preventDefault(); // 브라우저 자동 굴림 막음 event.stopPropagation(); guardMenuAfterDrag(event); drag = { id: event.pointerId, x: event.clientX, y: event.clientY, button: event.button }; box.setPointerCapture(event.pointerId); }, { capture: true }, ); box.addEventListener( "pointermove", (event) => { if (!drag || event.pointerId !== drag.id) return; if (!(event.buttons & buttonMask(drag.button))) return void (drag = null); event.stopPropagation(); const [dx, dy] = [event.clientX - drag.x, event.clientY - drag.y]; [drag.x, drag.y] = [event.clientX, event.clientY]; map.setCenter(pointAt(box.clientWidth / 2 - dx, box.clientHeight / 2 - dy)); }, { capture: true }, ); const stop = (event: PointerEvent): void => { if (drag?.id === event.pointerId) drag = null; }; box.addEventListener("pointerup", stop, { capture: true }); box.addEventListener("pointercancel", stop, { capture: true }); } /** 고르는 지도 — 움직일 표지 끌기 = 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 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로 let type: MapType = "ROADMAP"; let mapTypes: KakaoMaps["MapTypeId"] | null = null; const set = ( pins: MapPin[], lines: Array>, colors?: string[], focus?: string, ): 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 }); mapTypes = maps.MapTypeId; map.setMapTypeId(mapTypes[type]); followMouse(box, map, maps); 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, index) => new maps.Polyline({ path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)), strokeWeight: 6, strokeColor: colors?.[index] ?? 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: label(p), yAnchor: 2.6, }), ]; }), ]; drawn.forEach((o) => o.setMap(map)); if (fitted) { // 보기 밖 표지만 그쪽으로 옮김(줌 그대로) · focus 를 주면 그 표지만 봄 const view = map.getBounds(); const out = (focus === undefined ? moving : moving.filter((p) => p.key === focus)).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, setType: (next) => { type = next; if (map && mapTypes) map.setMapTypeId(mapTypes[next]); }, }; }