feat(b08): 거리이정표 [+] 모달 · 주소 검색 재활용 · 방위 자동 · 시점 모달 · 팬 줌 · 경로 탭 = 길찾기 그림 (244-4)

- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리
- 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간)
- 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan
- 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰
- 서버 site-address(좌표 → 주소 · VWorld getAddress)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 21:33:53 +09:00
co-authored by Claude Opus 5.5
parent 0dce57b0aa
commit 5f2e6e18b6
14 changed files with 774 additions and 357 deletions
+11 -3
View File
@@ -3,8 +3,9 @@
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
*
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들
* 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 둘 다 경위도가 있으면 방위각 ·
* 없으면 사용자가 고른 8방 · 고르지 않았으면 빈 방위를 차례로 · 같은 방위 여럿 = 옆으로 나란히
* 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
* 같은 방위 여럿 = 옆으로 나란히
* 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서)
* 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
* 화면 = 북 위(SVG y 아래로 = 남)
@@ -167,7 +168,7 @@ export function layoutMilestone(
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
const items = data.routes.map((r) => {
const anchor = useBase && r.via_base ? "base" : "site";
return { r, anchor, deg: placeDeg(r, anchor === "base" ? baseLL : startLL) };
return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) };
});
for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg));
let auto = 0;
@@ -219,3 +220,10 @@ export function layoutMilestone(
view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY],
};
}
/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */
export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string {
const part = (p: { name: string; lon: number; lat: number }): string =>
`${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`;
return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`;
}
@@ -5,6 +5,8 @@
POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체)
GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기
DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함)
정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 ·
폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님).
@@ -26,6 +28,8 @@ from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, ValidationError, model_validator
from B01_Dashboard.B01_Dashboard_Map import reverse_geocode
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
@@ -219,6 +223,27 @@ async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
return await asyncio.to_thread(save_data, root, body)
@router.get(PATH + "/site-address")
async def milestone_site_address(project_id: UUID) -> dict[str, Any]:
"""시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소."""
root = await _project_root(project_id)
data = await asyncio.to_thread(read_data, root)
site = await asyncio.to_thread(site_info, root, data["site"]["mid_station_m"])
project = await get_project_by_id(str(project_id)) or {}
async def where(lonlat: list[float] | None) -> str | None:
return await reverse_geocode(*lonlat) if lonlat else None
start, mid = await asyncio.gather(where(site["start_lonlat"]), where(site["mid_lonlat"]))
return {
"region": project.get("region") or "",
"start_lonlat": site["start_lonlat"],
"mid_lonlat": site["mid_lonlat"],
"start_address": start,
"mid_address": mid,
}
@router.post(PATH + "/image")
async def milestone_image_upload(
project_id: UUID, route: str = Query(...), file: UploadFile = File(...)
@@ -114,3 +114,14 @@
stroke-width: 4px;
stroke-linejoin: round;
}
/* 모달 구간 · 표 줄 — 칸 · [×] 한 줄(244-4) */
.b08-milestone__line-edit {
display: flex;
align-items: flex-end;
gap: 8px;
}
.b08-milestone__diagram svg.is-panning {
cursor: grabbing;
}
+9 -41
View File
@@ -1,18 +1,18 @@
/* =============================================================================
* B08_Quantity_UI_Milestone.ts
* B08 메인 탭 「거리이정표」(PLAN 244-1) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름) +
* [+ 추가] · [삭제].
* B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름).
* 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음).
*
* [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
* 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(244-2 · 상자 누름 = 경로 탭 · [+] = 항목 추가)
* 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄)
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
* 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State`
* ========================================================================== */
import "./B08_Quantity_UI_Milestone.css";
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
import {
@@ -64,8 +64,8 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
}
/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
function tableView(projectId: string, open: (id: string) => void): MilestoneView {
const diagram = createDiagram(projectId, open, (init) => addRoute(projectId, init));
function tableView(projectId: string): MilestoneView {
const diagram = createDiagram(projectId, (init) => addRoute(projectId, init));
let keys: Array<{ route: string; index: number }> = [];
const table = createDataTable({
columns: COLUMNS,
@@ -100,7 +100,7 @@ function tableView(projectId: string, open: (id: string) => void): MilestoneView
table.root.dataset.b08Milestone = "table";
const none = el("p", {
className: "ui-general-block__empty",
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+] 또는 하위 탭 줄 [+ 추가]로 업체를 넣으세요.",
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.",
});
const refresh = (): void => {
const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null);
@@ -139,48 +139,16 @@ export function renderMilestone(host: HTMLElement, projectId: string): void {
button.dataset.b08MilestoneTab = id;
return button;
};
const add = createButton({
label: "+ 추가",
size: "sm",
variant: "ghost",
onClick: () => open(addRoute(projectId)),
});
add.dataset.b08MilestoneAdd = "";
const remove = createButton({
label: "삭제",
size: "sm",
variant: "danger",
onClick: () => void removeCurrent(),
});
remove.disabled = current === "table";
remove.dataset.b08MilestoneDelete = "";
tabs.replaceChildren(
tab("table", "거리이정표"),
...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)),
add,
remove,
);
};
const removeCurrent = async (): Promise<void> => {
const data = liveMilestone(projectId);
const route = data.routes.find((r) => r.id === current);
if (!route) return;
if (
!(await showConfirmDialog(
`「${route.name || "업체"}」 경로를 지울까요? — [저장] 때 반영`,
"삭제",
))
)
return;
data.routes = data.routes.filter((r) => r.id !== route.id);
putMilestone(projectId, data); // 알림이 없는 경로 탭을 보고 [거리이정표] 로 옮김
};
function open(id: string): void {
current = id;
drawTabs();
body = id === "table" ? tableView(projectId, open) : routeView(projectId, id);
body = id === "table" ? tableView(projectId) : routeView(projectId, id);
view.replaceChildren(body.root);
}
@@ -3,20 +3,22 @@
* [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
*
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글
* 업체 상자 누름 = 그 경로 하위 탭으로 · 공사지 · 기준점 상자 [+] = 항목 추가(작은 창 → 경로 하위 탭 생김 ·
* 같은 업체 이름이면 그 경로에 표 줄만 더함) · 조작 = 캐시(`…_UI_Milestone_State`)
* 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 ·
* 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`)
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 더블클릭 = 원배율)
* ========================================================================== */
import { openModal } from "@ui/ui_template_modal";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { midKm, type Dest, type Dir, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { el } from "@ui/ui_template_elements";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
import {
dirOptions,
liveMilestone,
putMilestone,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
openAddItem,
openBaseInfo,
openRouteInfo,
openSiteInfo,
} from "./B08_Quantity_UI_Milestone_Modal";
import { liveMilestone, siteInfo } from "./B08_Quantity_UI_Milestone_State";
const NS = "http://www.w3.org/2000/svg";
@@ -31,69 +33,21 @@ function svg<K extends keyof SVGElementTagNameMap>(
return node;
}
/** [+] 작은 창 — 항목 · 업체 이름 · 목적지 · 방위 → 새 경로(같은 이름 = 그 경로에 줄) */
export function openAddItem(
projectId: string,
viaBase: boolean,
addRoute: (init: Partial<MilestoneRoute>) => string,
): void {
openModal({
title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로",
closeLabel: "닫기",
mount: (body, close, actions) => {
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
const company = createInputField({ label: "업체 이름", compact: true });
const dest = createSelectField({
label: "목적지",
compact: true,
options: [
{ value: "start", text: "시점" },
{ value: "mid", text: "중점" },
],
});
const dir = createSelectField({
label: viaBase ? "방위(기준점에서 본)" : "방위(공사지에서 본)",
compact: true,
options: dirOptions,
});
item.input.dataset.b08MilestoneAdd = "item";
company.input.dataset.b08MilestoneAdd = "company";
const ok = createButton({
label: "추가",
variant: "filled",
onClick: () => {
const name = company.input.value.trim();
if (!name) return company.setError("업체 이름을 넣으세요.");
const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" };
const data = liveMilestone(projectId);
const same = data.routes.find((r) => r.name.trim() === name);
if (same) {
same.rows.push(row);
putMilestone(projectId, data);
} else
addRoute({
name,
via_base: viaBase,
dir: (dir.select.value || null) as Dir | null,
rows: [row],
});
close();
},
});
actions.append(ok);
body.append(item.root, company.root, dest.root, dir.root);
},
});
}
/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */
export function createDiagram(
projectId: string,
openTab: (routeId: string) => void,
addRoute: (init: Partial<MilestoneRoute>) => string,
): { root: HTMLElement; refresh: () => void } {
const host = el("div", { className: "b08-milestone__diagram" });
host.dataset.b08Milestone = "diagram";
let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로
const INFO: Record<DiagramBox["kind"], (id: string) => void> = {
route: (id) => openRouteInfo(projectId, id),
site: () => openSiteInfo(projectId),
start: () => openSiteInfo(projectId),
mid: () => openSiteInfo(projectId),
base: () => openBaseInfo(projectId),
};
const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => {
const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 });
@@ -119,8 +73,9 @@ export function createDiagram(
const site = siteInfo();
const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null));
const [vx, vy, vw, vh] = layout.view;
// viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김
const root = svg("svg", {
viewBox: `${vx} ${vy} ${vw} ${vh}`,
viewBox: `0 0 ${vw} ${vh}`,
width: vw,
height: vh,
role: "img",
@@ -128,8 +83,12 @@ export function createDiagram(
});
root.style.maxWidth = "100%";
root.style.height = "auto";
const layer = svg("g", {});
const shift = svg("g", { transform: `translate(${-vx} ${-vy})` });
layer.append(shift);
root.append(layer);
for (const ln of layout.lines) {
root.append(
shift.append(
svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }),
);
}
@@ -149,15 +108,18 @@ export function createDiagram(
box.label,
),
);
if (box.kind === "route") {
g.setAttribute("role", "button");
g.setAttribute("tabindex", "0");
g.addEventListener("click", () => openTab(box.id));
g.addEventListener("keydown", (e) => e.key === "Enter" && openTab(box.id));
}
// 상자 누름 = 모달(탭 이동 아님)
const info = (): void => INFO[box.kind](box.id);
g.setAttribute("role", "button");
g.setAttribute("tabindex", "0");
g.addEventListener("click", (e) => {
e.stopPropagation(); // 공사지 안 시점 · 중점 상자 = 한 번만
info();
});
g.addEventListener("keydown", (e) => e.key === "Enter" && info());
if (box.kind === "site") g.append(plus(box, false));
if (box.kind === "base") g.append(plus(box, true));
root.append(g);
shift.append(g);
}
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
for (const ln of layout.lines) {
@@ -165,9 +127,19 @@ export function createDiagram(
const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2;
const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" });
ln.labels.forEach((label, i) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label)));
root.append(text);
shift.append(text);
}
host.replaceChildren(root);
attachSvgZoomPan({
svg: root,
layer,
widthPx: vw,
heightPx: vh,
pad: { left: 0, right: 0, top: 0, bottom: 0 },
initial: zoomState,
onChange: (state) => (zoomState = state),
wheelZoom: true,
});
};
refresh();
return { root: host, refresh };
@@ -0,0 +1,336 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_Modal.ts
* 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침.
*
* [+] 항목 추가 = 항목 · 업체 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] =
* 경로(하위 탭) 생김 · 같은 업체 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리
* 업체 상자 = 그 항목 정보(업체 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제]
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
* 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점)
* 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버
* ========================================================================== */
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
import { createCheckbox } from "@ui/ui_template_checkbox";
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
buildAddressField,
type AddressFieldHandle,
} from "../B01_Dashboard/B01_Dashboard_UI_AddressField";
import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview";
import {
ROADS,
type Dest,
type Leg,
type MilestoneData,
type MilestoneRoute,
type MilestoneRow,
type Road,
} from "./B08_Quantity_Milestone_Calc";
import { request } from "./B08_Quantity_UI_Hydro";
import { liveMilestone, putMilestone, roadOptions } from "./B08_Quantity_UI_Milestone_State";
const DEST_OPTIONS = [
{ value: "start", text: "시점" },
{ value: "mid", text: "중점" },
];
const line = (...children: HTMLElement[]): HTMLElement =>
el("div", { className: "b08-milestone__line-edit", children });
/** 구간 줄 — 도로 구분 ▾ · km · [×] · [+ 구간] */
function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () => Leg[] } {
const list = el("div");
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement }> = [];
const add = (leg: Leg): void => {
const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true });
const km = createInputField({ label: "km", type: "number", min: 0, compact: true });
road.select.value = leg.road;
km.input.value = leg.km == null ? "" : String(leg.km);
const ref = { road: road.select, km: km.input };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = line(road.root, km.root, drop);
row.dataset.b08MilestoneLeg = "";
list.append(row);
};
initial.forEach(add);
const more = createButton({
label: "+ 구간",
size: "sm",
variant: "ghost",
onClick: () => add({ road: fresh, km: null }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km }) => {
const n = Number(km.value);
return {
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : fresh,
km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null,
};
}),
};
}
/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */
function rowsEditor(initial: MilestoneRow[]): {
root: HTMLElement;
value: () => MilestoneRow[];
} {
const list = el("div");
const rows: Array<{ item: HTMLInputElement; dest: HTMLSelectElement; note: string }> = [];
const add = (r: MilestoneRow): void => {
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
const dest = createSelectField({ label: "목적지", options: DEST_OPTIONS, compact: true });
item.input.value = r.item;
dest.select.value = r.dest;
const ref = { item: item.input, dest: dest.select, note: r.note };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = line(item.root, dest.root, drop);
list.append(row);
};
initial.forEach(add);
const more = createButton({
label: "+ 줄",
size: "sm",
variant: "ghost",
onClick: () => add({ item: "", dest: "start", note: "" }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map((r) => ({ item: r.item.value.trim(), dest: r.dest.value as Dest, note: r.note })),
};
}
/** 주소 칸 값 → 주소 글 · 좌표(못 찾았고 글이 그대로면 옛 좌표 · 바뀌었으면 비움) */
function placeOf(
field: AddressFieldHandle,
old: { address: string; lon?: number | null; lat?: number | null },
): { address: string; lon: number | null; lat: number | null } {
const address = field.value() ?? "";
const point = field.point();
if (point) return { address, lon: point.lon, lat: point.lat };
return address === old.address
? { address, lon: old.lon ?? null, lat: old.lat ?? null }
: { address, lon: null, lat: null };
}
const head = (text: string): HTMLElement => el("h4", { text });
/** [+] — 항목 · 업체 이름 · 목적지 + 주소 검색 · 지도 → [등록] */
export function openAddItem(
projectId: string,
viaBase: boolean,
addRoute: (init: Partial<MilestoneRoute>) => string,
): void {
openModal({
title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로",
closeLabel: "닫기",
mount: (body, close, actions) => {
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
const company = createInputField({ label: "업체 이름", compact: true });
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
const address = buildAddressField(null);
item.input.dataset.b08MilestoneAdd = "item";
company.input.dataset.b08MilestoneAdd = "company";
const ok = createButton({
label: "등록",
variant: "filled",
onClick: () => {
const name = company.input.value.trim();
if (!name) return company.setError("업체 이름을 넣으세요.");
const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" };
const place = placeOf(address, { address: "" });
const data = liveMilestone(projectId);
const same = data.routes.find((r) => r.name.trim() === name);
if (same) {
same.rows.push(row);
if (place.lon != null) Object.assign(same, place);
putMilestone(projectId, data);
} else addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place });
close();
},
});
actions.append(ok);
body.append(
item.root,
company.root,
dest.root,
head("업체 위치 — 주소 검색(방위는 이 자리로 자동)"),
address.root,
);
},
});
}
/** 업체 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */
export function openRouteInfo(projectId: string, routeId: string): void {
const data = liveMilestone(projectId);
const route = data.routes.find((r) => r.id === routeId);
if (!route) return;
openModal({
title: `항목 정보 — ${route.name || "업체"}`,
closeLabel: "닫기",
mount: (body, close, actions) => {
const name = createInputField({ label: "업체 이름", compact: true });
name.input.value = route.name;
name.input.dataset.b08MilestoneInfo = "name";
const via = createCheckbox("기준점 거침");
via.input.checked = route.via_base;
via.root.hidden = !data.base.enabled;
const rows = rowsEditor(route.rows);
const address = buildAddressField(route.address || null);
const legs = legsEditor(route.legs, "2차포장");
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh = liveMilestone(projectId);
const r = fresh.routes.find((x) => x.id === routeId);
if (!r) return close();
Object.assign(r, placeOf(address, r), {
name: name.input.value.trim() || r.name,
via_base: via.input.checked,
rows: rows.value(),
legs: legs.value(),
});
putMilestone(projectId, fresh);
close();
},
});
const remove = createButton({
label: "삭제",
variant: "danger",
onClick: async () => {
if (
!(await showConfirmDialog(
`「${route.name || "업체"}」 항목을 지울까요? — [저장] 때 반영`,
"삭제",
))
)
return;
const fresh = liveMilestone(projectId);
fresh.routes = fresh.routes.filter((r) => r.id !== routeId);
putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐
close();
},
});
save.dataset.b08MilestoneInfo = "save";
remove.dataset.b08MilestoneInfo = "delete";
actions.append(remove, save);
body.append(
name.root,
via.root,
head("표 줄 — 항목 · 목적지"),
rows.root,
head(
`구간 — ${route.name || "업체"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`,
),
legs.root,
head("주소 · 지도"),
address.root,
);
},
});
}
/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */
export function openBaseInfo(projectId: string): void {
const base = liveMilestone(projectId).base;
openModal({
title: `기준점 — ${base.name || "기준점"}`,
closeLabel: "닫기",
mount: (body, close, actions) => {
const name = createInputField({ label: "기준점 이름", compact: true });
name.input.value = base.name;
const address = buildAddressField(base.address || null);
const legs = legsEditor(base.legs, "1차포장");
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh: MilestoneData = liveMilestone(projectId);
Object.assign(fresh.base, placeOf(address, fresh.base), {
name: name.input.value.trim(),
legs: legs.value(),
});
putMilestone(projectId, fresh);
close();
},
});
actions.append(save);
body.append(
name.root,
head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"),
legs.root,
head("주소 · 지도"),
address.root,
);
},
});
}
interface SiteAddress {
region: string;
start_lonlat: [number, number] | null;
mid_lonlat: [number, number] | null;
start_address: string | null;
mid_address: string | null;
}
/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */
export function openSiteInfo(projectId: string): void {
openModal({
title: "공사지 — 시점",
closeLabel: "닫기",
mount: (body) => {
const text = (label: string, value: string): HTMLElement =>
el("p", { className: "b08-options__hint", text: `${label} — ${value}` });
const map = buildAddressMap();
const info = el("div", { children: [text("주소", "불러오는 중…")] });
body.append(info, map.root);
request<SiteAddress>(`/projects/${projectId}/quantity/milestone/site-address`).then(
(s) => {
const ll = (p: [number, number] | null): string =>
p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : "";
info.replaceChildren(
text("프로젝트 등록 주소(사업지역)", s.region || "없음"),
text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)),
text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)),
);
if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]);
else if (s.region) void map.show(s.region);
},
(error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"),
);
},
});
}
+95 -221
View File
@@ -1,39 +1,23 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_Route.ts
* 거리이정표 경로 하위 탭(PLAN 244-1) — 컨테이너 하나 = 업체 한 곳 · 그림 하나.
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달).
*
* 위 = 업체 이름(= 하위 탭 이름) · 주소 · 방위(기준점 · 없으면 공사지 기준 8방) · 기준점 거침
* 구간 표 = 업체 → 기준점(또는 시점) · 기준점 → 시점(공통 · 모든 경로가 같이 씀) · 시점 → 중점(측점 km) ·
* 구간마다 km(더블클릭 손 입력) · 도로 구분 ▾(2차포장 · 1차포장 · 비포장)
* 표 줄 = 항목 · 목적지 ▾ · 비고 — [거리이정표] 표에 이 업체 줄로 나감
* 그림 = 올리는 즉시 서버(교체 · 지우기) · 나머지 조작 = 캐시(`…_UI_Milestone_State`) · [저장] 때 서버
* 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 업체 좌표 ·
* 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로
* 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에
* 그림 = 올리는 즉시 서버(교체 · 지우기)
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import {
createMapContextMenu,
type MapContextMenu,
type MapContextMenuItem,
} from "@ui/ui_template_context_menu";
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
ROADS,
midKm,
type Dest,
type Dir,
type MilestoneData,
type MilestoneRoute,
} from "./B08_Quantity_Milestone_Calc";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout";
import {
deleteImage,
dirOptions,
imageBlobUrl,
liveMilestone,
putMilestone,
@@ -61,12 +45,6 @@ export function pickAtCell(items: MapContextMenuItem[]): void {
menu.open(box.left, box.bottom, items);
}
/** 고른 줄 강조(공용 표 is-focus · 다시 그리지 않음 — 더블클릭 그대로) */
function markRow(root: HTMLElement, index: number | null): void {
root.querySelectorAll("tr.is-focus").forEach((tr) => tr.classList.remove("is-focus"));
if (index !== null) root.querySelector(`tr[data-i="${index}"]`)?.classList.add("is-focus");
}
export const DEST_TEXT: Record<Dest, string> = { start: "시점", mid: "중점" };
/** 그 경로를 고쳐 캐시에 */
@@ -82,180 +60,59 @@ export function editRoute(
putMilestone(projectId, data);
}
const kmOf = (text: string): number | null | undefined => {
if (!text.trim()) return null;
const value = Number(text.replace(/,/g, ""));
return Number.isFinite(value) && value >= 0 ? value : undefined; // undefined = 그대로
};
type LegRef = { group: "own" | "base" | "mid"; i: number };
const LEG_COLUMNS: DataColumn[] = [
{ key: "span", label: "구간", width: 200 },
{ key: "road", label: "도로 구분", width: 96 },
{ key: "km", label: "km", kind: "number", decimals: 3, trim: true, width: 80 },
];
const ROW_COLUMNS: DataColumn[] = [
{ key: "item", label: "항목", width: 140 },
{ key: "dest", label: "목적지", width: 80 },
{ key: "note", label: "비고", width: 180 },
];
export interface MilestoneView {
root: HTMLElement;
refresh: () => void;
}
/** 새 창 길찾기 지점 — 업체 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */
export function routePoints(
d: MilestoneData,
r: MilestoneRoute,
): Array<{ name: string; lon: number; lat: number }> | null {
const site = siteInfo();
const dest = r.rows[0]?.dest ?? "start";
const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat;
if (r.lon == null || r.lat == null || !to) return null;
const points = [{ name: r.name || "업체", lon: r.lon, lat: r.lat }];
if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null)
points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat });
points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] });
return points;
}
/** 화면 캡처 — 사용자가 고른 창 한 장(png) · 스트림은 바로 끔 */
async function captureWindow(): Promise<File> {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
try {
const video = el("video");
video.muted = true;
video.srcObject = stream;
await video.play();
await new Promise((done) => requestAnimationFrame(done));
const canvas = el("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
const blob = await new Promise<Blob>((done, fail) =>
canvas.toBlob((b) => (b ? done(b) : fail(new Error("캡처 그림을 못 만듦"))), "image/png"),
);
return new File([blob], "capture.png", { type: "image/png" });
} finally {
stream.getTracks().forEach((t) => t.stop());
}
}
export function routeView(projectId: string, routeId: string): MilestoneView {
const edit = (fn: (data: MilestoneData, route: MilestoneRoute) => void): void =>
editRoute(projectId, routeId, fn);
// 위 — 업체 이름 · 주소 · 방위 · 기준점 거침
const name = createInputField({ label: "업체 이름", compact: true });
const address = createInputField({ label: "주소", compact: true });
const dir = createSelectField({ label: "방위", options: dirOptions, compact: true });
const via = createCheckbox("기준점 거침");
name.input.dataset.b08Milestone = "route_name";
name.input.addEventListener("input", () => edit((_, r) => (r.name = name.input.value)));
address.input.addEventListener("input", () => edit((_, r) => (r.address = address.input.value)));
dir.select.addEventListener("change", () =>
edit((_, r) => (r.dir = (dir.select.value || null) as Dir | null)),
);
via.input.addEventListener("change", () => edit((_, r) => (r.via_base = via.input.checked)));
const head = el("div", {
className: "b08-milestone__head",
children: [name.root, address.root, dir.root, via.root],
});
// 구간 표
let legRefs: LegRef[] = [];
let pickedLeg: number | null = null;
const legRows = (d: MilestoneData, r: MilestoneRoute): DataRow[] => {
const useBase = d.base.enabled && r.via_base;
const baseName = d.base.name || "기준점";
const who = r.name || "업체";
const rows: Array<[LegRef, string, string, number | null]> = [
...r.legs.map((leg, i): [LegRef, string, string, number | null] => [
{ group: "own", i },
`${who} → ${useBase ? baseName : "시점"}`,
leg.road,
leg.km,
]),
...(useBase ? d.base.legs : []).map((leg, i): [LegRef, string, string, number | null] => [
{ group: "base", i },
`${baseName} → 시점 (공통)`,
leg.road,
leg.km,
]),
[
{ group: "mid", i: 0 },
"시점 → 중점 (측점)",
d.site.mid_road,
midKm(d, siteInfo()?.mid_default_m ?? null),
],
];
legRefs = rows.map(([ref]) => ref);
return rows.map(([, span, road, km]) => ({ span, road: `${road} ▾`, km }));
};
const setLeg = (ref: LegRef, fn: (leg: { road: string; km: number | null }) => void): void =>
edit((d, r) => {
if (ref.group === "mid") {
const leg = { road: d.site.mid_road as string, km: null };
fn(leg);
d.site.mid_road = leg.road as MilestoneData["site"]["mid_road"];
} else fn((ref.group === "own" ? r.legs : d.base.legs)[ref.i]);
});
const legs = createDataTable({
columns: LEG_COLUMNS,
rows: [],
caption: "구간",
empty: "",
editOnDouble: true,
editable: (i, key) => key === "km" && legRefs[i]?.group !== "mid",
onEdit: (i, _key, text) => {
const km = kmOf(text);
if (km !== undefined) setLeg(legRefs[i], (leg) => (leg.km = km));
else refresh();
},
onCellClick: (i, key) => {
if (key !== "road") return;
pickAtCell(ROADS.map((road) => [road, () => setLeg(legRefs[i], (leg) => (leg.road = road))]));
},
onRowClick: (i) => markRow(legs.root, (pickedLeg = i)),
});
trackCells(legs.root);
legs.root.dataset.b08Milestone = "legs";
const addOwn = createButton({
label: "+ 업체 구간",
size: "sm",
variant: "ghost",
onClick: () => edit((_, r) => r.legs.push({ road: "2차포장", km: null })),
});
const addBase = createButton({
label: "+ 공통 구간",
size: "sm",
variant: "ghost",
onClick: () => edit((d) => d.base.legs.push({ road: "1차포장", km: null })),
});
const dropLeg = createButton({
label: "고른 구간 지우기",
size: "sm",
variant: "ghost",
onClick: () => {
const ref = pickedLeg === null ? null : legRefs[pickedLeg];
if (!ref || ref.group === "mid") return showToast("지울 구간 줄을 먼저 고르세요.", "info");
pickedLeg = null;
edit((d, r) => (ref.group === "own" ? r.legs : d.base.legs).splice(ref.i, 1));
},
});
// 표 줄
let pickedRow: number | null = null;
const rows = createDataTable({
columns: ROW_COLUMNS,
rows: [],
caption: "표 줄",
empty: "",
editOnDouble: true,
editable: (_i, key) => key === "item" || key === "note",
onEdit: (i, key, text) =>
edit((_, r) => {
if (r.rows[i]) r.rows[i][key as "item" | "note"] = text;
}),
onCellClick: (i, key) => {
if (key !== "dest") return;
pickAtCell(
(["start", "mid"] as Dest[]).map((dest) => [
DEST_TEXT[dest],
() => edit((_, r) => r.rows[i] && (r.rows[i].dest = dest)),
]),
);
},
onRowClick: (i) => markRow(rows.root, (pickedRow = i)),
});
trackCells(rows.root);
rows.root.dataset.b08Milestone = "rows";
const addRow = createButton({
label: "+ 줄",
size: "sm",
variant: "ghost",
onClick: () => edit((_, r) => r.rows.push({ item: "", dest: "start", note: "" })),
});
const dropRow = createButton({
label: "고른 줄 지우기",
size: "sm",
variant: "ghost",
onClick: () => {
if (pickedRow === null) return showToast("지울 줄을 먼저 고르세요.", "info");
const at = pickedRow;
pickedRow = null;
edit((_, r) => r.rows.splice(at, 1));
},
const title = el("h4");
const hint = el("p", {
className: "b08-options__hint",
text: "[다음 지도에서 길찾기] → 새 창에서 길 확인 → [화면 캡처] → 그 창을 고름 = 이 탭 그림",
});
// 그림 — 경로 하나 = 그림 하나
const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } });
const noImage = el("p", { className: "ui-general-block__empty", text: "올린 그림 없음" });
const noImage = el("p", { className: "ui-general-block__empty", text: "길찾기 그림 없음" });
let shown: string | null | undefined;
const loadImage = (force = false): void => {
const name = routeImages()[routeId] ?? null;
@@ -271,21 +128,51 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
(error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"),
);
};
const put = (picked: File): Promise<void> =>
uploadImage(projectId, routeId, picked).then(
() => loadImage(true),
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
);
const file = el("input", { attrs: { type: "file", accept: "image/*" } });
file.hidden = true;
file.addEventListener("change", () => {
const picked = file.files?.[0];
file.value = "";
if (picked)
uploadImage(projectId, routeId, picked).then(
() => loadImage(true),
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
);
if (picked) void put(picked);
});
const kakao = createButton({
label: "다음 지도에서 길찾기",
size: "sm",
variant: "filled",
onClick: () => {
const d = liveMilestone(projectId);
const r = d.routes.find((x) => x.id === routeId);
const points = r ? routePoints(d, r) : null;
if (!points)
return showToast(
"업체 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.",
"info",
);
window.open(kakaoRouteUrl(points), "_blank", "noopener");
},
});
kakao.dataset.b08MilestoneKakao = "";
const capture = createButton({
label: "화면 캡처",
size: "sm",
variant: "ghost",
onClick: () =>
void captureWindow().then(put, (error: unknown) => {
if ((error as Error)?.name !== "NotAllowedError")
showToast(`화면을 캡처하지 못했습니다 — ${String(error)}`, "error");
}),
});
capture.dataset.b08MilestoneCapture = "";
const upload = createButton({
label: "그림 올리기",
size: "sm",
variant: "filled",
variant: "ghost",
onClick: () => file.click(),
});
const removeImage = createButton({
@@ -298,21 +185,16 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
),
});
const bar = (...items: HTMLElement[]): HTMLElement =>
el("div", { className: "b08-milestone__bar", children: items });
const root = el("section", {
className: "b08-milestone__route ui-card",
attrs: { "data-b08-milestone-route": routeId },
children: [
head,
el("h4", { text: "구간 (km 더블클릭 · 도로 구분 ▾)" }),
legs.root,
bar(addOwn, addBase, dropLeg),
el("h4", { text: "표 줄" }),
rows.root,
bar(addRow, dropRow),
el("h4", { text: "경로 그림" }),
bar(upload, removeImage, file),
title,
el("div", {
className: "b08-milestone__bar",
children: [kakao, capture, upload, removeImage, file],
}),
hint,
noImage,
img,
],
@@ -322,16 +204,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
const d = liveMilestone(projectId);
const r = d.routes.find((x) => x.id === routeId);
if (!r) return;
if (document.activeElement !== name.input) name.input.value = r.name;
if (document.activeElement !== address.input) address.input.value = r.address;
dir.select.value = r.dir ?? "";
via.input.checked = r.via_base;
via.root.hidden = !d.base.enabled;
addBase.hidden = !(d.base.enabled && r.via_base);
legs.setRows(legRows(d, r));
rows.setRows(r.rows.map((row) => ({ ...row, dest: `${DEST_TEXT[row.dest]} ▾` })));
markRow(legs.root, pickedLeg);
markRow(rows.root, pickedRow);
const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"];
title.textContent = `${r.name || "업체"} → ${dest}${r.address ? ` · ${r.address}` : ""}`;
loadImage();
};
refresh();