feat(b08): 거리이정표 [+] 모달 · 주소 검색 재활용 · 방위 자동 · 시점 모달 · 팬 줌 · 경로 탭 = 길찾기 그림 (244-4)
- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리 - 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간) - 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan - 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰 - 서버 site-address(좌표 → 주소 · VWorld getAddress) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -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]]:
|
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
|
||||||
return await asyncio.to_thread(_search_sync, query.strip(), size)
|
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;
|
root: HTMLElement;
|
||||||
/** 저장할 한 줄 주소. 비면 null. */
|
/** 저장할 한 줄 주소. 비면 null. */
|
||||||
value: () => string | null;
|
value: () => string | null;
|
||||||
|
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
|
||||||
|
point: () => { lat: number; lon: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
|
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
|
||||||
@@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|||||||
label: "지도 확인",
|
label: "지도 확인",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
onClick: async function onB01_Address_Map_Click() {
|
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;
|
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);
|
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
|
||||||
|
|
||||||
let manual = false;
|
let manual = false;
|
||||||
|
let picked: { lat: number; lon: number } | null = null;
|
||||||
|
|
||||||
function pick(item: AddressCandidate): void {
|
function pick(item: AddressCandidate): void {
|
||||||
base.input.value = candidateLine(item);
|
base.input.value = candidateLine(item);
|
||||||
@@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|||||||
zip.hidden = !item.zipcode;
|
zip.hidden = !item.zipcode;
|
||||||
list.hidden = true;
|
list.hidden = true;
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
|
picked = { lat: item.lat, lon: item.lon };
|
||||||
map.showPoint(item.lat, item.lon);
|
map.showPoint(item.lat, item.lon);
|
||||||
detail.input.focus();
|
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 {
|
return {
|
||||||
root,
|
root,
|
||||||
@@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|||||||
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
|
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
|
||||||
return merged || null;
|
return merged || null;
|
||||||
},
|
},
|
||||||
|
point: () => picked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ function tileIndex(
|
|||||||
|
|
||||||
export function buildAddressMap(): {
|
export function buildAddressMap(): {
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
show: (address: string) => Promise<void>;
|
/** 찾은 좌표를 돌려줌 — 못 찾으면 null. */
|
||||||
|
show: (address: string) => Promise<{ lat: number; lon: number } | null>;
|
||||||
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
|
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
|
||||||
showPoint: (lat: number, lon: number) => void;
|
showPoint: (lat: number, lon: number) => void;
|
||||||
} {
|
} {
|
||||||
@@ -110,17 +111,18 @@ export function buildAddressMap(): {
|
|||||||
note.textContent = message;
|
note.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
const show = async (address: string): Promise<void> => {
|
const show = async (address: string): Promise<{ lat: number; lon: number } | null> => {
|
||||||
if (!address.trim()) {
|
if (!address.trim()) {
|
||||||
fail("주소를 먼저 입력하십시오.");
|
fail("주소를 먼저 입력하십시오.");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
const point = await geocodeAddress(address).catch(() => null);
|
const point = await geocodeAddress(address).catch(() => null);
|
||||||
if (!point) {
|
if (!point) {
|
||||||
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
|
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
draw(point.lat, point.lon);
|
draw(point.lat, point.lon);
|
||||||
|
return { lat: point.lat, lon: point.lon };
|
||||||
};
|
};
|
||||||
|
|
||||||
return { root, show, showPoint: draw };
|
return { root, show, showPoint: draw };
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
|
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
|
||||||
*
|
*
|
||||||
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들
|
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들
|
||||||
* 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 둘 다 경위도가 있으면 방위각 ·
|
* 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면
|
||||||
* 없으면 사용자가 고른 8방 · 고르지 않았으면 빈 방위를 차례로 · 같은 방위 여럿 = 옆으로 나란히
|
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
|
||||||
|
* 같은 방위 여럿 = 옆으로 나란히
|
||||||
* 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서)
|
* 기준점 자리 = 공사지 기준 같은 규칙(경위도 · 8방 · 기본 서)
|
||||||
* 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
|
* 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
|
||||||
* 화면 = 북 위(SVG y 아래로 = 남)
|
* 화면 = 북 위(SVG y 아래로 = 남)
|
||||||
@@ -167,7 +168,7 @@ export function layoutMilestone(
|
|||||||
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
|
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
|
||||||
const items = data.routes.map((r) => {
|
const items = data.routes.map((r) => {
|
||||||
const anchor = useBase && r.via_base ? "base" : "site";
|
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));
|
for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg));
|
||||||
let auto = 0;
|
let auto = 0;
|
||||||
@@ -219,3 +220,10 @@ export function layoutMilestone(
|
|||||||
view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY],
|
view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 다음(카카오) 지도 자동차 길찾기 새 창 주소(공식 `/link/by/car/이름,위도,경도/…` · 경유 ≤ 5) */
|
||||||
|
export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: number }>): string {
|
||||||
|
const part = (p: { name: string; lon: number; lat: number }): string =>
|
||||||
|
`${encodeURIComponent(p.name.replace(/[,/]/g, " ").trim() || "지점")},${p.lat},${p.lon}`;
|
||||||
|
return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체)
|
POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체)
|
||||||
GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기
|
GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기
|
||||||
DELETE /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>.<확장자>` 그림 ·
|
정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 ·
|
||||||
폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님).
|
폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님).
|
||||||
@@ -26,6 +28,8 @@ from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
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 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_Milestone_Calc import compute_milestone, r3
|
||||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
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)
|
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")
|
@router.post(PATH + "/image")
|
||||||
async def milestone_image_upload(
|
async def milestone_image_upload(
|
||||||
project_id: UUID, route: str = Query(...), file: UploadFile = File(...)
|
project_id: UUID, route: str = Query(...), file: UploadFile = File(...)
|
||||||
|
|||||||
@@ -114,3 +114,14 @@
|
|||||||
stroke-width: 4px;
|
stroke-width: 4px;
|
||||||
stroke-linejoin: round;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B08_Quantity_UI_Milestone.ts
|
* B08_Quantity_UI_Milestone.ts
|
||||||
* B08 메인 탭 「거리이정표」(PLAN 244-1) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름) +
|
* B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름).
|
||||||
* [+ 추가] · [삭제].
|
* 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음).
|
||||||
*
|
*
|
||||||
* [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
|
* [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
|
||||||
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
|
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
|
||||||
* 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도
|
* 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도
|
||||||
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(244-2 · 상자 누름 = 경로 탭 · [+] = 항목 추가)
|
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
|
||||||
* 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄)
|
* 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State`
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import "./B08_Quantity_UI_Milestone.css";
|
import "./B08_Quantity_UI_Milestone.css";
|
||||||
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
|
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 { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||||
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
|
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
|
||||||
import {
|
import {
|
||||||
@@ -64,8 +64,8 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
|
/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
|
||||||
function tableView(projectId: string, open: (id: string) => void): MilestoneView {
|
function tableView(projectId: string): MilestoneView {
|
||||||
const diagram = createDiagram(projectId, open, (init) => addRoute(projectId, init));
|
const diagram = createDiagram(projectId, (init) => addRoute(projectId, init));
|
||||||
let keys: Array<{ route: string; index: number }> = [];
|
let keys: Array<{ route: string; index: number }> = [];
|
||||||
const table = createDataTable({
|
const table = createDataTable({
|
||||||
columns: COLUMNS,
|
columns: COLUMNS,
|
||||||
@@ -100,7 +100,7 @@ function tableView(projectId: string, open: (id: string) => void): MilestoneView
|
|||||||
table.root.dataset.b08Milestone = "table";
|
table.root.dataset.b08Milestone = "table";
|
||||||
const none = el("p", {
|
const none = el("p", {
|
||||||
className: "ui-general-block__empty",
|
className: "ui-general-block__empty",
|
||||||
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+] 또는 하위 탭 줄 [+ 추가]로 업체를 넣으세요.",
|
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.",
|
||||||
});
|
});
|
||||||
const refresh = (): void => {
|
const refresh = (): void => {
|
||||||
const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null);
|
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;
|
button.dataset.b08MilestoneTab = id;
|
||||||
return button;
|
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(
|
tabs.replaceChildren(
|
||||||
tab("table", "거리이정표"),
|
tab("table", "거리이정표"),
|
||||||
...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)),
|
...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 {
|
function open(id: string): void {
|
||||||
current = id;
|
current = id;
|
||||||
drawTabs();
|
drawTabs();
|
||||||
body = id === "table" ? tableView(projectId, open) : routeView(projectId, id);
|
body = id === "table" ? tableView(projectId) : routeView(projectId, id);
|
||||||
view.replaceChildren(body.root);
|
view.replaceChildren(body.root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,22 @@
|
|||||||
* [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
|
* [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
|
||||||
*
|
*
|
||||||
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글
|
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글
|
||||||
* 업체 상자 누름 = 그 경로 하위 탭으로 · 공사지 · 기준점 상자 [+] = 항목 추가(작은 창 → 경로 하위 탭 생김 ·
|
* 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 ·
|
||||||
* 같은 업체 이름이면 그 경로에 표 줄만 더함) · 조작 = 캐시(`…_UI_Milestone_State`)
|
* 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`)
|
||||||
|
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 더블클릭 = 원배율)
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { openModal } from "@ui/ui_template_modal";
|
import { el } from "@ui/ui_template_elements";
|
||||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
||||||
import { midKm, type Dest, type Dir, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||||
import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
|
import { layoutMilestone, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
|
||||||
import {
|
import {
|
||||||
dirOptions,
|
openAddItem,
|
||||||
liveMilestone,
|
openBaseInfo,
|
||||||
putMilestone,
|
openRouteInfo,
|
||||||
siteInfo,
|
openSiteInfo,
|
||||||
} from "./B08_Quantity_UI_Milestone_State";
|
} from "./B08_Quantity_UI_Milestone_Modal";
|
||||||
|
import { liveMilestone, siteInfo } from "./B08_Quantity_UI_Milestone_State";
|
||||||
|
|
||||||
const NS = "http://www.w3.org/2000/svg";
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
@@ -31,69 +33,21 @@ function svg<K extends keyof SVGElementTagNameMap>(
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [+] 작은 창 — 항목 · 업체 이름 · 목적지 · 방위 → 새 경로(같은 이름 = 그 경로에 줄) */
|
|
||||||
export function openAddItem(
|
|
||||||
projectId: string,
|
|
||||||
viaBase: boolean,
|
|
||||||
addRoute: (init: Partial<MilestoneRoute>) => string,
|
|
||||||
): void {
|
|
||||||
openModal({
|
|
||||||
title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로",
|
|
||||||
closeLabel: "닫기",
|
|
||||||
mount: (body, close, actions) => {
|
|
||||||
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
|
|
||||||
const company = createInputField({ label: "업체 이름", compact: true });
|
|
||||||
const dest = createSelectField({
|
|
||||||
label: "목적지",
|
|
||||||
compact: true,
|
|
||||||
options: [
|
|
||||||
{ value: "start", text: "시점" },
|
|
||||||
{ value: "mid", text: "중점" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const dir = createSelectField({
|
|
||||||
label: viaBase ? "방위(기준점에서 본)" : "방위(공사지에서 본)",
|
|
||||||
compact: true,
|
|
||||||
options: dirOptions,
|
|
||||||
});
|
|
||||||
item.input.dataset.b08MilestoneAdd = "item";
|
|
||||||
company.input.dataset.b08MilestoneAdd = "company";
|
|
||||||
const ok = createButton({
|
|
||||||
label: "추가",
|
|
||||||
variant: "filled",
|
|
||||||
onClick: () => {
|
|
||||||
const name = company.input.value.trim();
|
|
||||||
if (!name) return company.setError("업체 이름을 넣으세요.");
|
|
||||||
const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" };
|
|
||||||
const data = liveMilestone(projectId);
|
|
||||||
const same = data.routes.find((r) => r.name.trim() === name);
|
|
||||||
if (same) {
|
|
||||||
same.rows.push(row);
|
|
||||||
putMilestone(projectId, data);
|
|
||||||
} else
|
|
||||||
addRoute({
|
|
||||||
name,
|
|
||||||
via_base: viaBase,
|
|
||||||
dir: (dir.select.value || null) as Dir | null,
|
|
||||||
rows: [row],
|
|
||||||
});
|
|
||||||
close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
actions.append(ok);
|
|
||||||
body.append(item.root, company.root, dest.root, dir.root);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */
|
/** 다이어그램 — refresh() 로 캐시 값 다시 그림 */
|
||||||
export function createDiagram(
|
export function createDiagram(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
openTab: (routeId: string) => void,
|
|
||||||
addRoute: (init: Partial<MilestoneRoute>) => string,
|
addRoute: (init: Partial<MilestoneRoute>) => string,
|
||||||
): { root: HTMLElement; refresh: () => void } {
|
): { root: HTMLElement; refresh: () => void } {
|
||||||
const host = el("div", { className: "b08-milestone__diagram" });
|
const host = el("div", { className: "b08-milestone__diagram" });
|
||||||
host.dataset.b08Milestone = "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 plus = (box: DiagramBox, viaBase: boolean): SVGGElement => {
|
||||||
const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 });
|
const g = svg("g", { class: "b08-milestone__plus", role: "button", tabindex: 0 });
|
||||||
@@ -119,8 +73,9 @@ export function createDiagram(
|
|||||||
const site = siteInfo();
|
const site = siteInfo();
|
||||||
const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null));
|
const layout = layoutMilestone(data, site, midKm(data, site?.mid_default_m ?? null));
|
||||||
const [vx, vy, vw, vh] = layout.view;
|
const [vx, vy, vw, vh] = layout.view;
|
||||||
|
// viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김
|
||||||
const root = svg("svg", {
|
const root = svg("svg", {
|
||||||
viewBox: `${vx} ${vy} ${vw} ${vh}`,
|
viewBox: `0 0 ${vw} ${vh}`,
|
||||||
width: vw,
|
width: vw,
|
||||||
height: vh,
|
height: vh,
|
||||||
role: "img",
|
role: "img",
|
||||||
@@ -128,8 +83,12 @@ export function createDiagram(
|
|||||||
});
|
});
|
||||||
root.style.maxWidth = "100%";
|
root.style.maxWidth = "100%";
|
||||||
root.style.height = "auto";
|
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) {
|
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 }),
|
svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,15 +108,18 @@ export function createDiagram(
|
|||||||
box.label,
|
box.label,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (box.kind === "route") {
|
// 상자 누름 = 모달(탭 이동 아님)
|
||||||
g.setAttribute("role", "button");
|
const info = (): void => INFO[box.kind](box.id);
|
||||||
g.setAttribute("tabindex", "0");
|
g.setAttribute("role", "button");
|
||||||
g.addEventListener("click", () => openTab(box.id));
|
g.setAttribute("tabindex", "0");
|
||||||
g.addEventListener("keydown", (e) => e.key === "Enter" && openTab(box.id));
|
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 === "site") g.append(plus(box, false));
|
||||||
if (box.kind === "base") g.append(plus(box, true));
|
if (box.kind === "base") g.append(plus(box, true));
|
||||||
root.append(g);
|
shift.append(g);
|
||||||
}
|
}
|
||||||
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
|
// 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄
|
||||||
for (const ln of layout.lines) {
|
for (const ln of layout.lines) {
|
||||||
@@ -165,9 +127,19 @@ export function createDiagram(
|
|||||||
const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2;
|
const 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" });
|
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)));
|
ln.labels.forEach((label, i) => text.append(svg("tspan", { x, dy: i ? 16 : 0 }, label)));
|
||||||
root.append(text);
|
shift.append(text);
|
||||||
}
|
}
|
||||||
host.replaceChildren(root);
|
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();
|
refresh();
|
||||||
return { root: host, 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
|
* B08_Quantity_UI_Milestone_Route.ts
|
||||||
* 거리이정표 경로 하위 탭(PLAN 244-1) — 컨테이너 하나 = 업체 한 곳 · 그림 하나.
|
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달).
|
||||||
*
|
*
|
||||||
* 위 = 업체 이름(= 하위 탭 이름) · 주소 · 방위(기준점 · 없으면 공사지 기준 8방) · 기준점 거침
|
* 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 업체 좌표 ·
|
||||||
* 구간 표 = 업체 → 기준점(또는 시점) · 기준점 → 시점(공통 · 모든 경로가 같이 씀) · 시점 → 중점(측점 km) ·
|
* 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로
|
||||||
* 구간마다 km(더블클릭 손 입력) · 도로 구분 ▾(2차포장 · 1차포장 · 비포장)
|
* 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에
|
||||||
* 표 줄 = 항목 · 목적지 ▾ · 비고 — [거리이정표] 표에 이 업체 줄로 나감
|
* 그림 = 올리는 즉시 서버(교체 · 지우기)
|
||||||
* 그림 = 올리는 즉시 서버(교체 · 지우기) · 나머지 조작 = 캐시(`…_UI_Milestone_State`) · [저장] 때 서버
|
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
|
||||||
import {
|
import {
|
||||||
createMapContextMenu,
|
createMapContextMenu,
|
||||||
type MapContextMenu,
|
type MapContextMenu,
|
||||||
type MapContextMenuItem,
|
type MapContextMenuItem,
|
||||||
} from "@ui/ui_template_context_menu";
|
} from "@ui/ui_template_context_menu";
|
||||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||||
import {
|
import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||||
createButton,
|
import { kakaoRouteUrl } from "./B08_Quantity_Milestone_Layout";
|
||||||
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 {
|
import {
|
||||||
deleteImage,
|
deleteImage,
|
||||||
dirOptions,
|
|
||||||
imageBlobUrl,
|
imageBlobUrl,
|
||||||
liveMilestone,
|
liveMilestone,
|
||||||
putMilestone,
|
putMilestone,
|
||||||
@@ -61,12 +45,6 @@ export function pickAtCell(items: MapContextMenuItem[]): void {
|
|||||||
menu.open(box.left, box.bottom, items);
|
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: "중점" };
|
export const DEST_TEXT: Record<Dest, string> = { start: "시점", mid: "중점" };
|
||||||
|
|
||||||
/** 그 경로를 고쳐 캐시에 */
|
/** 그 경로를 고쳐 캐시에 */
|
||||||
@@ -82,180 +60,59 @@ export function editRoute(
|
|||||||
putMilestone(projectId, data);
|
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 {
|
export interface MilestoneView {
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
refresh: () => void;
|
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 {
|
export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||||
const edit = (fn: (data: MilestoneData, route: MilestoneRoute) => void): void =>
|
const title = el("h4");
|
||||||
editRoute(projectId, routeId, fn);
|
const hint = el("p", {
|
||||||
|
className: "b08-options__hint",
|
||||||
// 위 — 업체 이름 · 주소 · 방위 · 기준점 거침
|
text: "[다음 지도에서 길찾기] → 새 창에서 길 확인 → [화면 캡처] → 그 창을 고름 = 이 탭 그림",
|
||||||
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 img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } });
|
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;
|
let shown: string | null | undefined;
|
||||||
const loadImage = (force = false): void => {
|
const loadImage = (force = false): void => {
|
||||||
const name = routeImages()[routeId] ?? null;
|
const name = routeImages()[routeId] ?? null;
|
||||||
@@ -271,21 +128,51 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
|||||||
(error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"),
|
(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/*" } });
|
const file = el("input", { attrs: { type: "file", accept: "image/*" } });
|
||||||
file.hidden = true;
|
file.hidden = true;
|
||||||
file.addEventListener("change", () => {
|
file.addEventListener("change", () => {
|
||||||
const picked = file.files?.[0];
|
const picked = file.files?.[0];
|
||||||
file.value = "";
|
file.value = "";
|
||||||
if (picked)
|
if (picked) void put(picked);
|
||||||
uploadImage(projectId, routeId, picked).then(
|
|
||||||
() => loadImage(true),
|
|
||||||
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
const upload = createButton({
|
||||||
label: "그림 올리기",
|
label: "그림 올리기",
|
||||||
size: "sm",
|
size: "sm",
|
||||||
variant: "filled",
|
variant: "ghost",
|
||||||
onClick: () => file.click(),
|
onClick: () => file.click(),
|
||||||
});
|
});
|
||||||
const removeImage = createButton({
|
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", {
|
const root = el("section", {
|
||||||
className: "b08-milestone__route ui-card",
|
className: "b08-milestone__route ui-card",
|
||||||
attrs: { "data-b08-milestone-route": routeId },
|
attrs: { "data-b08-milestone-route": routeId },
|
||||||
children: [
|
children: [
|
||||||
head,
|
title,
|
||||||
el("h4", { text: "구간 (km 더블클릭 · 도로 구분 ▾)" }),
|
el("div", {
|
||||||
legs.root,
|
className: "b08-milestone__bar",
|
||||||
bar(addOwn, addBase, dropLeg),
|
children: [kakao, capture, upload, removeImage, file],
|
||||||
el("h4", { text: "표 줄" }),
|
}),
|
||||||
rows.root,
|
hint,
|
||||||
bar(addRow, dropRow),
|
|
||||||
el("h4", { text: "경로 그림" }),
|
|
||||||
bar(upload, removeImage, file),
|
|
||||||
noImage,
|
noImage,
|
||||||
img,
|
img,
|
||||||
],
|
],
|
||||||
@@ -322,16 +204,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
|||||||
const d = liveMilestone(projectId);
|
const d = liveMilestone(projectId);
|
||||||
const r = d.routes.find((x) => x.id === routeId);
|
const r = d.routes.find((x) => x.id === routeId);
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
if (document.activeElement !== name.input) name.input.value = r.name;
|
const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"];
|
||||||
if (document.activeElement !== address.input) address.input.value = r.address;
|
title.textContent = `${r.name || "업체"} → ${dest}${r.address ? ` · ${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);
|
|
||||||
loadImage();
|
loadImage();
|
||||||
};
|
};
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble():
|
|||||||
if "editable:" in block:
|
if "editable:" in block:
|
||||||
seen += 1
|
seen += 1
|
||||||
assert "editOnDouble: true" in block, p.name
|
assert "editOnDouble: true" in block, p.name
|
||||||
# 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 셋(표 · 구간 · 표 줄 · 244-1)
|
# 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 표(구간 · 표 줄 표는 244-4 에서 모달로)
|
||||||
assert seen == 4
|
assert seen == 2
|
||||||
|
|
||||||
|
|
||||||
def test_고르기_메뉴_이동_그대로():
|
def test_고르기_메뉴_이동_그대로():
|
||||||
|
|||||||
@@ -159,12 +159,13 @@ def test_메인_탭_하위_탭_추가_삭제():
|
|||||||
)
|
)
|
||||||
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
|
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 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui
|
||||||
assert 'label: "+ 추가"' in ui and "open(addRoute(projectId))" in ui
|
# 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만)
|
||||||
assert 'label: "삭제"' in ui and 'remove.disabled = current === "table"' in ui
|
assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui
|
||||||
assert "data.routes.filter((r) => r.id !== route.id)" 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
|
assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui
|
||||||
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
|
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 · 자동 저장 없음
|
# 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음
|
||||||
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
|
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
|
||||||
assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state
|
assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state
|
||||||
|
|||||||
@@ -140,13 +140,16 @@ def test_빈_방위_차례_같은_방위_나란히():
|
|||||||
|
|
||||||
def test_화면_상자_누름_더하기_창():
|
def test_화면_상자_누름_더하기_창():
|
||||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8")
|
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 === "site") g.append(plus(box, false));' in ui
|
||||||
assert 'if (box.kind === "base") g.append(plus(box, true));' 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 "openAddItem(projectId, viaBase, addRoute)" in ui
|
||||||
assert "same.rows.push(row)" 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")
|
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
|
assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page
|
||||||
for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"):
|
for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"):
|
||||||
assert (
|
assert (
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""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):
|
||||||
|
return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "lon": lon,
|
||||||
|
"lat": lat, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]} # 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
|
||||||
Reference in New Issue
Block a user