- 기준지정 시점 · 종점 목록 → 「지점 목록」 하나 · [+ 지점] · 지점마다 차례 색 여섯 돌림(줄 왼 띠 · 지도 이름표 바탕)
- 저장 꼴 plan_base_json {epsg, points} · 옛 starts + ends · start · end 는 읽을 때 이어 붙임(옮김 없음) · 상한 40
- 노선분석 지점 = 핀 + 「지점 N」 · 기준지정과 같은 색 · 겹 목록 맨 끝(늘 맨 위) · 지점 누름 = 새 본선(No.0 = 그 지점) · 선 놓인 지점 = 갈래
- 공용 카카오 지도 = 휠 설정 방향 한 단(마우스 자리 고정) · 가운데 끌기 옮김 — 기준지정 · 지형 범위 창 · B08 이정표 같이
- 시험: 529-3 · 530-2 새 꼴 · 585-1 새 지킴 시험 · 낡은 B08 가짜 카카오(바탕 종류 · 사건 받기 없음) 맞춤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
12 KiB
TypeScript
318 lines
12 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) 등록 안내 한 줄
|
|
* (560-3) 바탕 종류 = `setType("ROADMAP" | "HYBRID")`(위성 + 지명) · 단추는 부르는 쪽 부품
|
|
* (585-1) 마우스 = ☰ [설정] 따름 — 휠 = 설정 확대 방향(카카오 제 휠은 잡는 단계에서 막음 · 한 단씩) ·
|
|
* 가운데 끌기 = 옮김 · 왼쪽 끌기 · 손가락 = 카카오 제 것 · 표지 색 = `MapPin.color`(이름표 바탕)
|
|
* ========================================================================== */
|
|
|
|
import "./ui_template_kakao_map.css";
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { el } from "./ui_template_elements";
|
|
import { 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<MapType, number>;
|
|
}
|
|
|
|
/** 바탕 지도 종류 — 일반 · 위성 + 지명 */
|
|
export type MapType = "ROADMAP" | "HYBRID";
|
|
|
|
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;
|
|
/** 585-1 이름표 바탕 색(없으면 흰 바탕) */
|
|
color?: string;
|
|
}
|
|
|
|
export interface EditMap {
|
|
root: HTMLElement;
|
|
/** 표지 · 길 선 다시 놓고 맞춤 · colors = 선마다 색 · focus = 고친 표지 key(529-3 · 없으면 지금 규칙) */
|
|
set: (
|
|
pins: MapPin[],
|
|
lines: Array<Array<[number, number]>>,
|
|
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 } | null = null;
|
|
box.addEventListener(
|
|
"pointerdown",
|
|
(event) => {
|
|
if (event.button !== 1) return;
|
|
event.preventDefault(); // 브라우저 자동 굴림 막음
|
|
event.stopPropagation();
|
|
drag = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
|
box.setPointerCapture(event.pointerId);
|
|
},
|
|
{ capture: true },
|
|
);
|
|
box.addEventListener(
|
|
"pointermove",
|
|
(event) => {
|
|
if (!drag || event.pointerId !== drag.id) return;
|
|
if (!(event.buttons & 4)) 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<Array<[number, number]>>,
|
|
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]);
|
|
},
|
|
};
|
|
}
|