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:
@@ -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"]),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(...)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user