feat(B08): 거리이정표 등록 모달 2열 · 소재지 다음 지도 · 연결선 모달 길찾기 · 도로 구분 갈래 열(244-13)

- 등록 모달 한 벌(추가 · 상자 고침 · 삭제) — 분류(기준위치 · 거래처) · 항목 고르기 · 거래처 이름 · 목적지 2열 · 큰 창
- 소재지 = 주소 찾기 · 다음 지도 누름 · 표지 끌기(직접 입력 없음) · 좌표 → VWorld 주소 서버 길
- 다이어그램 [기준점 +] 없앰 · 연결선 누름 = 도로 구분 · 거리 · 다음 지도 경로 · 끝점 끌기 = 다시 길찾기
- 시점 → 중점 = 손 입력만 · 자동 그림 = 거래처 쪽 구간
- 도로 구분 = 기준점 거침 2차 · 1차 · 비포장 · 그 밖 포장 · 비포장(파이썬 · TS 짝) · 표 열 기준점 따라

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 23:36:39 +09:00
co-authored by Claude Opus 5.5
parent 1173d237dd
commit 2afb1af1ca
21 changed files with 1315 additions and 499 deletions
+8 -4
View File
@@ -10,6 +10,9 @@
끝수 = 소수 셋째 자리(반올림).
구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고
pick = 참(화면 「도로 구분 고르기」).
도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간 = 2차포장 · 1차포장 · 비포장 ·
그 밖 = 포장 · 비포장 ·
포장 = 2차 + 1차 + 포장.
"""
from __future__ import annotations
@@ -17,8 +20,8 @@ from __future__ import annotations
import math
from typing import Any
ROADS = ("2차포장", "1차포장", "비포장")
KEYS = {"2차포장": "s2", "1차포장": "s1", "비포장": "un"}
ROADS = ("2차포장", "1차포장", "포장", "비포장")
KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"}
def r3(value: float) -> float:
@@ -60,13 +63,13 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list
out: list[dict[str, Any]] = []
for route in data.get("routes") or []:
for index, row in enumerate(route.get("rows") or []):
sums = {"s2": 0.0, "s1": 0.0, "un": 0.0}
sums = {"s2": 0.0, "s1": 0.0, "pv": 0.0, "un": 0.0}
pick = add_legs(sums, route.get("legs"))
if base.get("enabled") and route.get("via_base", True):
pick = add_legs(sums, base.get("legs")) or pick
if row.get("dest") == "mid":
add_legs(sums, [mid])
paved = sums["s2"] + sums["s1"]
paved = sums["s2"] + sums["s1"] + sums["pv"]
out.append(
{
"route": route.get("id"),
@@ -76,6 +79,7 @@ def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list
"dest": row.get("dest") or "start",
"s2": r3(sums["s2"]),
"s1": r3(sums["s1"]),
"pv": r3(sums["pv"]),
"un": r3(sums["un"]),
"paved": r3(paved),
"unpaved": r3(sums["un"]),
+13 -5
View File
@@ -11,10 +11,15 @@
* 끝수 = 소수 셋째 자리(반올림)
* 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) · 도로 구분 안 고른 구간 = 합에 안 넣고
* pick = 참(화면 「도로 구분 고르기」)
* 도로 구분(244-13) = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 ·
* 비포장 · 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장 · 포장 = 2차 + 1차 + 포장
* ========================================================================== */
export const ROADS = ["2차포장", "1차포장", "비포장"] as const;
export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const;
export type Road = (typeof ROADS)[number];
/** 구간 도로 구분 고르기 — 기준점 거침 = 2차 · 1차 · 비포장 · 아니면 포장 · 비포장(244-13) */
export const roadsFor = (viaBase: boolean): Road[] =>
viaBase ? ["2차포장", "1차포장", "비포장"] : ["포장", "비포장"];
/** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */
export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const;
export type Dir = (typeof DIRS)[number];
@@ -73,6 +78,7 @@ export interface MilestoneTableRow {
dest: Dest;
s2: number;
s1: number;
pv: number;
un: number;
paved: number;
unpaved: number;
@@ -82,7 +88,8 @@ export interface MilestoneTableRow {
pick: boolean;
}
const KEYS: Record<string, "s2" | "s1" | "un"> = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" };
type Key = "s2" | "s1" | "pv" | "un";
const KEYS: Record<string, Key> = { "2차포장": "s2", "1차포장": "s1", 포장: "pv", 비포장: "un" };
/** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */
export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000;
@@ -91,7 +98,7 @@ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 10
export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null;
/** 도로 구분마다 더함 — 안 고른 구간이 있으면 참 */
function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): boolean {
function addLegs(sums: Record<Key, number>, legs: Leg[] | undefined): boolean {
let pick = false;
for (const leg of legs ?? []) {
const key = leg.road ? KEYS[leg.road] : undefined;
@@ -117,11 +124,11 @@ export function computeMilestone(
const out: MilestoneTableRow[] = [];
for (const route of data.routes ?? []) {
(route.rows ?? []).forEach((row, index) => {
const sums = { s2: 0, s1: 0, un: 0 };
const sums = { s2: 0, s1: 0, pv: 0, un: 0 };
let pick = addLegs(sums, route.legs);
if (base?.enabled && route.via_base !== false) pick = addLegs(sums, base.legs) || pick;
if (row.dest === "mid") addLegs(sums, [mid]);
const paved = sums.s2 + sums.s1;
const paved = sums.s2 + sums.s1 + sums.pv;
out.push({
route: route.id,
index,
@@ -130,6 +137,7 @@ export function computeMilestone(
dest: row.dest ?? "start",
s2: r3(sums.s2),
s1: r3(sums.s1),
pv: r3(sums.pv),
un: r3(sums.un),
paved: r3(paved),
unpaved: r3(sums.un),
+31 -17
View File
@@ -11,6 +11,7 @@
* 화면 = 북 위(SVG y 아래로 = 남)
* 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌)
* [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」
* 연결선 구간(244-13) = `segmentEnds` · 도로 구분 갈래 = `chainVia`(기준점 거침 = 2차 · 1차 · 비포장)
* 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘
* 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버
* 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 ·
@@ -210,25 +211,37 @@ export interface KmSegment {
const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null =>
p.lon != null && p.lat != null ? [p.lon, p.lat] : null;
/** 구간 한 줄(244-13 연결선) — from = 거래처 id(거래처 → 기준점(거침) · 시점) · "base"(기준점 → 시점) ·
* 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함) */
export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null {
const baseName = data.base.name || "기준점";
if (from === "base")
return data.base.enabled
? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null }
: null;
const route = data.routes.find((r) => r.id === from);
if (!route) return null;
const viaBase = data.base.enabled && route.via_base;
return {
key: "route",
text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`,
from: pointOf(route),
to: viaBase ? pointOf(data.base) : (start ?? null),
};
}
/** 기준점 거쳐 시점 들어가는 구간(244-13 · 도로 구분 2차 · 1차 · 비포장) — 기준점 → 시점 · 거침 거래처 */
export function chainVia(data: MilestoneData, from: string): boolean {
if (!data.base.enabled || from === "start") return false;
return from === "base" || !!data.routes.find((r) => r.id === from)?.via_base;
}
/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점
* (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */
export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] {
const route = data.routes.find((r) => r.id === routeId);
if (!route) return [];
const viaBase = data.base.enabled && route.via_base;
const baseName = data.base.name || "기준점";
const from = pointOf(route);
const first: KmSegment = {
key: "route",
text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`,
from,
to: viaBase ? pointOf(data.base) : (start ?? null),
};
if (!viaBase) return [first];
return [
first,
{ key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null },
];
const first = segmentEnds(data, routeId, start);
if (!first) return [];
return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first];
}
/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */
@@ -544,7 +557,8 @@ export function setLeg(
}
/** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */
export const PAN_SKIP = ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus";
export const PAN_SKIP =
".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus, .b08-milestone__line-hit";
export function panStart(e: { button: number; target: EventTarget | null }): boolean {
if (e.button === 1) return true;
const target = e.target as { closest?: (selector: string) => unknown } | null;
+13 -1
View File
@@ -15,6 +15,8 @@
POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오
길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함)
+ 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함)
GET /api/projects/{id}/quantity/milestone/reverse?lon=&lat= 지도에서 고른 소재지(244-13) →
VWorld 주소 한 줄(서버 단독 · 저장 안 함 · 못 찾음 = null)
GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 ·
공개용 · .env KAKAO_JS_KEY · 없으면 null)
@@ -68,7 +70,7 @@ AUTO = "auto" # 자동 그림 하위 폴더(244-11)
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
Road = Literal["2차포장", "1차포장", "비포장"]
Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13)
Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
@@ -270,6 +272,16 @@ async def milestone_site_address(
}
@router.get(PATH + "/reverse")
async def milestone_reverse(
project_id: UUID,
lon: Annotated[float, Query(ge=-180, le=180)],
lat: Annotated[float, Query(ge=-90, le=90)],
) -> dict[str, Any]:
"""지도에서 고른 소재지(244-13) — 좌표 → VWorld 주소(못 찾음 = null)."""
return {"address": await reverse_geocode(lon, lat)}
@router.post(PATH + "/image")
async def milestone_image_upload(
project_id: UUID, route: str = Query(...), file: UploadFile = File(...)
+27 -1
View File
@@ -134,7 +134,7 @@
/* 선 글 바로 고침(244-4 ⑥) — 글 아래 작은 판 */
.b08-milestone__km tspan {
cursor: text;
cursor: pointer;
}
.b08-milestone__leg-edit {
@@ -186,3 +186,29 @@
font-size: 12px;
white-space: nowrap;
}
/* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */
.ui-modal.b08-milestone__modal {
width: min(960px, 94vw);
--modal-max: 90vh;
}
.b08-milestone__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
align-items: end;
}
.b08-milestone__place {
margin: 6px 0;
font-weight: 600;
}
/* 연결선 누름 자리 — 투명한 굵은 선(244-13) */
.b08-milestone__line-hit {
stroke: transparent;
stroke-width: 14;
pointer-events: stroke;
cursor: pointer;
}
+82 -46
View File
@@ -3,7 +3,8 @@
* B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름).
* 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음).
*
* [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
* [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 도로 구분 · 계 · 비고) — 열(244-13) = 기준점 있음 =
* 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
* 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
@@ -32,18 +33,34 @@ import {
} from "./B08_Quantity_UI_Milestone_Route";
const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const;
const COLUMNS: DataColumn[] = [
{ key: "item", label: "항목", width: 120 },
{ key: "company", label: "거래처 이름", width: 120 },
{ key: "dest", label: "목적지", width: 72 },
{ key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM },
{ key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM },
{ key: "un", label: "비포장", group: ["도로 구분 (km)"], ...KM },
{ key: "paved", label: "포장", group: ["계 (km)"], ...KM },
{ key: "unpaved", label: "비포장", group: ["계 (km)"], ...KM },
{ key: "total", label: "합계", group: ["계 (km)"], ...KM, width: 108 },
{ key: "note", label: "비고", width: 160 },
];
const ROAD = ["도로 구분 (km)"];
const SUM = ["계 (km)"];
/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 ·
* 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */
export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] {
const roads: DataColumn[] = withBase
? [
{ key: "s2", label: "2차포장", group: ROAD, ...KM },
{ key: "s1", label: "1차포장", group: ROAD, ...KM },
...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []),
{ key: "un", label: "비포장", group: ROAD, ...KM },
{ key: "paved", label: "포장", group: SUM, ...KM },
{ key: "unpaved", label: "비포장", group: SUM, ...KM },
]
: [
{ key: "paved", label: "포장", group: ROAD, ...KM },
{ key: "un", label: "비포장", group: ROAD, ...KM },
];
return [
{ key: "item", label: "항목", width: 120 },
{ key: "company", label: "거래처 이름", width: 120 },
{ key: "dest", label: "목적지", width: 72 },
...roads,
{ key: "total", label: "합계", group: SUM, ...KM, width: 108 },
{ key: "note", label: "비고", width: 160 },
];
}
/** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */
export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}): string {
@@ -68,44 +85,63 @@ function tableView(projectId: string): MilestoneView {
const diagram = createDiagram(projectId, (init) => addRoute(projectId, init));
let keys: Array<{ route: string; index: number }> = [];
let pickRows: boolean[] = [];
const table = createDataTable({
columns: COLUMNS,
rows: [],
caption: "거리이정표",
empty: "",
editOnDouble: true,
cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null),
editable: (_i, key) => key === "item" || key === "company" || key === "note",
onEdit: (i, key, text) => {
const at = keys[i];
if (!at) return;
editRoute(projectId, at.route, (_, r) => {
if (key === "company") r.name = text;
else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text;
});
},
onCellClick: (i, key) => {
const at = keys[i];
if (key !== "dest" || !at) return;
pickAtCell(
(["start", "mid"] as const).map((dest) => [
DEST_TEXT[dest],
() =>
editRoute(projectId, at.route, (_, r) => {
if (r.rows[at.index]) r.rows[at.index].dest = dest;
}),
]),
);
},
});
trackCells(table.root);
table.root.dataset.b08Milestone = "table";
const makeTable = (columns: DataColumn[]) => {
const t = createDataTable({
columns,
rows: [],
caption: "거리이정표",
empty: "",
editOnDouble: true,
cellClass: (i, key) => (key === "total" && pickRows[i] ? "is-pick-road" : null),
editable: (_i, key) => key === "item" || key === "company" || key === "note",
onEdit: (i, key, text) => {
const at = keys[i];
if (!at) return;
editRoute(projectId, at.route, (_, r) => {
if (key === "company") r.name = text;
else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text;
});
},
onCellClick: (i, key) => {
const at = keys[i];
if (key !== "dest" || !at) return;
pickAtCell(
(["start", "mid"] as const).map((dest) => [
DEST_TEXT[dest],
() =>
editRoute(projectId, at.route, (_, r) => {
if (r.rows[at.index]) r.rows[at.index].dest = dest;
}),
]),
);
},
});
trackCells(t.root);
t.root.dataset.b08Milestone = "table";
return t;
};
let mode = "false:false";
let table = makeTable(milestoneColumns(false, false));
const none = el("p", {
className: "ui-general-block__empty",
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.",
});
const refresh = (): void => {
const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null);
const data = liveMilestone(projectId);
const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null);
// 기준점 생김 · 없앰 = 열 바뀜 → 표 새로
const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`;
if (next !== mode) {
mode = next;
const fresh = makeTable(
milestoneColumns(
data.base.enabled,
rows.some((r) => r.pv > 0),
),
);
table.root.replaceWith(fresh.root);
table = fresh;
}
keys = rows.map(({ route, index }) => ({ route, index }));
pickRows = rows.map((r) => r.pick);
none.hidden = rows.length > 0;
@@ -6,17 +6,20 @@
* 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 거래처 = 항목 정보 · 공사지 = 시점 주소 ·
* 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`)
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율)
* 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6)
* 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 = [+] 등록 모달 분류 「기준위치」(244-13 · [기준점 +] 없앰)
* 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 고침 · 삭제) · 선 · 선 글 누름 = 연결선 모달
* (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로)
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7)
* 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
* 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
* ========================================================================== */
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { createSelectField, el } from "@ui/ui_template_elements";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { midKm, type MilestoneRoute, type Road } from "./B08_Quantity_Milestone_Calc";
import {
chainVia,
layoutMilestone,
legAt,
panContent,
@@ -24,14 +27,9 @@ import {
setLeg,
type DiagramBox,
} from "./B08_Quantity_Milestone_Layout";
import { openLegInfo } from "./B08_Quantity_UI_Milestone_LegModal";
import { openPlaceModal, openSiteInfo } from "./B08_Quantity_UI_Milestone_Modal";
import {
openAddItem,
openBaseInfo,
openRouteInfo,
openSiteInfo,
} from "./B08_Quantity_UI_Milestone_Modal";
import {
legRoadOptions,
liveMilestone,
putMilestone,
roadOptions,
@@ -60,12 +58,13 @@ function editLegAt(
i: number,
midKm: number,
): void {
const leg = legAt(liveMilestone(projectId), from, i, midKm);
const data = liveMilestone(projectId);
const leg = legAt(data, from, i, midKm);
if (!leg) return;
document.querySelector(".b08-milestone__leg-edit")?.remove();
const road = createSelectField({
label: "도로 구분",
options: from === "start" ? roadOptions : legRoadOptions, // 시점 → 중점 = 늘 고름
options: roadOptions(chainVia(data, from), from !== "start", leg.road), // 시점 → 중점 = 늘 고름
compact: true,
});
road.select.value = leg.road ?? "";
@@ -121,11 +120,11 @@ export function createDiagram(
host.addEventListener("wheel", (e) => e.preventDefault(), { passive: false });
let zoomState: ZoomPanState | undefined; // 다시 그려도 배율 그대로
const INFO: Record<DiagramBox["kind"], (id: string) => void> = {
route: (id) => openRouteInfo(projectId, id),
route: (id) => openPlaceModal(projectId, { add: false, id }),
site: () => openSiteInfo(projectId),
start: () => openSiteInfo(projectId),
mid: () => openSiteInfo(projectId),
base: () => openBaseInfo(projectId),
base: () => openPlaceModal(projectId, { add: false, id: "base" }),
};
const plus = (box: DiagramBox, viaBase: boolean): SVGGElement => {
@@ -140,7 +139,7 @@ export function createDiagram(
g.dataset.b08MilestonePlus = viaBase ? "base" : "site";
const go = (e: Event): void => {
e.stopPropagation();
openAddItem(projectId, viaBase, addRoute);
openPlaceModal(projectId, { add: true, viaBase }, addRoute);
};
g.addEventListener("click", go);
g.addEventListener("keydown", (e) => e.key === "Enter" && go(e));
@@ -183,9 +182,15 @@ export function createDiagram(
// 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림)
const lineLayer = svg("g", {});
for (const ln of layout.lines) {
lineLayer.append(
svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }),
);
const ends = { x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 };
// 누르는 자리 = 투명한 굵은 선(244-13 · 연결선 모달)
const hit = svg("line", { class: "b08-milestone__line-hit", ...ends });
hit.dataset.b08MilestoneLine = ln.from;
hit.addEventListener("click", (e) => {
e.stopPropagation();
openLegInfo(projectId, ln.from);
});
lineLayer.append(svg("line", { class: "b08-milestone__line", ...ends }), hit);
}
for (const box of layout.boxes) {
const g = svg("g", { class: `b08-milestone__box is-${box.kind}` });
@@ -240,23 +245,23 @@ export function createDiagram(
const span = svg("tspan", { x, dy: i ? 16 : 0 }, label);
span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;
if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i]));
// 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침
let wait = 0;
span.addEventListener("click", (e) => {
e.stopPropagation();
clearTimeout(wait);
wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250);
});
span.addEventListener("dblclick", (e) => {
e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음
clearTimeout(wait);
editLegAt(projectId, span, ln.from, i, mk);
});
text.append(span);
});
shift.append(text);
}
const addBase = createButton({
label: "기준점 +",
size: "sm",
variant: "ghost",
onClick: () => openBaseInfo(projectId, true),
});
addBase.dataset.b08MilestoneAddBase = "";
addBase.hidden = data.base.enabled;
host.replaceChildren(el("div", { className: "b08-milestone__bar", children: [addBase] }), root);
host.replaceChildren(root);
attachSvgZoomPan({
svg: root,
layer,
@@ -21,14 +21,22 @@ const SDK_FAIL = "KakaoSdkFail";
const SDK = "https://dapi.kakao.com/v2/maps/sdk.js";
const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선
type LatLng = object;
interface LatLng {
getLat(): number;
getLng(): number;
}
interface KMap {
setBounds(bounds: object): void;
setCenter(p: LatLng): void;
setLevel(level: number): void;
relayout(): void;
}
interface KOverlay {
setMap(map: KMap | null): void;
}
interface KMarker extends KOverlay {
getPosition(): LatLng;
}
interface KakaoMaps {
load(done: () => void): void;
LatLng: new (lat: number, lng: number) => LatLng;
@@ -40,7 +48,10 @@ interface KakaoMaps {
strokeColor: string;
strokeOpacity: number;
}) => KOverlay;
Marker: new (o: { position: LatLng; title: string }) => KOverlay;
Marker: new (o: { position: LatLng; title: string; draggable?: boolean }) => KMarker;
event: {
addListener(target: object, type: string, fn: (e: { latLng: LatLng }) => void): void;
};
CustomOverlay: new (o: { position: LatLng; content: HTMLElement; yAnchor: number }) => KOverlay;
}
@@ -160,3 +171,106 @@ export function routeMapView(projectId: string, routeId: string): RouteMapView {
draw();
return { root, draw };
}
/** 고르는 지도 표지(244-13) — key = 끌어 옮길 때 돌려받는 이름 */
export interface MapPin {
key: string;
name: string;
lon: number;
lat: number;
movable: boolean;
}
export interface EditMap {
root: HTMLElement;
/** 표지 · 길 선 다시 놓고 맞춤 */
set: (pins: MapPin[], lines: Array<Array<[number, number]>>) => void;
}
/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */
export function editMap(
projectId: string,
opts: {
onMove: (key: string, lon: number, lat: number) => void;
onClick?: (lon: number, lat: number) => void;
center?: () => [number, number] | null;
hint: string;
},
): EditMap {
const box = el("div", { className: "b08-milestone__map" });
const hint = el("p", { className: "b08-options__hint", text: opts.hint });
hint.dataset.b08MilestoneMapHint = "";
let map: KMap | null = null;
let drawn: KOverlay[] = [];
const set = (pins: MapPin[], lines: Array<Array<[number, number]>>): void => {
loadKakaoMaps(projectId).then(
(maps) =>
requestAnimationFrame(() => {
const points = [
...pins.map((p) => new maps.LatLng(p.lat, p.lon)),
...lines.flat().map(([lat, lon]) => new maps.LatLng(lat, lon)),
];
const c = opts.center?.();
const first = points[0] ?? (c ? new maps.LatLng(c[1], c[0]) : null);
if (!first) return void (box.hidden = true);
box.hidden = false;
if (!map) {
map = new maps.Map(box, { center: first, level: 7 });
if (opts.onClick)
maps.event.addListener(map, "click", (e) =>
opts.onClick!(e.latLng.getLng(), e.latLng.getLat()),
);
}
map.relayout();
drawn.forEach((o) => o.setMap(null));
drawn = [
...lines.map(
(line) =>
new maps.Polyline({
path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)),
strokeWeight: 6,
strokeColor: LINE_COLOR,
strokeOpacity: 0.9,
}),
),
...pins.flatMap((p) => {
const at = new maps.LatLng(p.lat, p.lon);
const marker = new maps.Marker({ position: at, title: p.name, draggable: p.movable });
if (p.movable)
maps.event.addListener(marker, "dragend", () => {
const to = marker.getPosition();
opts.onMove(p.key, to.getLng(), to.getLat());
});
return [
marker,
new maps.CustomOverlay({
position: at,
content: el("span", { className: "b08-milestone__map-label", text: p.name }),
yAnchor: 2.6,
}),
];
}),
];
drawn.forEach((o) => o.setMap(map));
if (points.length > 1) {
const bounds = new maps.LatLngBounds();
points.forEach((p) => bounds.extend(p));
map.setBounds(bounds);
} else {
map.setCenter(first);
map.setLevel(points.length ? 4 : 9);
}
}),
(error: unknown) => {
box.hidden = true;
hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP;
},
);
};
return {
root: el("div", { className: "b08-milestone__map-wrap", children: [hint, box] }),
set,
};
}
@@ -0,0 +1,251 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_LegModal.ts
* 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름.
*
* 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7)
* 도로 구분 = 기준점 거쳐 시점 들어가는 구간(거래처 → 기준점 · 기준점 → 시점) = 2차포장 · 1차포장 · 비포장 ·
* 그 밖(거래처 → 시점 · 시점 → 중점) = 포장 · 비포장(`roadOptions` · `chainVia`)
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { ROADS, type Leg, type Road } from "./B08_Quantity_Milestone_Calc";
import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout";
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { toastKm } from "./B08_Quantity_UI_Milestone_Modal";
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
import {
findSegmentKm,
liveMilestone,
putMilestone,
roadOptions,
segmentPath,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const km = (text: string): number | null => {
const n = Number(text);
return text.trim() && Number.isFinite(n) && n >= 0 ? n : null;
};
/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · 거리 km(빈칸 = 자동 값) · [×] · [+ 구간] */
export function legsEditor(
initial: Leg[],
via: boolean,
): { root: HTMLElement; value: () => Leg[] } {
const list = el("div");
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = [];
const add = (leg: Leg): void => {
const road = createSelectField({
label: "도로 구분",
options: roadOptions(via, true, leg.road),
compact: true,
});
const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true });
road.select.value = leg.road ?? "";
dist.input.value = leg.km == null ? "" : String(leg.km);
const auto = leg.auto_km ?? null;
if (auto != null) {
dist.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
dist.input.title = `자동 값 ${auto} km`;
}
const ref = { road: road.select, km: dist.input, auto };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = el("div", {
className: "b08-milestone__line-edit",
children: [road.root, dist.root, drop],
});
row.dataset.b08MilestoneLeg = "";
list.append(row);
};
(initial.length ? initial : [{ road: null, km: null }]).forEach(add);
const more = createButton({
label: "+ 구간",
size: "sm",
variant: "ghost",
onClick: () => add({ road: null, km: null }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km: dist, auto }) => ({
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
km: km(dist.value),
auto_km: auto,
})),
};
}
const hint = (text: string): HTMLElement => el("p", { className: "b08-options__hint", text });
/** 시점 → 중점 — 도로 구분 · 거리 늘 손 입력(빈칸 = 노선 ÷ 2) */
function openMidLeg(projectId: string): void {
const data = liveMilestone(projectId);
const site = siteInfo();
openModal({
title: "구간 — 시점 → 중점",
closeLabel: "닫기",
dialogClass: "b08-milestone__modal",
mount: (body, close, actions) => {
const road = createSelectField({
label: "도로 구분",
options: roadOptions(false, false, data.site.mid_road),
compact: true,
});
road.select.value = data.site.mid_road;
const dist = createInputField({ label: "거리 (km)", type: "number", min: 0, compact: true });
dist.input.value =
data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000);
dist.input.placeholder = site?.mid_default_m == null ? "" : String(site.mid_default_m / 1000); // 빈칸 = 노선 ÷ 2
dist.input.dataset.b08MilestoneLegKm = "start";
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh = liveMilestone(projectId);
putMilestone(
projectId,
setLeg(fresh, "start", 0, {
road: road.select.value as Road,
km: km(dist.input.value),
}),
);
close();
},
});
save.dataset.b08MilestoneLegSave = "";
actions.append(save);
body.append(
el("div", { className: "b08-milestone__grid", children: [road.root, dist.root] }),
hint("시점 → 중점 = 늘 손 입력(길찾기 없음) · 빈칸 = 노선 ÷ 2"),
);
},
});
}
/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */
export function openLegInfo(projectId: string, from: string): void {
if (from === "start") return openMidLeg(projectId);
const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null;
const seg = segmentEnds(liveMilestone(projectId), from, start());
if (!seg) return;
openModal({
title: `구간 — ${seg.text}`,
closeLabel: "닫기",
dialogClass: "b08-milestone__modal",
mount: (body, close, actions) => {
const legsOf = (): Leg[] => {
const d = liveMilestone(projectId);
return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? [];
};
const via = chainVia(liveMilestone(projectId), from);
let legs = legsEditor(legsOf(), via);
const status = hint("");
status.dataset.b08MilestoneLegStatus = "";
/** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */
const apply = (): void => {
const fresh = liveMilestone(projectId);
const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from);
if (!target) return;
target.legs = legs.value();
putMilestone(projectId, fresh);
};
const redrawLegs = (): void => {
const next = legsEditor(legsOf(), via);
legs.root.replaceWith(next.root);
legs = next;
};
const pins = (): MapPin[] => {
const d = liveMilestone(projectId);
const route = d.routes.find((r) => r.id === from);
const out: MapPin[] = [];
const add = (key: string, name: string, lon?: number | null, lat?: number | null): void => {
if (lon != null && lat != null)
out.push({ key, name, lon, lat, movable: key !== "start" });
};
if (route) add(route.id, route.name || "거래처", route.lon, route.lat);
if (from === "base" || via) add("base", d.base.name || "기준점", d.base.lon, d.base.lat);
else add("start", "시점", start()?.[0], start()?.[1]);
if (from === "base") add("start", "시점", start()?.[0], start()?.[1]);
return out;
};
const drawMap = (): void => {
const path = segmentPath(from);
map.set(pins(), path.length >= 2 ? [path] : []);
};
const run = async (image = true): Promise<void> => {
find.disabled = true;
status.textContent = "길찾기 중…";
const typed = JSON.stringify(legs.value()); // 기다리는 동안 손댄 칸은 그대로
const text = await findSegmentKm(projectId, from, image).finally(
() => (find.disabled = false),
);
status.textContent = text;
if (image) toastKm(text);
if (JSON.stringify(legs.value()) === typed) redrawLegs();
drawMap();
};
// 끝점 끌어 옮김 = 그 소재지 고침(주소 다시) + 다시 길찾기
const map = editMap(projectId, {
hint: "경로 = 카카오 길찾기 · 거래처 · 기준점 표지를 끌어 옮기면 다시 길찾기",
onMove: (key, lon, lat) => {
apply();
void reverseAddress(projectId, lon, lat).then((address) => {
const fresh = liveMilestone(projectId);
const target = key === "base" ? fresh.base : fresh.routes.find((r) => r.id === key);
if (!target) return;
Object.assign(target, { lon, lat, address });
putMilestone(projectId, fresh);
void run();
});
},
});
const find = createButton({
label: "길찾기",
variant: "ghost",
onClick: () => {
apply();
void run();
},
});
find.dataset.b08MilestoneLegFind = "";
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
apply();
close();
},
});
save.dataset.b08MilestoneLegSave = "";
actions.append(find, save);
body.append(
legs.root,
hint(
from === "base"
? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)"
: "빈칸 = 길찾기 자동 값 · 넣은 값이 우선",
),
status,
el("h4", { text: "경로 — 다음(카카오) 지도" }),
map.root,
);
drawMap();
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음)
if (!segmentPath(from).length && seg.from && seg.to) void run(false);
},
});
}
+183 -311
View File
@@ -1,17 +1,15 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_Modal.ts
* 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침.
* 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침.
*
* [+] 항목 추가 = 항목 · 거래처 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] =
* 경로(하위 탭) 생김 · 같은 거래처 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리
* 거래처 상자 = 그 항목 정보(거래처 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제]
* 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 ·
* 2열(분류(기준위치 · 거래처) · 항목(분류 따라 고르기 목록) · 거래처 이름 · 목적지) + 소재지(주소 찾기 ·
* 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제]
* 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정
* 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
* 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점)
* 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버
* [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 ·
* 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」)
* 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · 보기만) · 경로 그림 = [등록] · [길찾기] 뒤
* 서버 자동 캡처(244-11)
* 조작 = 캐시 · [저장] 때 서버
* ========================================================================== */
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
@@ -25,25 +23,12 @@ import {
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 type { Dest, MilestoneRoute, MilestoneRow, Road } from "./B08_Quantity_Milestone_Calc";
import { request } from "./B08_Quantity_UI_Hydro";
import { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { placePicker } from "./B08_Quantity_UI_Milestone_Place";
import {
findRouteKm,
legRoadOptions,
findSegmentKm,
liveMilestone,
putMilestone,
roadOptions,
@@ -55,324 +40,211 @@ const DEST_OPTIONS = [
{ value: "mid", text: "중점" },
];
/** 분류 → 항목 고르기 목록(거래처 = 견본 「거리이정표」 시트 다섯) */
export const ITEMS: Record<"route" | "base", string[]> = {
route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"],
base: ["기준점"],
};
const line = (...children: HTMLElement[]): HTMLElement =>
el("div", { className: "b08-milestone__line-edit", children });
/** 구간 줄 — 도로 구분 ▾(빈 값 = 안 고름) · km(빈칸 = 자동 값) · [×] · [+ 구간] */
function legsEditor(initial: Leg[]): { root: HTMLElement; value: () => Leg[] } {
const list = el("div");
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = [];
const add = (leg: Leg): void => {
const road = createSelectField({ label: "도로 구분", options: legRoadOptions, 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 auto = leg.auto_km ?? null;
if (auto != null) {
km.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
km.input.title = `자동 값 ${auto} km`;
}
const ref = { road: road.select, km: km.input, auto };
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: null, km: null }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km, auto }) => {
const n = Number(km.value);
return {
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null,
auto_km: auto,
};
}),
};
}
/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */
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 });
/** [길찾기] 한 번 — 구간마다 결과 한 줄(알림) · 실패 줄이 있으면 경고 */
async function routeKm(projectId: string, routeId: string): Promise<string[]> {
const lines = await findRouteKm(projectId, routeId);
if (lines.length)
showToast(
`길찾기 — ${lines.join(" / ")}`,
lines.some((l) => l.includes(" — ")) ? "warning" : "success",
/** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */
export function toastKm(text: string): void {
if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success");
}
/** 항목 ▾ · 목적지 ▾ 한 줄 — 목록 밖 옛 항목 글도 보임 */
function rowField(r: MilestoneRow, kind: "route" | "base") {
const item = createSelectField({ label: "항목", compact: true, options: [] });
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
const setKind = (k: "route" | "base"): void => {
const list = [...ITEMS[k]];
if (r.item && !list.includes(r.item)) list.unshift(r.item);
const keep = list.includes(item.select.value) ? item.select.value : r.item || list[0];
item.setOptions(
list.map((t) => ({ value: t, text: t })),
keep,
);
return lines;
dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정
};
setKind(kind);
dest.select.value = r.dest;
item.select.dataset.b08MilestoneAdd = "item";
return { item, dest, note: r.note, setKind };
}
/** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */
export function openAddItem(
export type PlaceTarget = { add: true; viaBase: boolean } | { add: false; id: string };
/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */
export function openPlaceModal(
projectId: string,
viaBase: boolean,
addRoute: (init: Partial<MilestoneRoute>) => string,
target: PlaceTarget,
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);
let id = same?.id;
if (same) {
same.rows.push(row);
if (place.lon != null) Object.assign(same, place);
putMilestone(projectId, data);
} else id = addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place });
close();
if (id && place.lon != null) void routeKm(projectId, id); // [등록] 뒤 한 번
},
});
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;
const editBase = !target.add && target.id === "base";
const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined;
if (!target.add && !editBase && !route) return;
const title = target.add
? `항목 추가 — ${target.viaBase ? "기준점 거침" : "공사지로 바로"}`
: editBase
? `기준점 — ${data.base.name || "기준점"}`
: `항목 정보 — ${route!.name || "거래처"}`;
openModal({
title: `항목 정보 — ${route.name || "거래처"}`,
title,
closeLabel: "닫기",
dialogClass: "b08-milestone__modal",
mount: (body, close, actions) => {
let kind: "route" | "base" = editBase ? "base" : "route";
const sort = createSelectField({
label: "분류",
compact: true,
options: [
{ value: "base", text: "기준위치" },
{ value: "route", text: "거래처" },
],
});
sort.select.value = kind;
sort.select.disabled = !target.add;
// 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침)
const baseOption = sort.select.querySelector<HTMLOptionElement>('option[value="base"]');
if (target.add && data.base.enabled && baseOption) {
baseOption.disabled = true;
baseOption.textContent = "기준위치(있음 — 기준점 상자에서 고침)";
}
sort.select.dataset.b08MilestoneAdd = "sort";
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);
let legs = legsEditor(route.legs);
const status = el("p", { className: "b08-options__hint" });
status.dataset.b08MilestoneInfo = "status";
const mapView = routeMapView(projectId, routeId);
/** 모달 값 → 캐시(고침 · 길찾기 앞) */
const apply = (): boolean => {
const fresh = liveMilestone(projectId);
const r = fresh.routes.find((x) => x.id === routeId);
if (!r) return false;
Object.assign(r, placeOf(address, r), {
name: name.input.value.trim() || r.name,
via_base: via.input.checked,
rows: rows.value(),
legs: legs.value(),
name.input.value = editBase ? data.base.name : (route?.name ?? "");
name.input.dataset.b08MilestoneAdd = "company";
const nameLabel = name.root.querySelector(".ui-field__label");
const initRows: MilestoneRow[] = route?.rows.length
? route.rows
: [{ item: "", dest: "start", note: "" }];
const rows = initRows.map((r) => rowField(r, kind));
const [first] = rows;
const more = el("div");
const addRow = (field: ReturnType<typeof rowField>): void => {
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(field), 1);
row.remove();
},
});
putMilestone(projectId, fresh);
return true;
const row = line(field.item.root, field.dest.root, drop);
more.append(row);
};
const save = createButton({
label: "고침",
rows.slice(1).forEach(addRow);
const plusRow = createButton({
label: "+ 줄",
size: "sm",
variant: "ghost",
onClick: () => {
const field = rowField({ item: "", dest: "start", note: "" }, kind);
rows.push(field);
addRow(field);
},
});
const via = createCheckbox("기준점 거침");
via.input.checked = route?.via_base ?? true;
via.root.hidden = !route || !data.base.enabled;
const old = editBase ? data.base : route;
const place = placePicker(
projectId,
{ address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null },
() => name.input.value.trim() || (kind === "base" ? "기준점" : "거래처"),
);
const setKind = (k: "route" | "base"): void => {
kind = k;
if (nameLabel) nameLabel.textContent = k === "base" ? "기준점 이름" : "거래처 이름";
rows.forEach((r) => r.setKind(k));
more.hidden = plusRow.hidden = k === "base";
};
sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base"));
setKind(kind);
const rowValues = (): MilestoneRow[] =>
rows.map((r) => ({
item: r.item.select.value,
dest: r.dest.select.value as Dest,
note: r.note,
}));
const moved = (p: { lon: number | null; lat: number | null }): boolean =>
p.lon != null && (p.lon !== old?.lon || p.lat !== old?.lat);
const ok = createButton({
label: target.add ? "등록" : "고침",
variant: "filled",
onClick: () => {
apply();
const text = name.input.value.trim();
const where = place.value();
const fresh = liveMilestone(projectId);
let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번
if (kind === "base") {
Object.assign(fresh.base, where, { enabled: true, name: text, dir: null });
if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }];
putMilestone(projectId, fresh);
if (moved(where)) segment = "base";
} else if (route) {
const r = fresh.routes.find((x) => x.id === route.id);
if (!r) return close();
Object.assign(r, where, {
name: text || r.name,
via_base: via.input.checked,
rows: rowValues(),
});
putMilestone(projectId, fresh);
if (moved(where) || r.via_base !== route.via_base) segment = r.id;
} else {
if (!text) return name.setError("거래처 이름을 넣으세요.");
const same = fresh.routes.find((r) => r.name.trim() === text);
if (same) {
same.rows.push(...rowValues());
if (where.lon != null) Object.assign(same, where);
putMilestone(projectId, fresh);
}
const viaBase = target.add && target.viaBase;
const id =
same?.id ??
addRoute?.({ name: text, via_base: viaBase, dir: null, rows: rowValues(), ...where });
if (id && where.lon != null) segment = id;
}
close();
if (segment) void findSegmentKm(projectId, segment).then(toastKm);
},
});
// [길찾기] = 지금 모달 값을 캐시에 넣고 구간마다 카카오 km(서버) → 구간 칸 다시 그림
const find = createButton({
label: "길찾기",
variant: "ghost",
onClick: async () => {
if (!apply()) return close();
find.disabled = true;
status.textContent = "길찾기 중…";
const lines = await routeKm(projectId, routeId).finally(() => (find.disabled = false));
status.textContent = lines.join(" / ");
const r = liveMilestone(projectId).routes.find((x) => x.id === routeId);
if (!r) return;
const next = legsEditor(r.legs);
legs.root.replaceWith(next.root);
legs = next;
mapView.draw(); // 길 선(그때그때 · 저장 안 함)
},
});
find.dataset.b08MilestoneInfo = "find";
ok.dataset.b08MilestoneInfo = "save";
const remove = createButton({
label: "삭제",
variant: "danger",
onClick: async () => {
if (
!(await showConfirmDialog(
`「${route.name || "거래처"}」 항목을 지울까요? — [저장] 때 반영`,
"삭제",
))
)
const label = editBase ? data.base.name || "기준점" : route?.name || "거래처";
if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제")))
return;
const fresh = liveMilestone(projectId);
fresh.routes = fresh.routes.filter((r) => r.id !== routeId);
putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐
if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] };
else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이
putMilestone(projectId, fresh);
close();
},
});
save.dataset.b08MilestoneInfo = "save";
remove.dataset.b08MilestoneInfo = "delete";
actions.append(remove, find, save);
remove.hidden = target.add;
actions.append(remove, ok);
body.append(
name.root,
via.root,
head("표 줄 — 항목 · 목적지"),
rows.root,
head(
`구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`,
),
legs.root,
status,
head("주소 · 지도"),
address.root,
head("경로 지도 — 카카오"),
mapView.root,
);
},
});
}
/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */
export function openBaseInfo(projectId: string, create = false): void {
const base = liveMilestone(projectId).base;
openModal({
title: create ? "기준점 추가" : `기준점 — ${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.length ? base.legs : [{ road: null, km: null }]);
const save = createButton({
label: create ? "등록" : "고침",
variant: "filled",
onClick: () => {
const fresh: MilestoneData = liveMilestone(projectId);
Object.assign(fresh.base, placeOf(address, fresh.base), {
enabled: true,
name: name.input.value.trim(),
legs: legs.value(),
});
putMilestone(projectId, fresh);
close();
},
});
// 기준점 없앰 = 꺼 둠(값은 남김 · 거래처는 시점으로 바로)
const off = createButton({
label: "기준점 없앰",
variant: "danger",
onClick: () => {
const fresh: MilestoneData = liveMilestone(projectId);
fresh.base.enabled = false;
putMilestone(projectId, fresh);
close();
},
});
off.hidden = create;
save.dataset.b08MilestoneBase = "save";
off.dataset.b08MilestoneBase = "off";
actions.append(off, save);
body.append(
name.root,
head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"),
legs.root,
head("주소 · 지도"),
el("p", {
className: "b08-options__hint",
text: "주소를 고르면 다이어그램에 실제 방위로 놓임 — 안 고르면 공사지 서쪽(왼쪽) 기본 자리",
el("div", {
className: "b08-milestone__grid",
children: [sort.root, first.item.root, name.root, first.dest.root],
}),
address.root,
more,
plusRow,
via.root,
head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"),
place.root,
);
},
});
@@ -410,7 +282,7 @@ export function openSiteInfo(projectId: string): void {
mid.input.dataset.b08Milestone = "mid_station_m";
const road = createSelectField({
label: "시점 → 중점 도로",
options: roadOptions,
options: roadOptions(false, false, data.site.mid_road),
compact: true,
});
road.select.value = data.site.mid_road;
@@ -0,0 +1,144 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_Place.ts
* 거리이정표 등록 모달 「소재지」 칸(PLAN 244-13 · 사용자 147번 5) — 주소 찾기 · 지도에서 고르기만(직접 입력 없음).
*
* 주소 찾기 = B01 회사 주소와 같은 서버 후보 검색(VWorld 대리 · `searchAddress`) → 후보 고름 = 소재지 · 표지
* 지도 = 다음(카카오) 지도 JS(`…_KakaoMap` editMap) — 지도 누름 = 그 자리 소재지 · 표지 끌어 옮김 = 다시 고름 ·
* 고른 좌표 → 서버 GET …/milestone/reverse(VWorld 주소 한 줄 · 못 찾음 = 위도, 경도 글)
* 값 = 주소 글 · 경도 · 위도(캐시 · [저장] 때 서버)
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { request } from "./B08_Quantity_UI_Hydro";
import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { siteInfo } from "./B08_Quantity_UI_Milestone_State";
export interface Place {
address: string;
lon: number | null;
lat: number | null;
}
export interface PlaceHandle {
root: HTMLElement;
value: () => Place;
}
/** 지번 후보 = 지번 줄이 시 · 도까지 갖춤(B01 주소 칸과 같은 고름) */
const lineOf = (c: AddressCandidate): string =>
(c.category === "parcel" ? c.parcel : c.road) || c.road || c.parcel;
/** 좌표 → 주소 한 줄(서버 VWorld) · 못 찾음 = 「위도, 경도」 글 */
export async function reverseAddress(projectId: string, lon: number, lat: number): Promise<string> {
const got = await request<{ address: string | null }>(
`/projects/${projectId}/quantity/milestone/reverse?lon=${lon}&lat=${lat}`,
).catch(() => null);
return got?.address || `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
}
/** 소재지 칸 — name() = 지도 표지 이름(거래처 · 기준점 이름) */
export function placePicker(projectId: string, initial: Place, name: () => string): PlaceHandle {
let place: Place = { ...initial };
let seq = 0; // 늦게 온 주소가 새 자리를 덮지 않게
const query = createInputField({
label: "주소 찾기",
compact: true,
placeholder: "도로명 · 지번 · 건물명 일부 (예 병곡리 968-31)",
});
const find = createButton({ label: "주소 찾기", variant: "ghost", onClick: () => void search() });
find.dataset.b08MilestonePlace = "find";
const list = el("div", { className: "b01-dashboard__address-list" });
list.hidden = true;
const where = el("p", { className: "b08-milestone__place" });
where.dataset.b08MilestonePlace = "value";
const map = editMap(projectId, {
hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김",
onClick: (lon, lat) => void moveTo(lon, lat),
onMove: (_key, lon, lat) => void moveTo(lon, lat),
center: () => siteInfo()?.start_lonlat ?? null,
});
const draw = (): void => {
where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`;
map.set(
place.lon != null && place.lat != null
? [
{
key: "place",
name: name() || "소재지",
lon: place.lon,
lat: place.lat,
movable: true,
},
]
: [],
[],
);
};
async function moveTo(lon: number, lat: number): Promise<void> {
const mine = ++seq;
place = { ...place, lon, lat };
draw();
const address = await reverseAddress(projectId, lon, lat);
if (mine !== seq) return;
place.address = address;
draw();
}
async function search(): Promise<void> {
const text = query.input.value.trim();
if (text.length < 2) return showToast("주소를 두 글자 이상 넣으세요.", "error");
find.disabled = true;
try {
const items = await searchAddress(text);
list.hidden = false;
list.replaceChildren(
...(items.length
? items.map((item) => {
const option = el("button", {
className: "b01-dashboard__address-item",
attrs: { type: "button" },
children: [
el("strong", { text: lineOf(item) }),
el("small", { text: [item.building, item.zipcode].filter(Boolean).join(" · ") }),
],
});
option.addEventListener("click", () => {
seq++;
place = { address: lineOf(item), lon: item.lon, lat: item.lat };
list.hidden = true;
draw();
});
return option;
})
: [el("p", { text: "찾은 주소가 없습니다 — 다른 낱말로 찾거나 지도에서 고르세요." })]),
);
} catch {
showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하세요.", "error");
} finally {
find.disabled = false;
}
}
// 검색 칸 Enter = 찾기만(모달 안 닫힘)
query.input.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
void search();
});
draw();
return {
root: el("div", {
className: "b08-milestone__place-field",
children: [
el("div", { className: "b08-milestone__line-edit", children: [query.root, find] }),
list,
where,
map.root,
],
}),
value: () => ({ ...place }),
};
}
+66 -52
View File
@@ -11,14 +11,16 @@
* 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐)
* 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) ·
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { request } from "./B08_Quantity_UI_Hydro";
import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc";
import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout";
import { roadsFor, type MilestoneData } from "./B08_Quantity_Milestone_Calc";
import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout";
import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal";
export interface SiteInfo {
@@ -182,7 +184,11 @@ export async function autoRouteImage(
routeId: string,
lines: Array<Array<[number, number]>>,
): Promise<void> {
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
// 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘)
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice(
0,
2,
);
autoNotes.set(routeId, "경로 그림 만드는 중…(서버 지도 캡처)");
changes.dispatchEvent(new Event("change"));
let reason: string | null;
@@ -213,59 +219,67 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise<
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
}
export const roadOptions = ROADS.map((r) => ({ value: r, text: r }));
/** 도로 구분 ▾ — via = 기준점 거쳐 시점 들어가는 구간(2차 · 1차 · 비포장) · 아니면 포장 · 비포장(244-13) ·
* blank = 「도로 구분 고르기」 빈 값(자동 판정 없음 · 244-7) · 목록 밖 옛 값도 보임 */
export function roadOptions(
via: boolean,
blank: boolean,
current?: string | null,
): Array<{ value: string; text: string }> {
const roads: string[] = roadsFor(via);
if (current && !roads.includes(current)) roads.push(current);
return [
...(blank ? [{ value: "", text: "도로 구분 고르기" }] : []),
...roads.map((r) => ({ value: r, text: r })),
];
}
/** 구간 도로 구분 ▾ — 빈 값 = 안 고름(자동 판정 없음 · 244-7) */
export const legRoadOptions = [{ value: "", text: "도로 구분 고르기" }, ...roadOptions];
/** 마지막 [길찾기] 길 선 — 경로 id → 구간마다 [위도, 경도] 목록(메모리만 · 저장 안 함) */
const paths = new Map<string, Array<Array<[number, number]>>>();
/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */
const paths = new Map<string, Array<[number, number]>>();
export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? [];
/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */
export const routePaths = (routeId: string): Array<Array<[number, number]>> =>
paths.get(routeId) ?? [];
paths.has(routeId) ? [paths.get(routeId)!] : [];
/** [길찾기] — 그 거래처의 구간마다(`kmSegments`) 서버가 카카오로 km · 자동 값을 캐시에 ·
* 돌려줌 = 구간마다 결과 한 줄(km 또는 까닭 · 까닭 줄엔 「 — 」) */
export async function findRouteKm(projectId: string, routeId: string): Promise<string[]> {
const segments = kmSegments(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null);
const out: string[] = [];
const got: Array<[KmSegment["key"], number]> = [];
const lines: Array<Array<[number, number]>> = [];
for (const seg of segments) {
if (!seg.from || !seg.to) {
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
out.push(
`${seg.text} — ${missing} 좌표가 없습니다(주소 검색 · 노선) · km 를 손으로 넣으세요.`,
);
continue;
}
try {
const res = await request<{
km: number | null;
reason: string | null;
path?: Array<[number, number]>;
}>(`/projects/${projectId}/quantity/milestone/directions`, {
method: "POST",
body: JSON.stringify({
origin: { lon: seg.from[0], lat: seg.from[1] },
destination: { lon: seg.to[0], lat: seg.to[1] },
}),
});
if (res.km == null) out.push(`${seg.text} — ${res.reason ?? "길찾기 실패"}`);
else {
out.push(`${seg.text} ${res.km} km`);
got.push([seg.key, res.km]);
if ((res.path?.length ?? 0) >= 2) lines.push(res.path!);
}
} catch (error) {
out.push(`${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`);
}
/** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) ·
* 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */
export async function findSegmentKm(
projectId: string,
from: string,
image = true,
): Promise<string> {
const data = liveMilestone(projectId);
const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null);
if (!seg) return "";
if (!seg.from || !seg.to) {
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
return `${seg.text} — ${missing} 좌표가 없습니다(소재지 · 노선) · km 를 손으로 넣으세요.`;
}
paths.set(routeId, lines);
if (lines.length) void autoRouteImage(projectId, routeId, lines); // 뒤에서 · km 알림 안 기다림
if (got.length) {
try {
const res = await request<{
km: number | null;
reason: string | null;
path?: Array<[number, number]>;
}>(`/projects/${projectId}/quantity/milestone/directions`, {
method: "POST",
body: JSON.stringify({
origin: { lon: seg.from[0], lat: seg.from[1] },
destination: { lon: seg.to[0], lat: seg.to[1] },
}),
});
if (res.km == null) return `${seg.text} — ${res.reason ?? "길찾기 실패"}`;
if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!);
else paths.delete(from);
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
for (const [key, km] of got) putAutoKm(fresh, routeId, key, km);
putMilestone(projectId, fresh);
const legs = from === "base" ? fresh.base.legs : fresh.routes.find((r) => r.id === from)?.legs;
if (legs?.[0]?.auto_km !== res.km) {
putAutoKm(fresh, from, seg.key, res.km);
putMilestone(projectId, fresh);
} else changes.dispatchEvent(new Event("change"));
if (image && seg.key === "route" && paths.has(from))
void autoRouteImage(projectId, from, routePaths(from)); // 뒤에서 · km 알림 안 기다림
return `${seg.text} ${res.km} km`;
} catch (error) {
return `${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`;
}
return out;
}
@@ -260,7 +260,9 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동():
assert "getDisplayMedia" not in text and "지도 캡처" not in text, name
assert "캡처는 localhost" not in text, name
state = read("B08_Quantity_UI_Milestone_State.ts")
assert "if (lines.length) void autoRouteImage(projectId, routeId, lines);" in state
# 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림
assert 'if (image && seg.key === "route" && paths.has(from))' in state
assert "void autoRouteImage(projectId, from, routePaths(from));" in state
assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state
assert "user_only=true" in state and "showToast(reason," in state
route = read("B08_Quantity_UI_Milestone_Route.ts")
@@ -0,0 +1,305 @@
"""244-13 B08 거리이정표 등록 모달 · 연결선 길찾기 · 도로 구분 열(사용자 147번 5 · 8).
도로 구분 = 기준점 거쳐 시점 들어가는 구간 2차포장 · 1차포장 · 비포장 · 그 밖 포장 · 비포장
(파이썬 · TS 거울) · 연결선 구간 끝 · 표 열(2차포장 = 기준점 있을 때만) · 소재지 역지오코딩 길 ·
고르는 지도(누름 · 끌기) · 연결선 한 구간 [길찾기](거래처 쪽만 자동 그림) — node 로 실제 TS 를 돌림.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
B08 = "B08_Quantity/B08_Quantity_"
CALC = B08 + "Milestone_Calc.ts"
LAYOUT = B08 + "Milestone_Layout.ts"
KMAP = B08 + "UI_Milestone_KakaoMap.ts"
STATE = B08 + "UI_Milestone_State.ts"
BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone"
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(rels, script, pre=""):
src = pre + "".join(IMPORTS.sub("", _read(rel)) for rel in rels)
result = subprocess.run( # 원본이 길어 명령 줄 대신 표준 입력
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=src + "\n" + script, 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, legs, via=True, rows=None, **kw):
return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "legs": legs,
"rows": rows or [{"item": "철근", "dest": "start", "note": ""}], **kw} # fmt: skip
NO_BASE = {
"site": {"mid_station_m": 800, "mid_road": "비포장"},
"base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []},
"routes": [
_route("g", [{"road": "포장", "km": 10.4}, {"road": "비포장", "km": 0.5}], rows=[
{"item": "철근", "dest": "start", "note": ""},
{"item": "혼합골재", "dest": "mid", "note": ""}]),
],
} # fmt: skip
WITH_BASE = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": True, "name": "병곡리", "address": "", "dir": None, "lon": 127.85,
"lat": 35.66, "legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]},
"routes": [
_route("g", [{"road": "2차포장", "km": 23.8}], lon=127.9, lat=35.68),
_route("d", [{"road": "포장", "km": 5}], via=False, lon=127.8, lat=35.6),
],
} # fmt: skip
def test_포장_도로_구분_거울():
a = compute_milestone(NO_BASE, 1500.0)
assert [(r["pv"], r["un"], r["paved"], r["total"]) for r in a] == [
(10.4, 0.5, 10.4, 10.9),
(10.4, 1.3, 10.4, 11.7), # 목적지 중점 = 시점 → 중점 0.8 km 비포장 더함
]
b = compute_milestone(WITH_BASE, 1500.0)
assert (b[0]["s2"], b[0]["s1"], b[0]["pv"], b[0]["un"]) == (23.8, 0.6, 0, 0.8)
assert (b[0]["paved"], b[0]["total"]) == (24.4, 25.2) # 견본 수식 = 포장 2차 + 1차
assert (b[1]["s2"], b[1]["pv"], b[1]["paved"], b[1]["total"]) == (0, 5, 5, 5) # 거침 안 함
ts = _node([CALC], f"console.log(JSON.stringify([computeMilestone({json.dumps(NO_BASE)}, "
f"1500), computeMilestone({json.dumps(WITH_BASE)}, 1500)]));") # fmt: skip
assert ts == [a, b]
assert ms.Leg.model_validate({"road": "포장", "km": 1}).road == "포장"
with pytest.raises(ValidationError):
ms.Leg.model_validate({"road": "3차포장"})
def test_도로_구분_갈래_연결선_구간_끝():
d = json.dumps(WITH_BASE)
off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}})
got = _node(
[CALC, LAYOUT],
f"""const D = {d}; const O = {off}; const S = [127.83, 35.64];
console.log(JSON.stringify([roadsFor(true), roadsFor(false),
["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)),
segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S),
segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S),
kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""",
)
assert got[0] == ["2차포장", "1차포장", "비포장"] and got[1] == ["포장", "비포장"]
assert got[2] == [True, False, True, False] and got[3] == [False, False]
assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68],
"to": [127.85, 35.66]} # fmt: skip
assert got[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64]
assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66],
"to": [127.83, 35.64]} # fmt: skip
assert got[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음
assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로
assert got[10] == ["route", "base"] and got[11] == ["route"]
def test_표_열_기준점_따라():
got = _node(
[B08 + "UI_Milestone.ts"],
"console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>"
" milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));",
)
assert got[0] == ["|항목|item", "|거래처 이름|company", "|목적지|dest",
"도로 구분 (km)|포장|paved", "도로 구분 (km)|비포장|un",
"계 (km)|합계|total", "|비고|note"] # fmt: skip
roads = [c for c in got[1] if c.startswith("도로 구분")]
assert roads == ["도로 구분 (km)|2차포장|s2", "도로 구분 (km)|1차포장|s1",
"도로 구분 (km)|비포장|un"] # fmt: skip
assert "계 (km)|포장|paved" in got[1] and "계 (km)|비포장|unpaved" in got[1]
assert "도로 구분 (km)|포장|pv" in got[2] # 기준점 있어도 거침 안 한 「포장」 값 = 포장 열
assert not any("2차포장" in c for c in got[0]) # 2차포장 열 = 기준점 있을 때만
def test_소재지_역지오코딩(monkeypatch):
asked = []
async def fake(lon, lat):
asked.append((lon, lat))
return "경남 거창군 북상면 병곡리 968-31"
monkeypatch.setattr(ms, "reverse_geocode", fake)
app = FastAPI()
app.include_router(ms.router)
client = TestClient(app)
res = client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 35.66})
assert res.json() == {"address": "경남 거창군 북상면 병곡리 968-31"}
assert asked == [(127.85, 35.66)]
assert client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 91}).status_code == 422
MAP_STUBS = """
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [] });
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
(globalThis as any).window = globalThis;
(globalThis as any).document = { createElement: () => ({}), head: { append: () => undefined } };
const request = async () => ({ key: "fake-js-key" });
const LOG: any = { markers: [], handlers: [], lines: 0 };
class LatLng { lat: number; lng: number;
constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; }
getLat() { return this.lat; } getLng() { return this.lng; } }
const overlay = () => ({ setMap: () => undefined });
(globalThis as any).kakao = { maps: {
load: (f: () => void) => f(), LatLng,
LatLngBounds: class { extend() {} },
Map: class { relayout() {} setBounds() { LOG.fit = true; }
setCenter(p: any) { LOG.center = [p.lng, p.lat]; } setLevel(l: number) { LOG.level = l; } },
Polyline: function () { LOG.lines++; return overlay(); },
Marker: function (o: any) { const m = { ...overlay(), o, at: o.position,
getPosition: () => m.at }; LOG.markers.push(m); return m; },
CustomOverlay: function () { return overlay(); },
event: { addListener: (t: any, type: string, fn: any) => LOG.handlers.push([t, type, fn]) },
} };
"""
def test_고르는_지도_누름_끌기():
got = _node(
[KMAP],
"""
const moved: any[] = []; const clicked: any[] = [];
const m = editMap("p1", { hint: "h", onMove: (k, lon, lat) => moved.push([k, lon, lat]),
onClick: (lon, lat) => clicked.push([lon, lat]), center: () => [127.83, 35.64] });
m.set([], []);
await new Promise((r) => setTimeout(r, 20));
const empty = { center: LOG.center, level: LOG.level, hidden: m.root.children[1].hidden };
m.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true },
{ key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }],
[[[35.68, 127.9], [35.64, 127.83]]]);
await new Promise((r) => setTimeout(r, 20));
const click = LOG.handlers.find(([, type]) => type === "click");
click[2]({ latLng: new LatLng(35.7, 127.95) });
const drags = LOG.handlers.filter(([, type]) => type === "dragend");
const [r1] = LOG.markers.slice(-2);
r1.at = new LatLng(35.69, 127.91);
drags[0][2]();
console.log(JSON.stringify({ empty, moved, clicked, drags: drags.length, fit: !!LOG.fit,
lines: LOG.lines, draggable: LOG.markers.slice(-2).map((x: any) => !!x.o.draggable),
clicks: LOG.handlers.filter(([, t]) => t === "click").length }));
""",
MAP_STUBS,
)
assert got["empty"] == {
"center": [127.83, 35.64],
"level": 9,
"hidden": False,
} # 점 없음 = 시점
assert got["draggable"] == [True, False] and got["drags"] == 1 # 시점 = 고정
assert got["moved"] == [["r1", 127.91, 35.69]] and got["clicked"] == [[127.95, 35.7]]
assert got["fit"] and got["lines"] == 1 and got["clicks"] == 1 # 지도 누름 사건 한 번만
STATE_STUBS = """
const API_BASE_URL = "";
const showToast = () => undefined;
const store = new Map<string, any>();
const readState = (k: string, p: string) => store.get(k + p) ?? null;
const writeState = (k: string, v: any, p: string) => (store.set(k + p, v), WRITES.n++);
const clearState = (k: string, p: string) => store.delete(k + p);
const WRITES = { n: 0 };
const CALLS: any[] = [];
const request = async (url: string, init: any = {}) => {
CALLS.push([url, init.body ? JSON.parse(init.body) : null]);
if (url.endsWith("/milestone")) return { data: DATA, site: { start_lonlat: [127.83, 35.64],
mid_default_m: 1500 }, images: {}, auto_images: {} };
if (url.includes("/directions")) return { km: 10.5, reason: null,
path: [[35.68, 127.9], [35.66, 127.85]] };
return { name: "auto/g.png", reason: null };
};
"""
def test_연결선_한_구간_길찾기_거래처_쪽만_그림():
script = f"""const DATA = {json.dumps(WITH_BASE)};
await loadMilestone("p1");
const a = await findSegmentKm("p1", "g");
await new Promise((r) => setTimeout(r, 10));
const afterRoute = CALLS.length;
const b = await findSegmentKm("p1", "base");
const writes = WRITES.n;
await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀
const live = liveMilestone("p1");
console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n,
route: live.routes[0].legs, base: live.base.legs, path: segmentPath("g"),
routeLines: routePaths("g"), baseLines: routePaths("base"),
start: await findSegmentKm("p1", "start") }}));"""
got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS)
assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km"
(url, body), (img, payload), (url2, body2) = got["calls"][:3]
assert url.endswith("/directions") and body == {
"origin": {"lon": 127.9, "lat": 35.68},
"destination": {"lon": 127.85, "lat": 35.66},
}
assert img.endswith("/image/auto?route=g") # 거래처 쪽 구간 = 자동 그림
assert payload["lines"] == [[[35.68, 127.9], [35.66, 127.85]]]
assert [m["name"] for m in payload["marks"]] == ["g", "병곡리"] # 두 끝만(시점 없음)
assert body2["origin"] == {"lon": 127.85, "lat": 35.66}
assert body2["destination"] == {"lon": 127.83, "lat": 35.64}
assert not any(
"/image/auto" in c[0] for c in got["calls"][got["afterRoute"] :]
) # 기준점 = 그림 없음
assert got["route"][0]["km"] == 23.8 and got["route"][0]["auto_km"] == 10.5 # 사용자 값 그대로
assert got["base"][0]["auto_km"] == 10.5 and got["again"] == got["writes"]
assert got["routeLines"] == [got["path"]] and got["baseLines"] == [
got["path"]
] # 구간마다 길 선
assert got["start"] == "" # 시점 → 중점 = 길찾기 없음
def test_화면_등록_모달_연결선_모달():
modal = _read(B08 + "UI_Milestone_Modal.ts")
place = _read(B08 + "UI_Milestone_Place.ts")
leg = _read(B08 + "UI_Milestone_LegModal.ts")
diagram = _read(B08 + "UI_Milestone_Diagram.ts")
css = _read(B08 + "UI_Milestone.css")
# ① 큰 창 · 2열 · 분류 · 항목 목록 · 소재지 · 직접 입력 없음 · 다음 지도
assert 'dialogClass: "b08-milestone__modal"' in modal
assert ".ui-modal.b08-milestone__modal {\n width: min(960px, 94vw);" in css
assert "grid-template-columns: 1fr 1fr;" in css
assert "children: [sort.root, first.item.root, name.root, first.dest.root]" in modal
assert (
'{ value: "base", text: "기준위치" }' in modal
and '{ value: "route", text: "거래처" }' in modal
)
assert 'route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"]' in modal
assert "소재지" in modal and "거래처 위치" not in modal
for text in (modal, place):
assert 'label: "직접 입력"' not in text and "buildAddressField" not in text
assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place
assert "/quantity/milestone/reverse?lon=" in place
# ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음
assert 'base: () => openPlaceModal(projectId, { add: false, id: "base" }),' in diagram
assert 'label: "기준점 +"' not in diagram and "addBase" not in diagram
# ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만
assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram
assert "clearTimeout(wait);" in diagram # 선 글 더블클릭 = 바로 고침 그대로
mid = leg[leg.index("function openMidLeg") : leg.index("export function openLegInfo")]
assert "길찾기" not in mid.replace("길찾기 없음", "") and "editMap" not in mid
assert 'if (from === "start") return openMidLeg(projectId);' in leg
assert "void reverseAddress(projectId, lon, lat)" in leg and "void run();" in leg
assert "if (!segmentPath(from).length && seg.from && seg.to) void run(false);" in leg
for rel in (B08 + "UI_Milestone_Modal.ts", B08 + "UI_Milestone_Place.ts",
B08 + "UI_Milestone_LegModal.ts", KMAP, STATE, LAYOUT, CALC,
B08 + "UI_Milestone.ts", B08 + "UI_Milestone_Diagram.ts",
"B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip
assert len(_read(rel).splitlines()) <= 700, rel
+2 -2
View File
@@ -162,8 +162,8 @@ def test_메인_탭_하위_탭_추가_삭제():
# 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
assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal
assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route
# 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음
@@ -141,13 +141,16 @@ def test_빈_방위_차례_같은_방위_나란히():
def test_화면_상자_누름_더하기_창():
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8")
# 244-4 — 상자 누름 = 모달(탭 이동 아님)
assert "openTab" not in ui and "route: (id) => openRouteInfo(projectId, id)," in ui
assert (
"openTab" not in ui
and "route: (id) => openPlaceModal(projectId, { add: false, 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
assert "openPlaceModal(projectId, { add: true, 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 # 같은 업체 이름 = 그 경로에 표 줄
assert "same.rows.push(...rowValues())" in modal # 같은 업체 이름 = 그 경로에 표 줄
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8")
assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page
assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page
@@ -140,30 +140,30 @@ def test_시점_모달_주소_길(tmp_path, monkeypatch):
def test_화면_모달_팬줌_그림탭():
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
# ① [+] 모달 — 항목 · 업체 이름 · 목적지 + 주소 검색(B01 재활용) · 방위 고르기 없음 · [등록]
# ① [+] 모달 — 항목 · 거래처 이름 · 목적지 + 소재지(244-13 · 주소 찾기 = B01 후보 검색 재활용 ·
# 지도 = 다음 지도) · 방위 고르기 없음 · [등록]
add = modal[
modal.index("export function openAddItem") : modal.index("export function openRouteInfo")
modal.index("export function openPlaceModal") : modal.index("export interface SiteAddress")
]
for text in ('label: "항목"', 'label: "거래처 이름"', 'label: "목적지"', 'label: "등록"',
"buildAddressField(null)", "placeOf(address,"): # fmt: skip
for text in ('label: "항목"', 'label: "목적지"'): # 항목 · 목적지 줄 = rowField
assert text in modal
for text in ('label: "거래처 이름"', '"등록"', "placePicker(", "place.value()"):
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
place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts")
assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place
assert "searchAddress(text)" in place
assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도
# ② 시점 모달 — 등록 주소 · 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)',
"rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip
assert text in info
# ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달
for text in ('"고침"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"):
assert text in add
leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts")
assert "legsEditor(legsOf(), via)" in leg
diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert "route: (id) => openRouteInfo(projectId, id)," in diagram
assert "route: (id) => openPlaceModal(projectId, { add: false, id })," in diagram
assert "start: () => openSiteInfo(projectId)," in diagram
# ③ 팬 · 줌 = 공용
assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram
@@ -55,8 +55,10 @@ def test_좌측_패널_방위_칸_없음_모달_안내():
assert "공사지에서 본 방위" not in state and "dirOptions" not in state
assert "d.base.dir" not in state # 244-6 — 기준점 칸은 다이어그램 기준점 모달로
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")]
assert "주소를 고르면 다이어그램에 실제 방위로 놓임" in base
base = modal[
modal.index("export function openPlaceModal") : modal.index("interface SiteAddress")
]
assert "방위는 이 자리로 자동" in base # 244-13 — 기준점 = 등록 모달 분류 기준위치
layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
assert (
"placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270" in layout
@@ -118,11 +118,13 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음():
site = modal[modal.index("export function openSiteInfo") :]
assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site
assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site
base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")]
assert "enabled: true," in base and 'label: "기준점 없앰"' in base
assert "fresh.base.enabled = false;" in base
assert 'label: "기준점 +"' in ui and "openBaseInfo(projectId, true)" in ui
assert "addBase.hidden = data.base.enabled;" in ui
# 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음
base = modal[
modal.index("export function openPlaceModal") : modal.index("interface SiteAddress")
]
assert "enabled: true," in base and 'label: "삭제"' in base
assert "fresh.base = { enabled: false," in base
assert 'label: "기준점 +"' not in ui and "openBaseInfo" not in ui and "addBase" not in ui
assert "content: panContent(vw, vh)" in ui and "panStart," in ui
@@ -219,8 +219,10 @@ def test_화면_길찾기_단추_키_화면에_없음():
modal = read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
state = read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
table = read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert 'label: "길찾기"' in modal and "routeKm(projectId, id)" in modal # 단추 · [등록] 뒤
assert "/quantity/milestone/directions" in state and "kmSegments(" in state
# 244-13 — [길찾기] = 연결선 모달 단추 · [등록] 뒤 그 구간 한 번
leg = read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts")
assert 'label: "길찾기"' in leg and "findSegmentKm(projectId, segment)" in modal
assert "/quantity/milestone/directions" in state and "segmentEnds(" in state
assert '"도로 구분 고르기"' in table and "r.pick" in table
assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름
# 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음
@@ -215,13 +215,13 @@ def test_화면_자리_캡처_키_서버만():
kmap = _read(KMAP)
assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap
assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert "mapView.root" in modal and "mapView.draw();" in modal
leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도
assert "map.root," in leg and "map.set(pins()" in leg
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "routeMapView(projectId, routeId)" in route
assert "mapView.draw();" in route
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감)
assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감)
for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만
text = rel.read_text(encoding="utf-8")
assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel