Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-10 21:37:37 +09:00
14 changed files with 916 additions and 351 deletions
+27
View File
@@ -114,3 +114,30 @@ def _search_sync(query: str, size: int) -> list[dict[str, Any]]:
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size)
def _reverse_sync(lon: float, lat: float) -> str | None:
"""좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번)."""
query = urllib.parse.urlencode(
{
"service": "address",
"request": "getAddress",
"version": "2.0",
"crs": "epsg:4326",
"point": f"{lon},{lat}",
"type": "both",
"format": "json",
"key": VWORLD_API_KEY,
}
)
try:
body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8"))
except Exception:
return None
items = (body.get("response") or {}).get("result") or []
texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)}
return texts.get("road") or texts.get("parcel") or None
async def reverse_geocode(lon: float, lat: float) -> str | None:
return await asyncio.to_thread(_reverse_sync, lon, lat)
@@ -19,6 +19,8 @@ export interface AddressFieldHandle {
root: HTMLElement;
/** 저장할 한 줄 주소. 비면 null. */
value: () => string | null;
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
point: () => { lat: number; lon: number } | null;
}
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
@@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Address_Map_Click() {
await map.show(base.input.value.trim());
picked = await map.show(base.input.value.trim());
},
});
mapBtn.hidden = true;
@@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
let manual = false;
let picked: { lat: number; lon: number } | null = null;
function pick(item: AddressCandidate): void {
base.input.value = candidateLine(item);
@@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
zip.hidden = !item.zipcode;
list.hidden = true;
list.innerHTML = "";
picked = { lat: item.lat, lon: item.lon };
map.showPoint(item.lat, item.lon);
detail.input.focus();
}
@@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
});
// 이미 있는 주소는 열자마자 지도로 보여 준다.
if (initial && initial.trim()) void map.show(initial.trim());
if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p));
return {
root,
@@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
return merged || null;
},
point: () => picked,
};
}
+6 -4
View File
@@ -32,7 +32,8 @@ function tileIndex(
export function buildAddressMap(): {
root: HTMLElement;
show: (address: string) => Promise<void>;
/** 찾은 좌표를 돌려줌 — 못 찾으면 null. */
show: (address: string) => Promise<{ lat: number; lon: number } | null>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void;
} {
@@ -110,17 +111,18 @@ export function buildAddressMap(): {
note.textContent = message;
}
const show = async (address: string): Promise<void> => {
const show = async (address: string): Promise<{ lat: number; lon: number } | null> => {
if (!address.trim()) {
fail("주소를 먼저 입력하십시오.");
return;
return null;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return;
return null;
}
draw(point.lat, point.lon);
return { lat: point.lat, lon: point.lon };
};
return { root, show, showPoint: draw };
+38 -3
View File
@@ -3,11 +3,13 @@
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
*
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들
* 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 둘 다 경위도가 있으면 방위각 ·
* 없으면 사용자가 고른 8방 · 고르지 않았으면 빈 방위를 차례로 · 같은 방위 여럿 = 옆으로 나란히
* 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
* 같은 방위 여럿 = 옆으로 나란히
* 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서)
* 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
* 화면 = 북 위(SVG y 아래로 = 남)
* 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌)
* ========================================================================== */
import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc";
@@ -167,7 +169,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 +221,36 @@ 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("/")}`;
}
/** 선 글 한 줄 → 그 구간 — from = 업체 id(업체 구간) · "base"(공통 구간) · "start"(시점 → 중점) */
export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null {
if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 };
const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs;
return legs?.[i] ?? null;
}
/** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */
export function setLeg(
data: MilestoneData,
from: string,
i: number,
patch: { road?: Leg["road"]; km?: number | null },
): MilestoneData {
if (from === "start") {
if (patch.road) data.site.mid_road = patch.road;
if (patch.km !== undefined)
data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000;
return data;
}
const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs;
const leg = legs?.[i];
if (leg) Object.assign(leg, patch);
return data;
}
@@ -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,36 @@
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;
}
/* 선 글 바로 고침(244-4 ⑥) — 글 아래 작은 판 */
.b08-milestone__km tspan {
cursor: text;
}
.b08-milestone__leg-edit {
position: fixed;
z-index: 1000;
display: flex;
align-items: flex-end;
gap: 6px;
padding: 6px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.b08-milestone__leg-edit .ui-input {
width: 80px;
}
+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);
}
+107 -70
View File
@@ -3,18 +3,26 @@
* [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
*
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글
* 업체 상자 누름 = 그 경로 하위 탭으로 · 공사지 · 기준점 상자 [+] = 항목 추가(작은 창 → 경로 하위 탭 생김 ·
* 같은 업체 이름이면 그 경로에 표 줄만 더함) · 조작 = 캐시(`…_UI_Milestone_State`)
* 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 ·
* 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`)
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 빈 곳 더블클릭 = 원배율)
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
* ========================================================================== */
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 { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
import { createSelectField, 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, legAt, setLeg, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
import {
openAddItem,
openBaseInfo,
openRouteInfo,
openSiteInfo,
} from "./B08_Quantity_UI_Milestone_Modal";
import {
dirOptions,
liveMilestone,
putMilestone,
roadOptions,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
@@ -31,69 +39,71 @@ function svg<K extends keyof SVGElementTagNameMap>(
return node;
}
/** [+] 작은 창 — 항목 · 업체 이름 · 목적지 · 방위 → 새 경로(같은 이름 = 그 경로에 줄) */
export function openAddItem(
/** 선 글 바로 고침 — 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소 */
function editLegAt(
projectId: string,
viaBase: boolean,
addRoute: (init: Partial<MilestoneRoute>) => string,
anchor: Element,
from: string,
i: number,
midKm: number,
): 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);
},
const leg = legAt(liveMilestone(projectId), from, i, midKm);
if (!leg) return;
document.querySelector(".b08-milestone__leg-edit")?.remove();
const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true });
road.select.value = leg.road;
const km = el("input", {
className: "ui-input",
attrs: { type: "number", min: "0", step: "any" },
});
km.value = leg.km == null ? "" : String(leg.km);
km.setAttribute("aria-label", "km");
const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] });
box.dataset.b08MilestoneLegEdit = `${from}:${i}`;
const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로
box.style.left = `${at.left}px`;
box.style.top = `${at.bottom + 4}px`;
let done = false;
const finish = (apply: boolean): void => {
if (done) return;
done = true;
box.remove();
if (!apply) return;
const n = Number(km.value);
const value = km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null;
const patch = {
road: road.select.value as typeof leg.road,
...(km.value.trim() && value === null ? {} : { km: value }),
};
putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch));
};
box.addEventListener("keydown", (e) => {
if (e.key === "Enter") finish(true);
if (e.key === "Escape") finish(false);
});
box.addEventListener("focusout", (e) => {
if (!box.contains(e.relatedTarget as Node | null)) finish(true);
});
document.body.append(box);
km.focus();
km.select();
}
/** 다이어그램 — 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 });
@@ -117,10 +127,12 @@ export function createDiagram(
const refresh = (): void => {
const data = liveMilestone(projectId);
const site = siteInfo();
const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null));
const mk = midKm(data, site?.mid_default_m ?? null);
const layout = layoutMilestone(data, site, mk);
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 +140,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,25 +165,46 @@ 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) {
const x = (ln.x1 + ln.x2) / 2;
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);
ln.labels.forEach((label, i) => {
const span = svg("tspan", { x, dy: i ? 16 : 0 }, label);
span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;
span.addEventListener("dblclick", (e) => {
e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음
editLegAt(projectId, span, ln.from, i, mk);
});
text.append(span);
});
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();
@@ -119,8 +119,8 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble():
if "editable:" in block:
seen += 1
assert "editOnDouble: true" in block, p.name
# 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 셋(표 · 구간 · 표 줄 · 244-1)
assert seen == 4
# 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 표(구간 · 표 줄 표는 244-4 에서 모달로)
assert seen == 2
def test_고르기_메뉴_이동_그대로():
+5 -4
View File
@@ -159,12 +159,13 @@ def test_메인_탭_하위_탭_추가_삭제():
)
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui
assert 'label: "+ 추가"' in ui and "open(addRoute(projectId))" in ui
assert 'label: "삭제"' in ui and 'remove.disabled = current === "table"' in ui
assert "data.routes.filter((r) => r.id !== route.id)" in ui
# 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만)
assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert "fresh.routes = fresh.routes.filter((r) => r.id !== routeId);" in modal
assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert route.count("editOnDouble: true") == 2 and "uploadImage(projectId, routeId" in route
assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route
# 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state
@@ -140,13 +140,16 @@ def test_빈_방위_차례_같은_방위_나란히():
def test_화면_상자_누름_더하기_창():
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8")
assert 'g.addEventListener("click", () => openTab(box.id));' in ui
# 244-4 — 상자 누름 = 모달(탭 이동 아님)
assert "openTab" not in ui and "route: (id) => openRouteInfo(projectId, id)," in ui
assert 'if (box.kind === "site") g.append(plus(box, false));' in ui
assert 'if (box.kind === "base") g.append(plus(box, true));' in ui
assert "openAddItem(projectId, viaBase, addRoute)" in ui and "openModal({" in ui
assert "same.rows.push(row)" in ui # 같은 업체 이름 = 그 경로에 표 줄
assert "openAddItem(projectId, viaBase, addRoute)" in ui
modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8")
assert "openModal({" in modal
assert "same.rows.push(row)" in modal # 같은 업체 이름 = 그 경로에 표 줄
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8")
assert "createDiagram(projectId, open, (init) => addRoute(projectId, init))" in page
assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page
assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page
for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"):
assert (
@@ -0,0 +1,219 @@
"""244-4 B08 거리이정표 고침(사용자 130번) — [+] 모달(항목 · 업체 이름 · 목적지 +
주소 검색 재활용) · 방위 = 좌표 자동 · 시점 모달(등록 주소 · VWorld 주소 · 지도) ·
다이어그램 팬 · 줌 공용 ·
경로 하위 탭 = 그림만(다음 지도 길찾기 새 창 · 화면 캡처) · 상자 누름 = 항목 정보 모달 ·
선 글 바로 고침(⑥).
"""
import json
import re
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(expr):
src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e",
IMPORTS.sub("", src) + f"\nconsole.log(JSON.stringify({expr}));"],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _route(rid, lon=None, lat=None, via=True):
row = {"item": "", "dest": "start", "note": ""}
return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "lon": lon,
"lat": lat, "legs": [], "rows": [row]} # fmt: skip
def _center(lay, bid):
box = next(b for b in lay["boxes"] if b["id"] == bid)
return box["x"] + box["w"] / 2, box["y"] + box["h"] / 2
def test_방위_좌표_자동():
site = {"start_lonlat": [127.8, 35.7], "mid_lonlat": None}
base = {"enabled": True, "name": "기준", "address": "", "dir": None, "legs": []}
data = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": base,
# 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽
"routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)],
}
lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "base")
sx, sy = _center(lay, "s")
assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래
cx, cy = _center(lay, "site")
ex, ey = _center(lay, "e")
assert ex > cx + 200 and abs(ey - cy) < 1
# 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽)
data["base"] = {**base, "lon": 127.7, "lat": 35.7}
data["routes"] = [_route("n", 127.7, 35.8)]
lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "base")
nx, ny = _center(lay, "n")
assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표)
assert abs(nx - bx) < 1 and ny < by - 200
def test_다음_지도_길찾기_주소():
pts = [{"name": "거창군청", "lon": 127.909, "lat": 35.686},
{"name": "병곡리, 968/31", "lon": 127.75, "lat": 35.78},
{"name": "임도 시점", "lon": 127.74, "lat": 35.79}] # fmt: skip
url = _node(f"kakaoRouteUrl({json.dumps(pts, ensure_ascii=False)})")
# 공식 꼴 /link/by/car/이름,위도,경도/… · 이름의 쉼표 · 빗금은 빈칸
assert url == (
"https://map.kakao.com/link/by/car/%EA%B1%B0%EC%B0%BD%EA%B5%B0%EC%B2%AD,35.686,127.909/"
"%EB%B3%91%EA%B3%A1%EB%A6%AC%20%20968%2031,35.78,127.75/"
"%EC%9E%84%EB%8F%84%20%EC%8B%9C%EC%A0%90,35.79,127.74"
)
def test_VWorld_좌표_주소(monkeypatch):
body = {"response": {"status": "OK", "result": [
{"type": "parcel", "text": "경남 거창군 북상면 병곡리 968-31"},
{"type": "road", "text": "경남 거창군 북상면 덕유월성로 1"}]}} # fmt: skip
seen = []
def fetch(url, timeout=5):
seen.append(url)
return json.dumps(body).encode()
monkeypatch.setattr(vmap, "_fetch", fetch)
assert vmap._reverse_sync(127.75, 35.78) == "경남 거창군 북상면 덕유월성로 1"
assert "request=getAddress" in seen[0] and "point=127.75%2C35.78" in seen[0]
body["response"]["result"] = body["response"]["result"][:1]
assert vmap._reverse_sync(127.75, 35.78) == "경남 거창군 북상면 병곡리 968-31"
monkeypatch.setattr(vmap, "_fetch", lambda url, timeout=5: b"not json")
assert vmap._reverse_sync(127.75, 35.78) is None
def test_시점_모달_주소_길(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
async def root(_pid):
return tmp_path
async def project(_pid):
return {"region": "경남 거창군 북상면"}
async def where(lon, lat):
return f"주소 {lon:.3f}"
monkeypatch.setattr(ms, "_project_root", root)
monkeypatch.setattr(ms, "get_project_by_id", project)
monkeypatch.setattr(ms, "reverse_geocode", where)
app = FastAPI()
app.include_router(ms.router)
client = TestClient(app)
url = f"/api/projects/{uuid4()}/quantity/milestone/site-address"
empty = client.get(url).json()
assert empty["region"] == "경남 거창군 북상면" and empty["start_address"] is None
path = tmp_path / "B05_Profile" / "route" / "planned_route.csv"
path.parent.mkdir(parents=True)
path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8")
got = client.get(url).json()
lon, lat = got["start_lonlat"]
assert 124 < lon < 132 and 33 < lat < 39
assert got["start_address"] == f"주소 {lon:.3f}" and got["mid_address"].startswith("주소 ")
def test_화면_모달_팬줌_그림탭():
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
# ① [+] 모달 — 항목 · 업체 이름 · 목적지 + 주소 검색(B01 재활용) · 방위 고르기 없음 · [등록]
add = modal[
modal.index("export function openAddItem") : modal.index("export function openRouteInfo")
]
for text in ('label: "항목"', 'label: "업체 이름"', 'label: "목적지"', 'label: "등록"',
"buildAddressField(null)", "placeOf(address,"): # fmt: skip
assert text in add
assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal
assert 'from "../B01_Dashboard/B01_Dashboard_UI_AddressField"' in modal
assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal
field = _read("B01_Dashboard/B01_Dashboard_UI_AddressField.ts")
assert "point: () => picked," in field and "searchAddress(text)" in field
# ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도
assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal
assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal
# ⑤ 업체 상자 = 항목 정보(구간 km · 도로 구분 · 고침 · 삭제)
info = modal[
modal.index("export function openRouteInfo") : modal.index("export function openBaseInfo")
]
for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs, "2차포장")',
"rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip
assert text in info
diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert "route: (id) => openRouteInfo(projectId, id)," in diagram
assert "start: () => openSiteInfo(projectId)," in diagram
# ③ 팬 · 줌 = 공용
assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram
assert "wheelZoom: true" in diagram and "initial: zoomState" in diagram
# ④ 경로 하위 탭 = 그림만 · 다음 지도 새 창 · 화면 캡처 → 그림 올리기 길
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert (
"createDataTable" not in route and "구간" not in route.split("export function routeView")[1]
)
assert "window.open(kakaoRouteUrl(points)" in route
assert "getDisplayMedia({ video: true, audio: false })" in route
assert (
"captureWindow().then(put," in route and "uploadImage(projectId, routeId, picked)" in route
)
assert 'label: "그림 올리기"' in route
page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert 'label: "+ 추가"' not in page and 'label: "삭제"' not in page
for rel in ("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts",
"B08_Quantity/B08_Quantity_UI_Milestone_Route.ts",
"B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts",
"B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip
assert len(_read(rel).splitlines()) <= 700
def test_선_글_바로_고침_모달과_한_벌():
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone
data = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": True, "name": "기준", "address": "", "dir": None,
"legs": [{"road": "1차포장", "km": 0.6}]},
"routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}],
} # fmt: skip
d = json.dumps(data)
got = _node(
f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0),"
f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}}),"
f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]"
)
assert got[0] == {"road": "2차포장", "km": None}
assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None
after = got[3]
assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}]
assert after["base"]["legs"] == [{"road": "1차포장", "km": None}]
assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"}
# 표 셈 그대로 — 고친 값 = 모달 값과 같은 칸
(row,) = compute_milestone(after, 1500.0)
assert (row["s2"], row["s1"], row["un"], row["total"]) == (0, 23.8, 0, 23.8)
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert 'span.addEventListener("dblclick"' in ui and "e.stopPropagation();" in ui
assert "editLegAt(projectId, span, ln.from, i, mk)" in ui
assert "anchor.getBoundingClientRect()" in ui # 팬 · 줌 뒤 글 화면 자리
assert "putMilestone(projectId, setLeg(liveMilestone(projectId), from, i, patch))" in ui