feat(b08): 거리이정표 표 폭 = 다이어그램 폭 · 거래처 상자 항목 글 · 좌측 카드 없앰 · 팬 고침 · 거래처 글 (244-6)

- 틀 너비 = 표 너비 · 다이어그램은 그 너비로 늘음
- 거래처 상자 = 항목(윗줄) + 거래처 이름
- 좌측 패널 거리이정표 카드 없앰 — 중점 측점 · 도로는 공사지 모달 · 기준점은 기준점 모달([기준점 +] · 기준점 없앰)
- 팬 고침: 원배율에서 공용 팬 범위가 0 이던 것 — 범위 줌 · 빈 곳 왼쪽 끌기 더함(공용 panStart 선택 칸 · 기본 그대로)
- 화면 글 「업체」 → 「거래처」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 21:46:37 +09:00
co-authored by Claude Opus 5.5
parent 7964fbcf31
commit 4b70627682
14 changed files with 328 additions and 137 deletions
+4 -2
View File
@@ -67,6 +67,8 @@ export interface SvgZoomPanOptions {
content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean;
/** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */
panStart?: (event: PointerEvent) => boolean;
}
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
@@ -146,8 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
if (event.button !== 1) return;
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
event.preventDefault();
panning = true;
moved = false;
+25 -8
View File
@@ -2,12 +2,12 @@
* B08_Quantity_Milestone_Layout.ts
* 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림).
*
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들
* 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면
* 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 거래처들
* 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면
* 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 ·
* 같은 방위 여럿 = 옆으로 나란히
* 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5)
* 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
* 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다
* 화면 = 북 위(SVG y 아래로 = 남)
* 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌)
* ========================================================================== */
@@ -22,6 +22,8 @@ export interface DiagramBox {
w: number;
h: number;
label: string;
/** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */
items?: string;
}
export interface DiagramLine {
@@ -160,10 +162,10 @@ export function layoutMilestone(
line("base", "start", legText(data.base.legs));
}
// 업체 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
// 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히
const groups = new Map<
string,
Array<{ id: string; label: string; anchor: string; deg: number; legs: Leg[] }>
Array<{ id: string; label: string; items: string; anchor: string; deg: number; legs: Leg[] }>
>();
const used = new Set<string>();
const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`;
@@ -173,7 +175,7 @@ export function layoutMilestone(
});
for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg));
let auto = 0;
/** 방위 안 고른 업체 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */
/** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */
const free = (anchor: string): number => {
const turn = anchor === "base" ? baseDeg : 0;
const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1;
@@ -187,7 +189,10 @@ export function layoutMilestone(
used.add(keyOf(anchor, d));
const key = keyOf(anchor, d);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push({ id: r.id, label: r.name || "업체", anchor, deg: d, legs: r.legs });
const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · ");
groups
.get(key)!
.push({ id: r.id, label: r.name || "거래처", items, anchor, deg: d, legs: r.legs });
}
for (const group of groups.values()) {
group.forEach((g, k) => {
@@ -205,6 +210,7 @@ export function layoutMilestone(
w: BOX.w,
h: BOX.h,
label: g.label,
items: g.items,
});
line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs));
});
@@ -229,7 +235,7 @@ export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: nu
return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`;
}
/** 선 글 한 줄 → 그 구간 — from = 업체 id(업체 구간) · "base"(공통 구간) · "start"(시점 → 중점) */
/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */
export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null {
if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 };
const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs;
@@ -254,3 +260,14 @@ export function setLeg(
if (leg) Object.assign(leg, patch);
return data;
}
/** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */
export const PAN_SKIP = ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus";
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;
return e.button === 0 && !target?.closest?.(PAN_SKIP);
}
/** 팬 한계 — 원배율에서도 그림 둘레 한 폭씩 끌어 옮길 수 있게(공용 팬은 이 범위까지 엶) */
export const panContent = (w: number, h: number) => ({ x: -w, y: -h, width: 3 * w, height: 3 * h });
@@ -4,6 +4,13 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
/* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */
width: fit-content;
max-width: 100%;
}
.b08-milestone > .b08-milestone__diagram {
contain: inline-size;
}
.b08-milestone__route {
@@ -147,3 +154,9 @@
.b08-milestone__leg-edit .ui-input {
width: 80px;
}
/* 거래처 상자 윗줄 = 항목(244-6) */
.b08-milestone__items {
fill: var(--color-text-secondary);
font-size: 12px;
}
+7 -7
View File
@@ -1,11 +1,11 @@
/* =============================================================================
* B08_Quantity_UI_Milestone.ts
* B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름).
* B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름).
* 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음).
*
* [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
* [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
* 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도
* 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
* 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State`
* ========================================================================== */
@@ -34,7 +34,7 @@ import {
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: "company", label: "거래처 이름", width: 120 },
{ key: "dest", label: "목적지", width: 72 },
{ key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM },
{ key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM },
@@ -45,13 +45,13 @@ const COLUMNS: DataColumn[] = [
{ key: "note", label: "비고", width: 160 },
];
/** 새 경로(업체) — 캐시에 넣고 id 를 돌려줌 */
/** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */
export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}): string {
const data = liveMilestone(projectId);
const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
data.routes.push({
id,
name: `업체 ${data.routes.length + 1}`,
name: `거래처 ${data.routes.length + 1}`,
address: "",
via_base: true,
dir: null,
@@ -141,7 +141,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void {
};
tabs.replaceChildren(
tab("table", "거리이정표"),
...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)),
...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)),
);
};
@@ -2,17 +2,25 @@
* B08_Quantity_UI_Milestone_Diagram.ts
* [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`.
*
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글
* 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 ·
* 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 거래처들 · 선마다 「도로 구분 km」 글
* 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 거래처 = 항목 정보 · 공사지 = 시점 주소 ·
* 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`)
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 빈 곳 더블클릭 = 원배율)
* 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율)
* 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6)
* 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소)
* ========================================================================== */
import { createSelectField, el } from "@ui/ui_template_elements";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { layoutMilestone, legAt, setLeg, type DiagramBox } from "./B08_Quantity_Milestone_Layout";
import {
layoutMilestone,
legAt,
panContent,
panStart,
setLeg,
type DiagramBox,
} from "./B08_Quantity_Milestone_Layout";
import {
openAddItem,
openBaseInfo,
@@ -138,7 +146,8 @@ export function createDiagram(
role: "img",
"aria-label": "거리이정표 다이어그램",
});
root.style.maxWidth = "100%";
// 틀 너비 = 아래 표 너비(244-6) · 높이는 비율대로(팬 · 줌 화면 px ↔ 그림 좌표가 어긋나지 않게)
root.style.width = "100%";
root.style.height = "auto";
const layer = svg("g", {});
const shift = svg("g", { transform: `translate(${-vx} ${-vy})` });
@@ -162,9 +171,19 @@ export function createDiagram(
"text-anchor": "middle",
"dominant-baseline": "central",
},
box.label,
box.kind === "route" && box.items ? "" : box.label,
),
);
if (box.kind === "route" && box.items) {
// 거래처 상자 = 항목(윗줄) + 거래처 이름(아랫줄)
const x = box.x + box.w / 2;
const two = g.lastChild as SVGTextElement;
two.setAttribute("y", String(box.y + box.h / 2 - 8));
two.append(
svg("tspan", { x, class: "b08-milestone__items" }, box.items),
svg("tspan", { x, dy: 16 }, box.label),
);
}
// 상자 누름 = 모달(탭 이동 아님)
const info = (): void => INFO[box.kind](box.id);
g.setAttribute("role", "button");
@@ -194,7 +213,15 @@ export function createDiagram(
});
shift.append(text);
}
host.replaceChildren(root);
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);
attachSvgZoomPan({
svg: root,
layer,
@@ -204,6 +231,8 @@ export function createDiagram(
initial: zoomState,
onChange: (state) => (zoomState = state),
wheelZoom: true,
content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임)
panStart, // 가운데 버튼 · 빈 곳 왼쪽 끌기
});
};
refresh();
+86 -21
View File
@@ -2,9 +2,9 @@
* B08_Quantity_UI_Milestone_Modal.ts
* 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침.
*
* [+] 항목 추가 = 항목 · 업체 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] =
* 경로(하위 탭) 생김 · 같은 업체 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리
* 업체 상자 = 그 항목 정보(업체 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제]
* [+] 항목 추가 = 항목 · 거래처 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] =
* 경로(하위 탭) 생김 · 같은 거래처 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리
* 거래처 상자 = 그 항목 정보(거래처 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제]
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
* 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점)
* 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버
@@ -36,7 +36,12 @@ import {
type Road,
} from "./B08_Quantity_Milestone_Calc";
import { request } from "./B08_Quantity_UI_Hydro";
import { liveMilestone, putMilestone, roadOptions } from "./B08_Quantity_UI_Milestone_State";
import {
liveMilestone,
putMilestone,
roadOptions,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const DEST_OPTIONS = [
{ value: "start", text: "시점" },
@@ -145,7 +150,7 @@ function placeOf(
const head = (text: string): HTMLElement => el("h4", { text });
/** [+] — 항목 · 업체 이름 · 목적지 + 주소 검색 · 지도 → [등록] */
/** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */
export function openAddItem(
projectId: string,
viaBase: boolean,
@@ -156,7 +161,7 @@ export function openAddItem(
closeLabel: "닫기",
mount: (body, close, actions) => {
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
const company = createInputField({ label: "업체 이름", compact: true });
const company = createInputField({ label: "거래처 이름", compact: true });
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
const address = buildAddressField(null);
item.input.dataset.b08MilestoneAdd = "item";
@@ -166,7 +171,7 @@ export function openAddItem(
variant: "filled",
onClick: () => {
const name = company.input.value.trim();
if (!name) return company.setError("업체 이름을 넣으세요.");
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);
@@ -184,23 +189,23 @@ export function openAddItem(
item.root,
company.root,
dest.root,
head("업체 위치 — 주소 검색(방위는 이 자리로 자동)"),
head("거래처 위치 — 주소 검색(방위는 이 자리로 자동)"),
address.root,
);
},
});
}
/** 업체 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */
/** 거래처 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */
export function openRouteInfo(projectId: string, routeId: string): void {
const data = liveMilestone(projectId);
const route = data.routes.find((r) => r.id === routeId);
if (!route) return;
openModal({
title: `항목 정보 — ${route.name || "업체"}`,
title: `항목 정보 — ${route.name || "거래처"}`,
closeLabel: "닫기",
mount: (body, close, actions) => {
const name = createInputField({ label: "업체 이름", compact: true });
const name = createInputField({ label: "거래처 이름", compact: true });
name.input.value = route.name;
name.input.dataset.b08MilestoneInfo = "name";
const via = createCheckbox("기준점 거침");
@@ -232,7 +237,7 @@ export function openRouteInfo(projectId: string, routeId: string): void {
onClick: async () => {
if (
!(await showConfirmDialog(
`「${route.name || "업체"}」 항목을 지울까요? — [저장] 때 반영`,
`「${route.name || "거래처"}」 항목을 지울까요? — [저장] 때 반영`,
"삭제",
))
)
@@ -252,7 +257,7 @@ export function openRouteInfo(projectId: string, routeId: string): void {
head("표 줄 — 항목 · 목적지"),
rows.root,
head(
`구간 — ${route.name || "업체"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`,
`구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`,
),
legs.root,
head("주소 · 지도"),
@@ -263,22 +268,26 @@ export function openRouteInfo(projectId: string, routeId: string): void {
}
/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */
export function openBaseInfo(projectId: string): void {
export function openBaseInfo(projectId: string, create = false): void {
const base = liveMilestone(projectId).base;
openModal({
title: `기준점 — ${base.name || "기준점"}`,
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, "1차포장");
const legs = legsEditor(
base.legs.length ? base.legs : [{ road: "1차포장", km: null }],
"1차포장",
);
const save = createButton({
label: "고침",
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(),
});
@@ -286,7 +295,21 @@ export function openBaseInfo(projectId: string): void {
close();
},
});
actions.append(save);
// 기준점 없앰 = 꺼 둠(값은 남김 · 거래처는 시점으로 바로)
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 · 도로 구분)"),
@@ -313,14 +336,56 @@ interface SiteAddress {
/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */
export function openSiteInfo(projectId: string): void {
openModal({
title: "공사지 — 시점",
title: "공사지 — 시점 · 중점",
closeLabel: "닫기",
mount: (body) => {
mount: (body, close, actions) => {
const text = (label: string, value: string): HTMLElement =>
el("p", { className: "b08-options__hint", text: `${label} — ${value}` });
// 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6)
const data = liveMilestone(projectId);
const site = siteInfo();
const km = (m: number | null | undefined): string =>
m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 });
const mid = createInputField({
label: "중점 측점 (m)",
type: "number",
min: 0,
compact: true,
});
mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : "";
mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m);
mid.input.dataset.b08Milestone = "mid_station_m";
const road = createSelectField({
label: "시점 → 중점 도로",
options: roadOptions,
compact: true,
});
road.select.value = data.site.mid_road;
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh = liveMilestone(projectId);
const value = Number(mid.input.value);
fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null;
fresh.site.mid_road = road.select.value as Road;
putMilestone(projectId, fresh);
close();
},
});
save.dataset.b08MilestoneSite = "save";
actions.append(save);
const map = buildAddressMap();
const info = el("div", { children: [text("주소", "불러오는 중…")] });
body.append(info, map.root);
body.append(
line(mid.root, road.root),
text(
"시점 = 노선 첫 점(측점 0)",
`노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`,
),
info,
map.root,
);
request<SiteAddress>(`/projects/${projectId}/quantity/milestone/site-address`).then(
(s) => {
const ll = (p: [number, number] | null): string =>
@@ -2,7 +2,7 @@
* B08_Quantity_UI_Milestone_Route.ts
* 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달).
*
* 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 업체 좌표 ·
* 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 ·
* 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로
* 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에
* 그림 = 올리는 즉시 서버(교체 · 지우기)
@@ -65,7 +65,7 @@ export interface MilestoneView {
refresh: () => void;
}
/** 새 창 길찾기 지점 — 업체 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */
/** 새 창 길찾기 지점 — 거래처 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */
export function routePoints(
d: MilestoneData,
r: MilestoneRoute,
@@ -74,7 +74,7 @@ export function routePoints(
const dest = r.rows[0]?.dest ?? "start";
const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat;
if (r.lon == null || r.lat == null || !to) return null;
const points = [{ name: r.name || "업체", lon: r.lon, lat: r.lat }];
const points = [{ name: r.name || "거래처", lon: r.lon, lat: r.lat }];
if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null)
points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat });
points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] });
@@ -151,7 +151,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
const points = r ? routePoints(d, r) : null;
if (!points)
return showToast(
"업체 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.",
"거래처 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.",
"info",
);
window.open(kakaoRouteUrl(points), "_blank", "noopener");
@@ -205,7 +205,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
const r = d.routes.find((x) => x.id === routeId);
if (!r) return;
const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"];
title.textContent = `${r.name || "업체"} → ${dest}${r.address ? ` · ${r.address}` : ""}`;
title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`;
loadImage();
};
refresh();
@@ -1,6 +1,7 @@
/* =============================================================================
* B08_Quantity_UI_Milestone_State.ts
* 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길 + 좌측 패널 카드(공사지 · 기준점).
* 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길(좌측 패널 카드는 244-6 에서 없앰 ·
* 공사지 · 기준점은 다이어그램 모달).
*
* 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 +
* 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음.
@@ -8,11 +9,9 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createInputField, createSelectField, el } 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, type Road } from "./B08_Quantity_Milestone_Calc";
import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc";
export interface SiteInfo {
length_m: number | null;
@@ -127,73 +126,3 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise<
}
export const roadOptions = ROADS.map((r) => ({ value: r, text: r }));
const km = (m: number | null | undefined): string =>
m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 });
/** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위 = 주소 좌표 자동) */
export function milestoneCardBody(projectId: string | null): HTMLElement[] {
const mid = createInputField({ label: "중점 측점 (m)", type: "number", compact: true, min: 0 });
const midRoad = createSelectField({
label: "시점 → 중점 도로",
options: roadOptions,
compact: true,
});
const siteHint = el("p", { className: "b08-options__hint" });
const has = createCheckbox("기준점 있음");
const name = createInputField({ label: "기준점 이름", compact: true });
const address = createInputField({ label: "기준점 주소", compact: true });
mid.input.dataset.b08Milestone = "mid_station_m";
has.input.dataset.b08Milestone = "base_enabled";
const baseFields = el("div", { children: [name.root, address.root] });
const show = (): void => {
if (!projectId) return;
const data = liveMilestone(projectId);
const site = siteInfo();
mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : "";
if (document.activeElement !== mid.input)
mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m);
midRoad.select.value = data.site.mid_road;
siteHint.textContent =
`시점 = 노선 첫 점(측점 0) · 노선 ${km(site?.length_m)} km · ` +
`중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`;
has.input.checked = data.base.enabled;
baseFields.hidden = !data.base.enabled;
if (document.activeElement !== name.input) name.input.value = data.base.name;
if (document.activeElement !== address.input) address.input.value = data.base.address;
};
const edit = (fn: (data: MilestoneData) => void): void => {
if (!projectId) return;
const data = liveMilestone(projectId);
fn(data);
putMilestone(projectId, data);
};
mid.input.addEventListener("input", () =>
edit((d) => {
const value = Number(mid.input.value);
d.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null;
}),
);
midRoad.select.addEventListener("change", () =>
edit((d) => (d.site.mid_road = midRoad.select.value as Road)),
);
has.input.addEventListener("change", () =>
edit((d) => {
d.base.enabled = has.input.checked;
if (d.base.enabled && !d.base.legs.length) d.base.legs = [{ road: "1차포장", km: null }];
}),
);
name.input.addEventListener("input", () => edit((d) => (d.base.name = name.input.value)));
address.input.addEventListener("input", () =>
edit((d) => (d.base.address = address.input.value)),
);
const host = el("div", {
children: [mid.root, midRoad.root, siteHint, has.root, baseFields],
});
show();
onMilestoneChange(host, show); // 첫 알림(읽기 끝)은 패널이 붙은 뒤
if (projectId) void loadMilestone(projectId).catch(() => undefined);
return [host];
}
+3 -4
View File
@@ -8,8 +8,8 @@
* 자재 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100
* 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100)
* 수리집수면적표 — 빈 자리(다음 옵션)
* 거리이정표 › 공사지(중점 측점 · 시점 → 중점 도로) · 기준점 — `…_UI_Milestone_State`(캐시 `b08-milestone` ·
* [저장] 한 번에 같이 PUT)
* 거리이정표 — 카드 없음(244-6 · 공사지 · 기준점은 다이어그램 모달) · 캐시 `b08-milestone` 은
* [저장] 한 번에 같이 PUT(`…_UI_Milestone_State`)
* 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) ·
* [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음.
* ========================================================================== */
@@ -27,7 +27,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { request } from "./B08_Quantity_UI_Hydro";
import { clampPct } from "./B08_Quantity_Options_Calc";
import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc";
import { milestoneCardBody, saveMilestone } from "./B08_Quantity_UI_Milestone_State";
import { saveMilestone } from "./B08_Quantity_UI_Milestone_State";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -242,7 +242,6 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock),
card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")),
card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")),
card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId)),
actions,
);
attachCollapsible(panel);
+4 -2
View File
@@ -149,8 +149,10 @@ def test_좌측_패널_컨테이너_셋과_저장_줄():
"quantity",
] # 자재 탭 안 = 총괄 · 콘크리트소요 · 자재수량
assert 'select("material");' in page and 'select("summary");' in page
assert [panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in tabs] == sorted(
panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in tabs
cards = tabs[:-1] # 244-6 — 거리이정표 카드 없음(다이어그램 모달)
assert 'card("milestone"' not in panel
assert [panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in cards] == sorted(
panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in cards
)
assert 'card("summary", "B08_Quantity_Tab_StructureSummary", one.root),' in panel
assert 'card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock),' in panel
+1 -4
View File
@@ -171,10 +171,7 @@ def test_메인_탭_하위_탭_추가_삭제():
assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state
assert state.count('method: "PUT"') == 1 and "setTimeout" not in state
options = _read("B08_Quantity/B08_Quantity_UI_Options.ts")
assert (
'card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId))'
in options
)
assert 'card("milestone"' not in options and "milestoneCardBody" not in options # 244-6
assert "await saveMilestone(projectId);" in options
registry = _read("A00_Common/b_page_state.ts")
assert '"b08-milestone": { bucket: "draft", scope: "project" }' in registry
@@ -144,7 +144,7 @@ def test_화면_모달_팬줌_그림탭():
add = modal[
modal.index("export function openAddItem") : modal.index("export function openRouteInfo")
]
for text in ('label: "항목"', 'label: "업체 이름"', 'label: "목적지"', 'label: "등록"',
for text in ('label: "항목"', 'label: "거래처 이름"', 'label: "목적지"', 'label: "등록"',
"buildAddressField(null)", "placeOf(address,"): # fmt: skip
assert text in add
assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal
@@ -53,7 +53,7 @@ def test_좌표_있음_공사지_기준_방위각():
def test_좌측_패널_방위_칸_없음_모달_안내():
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "공사지에서 본 방위" not in state and "dirOptions" not in state
assert "d.base.dir" not in state and 'label: "기준점 주소"' 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
@@ -0,0 +1,138 @@
"""244-6 B08 거리이정표 추가 고침(사용자 132 · 133번) — 표 폭 = 다이어그램 폭 ·
거래처 상자 항목 글 · 좌측 패널 카드 없음(공사지 · 기준점 = 다이어그램 모달 · [기준점 +]) ·
팬(가운데 · 빈 곳 왼쪽 끌기 · 원배율에서도) · 화면 글 「업체」 → 「거래처」.
팬은 공용 `A00_Common/b_svg_zoom_pan.ts` + `…_Milestone_Layout.ts`(panStart · panContent)를
node 로 돌려 pointerdown · pointermove · pointerup 사건을 실제로 보내
그림 묶음 transform 이 바뀌는지 잼.
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
UI = ("UI_Milestone.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts",
"UI_Milestone_Route.ts", "UI_Milestone_State.ts", "Milestone_Layout.ts") # fmt: skip
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(script):
src = "".join(
IMPORTS.sub("", _read(rel))
for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts")
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", 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)
PAN = """
const W = 600, H = 400;
class Svg extends EventTarget {
classList = { add() {}, remove() {} };
setPointerCapture() {}
releasePointerCapture() {}
getBoundingClientRect() { return { left: 0, top: 0, width: W, height: H }; }
}
const shape = { closest: (s: string) => (s.includes(".b08-milestone__box") ? {} : null) };
const empty = { closest: () => null };
function fire(svg: EventTarget, type: string, button: number, x: number, y: number,
target: unknown) {
const ev = new Event(type, { cancelable: true });
for (const [k, v] of Object.entries({ button, clientX: x, clientY: y, pointerId: 1, target }))
Object.defineProperty(ev, k, { value: v });
svg.dispatchEvent(ev);
}
function drag(button: number, target: unknown) {
const svg = new Svg();
const layer = { t: "", setAttribute(_k: string, v: string) { this.t = v; } };
attachSvgZoomPan({ svg: svg as any, layer: layer as any, widthPx: W, heightPx: H,
pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true,
content: panContent(W, H), panStart });
fire(svg, "pointerdown", button, 100, 100, target);
fire(svg, "pointermove", button, 160, 130, target);
fire(svg, "pointerup", button, 160, 130, target);
return layer.t;
}
console.log(JSON.stringify({
leftEmpty: drag(0, empty), leftShape: drag(0, shape),
middleShape: drag(1, shape), right: drag(2, empty),
}));
"""
def test_실제_포인터_사건으로_팬():
got = _node(PAN)
# 원배율(1)에서도 빈 곳 왼쪽 끌기 · 가운데 버튼 끌기 = 그림 묶음이 끈 만큼 옮김
assert got["leftEmpty"] == "translate(60 30) scale(1)"
assert got["middleShape"] == "translate(60 30) scale(1)"
assert got["leftShape"] == "" and got["right"] == "" # 상자 위 왼쪽 = 그 화면 고르기 몫
def test_거래처_상자_항목_글():
data = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []},
"routes": [
{"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None,
"legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""},
{"item": " 장비 ", "dest": "mid", "note": ""},
{"item": "골재", "dest": "mid", "note": ""}]},
{"id": "k", "name": "", "address": "", "via_base": True, "dir": None,
"legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]},
],
} # fmt: skip
lay = _node(f"console.log(JSON.stringify(layoutMilestone({json.dumps(data)}, null, 0)));")
boxes = {b["id"]: b for b in lay["boxes"]}
assert boxes["g"]["items"] == "장비 · 골재" and boxes["g"]["label"] == "거창군청"
assert boxes["k"]["items"] == "" and boxes["k"]["label"] == "거래처"
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert 'svg("tspan", { x, class: "b08-milestone__items" }, box.items)' in ui
assert 'svg("tspan", { x, dy: 16 }, box.label)' in ui
def test_표_폭_다이어그램_폭_좌측_카드_없음():
css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css")
block = css[css.index(".b08-milestone {") :]
assert "width: fit-content;" in block[: block.index("}")] # 틀 = 표 너비
assert ".b08-milestone > .b08-milestone__diagram {\n contain: inline-size;" in css
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert 'root.style.width = "100%";' in ui # 다이어그램 = 틀 너비
page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert "children: [diagram.root, none, table.root]" in page
# 좌측 패널 카드 없음 · 공사지 · 기준점은 모달
options = _read("B08_Quantity/B08_Quantity_UI_Options.ts")
assert "milestoneCardBody" not in options and "await saveMilestone(projectId);" in options
assert "milestoneCardBody" not in _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
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
assert "content: panContent(vw, vh)" in ui and "panStart," in ui
def test_화면_글_거래처():
for name in UI:
text = _read(f"B08_Quantity/B08_Quantity_{name}")
assert "업체" not in text, name
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert 'label: "거래처 이름"' in modal
page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert '{ key: "company", label: "거래처 이름"' in page and "`거래처 ${" in page
for name in UI:
assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700