- 상세 화면(view=project) — 머리 · [⋯] · [작업 열기 — 지금 단계 ›] · 단계 진행(누름 = 그 단계) · 노선 지도(배경 타일 + 선 · 시점 · 종점) · 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경(있는 칸만) - 목록 · 상세 숫자 · 썸네일 = 547-1 카드 요약(project_summaries) 한 벌 - 색 = 셸 변수(--b01-*) 이름에 맞춤 - 시험 test_548_project_overview — 칸반 나눔 · 산출물 · 내 것 · 단계 수 짝 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96 lines
3.5 KiB
TypeScript
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;
|
|
}
|