Files
Aislo/B01_Dashboard/B01_Dashboard_UI_ProjectDetail_Map.ts
T
eomsangdonandClaude Opus 5.5 070791854c feat(B01): 프로젝트 상세 화면 · 카드 요약 연결 · 지킴 시험 (PLAN 548-2)
- 상세 화면(view=project) — 머리 · [⋯] · [작업 열기 — 지금 단계 ›] · 단계 진행(누름 = 그 단계) ·
  노선 지도(배경 타일 + 선 · 시점 · 종점) · 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경(있는 칸만)
- 목록 · 상세 숫자 · 썸네일 = 547-1 카드 요약(project_summaries) 한 벌
- 색 = 셸 변수(--b01-*) 이름에 맞춤
- 시험 test_548_project_overview — 칸반 나눔 · 산출물 · 내 것 · 단계 수 짝

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

96 lines
3.5 KiB
TypeScript

/* =============================================================================
* B01_Dashboard_UI_ProjectDetail_Map.ts
* 상세 지도 미리보기(PLAN 548-2) — 주소 지도(`UI_MapPreview`)와 같은 배경 타일 길(서버가 VWorld 를 받아 넘김)
* 위에 노선 선 · 시점 · 종점. 라이브러리 없이 SVG 한 장(타일 = image · 선 = path) — 칸 너비에 맞춰 줄어듦.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
const W = 640;
const H = 260;
const TILE = 256;
const PAD = 28;
const MAX_ZOOM = 17;
const MIN_ZOOM = 6;
const NS = "http://www.w3.org/2000/svg";
/** 위경도 → 그 배율의 전역 픽셀(웹 메르카토르) */
function toPixel(lat: number, lon: number, zoom: number): [number, number] {
const size = TILE * 2 ** zoom;
const rad = (lat * Math.PI) / 180;
return [
((lon + 180) / 360) * size,
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * size,
];
}
/** 선이 판(여백 뺌)에 다 드는 가장 큰 배율 */
export function fitZoom(points: [number, number][]): number {
for (let zoom = MAX_ZOOM; zoom > MIN_ZOOM; zoom -= 1) {
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
const xs = px.map((p) => p[0]);
const ys = px.map((p) => p[1]);
if (
Math.max(...xs) - Math.min(...xs) <= W - 2 * PAD &&
Math.max(...ys) - Math.min(...ys) <= H - 2 * PAD
) {
return zoom;
}
}
return MIN_ZOOM;
}
function node<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const element = document.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, String(value));
return element;
}
/** 노선 지도 — points = [lat, lon] 두 점 넘게 */
export function routeMap(points: [number, number][]): SVGSVGElement {
const zoom = fitZoom(points);
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
const xs = px.map((p) => p[0]);
const ys = px.map((p) => p[1]);
// 판 왼쪽 위 = 선 가운데 - 판 반
const left = (Math.min(...xs) + Math.max(...xs)) / 2 - W / 2;
const top = (Math.min(...ys) + Math.max(...ys)) / 2 - H / 2;
const svg = node("svg", { viewBox: `0 0 ${W} ${H}`, class: "b01-pd-map" });
const limit = 2 ** zoom;
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + W) / TILE); tx += 1) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + H) / TILE); ty += 1) {
if (tx < 0 || ty < 0 || tx >= limit || ty >= limit) continue;
svg.append(
node("image", {
href: `${API_BASE_URL}/dashboard/map/tile/${zoom}/${tx}/${ty}`,
x: tx * TILE - left,
y: ty * TILE - top,
width: TILE,
height: TILE,
}),
);
}
}
const local = px.map(([x, y]) => [x - left, y - top] as const);
svg.append(
node("path", {
class: "b01-pd-map__line",
d: local.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" "),
}),
);
const ends: [readonly [number, number], string][] = [
[local[0], "시점"],
[local[local.length - 1], "종점"],
];
for (const [[x, y], label] of ends) {
svg.append(node("circle", { class: "b01-pd-map__end", cx: x, cy: y, r: 5 }));
const text = node("text", { class: "b01-pd-map__label", x: x + 8, y: y - 6 });
text.textContent = label;
svg.append(text);
}
return svg;
}