Files
Aislo/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts
eomsangdonandClaude Opus 5 62dc2d744c feat(b01): 회사 주소를 검색해서 고르는 칸으로 교체
손으로 정확히 쳐야만 되던 주소칸을 후보 검색·선택 방식으로 바꿈.
VWorld 주소검색을 서버가 중계하고, 고른 즉시 좌표를 알므로 지도를 다시
찾지 않고 그림. 못 찾는 주소는 「직접 입력」으로 옛 방식을 씀.

- 회사 목록 SQL 이 business_address·business_owner 를 안 가져와 수정 모달이
  비어 열리고 저장 시 빈 값으로 덮어쓰던 것 수정
- 지도 기본 배율 15 → 17, +·- 단계 조절 추가
- 라벨·입력·버튼을 한 줄에 눕혀 줄 어긋남 해소
- 모달 안 휠이 뒤 대시보드로 새던 것 차단
- 회사 패널에 주소 한 줄 표시

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJC56e4osweKJ4vafm9ReM
2026-09-13 11:32:13 +09:00

128 lines
4.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { API_BASE_URL } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
/**
* 주소 지도 미리보기 (2026-09-06 사용자 지시).
*
* 지도 라이브러리를 얹지 않는다 — 배경지도 타일 3×3 장을 붙이고 가운데에 표식만 찍는다.
* 등록·수정 화면에서 "이 주소가 여기 맞나" 를 눈으로 보는 것이 목적이다.
* 건물을 알아볼 수 있어야 하므로 기본 배율을 17 로 두고 +·- 로 두 단계씩 움직인다
* (2026-09-13 사용자 지시 — 15 는 너무 멀었다).
*/
const DEFAULT_ZOOM = 17;
const MIN_ZOOM = 13;
const MAX_ZOOM = 18;
const TILE = 256;
const GRID = 3;
/** 타일 판은 CSS 에서 절반으로 줄여 붙인다 — 표식 자리도 같은 비율로 잡는다. */
const SCALE = 0.5;
function tileIndex(
lat: number,
lon: number,
zoom: number,
): { x: number; y: number; dx: number; dy: number } {
const n = 2 ** zoom;
const rad = (lat * Math.PI) / 180;
const fx = ((lon + 180) / 360) * n;
const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
return { x: Math.floor(fx), y: Math.floor(fy), dx: fx - Math.floor(fx), dy: fy - Math.floor(fy) };
}
export function buildAddressMap(): {
root: HTMLElement;
show: (address: string) => Promise<void>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void;
} {
const root = document.createElement("div");
root.className = "b01-dashboard__map";
const note = document.createElement("p");
note.className = "b01-dashboard__modal-text";
note.textContent = "주소를 넣고 「지도 확인」을 누르십시오.";
const zoomOut = createButton({
label: "",
variant: "ghost",
onClick: () => step(-1),
});
const zoomIn = createButton({
label: "",
variant: "ghost",
onClick: () => step(1),
});
const controls = document.createElement("div");
controls.className = "b01-dashboard__map-zoom";
controls.append(zoomOut, zoomIn);
controls.hidden = true;
const stage = document.createElement("div");
root.append(note, controls, stage);
let zoom = DEFAULT_ZOOM;
let last: { lat: number; lon: number } | null = null;
function step(delta: number): void {
if (!last) return;
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom + delta));
if (next === zoom) return;
zoom = next;
draw(last.lat, last.lon);
}
function draw(lat: number, lon: number): void {
last = { lat, lon };
note.hidden = true;
controls.hidden = false;
zoomOut.disabled = zoom <= MIN_ZOOM;
zoomIn.disabled = zoom >= MAX_ZOOM;
stage.innerHTML = "";
const center = tileIndex(lat, lon, zoom);
const grid = document.createElement("div");
grid.className = "b01-dashboard__map-grid";
const half = Math.floor(GRID / 2);
for (let row = -half; row <= half; row += 1) {
for (let col = -half; col <= half; col += 1) {
const img = document.createElement("img");
img.src = `${API_BASE_URL}/dashboard/map/tile/${zoom}/${center.x + col}/${center.y + row}`;
img.width = TILE;
img.height = TILE;
img.alt = "";
grid.append(img);
}
}
const marker = document.createElement("span");
marker.className = "b01-dashboard__map-marker";
marker.style.left = `${(half + center.dx) * TILE * SCALE}px`;
marker.style.top = `${(half + center.dy) * TILE * SCALE}px`;
const frame = document.createElement("div");
frame.className = "b01-dashboard__map-frame";
frame.append(grid, marker);
stage.append(frame);
}
function fail(message: string): void {
last = null;
stage.innerHTML = "";
controls.hidden = true;
note.hidden = false;
note.textContent = message;
}
const show = async (address: string): Promise<void> => {
if (!address.trim()) {
fail("주소를 먼저 입력하십시오.");
return;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return;
}
draw(point.lat, point.lon);
};
return { root, show, showPoint: draw };
}