Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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"),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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_고르기_메뉴_이동_그대로():
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user