sync: 4환경 수렴본 main 반영 (2026-09-08) #11

Open
eomsangdon wants to merge 599 commits from sub_laptop_1 into main
1542 changed files with 1388429 additions and 40333 deletions
+4
View File
@@ -75,3 +75,7 @@ VWORLD_SESSION_COOKIE=
# True면 대시보드 삭제 버튼이 DB 행과 storage/.../{프로젝트ID}/ 폴더를 실제로 지운다.
# 배포 전 반드시 False로 되돌릴 것.
PROJECT_DELETE_HARD_ENABLED=True
# 개발 단계에서 로그인 이메일 인증(OTP)을 건너뛴다 (2026-09-13 사용자 지시).
# ENVIRONMENT=development 일 때만 듣는다. 배포 전 반드시 False 로 되돌릴 것.
AUTH_OTP_DISABLED=True
+2
View File
@@ -65,6 +65,8 @@ tmp/
config/corridor_node/
# 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06).
config/server_calc_node/
# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13).
config/formula_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
+219
View File
@@ -0,0 +1,219 @@
/* =============================================================================
* b_svg_zoom_pan.ts
* SVG 그래프 한 장의 **확대·축소·팬** — 여러 화면이 한 코드를 쓴다.
*
* B06 횡단 카드(`B06_Section_UI_Cross_View_Zoom.ts`)에 있던 것을 옮겨 온 것이다
* (2026-09-12 사용자 지시 「공용화해줘」). 동작 규칙은 그때 정해진 것을 그대로 지킨다.
*
* · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼은 그 화면의 고르기·조작 몫이다.
* · **더블클릭이면 원배율**로 돌아간다.
* · **휠은 화면마다 다르다** — 카드가 수십 장 깔리는 B06 은 휠을 줌에 묶지 않는다
* (묶으면 목록을 훑을 수가 없다, 2026-08-02 사용자 확정). 판이 둘뿐인 B05 계획노선
* 횡단은 휠 = 줌이다(2026-09-12 사용자 지시 ③). `wheelZoom` 한 칸으로 가른다.
*
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
* ========================================================================== */
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState {
scale: number;
tx: number;
ty: number;
}
/**
* 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 범위 밖에 있어 잘려 있는
* **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다
* (2026-08-23 사용자 지시).
*/
export interface ContentBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
/** 현재 배율 — 원배율(1)일 때는 부르는 쪽 버튼이 배율 대신 표시 폭을 조절한다. */
scale: () => number;
}
/** 축 안쪽 여백(px) — 확대·축소의 중심이자 이동 한계의 기준이 된다. */
export interface ZoomPanPad {
left: number;
right: number;
top: number;
bottom: number;
}
export interface SvgZoomPanOptions {
svg: SVGSVGElement;
/** 밀고 키울 도형 묶음 — 여기에만 `transform` 이 적힌다. */
layer: SVGGElement;
/** `viewBox` 의 폭·높이(원배율 SVG 좌표). */
widthPx: number;
heightPx: number;
pad: ZoomPanPad;
/** 배율 상한. 안 주면 8. */
maxScale?: number;
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
initial?: ZoomPanState;
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
onChange?: (state: ZoomPanState) => void;
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean;
}
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
const WHEEL_IN = 1.15;
const WHEEL_OUT = 0.87;
export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
const { svg, layer, widthPx, heightPx, pad, initial, onChange, content } = options;
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
const plot = {
x: pad.left,
y: pad.top,
width: Math.max(widthPx - pad.left - pad.right, 1),
height: Math.max(heightPx - pad.top - pad.bottom, 1),
};
const maxScale = options.maxScale ?? 8;
let scale = initial?.scale ?? 1;
let tx = initial?.tx ?? 0;
let ty = initial?.ty ?? 0;
const applyTransform = (): void => {
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty });
};
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 범위 밖 캐시 보유분 포함)까지 늘어나,
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
const clampAxis = (
value: number,
start: number,
size: number,
from: number,
to: number,
): number =>
Math.min(
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
Math.max(start + size - scale * to, start - scale * from),
);
const clampPan = (): void => {
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
// 범위까지 열어 둔다 — 캐시 보유분이 표시 폭보다 넓으면 1배에서도 끌어다 봐야 한다
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
const bounds = content
? {
x: Math.min(content.x, plot.x),
y: Math.min(content.y, plot.y),
right: Math.max(content.x + content.width, plot.x + plot.width),
bottom: Math.max(content.y + content.height, plot.y + plot.height),
}
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
};
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
const zoomAt = (factor: number, cx: number, cy: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor));
tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
clampPan();
applyTransform();
};
// 버튼에는 마우스 자리가 없다 — 보이는 **플롯 영역의 중앙**을 붙잡는다.
const zoom = (factor: number): void =>
zoomAt(factor, plot.x + plot.width / 2, plot.y + plot.height / 2);
let panning = false;
let moved = false;
let lastX = 0;
let lastY = 0;
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
svg.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
if (event.button !== 1) return;
event.preventDefault();
panning = true;
moved = false;
lastX = event.clientX;
lastY = event.clientY;
svg.classList.add("is-panning");
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", (event) => {
if (!panning) return;
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
const rect = svg.getBoundingClientRect();
tx += ((event.clientX - lastX) / rect.width) * widthPx;
ty += ((event.clientY - lastY) / rect.height) * heightPx;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyTransform();
});
const endPan = (event: PointerEvent): void => {
if (!panning) return;
panning = false;
svg.classList.remove("is-panning");
try {
svg.releasePointerCapture(event.pointerId);
} catch {
/* 이미 해제됨 */
}
};
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
clampPan();
applyTransform();
}
svg.addEventListener("pointerup", endPan);
svg.addEventListener("pointercancel", endPan);
// 드래그(팬)로 끝난 클릭은 바깥(카드 고르기 등)으로 전파하지 않는다.
svg.addEventListener("click", (event) => {
if (moved) event.stopPropagation();
});
if (options.wheelZoom) {
svg.addEventListener(
"wheel",
(event) => {
event.preventDefault();
// 커서 아래 지점이 제자리에 남게 **그 자리**를 붙잡는다.
const rect = svg.getBoundingClientRect();
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
},
{ passive: false },
);
}
const currentScale = (): number => scale;
const reset = (): void => {
scale = 1;
tx = 0;
ty = 0;
applyTransform();
};
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
reset();
});
return { zoom, reset, scale: currentScale };
}
+1 -1
View File
@@ -52,7 +52,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
[ROUTES.B08_QUANTITY]: async () =>
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
[ROUTES.B09_ESTIMATION]: async () =>
(await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation,
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
[ROUTES.B10_PAYMENT]: async () =>
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
[ROUTES.B11_STATUS]: async () =>
+18 -6
View File
@@ -1,5 +1,6 @@
"""로그인, 재인증, 세션 및 비밀번호 API."""
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -34,10 +35,12 @@ from common_util.common_util_auth_repository import (
)
from common_util.common_util_email import send_email_background
from common_util.common_util_email_templates import otp_email, security_alert_email
from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS
from config.config_system import ADMIN_EMAIL, AUTH_OTP_DISABLED, EMAIL_REVERIFY_DAYS
from .A06_Login_Schema import LoginRequest, OtpVerifyRequest, PasswordChangeRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@@ -85,11 +88,20 @@ async def request_login(payload: LoginRequest, request: Request):
)
new_browser = not trusted_device
if periodic_reverify or new_browser:
await _send_otp(user, "LOGIN", "로그인")
return {
"status": "otp_required",
"reason": "PERIODIC" if periodic_reverify else "NEW_BROWSER",
}
# 개발 단계에서는 메일 인증을 건너뛴다 (2026-09-13 사용자 지시).
# 이 갈래는 ENVIRONMENT=development + AUTH_OTP_DISABLED=true 에서만 열린다.
if AUTH_OTP_DISABLED:
logger.warning(
"[auth] 개발용 OTP 생략 — %s (%s)",
email,
"PERIODIC" if periodic_reverify else "NEW_BROWSER",
)
else:
await _send_otp(user, "LOGIN", "로그인")
return {
"status": "otp_required",
"reason": "PERIODIC" if periodic_reverify else "NEW_BROWSER",
}
return await _finish_login(user, agent, device_token_hash)
+8
View File
@@ -1,5 +1,6 @@
"""회원가입, 이메일 인증 및 회사 검색 API."""
import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
@@ -27,9 +28,12 @@ from common_util.common_util_auth_repository import (
)
from common_util.common_util_email import send_email_background
from common_util.common_util_email_templates import otp_email
from config.config_system import AUTH_OTP_DISABLED
from .A07_Register_Schema import RegisterRequest, RegisterVerifyRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@@ -60,6 +64,10 @@ async def request_registration(payload: RegisterRequest):
await replace_otp(user_id, "REGISTER", hash_password(code))
subject, html = otp_email(code, "회원가입")
send_email_background(email, subject, html)
# 개발 단계에서는 메일을 못 받아도 가입을 이어갈 수 있게 코드를 로그에 찍는다
# (2026-09-13 사용자 지시). ENVIRONMENT=development + AUTH_OTP_DISABLED=true 에서만.
if AUTH_OTP_DISABLED:
logger.warning("[auth] 개발용 가입 인증 코드 — %s : %s", email, code)
return {"status": "success", "message": "인증 코드를 발송했습니다."}
+19
View File
@@ -316,6 +316,25 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; lo
return request(`/dashboard/company/geocode?address=${encodeURIComponent(address)}`);
}
/** 주소 후보 한 줄 — 검색해서 골라 넣는 용도. */
export interface AddressCandidate {
zipcode: string;
road: string;
parcel: string;
building: string;
category: string;
lat: number;
lon: number;
}
/** 주소를 검색해 후보를 받는다 (도로명 먼저, 없으면 지번). */
export async function searchAddress(query: string): Promise<AddressCandidate[]> {
const body = await request<{ items?: AddressCandidate[] }>(
`/dashboard/company/address/search?query=${encodeURIComponent(query)}`,
);
return body.items ?? [];
}
/** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */
export function updateCompany(
payload: {
+57
View File
@@ -57,3 +57,60 @@ async def geocode_address(address: str) -> dict[str, Any] | None:
async def fetch_base_tile(z: int, x: int, y: int) -> bytes:
url = _TILE_URL.format(key=VWORLD_API_KEY, z=z, y=y, x=x)
return await asyncio.to_thread(_fetch, url)
_SEARCH_URL = "https://api.vworld.kr/req/search"
def _search_sync(query: str, size: int) -> list[dict[str, Any]]:
"""도로명으로 먼저 찾고, 비면 지번으로 다시 찾는다 — 후보를 목록으로 돌려준다."""
for category in ("road", "parcel"):
params = urllib.parse.urlencode(
{
"service": "search",
"request": "search",
"version": "2.0",
"crs": "EPSG:4326",
"size": str(size),
"page": "1",
"query": query,
"type": "address",
"category": category,
"format": "json",
"errorformat": "json",
"key": VWORLD_API_KEY,
}
)
try:
body = json.loads(_fetch(f"{_SEARCH_URL}?{params}", timeout=8).decode("utf-8"))
except Exception:
continue
result = (body.get("response") or {}).get("result") or {}
items = result.get("items") or []
found: list[dict[str, Any]] = []
for item in items:
address = item.get("address") or {}
point = item.get("point") or {}
try:
lon = float(point["x"])
lat = float(point["y"])
except (KeyError, TypeError, ValueError):
continue
found.append(
{
"zipcode": address.get("zipcode") or "",
"road": address.get("road") or "",
"parcel": address.get("parcel") or "",
"building": address.get("bldnm") or "",
"category": category,
"lat": lat,
"lon": lon,
}
)
if found:
return found
return []
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size)
@@ -254,6 +254,7 @@ async def list_all_companies() -> list[dict[str, Any]]:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
c.business_address, c.business_owner,
c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count,
COUNT(DISTINCT p.id) AS project_count
FROM companies c
+11 -1
View File
@@ -25,7 +25,7 @@ from common_util.common_util_project_delete import hard_delete_project
from common_util.common_util_storage import read_stored_asset
from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
from .B01_Dashboard_Map import fetch_base_tile, geocode_address
from .B01_Dashboard_Map import fetch_base_tile, geocode_address, search_address
from .B01_Dashboard_Repository import (
assign_user_company,
change_user_role,
@@ -257,6 +257,16 @@ async def company_geocode(
return {"status": "success", **point}
@router.get("/company/address/search")
async def company_address_search(
query: str = Query(min_length=2, max_length=200),
session: dict[str, Any] = Depends(verify_session),
):
"""주소 후보를 찾아 준다 — 회사 등록·수정 화면에서 골라 넣는 용도."""
_ = session
return {"status": "success", "items": await search_address(query)}
@router.get("/map/tile/{z}/{x}/{y}")
async def map_tile(
z: int = Path(ge=0, le=19),
@@ -0,0 +1,172 @@
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { searchAddress, type AddressCandidate } from "./B01_Dashboard_Api_Fetch";
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
import { L } from "./B01_Dashboard_UI_Common";
/**
* 회사 주소 입력칸 — 검색해서 고르는 방식 (2026-09-13 사용자 지시).
*
* 손으로 정확히 치지 않아도 되게, 일부만 넣어 후보를 받고 고른다.
* 고른 즉시 좌표를 알므로 지도를 다시 찾지 않고 바로 그린다.
* 저장은 지금대로 한 줄 — 「기본주소 + 상세주소」. 우편번호는 화면 확인용.
*
* 줄 배치는 라벨·입력·버튼을 한 줄에 세운다(B06 횡단 설정과 같은 꼴) — ui-field 가
* 기본이 세로라 그대로 두면 오류 슬롯 높이만큼 버튼이 밀려 수평이 어긋난다.
* 그래서 오류 슬롯은 접고, 잘못 넣은 것은 토스트로 알린다.
*/
export interface AddressFieldHandle {
root: HTMLElement;
/** 저장할 한 줄 주소. 비면 null. */
value: () => string | null;
}
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
function candidateLine(item: AddressCandidate): string {
const main = item.category === "parcel" ? item.parcel : item.road;
return main || item.road || item.parcel;
}
function candidateHint(item: AddressCandidate): string {
const other = item.category === "parcel" ? item.road : item.parcel;
return [item.building, other, item.zipcode].filter(Boolean).join(" · ");
}
/** createButton 은 글자를 span 에 담는다 — textContent 로 덮으면 그 껍질이 사라진다. */
function setButtonLabel(button: HTMLButtonElement, label: string): void {
const slot = button.querySelector(".ui-btn__label");
if (slot) slot.textContent = label;
else button.textContent = label;
}
function row(...parts: HTMLElement[]): HTMLElement {
const line = document.createElement("div");
line.className = "b01-dashboard__address-row";
line.append(...parts);
return line;
}
export function buildAddressField(initial: string | null): AddressFieldHandle {
const root = document.createElement("div");
root.className = "b01-dashboard__address";
// 1) 검색 줄 — 일부만 넣어도 후보가 나온다.
const query = createInputField({
label: L("B01_Dashboard_Field_Address"),
placeholder: "도로명·지번·건물명 일부 (예: 판교역로 235)",
});
const findBtn = createButton({
label: "주소 찾기",
variant: "ghost",
onClick: () => void runSearch(),
});
const manualBtn = createButton({
label: "직접 입력",
variant: "ghost",
onClick: function onB01_Address_Manual_Click() {
manual = !manual;
setButtonLabel(manualBtn, manual ? "검색으로" : "직접 입력");
base.input.readOnly = !manual;
query.root.hidden = manual;
findBtn.hidden = manual;
mapBtn.hidden = !manual;
if (manual) base.input.focus();
},
});
const searchRow = row(query.root, findBtn, manualBtn);
// 2) 후보 목록 — 고르면 사라진다.
const list = document.createElement("div");
list.className = "b01-dashboard__address-list";
list.hidden = true;
// 3) 고른 주소 + 상세주소.
const base = createInputField({ label: "기본주소", value: initial ?? "" });
base.input.readOnly = true;
const zip = document.createElement("span");
zip.className = "b01-dashboard__address-zip";
zip.hidden = true;
const detail = createInputField({ label: "상세주소", placeholder: "동·층·호" });
// 4) 지도 — 고른 즉시 그린다. 직접 입력일 때만 버튼으로 확인한다.
const map = buildAddressMap();
const mapBtn = createButton({
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Address_Map_Click() {
await map.show(base.input.value.trim());
},
});
mapBtn.hidden = true;
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
let manual = false;
function pick(item: AddressCandidate): void {
base.input.value = candidateLine(item);
zip.textContent = item.zipcode ? `우편번호 ${item.zipcode}` : "";
zip.hidden = !item.zipcode;
list.hidden = true;
list.innerHTML = "";
map.showPoint(item.lat, item.lon);
detail.input.focus();
}
async function runSearch(): Promise<void> {
const text = query.input.value.trim();
if (text.length < 2) {
showToast("주소를 두 글자 이상 넣으십시오.", "error");
return;
}
findBtn.disabled = true;
setButtonLabel(findBtn, "찾는 중…");
try {
const items = await searchAddress(text);
list.innerHTML = "";
list.hidden = false;
if (items.length === 0) {
const empty = document.createElement("p");
empty.className = "b01-dashboard__modal-text";
empty.textContent = "찾은 주소가 없습니다. 다른 낱말로 넣거나 「직접 입력」을 쓰십시오.";
list.append(empty);
return;
}
for (const item of items) {
const option = document.createElement("button");
option.type = "button";
option.className = "b01-dashboard__address-item";
const line = document.createElement("strong");
line.textContent = candidateLine(item);
const hint = document.createElement("small");
hint.textContent = candidateHint(item);
option.append(line, hint);
option.addEventListener("click", () => pick(item));
list.append(option);
}
} catch {
showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하십시오.", "error");
} finally {
findBtn.disabled = false;
setButtonLabel(findBtn, "주소 찾기");
}
}
// 검색칸에서 Enter — 모달이 닫히지 않게 막고 찾기만 한다.
query.input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
void runSearch();
});
// 이미 있는 주소는 열자마자 지도로 보여 준다.
if (initial && initial.trim()) void map.show(initial.trim());
return {
root,
value: () => {
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
return merged || null;
},
};
}
+10
View File
@@ -157,6 +157,16 @@ export function attachModalDismiss(
if (ok) close();
};
// 바탕(패널 바깥) 위에서 굴린 휠이 뒤 화면을 움직이지 않게 막는다
// (2026-09-13 사용자 지시 — 모달을 열어 둔 채 대시보드가 함께 굴렀다).
modal.addEventListener(
"wheel",
(event) => {
if (event.target === modal) event.preventDefault();
},
{ passive: false },
);
// 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다.
let downOnOverlay = false;
modal.addEventListener("mousedown", (event) => {
@@ -44,6 +44,8 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
createTag(`${state.company.name} (${state.user.status})`, "success"),
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
);
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
if (state.user.role !== "USER" && state.company) {
+79 -24
View File
@@ -1,4 +1,5 @@
import { API_BASE_URL } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
/**
@@ -6,13 +7,23 @@ import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
*
* 지도 라이브러리를 얹지 않는다 — 배경지도 타일 3×3 장을 붙이고 가운데에 표식만 찍는다.
* 등록·수정 화면에서 "이 주소가 여기 맞나" 를 눈으로 보는 것이 목적이다.
* 건물을 알아볼 수 있어야 하므로 기본 배율을 17 로 두고 +·- 로 두 단계씩 움직인다
* (2026-09-13 사용자 지시 — 15 는 너무 멀었다).
*/
const ZOOM = 15;
const DEFAULT_ZOOM = 17;
const MIN_ZOOM = 13;
const MAX_ZOOM = 18;
const TILE = 256;
const GRID = 3;
/** 타일 판은 CSS 에서 절반으로 줄여 붙인다 — 표식 자리도 같은 비율로 잡는다. */
const SCALE = 0.5;
function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number; dy: number } {
const n = 2 ** ZOOM;
function tileIndex(
lat: number,
lon: number,
zoom: number,
): { x: number; y: number; dx: number; dy: number } {
const n = 2 ** zoom;
const rad = (lat * Math.PI) / 180;
const fx = ((lon + 180) / 360) * n;
const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
@@ -22,35 +33,59 @@ function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number
export function buildAddressMap(): {
root: HTMLElement;
show: (address: string) => Promise<void>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void;
} {
const root = document.createElement("div");
root.className = "b01-dashboard__map";
const note = document.createElement("p");
note.className = "b01-dashboard__modal-text";
note.textContent = "주소를 넣고 「지도 확인」을 누르십시오.";
root.append(note);
const show = async (address: string): Promise<void> => {
root.innerHTML = "";
if (!address.trim()) {
note.textContent = "주소를 먼저 입력하십시오.";
root.append(note);
return;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
note.textContent = "그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.";
root.append(note);
return;
}
const center = tileIndex(point.lat, point.lon);
const zoomOut = createButton({
label: "",
variant: "ghost",
onClick: () => step(-1),
});
const zoomIn = createButton({
label: "",
variant: "ghost",
onClick: () => step(1),
});
const controls = document.createElement("div");
controls.className = "b01-dashboard__map-zoom";
controls.append(zoomOut, zoomIn);
controls.hidden = true;
const stage = document.createElement("div");
root.append(note, controls, stage);
let zoom = DEFAULT_ZOOM;
let last: { lat: number; lon: number } | null = null;
function step(delta: number): void {
if (!last) return;
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom + delta));
if (next === zoom) return;
zoom = next;
draw(last.lat, last.lon);
}
function draw(lat: number, lon: number): void {
last = { lat, lon };
note.hidden = true;
controls.hidden = false;
zoomOut.disabled = zoom <= MIN_ZOOM;
zoomIn.disabled = zoom >= MAX_ZOOM;
stage.innerHTML = "";
const center = tileIndex(lat, lon, zoom);
const grid = document.createElement("div");
grid.className = "b01-dashboard__map-grid";
const half = Math.floor(GRID / 2);
for (let row = -half; row <= half; row += 1) {
for (let col = -half; col <= half; col += 1) {
const img = document.createElement("img");
img.src = `${API_BASE_URL}/dashboard/map/tile/${ZOOM}/${center.x + col}/${center.y + row}`;
img.src = `${API_BASE_URL}/dashboard/map/tile/${zoom}/${center.x + col}/${center.y + row}`;
img.width = TILE;
img.height = TILE;
img.alt = "";
@@ -59,14 +94,34 @@ export function buildAddressMap(): {
}
const marker = document.createElement("span");
marker.className = "b01-dashboard__map-marker";
// 타일 판은 CSS 에서 절반으로 줄여 붙이므로 표식 자리도 절반으로 잡는다.
marker.style.left = `${(half + center.dx) * TILE * 0.5}px`;
marker.style.top = `${(half + center.dy) * TILE * 0.5}px`;
marker.style.left = `${(half + center.dx) * TILE * SCALE}px`;
marker.style.top = `${(half + center.dy) * TILE * SCALE}px`;
const frame = document.createElement("div");
frame.className = "b01-dashboard__map-frame";
frame.append(grid, marker);
root.append(frame);
stage.append(frame);
}
function fail(message: string): void {
last = null;
stage.innerHTML = "";
controls.hidden = true;
note.hidden = false;
note.textContent = message;
}
const show = async (address: string): Promise<void> => {
if (!address.trim()) {
fail("주소를 먼저 입력하십시오.");
return;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return;
}
draw(point.lat, point.lon);
};
return { root, show };
return { root, show, showPoint: draw };
}
+5 -18
View File
@@ -33,7 +33,7 @@ import {
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
import { buildAddressField } from "./B01_Dashboard_UI_AddressField";
import {
attachModalDismiss,
buildUserFields,
@@ -438,28 +438,15 @@ function companyFields(company?: CompanyInfo): {
const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" });
logo.input.type = "file";
logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
const address = createInputField({
label: L("B01_Dashboard_Field_Address"),
value: company?.business_address ?? "",
});
// 주소가 맞는 자리인지 지도로 확인한다 (2026-09-06 사용자 지시).
const map = buildAddressMap();
const mapBtn = createButton({
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Company_Map_Click() {
await map.show(address.input.value.trim());
},
});
const addressRow = document.createElement("div");
addressRow.append(address.root, mapBtn, map.root);
// 주소는 검색해서 고른다 — 손으로 정확히 치지 않아도 되게 (2026-09-13 사용자 지시).
const address = buildAddressField(company?.business_address ?? null);
return {
rows: [number.root, name.root, owner.root, logo.root, addressRow],
rows: [number.root, name.root, owner.root, logo.root, address.root],
values: () => ({
name: name.input.value.trim(),
business_registration_number: number.input.value.trim(),
business_address: address.input.value.trim() || null,
business_address: address.value(),
business_owner: owner.input.value.trim() || null,
}),
logoFile: () => logo.input.files?.[0],
+95
View File
@@ -186,6 +186,8 @@
width: min(560px, 100%);
max-height: calc(100vh - 2 * var(--spacing-24));
overflow-y: auto;
/* 패널 끝까지 굴려도 뒤 대시보드로 넘어가지 않게 가둔다 (2026-09-13 사용자 지시). */
overscroll-behavior: contain;
background: var(--color-surface-raised);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
@@ -291,6 +293,99 @@
}
}
/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13).
라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라
그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */
.b01-dashboard__address {
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
}
.b01-dashboard__address-row {
display: flex;
align-items: center;
gap: var(--spacing-8, 8px);
}
.b01-dashboard__address-row > .ui-field {
display: flex;
flex: 1 1 auto;
flex-direction: row;
align-items: center;
gap: var(--spacing-8, 8px);
min-width: 0;
margin: 0;
}
.b01-dashboard__address .ui-field__label {
flex: 0 0 64px;
margin: 0;
white-space: nowrap;
}
.b01-dashboard__address .ui-input {
flex: 1 1 auto;
min-width: 0;
}
.b01-dashboard__address .ui-field__error {
display: none;
}
.b01-dashboard__address-row > .ui-btn {
flex: 0 0 auto;
}
.b01-dashboard__address-zip {
flex: 0 0 auto;
color: var(--color-text-muted, #666);
font-size: 0.85rem;
white-space: nowrap;
}
.b01-dashboard__address-list {
display: flex;
flex-direction: column;
max-height: 220px;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 8px);
}
.b01-dashboard__address-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8, 8px);
border: 0;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.b01-dashboard__address-item:last-child {
border-bottom: 0;
}
.b01-dashboard__address-item:hover,
.b01-dashboard__address-item:focus-visible {
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
}
.b01-dashboard__address-item small {
color: var(--color-text-muted, #666);
}
.b01-dashboard__map-zoom {
display: flex;
gap: var(--spacing-8, 8px);
margin-bottom: var(--spacing-8, 8px);
}
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
.b01-dashboard__map-frame {
position: relative;
@@ -202,6 +202,9 @@ export interface StationTickOptions {
toScreen: (x: number, y: number) => [number, number];
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
avoidChainages?: ReadonlyArray<number>;
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다.
* 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */
uprightRad?: number;
}
export function drawStationTicks(
@@ -274,11 +277,18 @@ export function drawStationTicks(
);
if (collides) continue;
drawn.push({ x: lx, y: ly, half });
context.save();
if (options.uprightRad) {
context.translate(lx, ly);
context.rotate(options.uprightRad);
context.translate(-lx, -ly);
}
// 배경을 깔아 등고선 위에서도 읽히게 한다.
context.fillStyle = "rgba(255, 255, 255, 0.78)";
context.fillRect(lx - half, ly - 8, width, 16);
context.fillStyle = "#222222";
context.fillText(label, lx, ly);
context.restore();
}
context.restore();
}
+178 -217
View File
@@ -42,14 +42,14 @@ export const ROUTE_LINE_WIDTH = 2.4;
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
*/
type PreparedPart = {
export type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
export type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
@@ -60,6 +60,8 @@ type PreparedFeature = {
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
/** 그 라벨의 표고(m). 어느 줄을 실제로 낼지는 `drawPreparedLabels` 가 줌을 보고 고른다. */
labelValue: number | null;
};
export type PreparedLayer = {
@@ -214,226 +216,81 @@ export function computeRouteView(
};
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
/** 화면 px 에 가장 가까운 선 피처의 자리. 그만큼 안에 없으면 -1(계획서 0-9 ⑦). */
export function hitPreparedLayer(
layer: PreparedLayer,
view: ViewState,
px: number,
py: number,
tolerancePx: number,
everyM?: number,
): number {
const affine = affineOf(view);
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
let best = -1;
let bestDistance = tolerancePx;
layer.features.forEach((feature, index) => {
if (feature.kind !== "line") return;
// **그리지 않은 줄은 집히지도 않는다** — 안 보이는 등고선이 골라지면 없던 선이 튀어나온다.
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) return;
// 화면 밖·멀리 있는 피처는 바운딩박스에서 먼저 떨군다 — 도엽 등고선은 수천 가닥이다.
const x0 = feature.minX * affine.ax + affine.bx - tolerancePx;
const x1 = feature.maxX * affine.ax + affine.bx + tolerancePx;
const y0 = feature.minY * affine.ay + affine.by - tolerancePx;
const y1 = feature.maxY * affine.ay + affine.by + tolerancePx;
if (px < x0 || px > x1 || py < y0 || py > y1) return;
for (const part of feature.parts) {
const coords = part.coords;
let lastX = NaN;
let lastY = NaN;
// 그릴 때와 **같은 LOD** 로 훑는다 — 화면에 없는 정점에 걸리면 눈과 손이 어긋난다.
const tolerance = LOD_PX / affine.ax;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (Number.isFinite(elevation) && elevation % 25 === 0) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
if (part.weights && part.weights[i / 2] < tolerance) continue;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (Number.isFinite(lastX)) {
const distance = pointSegmentDistance(px, py, lastX, lastY, x, y);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
}
lastX = x;
lastY = y;
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
});
return best;
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
},
],
};
/** 레이어 안의 피처 하나만 다시 그린다 — 고른 등고선을 도드라지게 할 때 쓴다. */
export function drawPreparedFeature(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
index: number,
view: ViewState,
): void {
const feature = layer.features[index];
if (!feature || feature.kind !== "line") return;
drawLineParts(context, feature, affineOf(view));
}
/** 점과 선분 사이 거리(px). */
function pointSegmentDistance(
px: number,
py: number,
ax: number,
ay: number,
bx: number,
by: number,
): number {
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= 1e-9) return Math.hypot(px - ax, py - ay);
const ratio = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
return Math.hypot(px - (ax + dx * ratio), py - (ay + dy * ratio));
}
/**
@@ -563,6 +420,9 @@ function drawPointParts(
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
const CULL_MARGIN = 32;
/** 등고 라벨끼리 이만큼(px)은 떨어져야 둘 다 낸다 — 가로 여백과 줄 높이. */
const LABEL_GAP_PX = 10;
const LABEL_ROW_PX = 14;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
const margin = CULL_MARGIN;
@@ -578,41 +438,142 @@ function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): b
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
/** 레이어가 차지하는 **화면 사각형**(px). 피처가 없으면 null.
*
* LAS 등고선처럼 도엽보다 좁은 자료 위에 다른 레이어를 겹칠 때, 그 자료가 있는 데까지만
* 그리려고 쓴다(계획서 0-9 ⑮ — 세류선이 등고선 밖까지 뻗던 자리). */
export function layerScreenBounds(
layer: PreparedLayer,
view: ViewState,
): { x: number; y: number; width: number; height: number } | null {
if (layer.features.length === 0) return null;
const affine = affineOf(view);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const feature of layer.features) {
minX = Math.min(minX, feature.minX);
minY = Math.min(minY, feature.minY);
maxX = Math.max(maxX, feature.maxX);
maxY = Math.max(maxY, feature.maxY);
}
const x0 = minX * affine.ax + affine.bx;
const x1 = maxX * affine.ax + affine.bx;
const y0 = minY * affine.ay + affine.by;
const y1 = maxY * affine.ay + affine.by;
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
/** 등고선을 몇 m 마다 낼지 고를 때 훑는 배수. 성긴 쪽으로 한 칸씩 물러난다. */
const LEVEL_STEP_MULTIPLES = [1, 2, 5, 10, 20, 50, 100];
/** 한 화면에 둘 등고선 가닥 수의 어림 상한 — 이보다 많으면 한 칸 성글게 간다. */
const LEVEL_BUDGET = 350;
/**
* 지금 화면에 **몇 m 간격**으로 등고선을 낼지 고른다.
*
* 간격을 줌으로만 정하면 가파른 데서는 여전히 선이 뭉개지고 완만한 데서는 너무 성기다.
* 그래서 **지금 화면에 실제로 들어오는 가닥 수**를 세어 상한을 넘지 않는 가장 촘촘한 간격을
* 고른다 — 확대하면 저절로 촘촘해지고 물러나면 성겨진다(2026-09-12 실화면: 1m LAS 등고선을
* 다 그리면 지형이 선으로 덮였다).
*/
export function pickLevelStep(
layer: PreparedLayer,
view: ViewState,
intervalM: number,
budget = LEVEL_BUDGET,
): number {
const interval = intervalM > 0 ? intervalM : 1;
const affine = affineOf(view);
let step = interval * LEVEL_STEP_MULTIPLES[LEVEL_STEP_MULTIPLES.length - 1];
for (const multiple of LEVEL_STEP_MULTIPLES) {
const candidate = interval * multiple;
let count = 0;
for (const feature of layer.features) {
if (feature.labelValue !== null && feature.labelValue % candidate !== 0) continue;
if (!isVisible(feature, affine, view)) continue;
count += 1;
if (count > budget) break;
}
if (count <= budget) return candidate;
step = candidate;
}
return step;
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다.
*
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */
export function drawPreparedLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
marker: MarkerKind,
everyM?: number,
): void {
const affine = affineOf(view);
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
for (const feature of layer.features) {
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
if (!isVisible(feature, affine, view)) continue;
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
else drawLineParts(context, feature, affine);
}
}
/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */
/** 사전 계산된 등고 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다.
*
* `everyM` 은 **몇 m 마다 한 줄을 라벨할지**다. 기본 25m(계곡선)는 B04 지도가 쓰던 값 그대로다
* — 전부 내면 화면이 숫자로 뒤덮인다. 확대가 큰 화면은 더 작은 값을 넘겨 촘촘히 낸다. */
export function drawPreparedLabels(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
color: string,
everyM = 25,
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */
uprightRad = 0,
): void {
const affine = affineOf(view);
const margin = CULL_MARGIN;
const step = everyM > 0 ? everyM : 25;
// 이미 찍은 라벨과 겹치면 건너뛴다 — LAS 등고선은 **한 표고가 여러 가닥**으로 끊겨 있어
// 가닥마다 숫자를 내면 화면이 숫자로 덮인다(2026-09-12 실화면). 도엽 계곡선은 원래
// 드물어 이 규칙에 걸리지 않으므로 B04 지도의 표기는 그대로다.
const drawn: Array<{ x: number; y: number; half: number }> = [];
for (const feature of layer.features) {
if (feature.labelText === null) continue;
// 표고를 못 읽은 라벨(값 없음)은 솎지 않고 그대로 낸다.
if (feature.labelValue !== null && feature.labelValue % step !== 0) continue;
const x = feature.labelAnchorX * affine.ax + affine.bx;
const y = feature.labelAnchorY * affine.ay + affine.by;
if (x < -margin || x > view.width + margin) continue;
if (y < -margin || y > view.height + margin) continue;
const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX;
if (
drawn.some(
(item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX,
)
) {
continue;
}
drawn.push({ x, y, half });
context.save();
if (uprightRad) {
// 글자 **자리는 그대로** 두고 글자만 되돌린다 — 180°에서 숫자가 뒤집혀 안 읽힌다.
context.translate(x, y);
context.rotate(uprightRad);
context.translate(-x, -y);
}
context.lineWidth = 3;
context.strokeStyle = haloColor();
context.strokeText(feature.labelText, x, y);
context.fillStyle = color;
context.fillText(feature.labelText, x, y);
context.restore();
}
}
@@ -0,0 +1,306 @@
/* =============================================================================
* B04_PreProcess_UI_MapRender_Prepare.ts
* 지도 레이어 **사전 투영** — GeoJSON·사업지 좌표 폴리라인을 정규화 좌표로 펴고,
* 줌 무손실 LOD 가중치(Douglas-Peucker)와 등고 라벨 앵커를 미리 잡아 둔다.
*
* `B04_PreProcess_UI_MapRender.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12).
* 본문 로직과 수치는 그대로다. 그리기는 그쪽, 준비는 이쪽 — 한 방향으로만 기대어
* 순환 참조가 생기지 않는다.
* ========================================================================== */
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
import type {
GeoJsonCollection,
GeoJsonGeometry,
Normalizer,
PreparedFeature,
PreparedLayer,
PreparedPart,
} from "./B04_PreProcess_UI_MapRender";
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelValue: number | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// **모든 등고선**에 앵커를 잡아 둔다. 어느 줄을 실제로 낼지는 그릴 때 고른다 —
// 화면마다 솎는 눈금이 다르기 때문이다(B04 지도는 계곡선만, 계획노선 편집 모달은
// 확대에 따라 더 촘촘히). 준비 단계에서 걸러 버리면 확대해도 되살릴 수가 없다.
if (Number.isFinite(elevation)) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelValue = elevation;
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({
kind,
parts,
minX,
minY,
maxX,
maxY,
labelAnchorX,
labelAnchorY,
labelText,
labelValue,
});
}
return { features };
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
labelValue: null,
},
],
};
}
/**
* 사업지 좌표계(m) 폴리라인 **여러 개**를 한 레이어로 사전 투영한다(LAS 등고선 등).
*
* `prepareMetricPolyline` 의 여러 줄 판이다. 줄마다 `label`(표고 m)을 주면 가운데 정점을
* 앵커로 잡아 `drawPreparedLabels` 가 그대로 쓸 수 있다.
*/
export function prepareMetricPolylines(
lines: ReadonlyArray<{ points: ReadonlyArray<readonly [number, number]>; label?: number }>,
meta: VWorldMeta,
): PreparedLayer {
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const features: PreparedFeature[] = [];
for (const line of lines) {
if (line.points.length < 2) continue;
const coords = new Float64Array(line.points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
line.points.forEach((point, index) => {
const nx = (point[0] - meta.x_min) / widthMeters;
const ny = 1 - (point[1] - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
const middle = Math.floor(line.points.length / 2) * 2;
features.push({
kind: "line",
parts: [
{ coords, closed: false, weights: computeDpWeights(coords, widthMeters / heightMeters) },
],
minX,
minY,
maxX,
maxY,
labelAnchorX: coords[middle],
labelAnchorY: coords[middle + 1],
labelText: line.label === undefined ? null : String(line.label),
labelValue: line.label ?? null,
});
}
return { features };
}
@@ -33,8 +33,6 @@ import {
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
prepareLayer,
prepareMetricPolyline,
routeLineColor,
ROUTE_LINE_WIDTH,
type GeoJsonCollection,
@@ -44,6 +42,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { prepareLayer, prepareMetricPolyline } from "./B04_PreProcess_UI_MapRender_Prepare";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
+59 -1
View File
@@ -56,8 +56,12 @@ export interface RoutePlanResponse {
nodes: RoutePlanNode[];
/** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */
curves: RoutePlanCurve[];
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */
min_radius_m: number;
/** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */
limit_radius_m?: number;
/** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */
limit_curve_length_m?: number;
curve_count: number;
violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
@@ -128,6 +132,60 @@ export async function replanRoute(
);
}
/** **** ( 0-9 ·).
*
* ****
* ( ). `null` . */
export async function fetchRouteElevations(
projectId: string,
points: Array<[number, number]>,
): Promise<Array<number | null>> {
const payload = await requestJson<{ z: Array<number | null> }>(
`/projects/${projectId}/route/elevations`,
{ method: "POST", body: JSON.stringify({ points }) },
60000,
);
return payload.z;
}
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
export interface CrossPreviewResponse {
status: string;
chainage_m: number;
label: string | null;
uphill_side: string | null;
plan_radius_m: number | null;
curve_widening_m: number | null;
/** 원지반 횡단 샘플. */
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
design: {
design_line: Array<{ offset_m: number; elevation_m: number }>;
cut_area_m2: number;
fill_area_m2: number;
[key: string]: unknown;
} | null;
}
export interface CrossPreviewRequest {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
chainage_m: number;
min_radius_m: number;
station_interval_m: number;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
export async function fetchCrossPreview(
projectId: string,
request: CrossPreviewRequest,
): Promise<CrossPreviewResponse> {
return requestJson<CrossPreviewResponse>(
`/projects/${projectId}/route/cross-preview`,
{ method: "POST", body: JSON.stringify(request) },
120000,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
+11
View File
@@ -31,6 +31,17 @@ export interface StructureOptionField {
/** B05 ·· (detail) B06/B07
* (2026-08-17 ). detail이면 required여도 B05 . */
phase?: "b05" | "detail";
/** ** ** .
* select ** **
* (2026-09-13: `fill_concrete_mpa` 180 210 ). */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음) — 기준값과 까닭 한 줄. */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null;
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/** B05 ··, **B06/B07
+31
View File
@@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal")
return float(speeds[terrain])
def plan_radius_limit_m(
grade_class: str,
design_speed_kph: int | None = None,
terrain_type: str = "normal",
) -> float:
"""계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음.
`legal_plan_radius_min_m` **기본값·위반 표시 기준**이고 이것은 **제한**이다
(2026-09-12 사용자 확정: 아예 넘게 막음). 임도 종류별 칸이 비어 있으면(None)
법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 값을 쓴다 작업임도는 별표2에
곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다.
"""
table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"]
override = table.get(grade_class)
if override is not None:
return float(override)
return legal_plan_radius_min_m(
resolve_design_speed(grade_class, design_speed_kph), terrain_type
)
def plan_curve_length_limit_m(grade_class: str) -> float:
"""평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음.
법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 자리만 열어 칸이라
실무값이 정해지면 `config_system_design` 표만 고치면 된다(2026-09-12 사용자 확정).
"""
table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"]
return float(table.get(grade_class) or 0.0)
def _pick(*candidates: Any) -> Any:
"""요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다."""
for value in candidates:
+38 -1
View File
@@ -37,7 +37,11 @@ from common_util.common_util_drainage_pipes import (
route_signature,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex
from common_util.common_util_route_geometry import (
RouteVertex,
planned_route_initial_path,
planned_route_working_path,
)
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
@@ -66,6 +70,34 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[
return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords]
def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
"""설계가 쓰는 계획노선의 **곡선표** — 없으면 빈 목록.
곡선표는 폴리라인 파일 옆에 같은 이름으로 선다(`_curves.json`). 정점 목록만으로는
어디부터 어디까지가 곡선이고 반지름이 얼마인지 없어, 곡선부 확폭을 측점마다
반경을 다시 재는 방식으로 매기면 같은 곡선 안에서도 값이 갈린다(2026-09-12 실측).
수정본(`planned_route.csv`) 있으면 ** 곡선표만** 쓴다 노선을 고쳤는데 초기본
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 목록을 돌려주고,
받는 쪽이 방식(측점별 실측 반경)으로 물러선다.
"""
route_path = planned_route_working_path(project_root)
if not route_path.is_file():
route_path = planned_route_initial_path(project_root)
target = route_path.with_name(f"{route_path.stem}_curves.json")
if not target.is_file():
return []
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
def cross_filename(chainage_m: float) -> str:
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
return f"cross_{int(round(float(chainage_m))):05d}m.json"
@@ -361,6 +393,9 @@ def run_section_generation(
replace(options or SectionGenerationOptions(), extra_stations=extras),
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
# 종전의 측점별 실측 반경으로 물러선다.
design_curves=_load_design_curves(project_root),
)
stage_root = project_root / _STAGE_SUBDIR
@@ -473,6 +508,8 @@ def generate_irregular_sections(
merged_options,
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
)
irregular_stations = [
station
+111 -5
View File
@@ -213,6 +213,101 @@ def _curve_widenings(
return widenings, sides
#: 곡선표의 시·종점이 노선 폴리라인에서 이만큼 떨어져 있으면 그 노선의 곡선이 아니라고 본다(m).
#: 노선을 잘라 쓰면(지표면 밖 트림) 곡선표에 남은 옛 곡선이 노선 밖에 뜬다.
DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0
def _project_chainage(
points: np.ndarray, route_chainage: np.ndarray, xy: tuple[float, float]
) -> tuple[float, float]:
"""점을 노선 폴리라인 위로 내려 (떨어진 거리 m, 누가거리 m)."""
starts = points[:-1, :2]
vectors = points[1:, :2] - starts
lengths2 = np.einsum("ij,ij->i", vectors, vectors)
safe = np.where(lengths2 > 1e-12, lengths2, 1.0)
target = np.asarray(xy, dtype=np.float64)
ratios = np.clip(np.einsum("ij,ij->i", target - starts, vectors) / safe, 0.0, 1.0)
feet = starts + vectors * ratios[:, None]
distances = np.hypot(feet[:, 0] - target[0], feet[:, 1] - target[1])
index = int(np.argmin(distances))
span = route_chainage[index + 1] - route_chainage[index]
return float(distances[index]), float(route_chainage[index] + span * ratios[index])
def _design_curve_spans(
points: np.ndarray,
route_chainage: np.ndarray,
curves: list[dict[str, Any]],
) -> list[tuple[float, float, float, str]]:
"""설계 곡선표 → [(시점 누가거리, 종점 누가거리, 반지름 m, 곡선 **바깥쪽**)].
바깥쪽은 회전 방향으로 가른다 시점교점종점의 외적 z가 양수면 좌회전이라 안쪽이
좌측이고 바깥은 우측이다(`_plan_radii` 같은 규약). 노선에서 멀리 떨어진 곡선과
방향을 재는 곡선은 버린다.
"""
spans: list[tuple[float, float, float, str]] = []
for curve in curves:
start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end")
radius = curve.get("radius_m")
if not (start and apex and end) or not isinstance(radius, (int, float)):
continue
start_gap, start_chainage = _project_chainage(points, route_chainage, tuple(start[:2]))
end_gap, end_chainage = _project_chainage(points, route_chainage, tuple(end[:2]))
if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M:
continue
cross = (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (
end[0] - apex[0]
)
if abs(cross) < 1e-12:
continue
spans.append(
(
min(start_chainage, end_chainage),
max(start_chainage, end_chainage),
float(radius),
"right" if cross > 0 else "left",
)
)
return sorted(spans)
def _design_curve_widenings(
station_chainage: np.ndarray,
spans: list[tuple[float, float, float, str]],
) -> tuple[list[float | None], list[str | None], list[float]]:
"""설계 곡선표로 측점별 (평면 곡선반경, 바깥쪽, 확폭량)을 낸다.
곡선 **** 곡선의 설계 반경이 그대로 반경이고 확폭도 표값 값이다. 곡선 앞뒤
`CURVE_WIDENING_TAPER_M` 구간은 0 으로 잇고(실무 관행 직선 테이퍼), 밖은 확폭이
없다. 곡선이 겹치면 **확폭이 ** 따르고, 반경은 작은 (급한 ) 남긴다.
"""
count = len(station_chainage)
radii: list[float | None] = [None] * count
sides: list[str | None] = [None] * count
widenings: list[float] = [0.0] * count
for start, end, radius, side in spans:
table = curve_widening_m(radius)
for index, value in enumerate(station_chainage):
chainage = float(value)
inside = start - 1e-9 <= chainage <= end + 1e-9
if inside and (radii[index] is None or radius < radii[index]):
radii[index] = round(radius, 3)
if table <= 0.0:
continue
if inside:
amount = table
else:
distance = start - chainage if chainage < start else chainage - end
if distance > CURVE_WIDENING_TAPER_M:
continue
amount = round(table * (1.0 - distance / CURVE_WIDENING_TAPER_M), 4)
if amount > widenings[index]:
widenings[index] = amount
sides[index] = side
return radii, sides, widenings
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
@@ -220,6 +315,7 @@ def generate_sections(
*,
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
options = options or SectionGenerationOptions()
@@ -267,11 +363,21 @@ def generate_sections(
# 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반
# 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세
# 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다.
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
# **설계 곡선표가 있으면 그것이 정본**이다(2026-09-12 사용자 지시). 측점마다 반경을
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if spans:
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
station_chainage, spans
)
else:
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
offsets = np.arange(
-options.cross_half_width_m,
+31 -6
View File
@@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str:
async def _min_plan_radius_m(project_id: UUID) -> float:
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
"""기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것."""
criteria = await _plan_criteria(project_id)
return criteria[0]
async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]:
"""이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값.
기본 반지름은 곡선을 만들 쓰는 값이고, 하한 둘은 **화면이 넘게 막는** 값이다
(2026-09-12 사용자 확정). 둘을 값으로 묶으면 하한 0 반지름 0 되어 곡선이
아예 그려지므로 반드시 갈라 둔다.
기본 반지름은 임도 종류·설계속도·지형으로 고른다.
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 .2.)이고 산식은 이미
`B05_Profile_Engine_Grade.legal_plan_radius_min_m` 있다 여기서 다시 짜지 않는다.
@@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
"""
import aiomysql
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed
from B05_Profile.B05_Profile_Engine_Grade import (
legal_plan_radius_min_m,
plan_curve_length_limit_m,
plan_radius_limit_m,
resolve_design_speed,
)
from common_util.common_util_workflow_state import get_workflow_state
grade_class, design_speed, terrain = "work", None, "special"
@@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
terrain = str(params["terrain_type"])
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain)
return (
legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain),
plan_radius_limit_m(grade_class, design_speed, terrain),
plan_curve_length_limit_m(grade_class),
)
def _nodes_path(path: Path) -> Path:
@@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
if not expected:
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
@@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
"min_radius_m": round(radius_m, 2),
# **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음
# (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정).
"limit_radius_m": round(radius_limit_m, 2),
"limit_curve_length_m": round(arc_limit_m, 2),
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
@@ -468,7 +493,7 @@ async def replan_route(
# 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다.
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
@@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working_path = planned_route_working_path(project_root)
if working_path.is_file():
+221
View File
@@ -0,0 +1,221 @@
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 점을 찍어
**구간 길이와 종단기울기** (0-9 ) 측점의 **횡단도 미리보기**() 지반고가
있어야 한다. 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 규칙과 부딪히지
않는다 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
표고 조회는 ·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`) 연다.
화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
POST /api/projects/{id}/route/elevations 묶음의 지반고
POST /api/projects/{id}/route/cross-preview 고치던 노선의 측점 횡단 미리보기
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
import numpy as np
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"])
_MODELS_SUBDIR = Path("B04_PreProcess") / "models"
#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 —
#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다.
MAX_POINTS = 4000
class ElevationRequest(BaseModel):
"""사업지 좌표계(m) 점 묶음 [[x, y], …]."""
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
"""확정 지표면에서 표고를 읽는다. 모델을 못 열면 None."""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc)
return None
z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64))
return z, valid
@router.post("/{project_id}/route/elevations", response_model=None)
async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse:
"""점 묶음의 지반고(m)와 유효 여부를 돌려준다.
지표면 밖이거나 자료가 없는 자리는 `valid=false` 나가고 표고는 `null` 이다
**임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 자리를 모름으로 낸다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
sampled = await asyncio.to_thread(_sample, project_root, params, request.points)
if sampled is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.",
},
)
z, valid = sampled
return {
"status": "success",
"project_id": str(project_id),
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
"valid": [bool(ok) for ok in valid],
}
class PreviewVertex(BaseModel):
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
x: float
y: float
curve: bool = True
radius_m: float | None = None
class CrossPreviewRequest(BaseModel):
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
vertices: list[PreviewVertex] = Field(..., min_length=2)
chainage_m: float = Field(..., ge=0)
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
min_radius_m: float = Field(12.0, gt=0)
station_interval_m: float | None = None
def _cross_preview(
project_root: Path,
params: dict,
request: CrossPreviewRequest,
) -> dict | None:
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
**B05·B06 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 기본 로직은 B06에
존재함. 재사용) `generate_sections` 측점·접선·지반 샘플을, `compute_cross_design`
설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
**계획고는 아직 없다.** 계획고는 [확인] 체인이 낳는 값이라 편집 중에는 존재하지
않는다. 그래서 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) ·성토가 사면
기울기만으로 서는 기본 계획 횡단이며, 사용자가 보기로 것도 그것이다.
"""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
return None
built = build_planned_polyline(
[(vertex.x, vertex.y) for vertex in request.vertices],
min_radius_m=request.min_radius_m,
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
simplify=False,
curve_flags=[vertex.curve for vertex in request.vertices],
radii=[vertex.radius_m for vertex in request.vertices],
)
interval = request.station_interval_m
options = (
SectionGenerationOptions(station_interval_m=float(interval))
if interval and interval > 0
else SectionGenerationOptions()
)
result = generate_sections(built.vertices, sampler, options)
sections = result["cross_sections"]
if not sections:
return None
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
design = None
center_z = section.get("center_z")
if center_z is not None:
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
design = compute_cross_design(
section["samples"],
float(center_z),
ground_type="soil",
section_mode=section_mode,
**curve_widening_args(section),
)
return {
"chainage_m": round(float(section["chainage_m"]), 3),
"label": section.get("label"),
"uphill_side": section.get("uphill_side"),
"plan_radius_m": section.get("plan_radius_m"),
"curve_widening_m": section.get("curve_widening_m"),
"samples": section["samples"],
"design": design,
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
if result.get("longitudinal", {}).get("total_length_m") is not None
else None,
}
@router.post("/{project_id}/route/cross-preview", response_model=None)
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
정본을 건드리지 않는다 파일도 DB 쓰지 않고 자리에서 셈해 돌려주기만 한다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
if preview is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
},
)
return {"status": "success", "project_id": str(project_id), **preview}
+171 -35
View File
@@ -134,9 +134,10 @@
"label": "유입 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "outlet_type",
@@ -186,9 +187,10 @@
"label": "유출 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "revet_foundation",
@@ -332,6 +334,26 @@
"unit": "%",
"default": null,
"required": false
},
{
"key": "thickness_cm",
"label": "포장 두께",
"input": "number",
"unit": "㎝",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)"
},
{
"key": "length_m",
"label": "포장 길이(노폭 방향)",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)"
}
]
},
@@ -479,6 +501,15 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
},
{
"key": "length_m",
"label": "연장",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
}
]
},
@@ -695,9 +726,11 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": 2.0,
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -783,7 +816,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -888,6 +922,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -924,6 +968,17 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "blinding_concrete",
"label": "버림 콘크리트",
"input": "select",
"choices": ["넣음", "안 넣음"],
"default": "넣음",
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
"required": false,
"phase": "detail",
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
}
]
},
@@ -945,7 +1000,10 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)",
"warn_above": 2.0,
"warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)"
},
{
"key": "length_m",
@@ -1050,6 +1108,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1086,6 +1154,17 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "blinding_concrete",
"label": "버림 콘크리트",
"input": "select",
"choices": ["넣음", "안 넣음"],
"default": "넣음",
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
"required": false,
"phase": "detail",
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
}
]
},
@@ -1200,7 +1279,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "thickness_bottom_m",
@@ -1210,7 +1290,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "back_len_cm",
@@ -1220,7 +1301,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_kind",
@@ -1230,7 +1312,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_supply",
@@ -1240,7 +1323,8 @@
"default": "채집",
"required": false,
"phase": "detail",
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음"
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1250,7 +1334,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1260,7 +1345,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1269,7 +1355,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "foundation",
@@ -1288,7 +1375,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "weep_hole_area_m2",
@@ -1298,7 +1386,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
}
]
},
@@ -1320,7 +1409,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -1374,7 +1464,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1384,7 +1475,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1394,7 +1486,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1430,7 +1523,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "weep_hole_area_m2",
@@ -1440,7 +1534,19 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "blinding_concrete",
"label": "버림 콘크리트",
"input": "select",
"choices": ["넣음", "안 넣음"],
"default": "넣음",
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
"required": false,
"phase": "detail",
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
}
]
},
@@ -1557,7 +1663,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "thickness_bottom_m",
@@ -1567,7 +1674,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "back_len_cm",
@@ -1585,7 +1693,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1595,7 +1704,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1706,7 +1816,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1716,7 +1827,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1726,7 +1838,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1735,7 +1848,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "foundation",
@@ -1877,7 +1991,7 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
@@ -1927,6 +2041,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1941,9 +2065,10 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)"
},
{
"key": "length_m",
@@ -1991,6 +2116,17 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "blinding_concrete",
"label": "버림 콘크리트",
"input": "select",
"choices": ["넣음", "안 넣음"],
"default": "넣음",
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
"required": false,
"phase": "detail",
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
}
]
},
@@ -60,6 +60,13 @@ class StructureOptionField(BaseModel):
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
default_basis: str | None = None
# 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」).
# 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호).
warn_above: float | None = None
warn_message: str | None = None
# 칸은 있는데 **원단위 표가 아직 안 읽는** 칸 — 넣어도 수량·금액이 안 바뀜을 칸 옆에 보임(2026-09-14 ①).
# 참말인지는 `test_b05_not_in_table_marks` 가 값을 바꿔 돌려 보고 지킴.
not_in_table: str | None = None
class StructureType(BaseModel):
+104 -194
View File
@@ -26,10 +26,6 @@
* 1m· 2m· 45° , "없음" .
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import type {
DetailPipeInput,
PipeFacility,
@@ -42,10 +38,12 @@ import {
createRevetmentFields,
createRevetSideGroup,
createWingFields,
fordSection,
grid,
group,
INLET_KINDS,
INLET_REVET_KEYS,
inletKindOf,
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
@@ -56,30 +54,10 @@ import {
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
/** B06 (2026-08-29 ).
* B05가 B06 . */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** "" (// I··)
* (2026-08-29 5: 일단 ).
* `inlet_type`(/) B06
* (`inlet_structure`). */
const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
/** (chainage) . (pipe)
* . */
@@ -209,17 +187,13 @@ export function createFacilityOptionsForm(
root.hidden = true;
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
// 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나.
const pipeMaterial = document.createElement("select");
pipeMaterial.replaceChildren(
...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)),
);
pipeMaterial.value = "파형강관";
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 (제안 Ø1000) —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17)
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
// (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다.
const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]);
@@ -228,11 +202,12 @@ export function createFacilityOptionsForm(
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
const inletGroup = group("유입구");
// 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로
// 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시).
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
const inletType = document.createElement("select");
inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)));
inletType.value = INLET_KINDS[0].label;
inletType.replaceChildren(
new Option("— 안 정함 (제안 기슭막이) —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
"동물이동형",
"□형(기본형)",
@@ -248,14 +223,16 @@ export function createFacilityOptionsForm(
]);
const basinMaterialField = labeled("형태", basinMaterial);
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1");
basinLength.value = "2";
const basinLength = numberInput("0.1", "0", "제안 2");
const basinRows = [
grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))),
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
height,
});
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
@@ -273,11 +250,11 @@ export function createFacilityOptionsForm(
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = document.createElement("select");
outletType.replaceChildren(new Option("기슭막이", "기슭막이"));
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
height,
});
outletGroup.body.append(
grid(labeled("구조", outletType), outletRevet.formField),
@@ -299,8 +276,7 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]);
revetSide.value = "양쪽";
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
const revetTopRow = grid(labeled("설치 측", revetSide));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
@@ -338,12 +314,12 @@ export function createFacilityOptionsForm(
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect("미지정", [
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1");
const boxHeight = numberInput("0.1");
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight));
const boxWrap = document.createElement("div");
boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow);
@@ -358,98 +334,50 @@ export function createFacilityOptionsForm(
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
boxCustomRow.hidden = !!preset;
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자
// 지시) 수량만 따로 받는다.
const fordCount = numberInput("1", "1", "련");
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3).
// 련은 정수라 소수 자릿수를 두지 않는다.
const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0)));
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
const ford = createFordFields(() => emit());
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시).
// 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음).
// 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는
// 있으나 계산값(필요 최소 수심) 미만은 되돌린다.
const fordWidth = numberInput("0.1");
const fordHeight = numberInput("0.01");
const fordWidthRow = grid(
labeled("월류 폭 (m)", stepper(fordWidth, 0.1)),
labeled("월류 높이 (m)", stepper(fordHeight, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const fordSlope = numberInput("0.1");
fordSlope.placeholder = "노면 기울기";
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)));
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let fordMinDepthM: number | null = null;
/** (m) ""
* .
* (2026-08-30 사용자: 폭을 ). */
let fordAutoDepthM: number | null = null;
function syncFordSummary(): void {
const section =
designFlowM3s !== null
? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value))
: null;
fordMinDepthM = section ? section.depthM : null;
if (section) {
// 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전
// 자동값 그대로면 계산값으로 다시 채운다.
const min = Number(section.depthM.toFixed(2));
const current = Number.parseFloat(fordHeight.value);
const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005;
if (!Number.isFinite(current) || current < min || untouched)
fordHeight.value = min.toFixed(2);
fordAutoDepthM = min;
}
if (designFlowM3s === null) {
fordSummary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
fordSummary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다.
fordHeight.addEventListener("change", () => {
if (fordMinDepthM !== null) {
const min = Number(fordMinDepthM.toFixed(2));
const value = Number.parseFloat(fordHeight.value);
if (!Number.isFinite(value) || value < min) {
fordHeight.value = min.toFixed(2);
fordHeight.classList.add("is-invalid");
window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900);
}
}
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
const suggest = document.createElement("button");
suggest.type = "button";
suggest.className = "b05-structure__suggest";
suggest.textContent = "제안값 넣기";
suggest.addEventListener("click", () => {
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
if (!pipeDiameter.value) pipeDiameter.value = "1000";
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
if (!outletType.value) outletType.value = "기슭막이";
if (!basinLength.value) basinLength.value = "2";
if (!revetSide.value) revetSide.value = "양쪽";
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
wingInFields.fillSuggested();
wingOutFields.fillSuggested();
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
emit();
});
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
saveNote.textContent =
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
saveNote,
pipeRow,
wingRow,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
ford.widthRow,
ford.slopeRow,
ford.countRow,
ford.summary,
inletGroup.root,
outletGroup.root,
extraGroup.root,
@@ -480,24 +408,23 @@ export function createFacilityOptionsForm(
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge";
fordRow.hidden = current !== "ford_bridge";
fordWidthRow.hidden = !isFord;
ford.countRow.hidden = current !== "ford_bridge";
ford.widthRow.hidden = !isFord;
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
fordSlopeRow.hidden = current !== "ford_pavement";
fordSummary.hidden = !isFord;
ford.slopeRow.hidden = current !== "ford_pavement";
ford.summary.hidden = !isFord;
const isRevet = current === "revetment";
revetTopRow.hidden = !isRevet;
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다.
revetInlet.root.hidden =
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet);
revetOutlet.root.hidden =
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet);
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
const side = revetSide.value || "양쪽";
revetInlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.inlet);
revetOutlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.outlet);
revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`;
revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`;
// 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고,
// 선택이 풀리면 스스로 사라진다.
extraGroup.root.hidden = extraSlot.childElementCount === 0;
if (isFord) syncFordSummary();
if (isFord) ford.sync();
if (isBox) syncBoxSize();
if (hasWing) {
wingInFields.syncVisibility();
@@ -516,12 +443,6 @@ export function createFacilityOptionsForm(
}
}
/** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */
function putFordHeight(options: Record<string, string | number>): void {
const value = Number.parseFloat(fordHeight.value);
if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2));
}
function emit(): void {
syncVisibility();
callbacks.onChange?.();
@@ -543,8 +464,7 @@ export function createFacilityOptionsForm(
boxHeight,
...wingInFields.inputs,
...wingOutFields.inputs,
fordCount,
fordWidth,
...ford.inputs,
].forEach((input) => input.addEventListener("change", emit));
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
@@ -581,35 +501,36 @@ export function createFacilityOptionsForm(
},
setFacility(facility, options = {}, designFlow = null) {
current = facility;
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(designFlow ?? null);
if (facility === null) {
syncVisibility();
return;
}
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
const isFord = facility === "ford_bridge";
pipeMaterial.value = text("pipe_kind") || "파형강관";
pipeDiameter.value = text("pipe_diameter_mm") || "1000";
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
const savedType = text("inlet_type") || "기슭막이";
const savedType = text("inlet_type");
const savedStructure = text("inlet_structure");
inletType.value = (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label;
outletType.value = text("outlet_type") || "기슭막이";
inletType.value = savedType
? (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label
: "";
outletType.value = text("outlet_type");
wingWall.value = text("wing_wall_type");
basinForm.value = text("inlet_basin_form");
basinMaterial.value = text("inlet_basin_material");
basinLength.value = text("inlet_basin_length_m") || "2";
basinLength.value = text("inlet_basin_length_m");
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다.
const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[0];
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
@@ -623,43 +544,38 @@ export function createFacilityOptionsForm(
Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05,
)
: undefined;
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : firstLabel;
boxWidth.value = String(hasSaved ? savedWidth : firstWidth);
boxHeight.value = String(hasSaved ? savedHeight : firstHeight);
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : "";
boxWidth.value = hasSaved ? String(savedWidth) : "";
boxHeight.value = hasSaved ? String(savedHeight) : "";
wingInFields.write(options);
wingOutFields.write(options);
fordCount.value = isFord ? text("pipe_count") : "";
fordWidth.value = text("ford_width_m");
fordHeight.value = text("ford_height_m");
// 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다.
fordAutoDepthM = null;
fordSlope.value = text("ford_slope_pct");
revetSide.value = text("side") || "양쪽";
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
revetSide.value = text("side");
const spread = legacyRevetOptions(options);
revetInlet.fields.write(spread);
revetOutlet.fields.write(spread);
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처).
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
fordWidth.value = String(
facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M,
);
}
syncVisibility();
},
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(designFlow ?? null);
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
const putPipe = (): void => {
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
};
if (current === "pipe") {
options.pipe_diameter_mm = Number(pipeDiameter.value);
options.pipe_kind = pipeMaterial.value;
putPipe();
const inletKind = inletKindOf(inletType.value);
if (wingWall.value) options.wing_wall_type = wingWall.value;
options.inlet_type = inletKind.type;
// B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29).
options.inlet_structure = inletKind.structure;
if (inletType.value) {
options.inlet_type = inletKind.type;
options.inlet_structure = inletKind.structure;
}
if (inletKind.type === "집수정") {
if (basinForm.value) options.inlet_basin_form = basinForm.value;
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
@@ -667,7 +583,7 @@ export function createFacilityOptionsForm(
} else {
inletRevet.read(options);
}
options.outlet_type = outletType.value;
if (outletType.value) options.outlet_type = outletType.value;
outletRevet.read(options);
} else if (current === "box_culvert") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
@@ -676,20 +592,14 @@ export function createFacilityOptionsForm(
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "ford_pavement") {
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putNumber(options, "ford_slope_pct", fordSlope.value);
ford.read(current, options);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putPipe();
ford.read(current, options);
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "revetment") {
options.side = revetSide.value;
if (revetSide.value) options.side = revetSide.value;
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
// 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30).
@@ -9,6 +9,33 @@
* ========================================================================== */
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
/** B06 (2026-08-29 ).
* B05가 B06 . */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** "" (// I··)
* (2026-08-29 5: 일단 ).
* `inlet_type`(/) B06
* (`inlet_structure`). */
export const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
@@ -127,14 +154,18 @@ export function putNumber(target: Record<string, string | number>, key: string,
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
}
/** ("") . ,
* (2026-08-17 1). */
/** ** ( )** .
* (2026-09-14 · 2026-08-17 )
* . ( ). */
export function optionalSelect(
_blankLabel: string,
blankLabel: string,
choices: ReadonlyArray<string>,
): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...choices.map((choice) => new Option(choice, choice)));
element.replaceChildren(
new Option(blankLabel, ""),
...choices.map((choice) => new Option(choice, choice)),
);
return element;
}
@@ -175,16 +206,20 @@ export interface RevetmentKeys {
after?: string;
}
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m. */
/** **** ()· (), 10m.
* ( ).
* ** ** [ ]
* (2026-09-14 ).
* ** ** . */
export interface RevetmentDefaults {
form: string;
length: string;
height: string;
height: string | (() => string);
before: string;
after: string;
}
/** 부위와 무관한 공통 기본 치수 — 길이 10m(전 5·후 5)·높이 2.5m. */
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
@@ -218,16 +253,43 @@ export interface RevetSideGroup {
slot: HTMLElement;
}
/** `revetment_sabang_*.json` (B08 · · ).
* import (`test_b05_revetment_sabang_basis`) . */
function sabangBasisLines(): string[] {
const { items, scope } = revetmentSabang;
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
return [
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
scope,
];
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault });
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
const fields = createRevetmentFields(keys, {
...REVET_COMMON_DEFAULTS,
form: formDefault,
height: "",
});
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
...sabangBasisLines().flatMap((line, index) =>
index ? [document.createElement("br"), line] : [line],
),
);
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(
grid(labeled("구조", kind), fields.formField),
...fields.rows,
basis,
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
slot,
@@ -258,26 +320,32 @@ export function createRevetmentFields(
keys: RevetmentKeys,
defaults: RevetmentDefaults,
): RevetmentFields {
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
const suggestHeight = (): string =>
typeof defaults.height === "function" ? defaults.height() : defaults.height;
const form = document.createElement("select");
form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value)));
form.value = defaults.form;
const length = keys.length ? numberInput("0.1") : null;
if (length) length.value = defaults.length;
const height = numberInput("0.1");
height.value = defaults.height;
form.replaceChildren(
new Option(`제안 ${defaults.form}`, ""),
...REVET_FORMS.map((value) => new Option(value, value)),
);
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
const height = numberInput("0.1", "0", heightHint());
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1") : null;
const after = keys.after && length ? numberInput("0.1") : null;
if (before) before.value = defaults.before;
if (after) after.value = defaults.after;
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
let lastSplitEdit: "before" | "after" = "before";
const readNumber = (input: HTMLInputElement | null): number => {
const value = input ? Number.parseFloat(input.value) : Number.NaN;
return Number.isFinite(value) && value >= 0 ? value : 0;
};
const syncSplit = (source: "length" | "before" | "after"): void => {
if (!length || !before || !after) return;
// 길이가 비었으면 전/후를 0 으로 채우지 않음 — 빈 칸이 「0 으로 적음」이 되면 안 됨.
if (!length || !before || !after || length.value === "") return;
if (source !== "length") lastSplitEdit = source;
const total = readNumber(length);
const keep = lastSplitEdit === "after" ? after : before;
@@ -293,10 +361,25 @@ export function createRevetmentFields(
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null;
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null;
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
const fill = document.createElement("button");
fill.type = "button";
fill.className = "b05-structure__suggest";
fill.textContent = "제안값 넣기";
fill.addEventListener("click", () => {
if (!form.value) form.value = defaults.form;
if (length && !length.value) length.value = defaults.length;
if (!height.value) height.value = suggestHeight();
if (before && !before.value) before.value = defaults.before;
if (after && !after.value) after.value = defaults.after;
form.dispatchEvent(new Event("change"));
});
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
grid(fill),
];
return {
@@ -304,13 +387,15 @@ export function createRevetmentFields(
rows,
lengthInput: length,
write(options) {
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
form.value = text(keys.form) || defaults.form;
if (length && keys.length) length.value = text(keys.length) || defaults.length;
height.value = text(keys.height) || defaults.height;
if (before && keys.before) before.value = text(keys.before) || defaults.before;
if (after && keys.after) after.value = text(keys.after) || defaults.after;
form.value = text(keys.form);
if (length && keys.length) length.value = text(keys.length);
height.value = text(keys.height);
height.placeholder = heightHint();
if (before && keys.before) before.value = text(keys.before);
if (after && keys.after) after.value = text(keys.after);
syncSplit("length");
},
read(target) {
@@ -395,19 +480,18 @@ interface WingFields {
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
fillSuggested: () => void;
}
/** ( + ··) · .
* "없음" ( ). */
* "없음" ( ).
* (·1·2·45, ) **** (2026-09-14 ). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect("미지정", ["있음", "없음"]);
install.value = WING_DEFAULTS.install;
const height = numberInput("0.1");
height.value = WING_DEFAULTS.height;
const length = numberInput("0.1");
length.value = WING_DEFAULTS.length;
const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle;
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1));
@@ -426,7 +510,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
@@ -437,18 +520,25 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
write(options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
install.value = text(keys.install) || WING_DEFAULTS.install;
height.value = text(keys.height) || WING_DEFAULTS.height;
length.value = text(keys.length) || WING_DEFAULTS.length;
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
install.value = text(keys.install);
height.value = text(keys.height);
length.value = text(keys.length);
angle.value = text(keys.angle);
syncVisibility();
},
read(target) {
target[keys.install] = install.value;
if (install.value !== "음") return;
if (install.value) target[keys.install] = install.value;
if (install.value === "음") return;
putNumber(target, keys.height, height.value);
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
fillSuggested() {
if (!install.value) install.value = WING_DEFAULTS.install;
if (!height.value) height.value = WING_DEFAULTS.height;
if (!length.value) length.value = WING_DEFAULTS.length;
if (!angle.value) angle.value = WING_DEFAULTS.angle;
syncVisibility();
},
};
}
@@ -0,0 +1,151 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Ford.ts
* · ·· · ·· + .
*
* (`_Drainage_Facility.ts`) 700 (2026-09-14).
* (2026-09-14 ):
* 10m· 5m( ) ** **, ·
* ** ** [ ] .
* ( ).
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import {
fordSection,
grid,
labeled,
numberInput,
putNumber,
stepper,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
type FordKind = "ford_pavement" | "ford_bridge";
export interface FordFields {
widthRow: HTMLElement;
slopeRow: HTMLElement;
countRow: HTMLElement;
summary: HTMLElement;
/** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */
inputs: HTMLInputElement[];
setDesignFlow: (designFlow: number | null) => void;
sync: () => void;
write: (kind: FordKind, options: Record<string, string | number>) => void;
read: (kind: FordKind, target: Record<string, string | number>) => void;
fillSuggested: (kind: FordKind) => void;
}
export function createFordFields(emit: () => void): FordFields {
// 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다.
const count = numberInput("1", "1", "련");
const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0)));
const width = numberInput("0.1");
const height = numberInput("0.01");
const widthRow = grid(
labeled("월류 폭 (m)", stepper(width, 0.1)),
labeled("월류 높이 (m)", stepper(height, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const slope = numberInput("0.1");
slope.placeholder = "노면 기울기";
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3).
const thickness = numberInput("1");
const length = numberInput("0.1");
const slopeRow = grid(
labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1)),
labeled("포장 두께 (㎝)", stepper(thickness, 1, 0)),
labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1)),
);
const summary = document.createElement("p");
summary.className = "b05-drainage__facility-note";
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let minDepthM: number | null = null;
const suggestedWidth = (kind: FordKind): number =>
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
function sync(): void {
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
if (designFlowM3s === null) {
summary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
summary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸).
height.addEventListener("change", () => {
const value = Number.parseFloat(height.value);
if (
minDepthM !== null &&
height.value !== "" &&
(!Number.isFinite(value) || value < minDepthM)
) {
height.value = minDepthM.toFixed(2);
height.classList.add("is-invalid");
window.setTimeout(() => height.classList.remove("is-invalid"), 900);
}
emit();
});
return {
widthRow,
slopeRow,
countRow,
summary,
inputs: [count, width],
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
},
sync,
write(kind, options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
width.value = text("ford_width_m");
width.dataset.suggested = String(suggestedWidth(kind));
width.placeholder = `제안 ${suggestedWidth(kind)}`;
height.value = text("ford_height_m");
slope.value = text("ford_slope_pct");
thickness.value = text("thickness_cm");
length.value = text("length_m");
},
read(kind, target) {
putNumber(target, "ford_width_m", width.value);
// 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림).
const value = Number.parseFloat(height.value);
if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2));
if (kind === "ford_pavement") {
putNumber(target, "ford_slope_pct", slope.value);
putNumber(target, "thickness_cm", thickness.value);
putNumber(target, "length_m", length.value);
return;
}
const pipes = Number.parseInt(count.value, 10);
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
},
fillSuggested(kind) {
if (!width.value) width.value = String(suggestedWidth(kind));
sync();
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
},
};
}
@@ -0,0 +1,73 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Merge.ts
* [] ** , **
* (2026-09-14 ).
*
* **** · (
* `revet_foundation` · `foundation`·· ) B05
* ** **. [] []
* .
* ** ** .
* ( · )
* .
* . ** **
* ( ). `test_b05_facility_options_merge`.
* import .
* ========================================================================== */
const revetKeys = (side: "inlet" | "outlet"): string[] =>
["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`);
const wingKeys = (side: "in" | "out"): string[] => [
`wing_${side}`,
`wing_${side}_height_m`,
`wing_${side}_length_m`,
`wing_${side}_angle_deg`,
];
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
pipe: [
"pipe_diameter_mm",
"pipe_kind",
"wing_wall_type",
"inlet_type",
"inlet_structure",
"inlet_basin_form",
"inlet_basin_material",
"inlet_basin_length_m",
...revetKeys("inlet"),
"outlet_type",
...revetKeys("outlet"),
],
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
ford_bridge: [
"pipe_kind",
"pipe_diameter_mm",
"pipe_count",
"ford_width_m",
"ford_height_m",
...wingKeys("in"),
...wingKeys("out"),
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
interface MergeableAttributes {
facility: string;
start_m?: number;
end_m?: number;
options?: Record<string, string | number>;
}
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
export function mergeFacilityOptions<T extends MergeableAttributes>(
previous: T | null,
next: T,
): T {
if (!previous || previous.facility !== next.facility) return next;
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
return { ...next, options: Object.keys(options).length ? options : undefined };
}
+8 -3
View File
@@ -15,19 +15,22 @@ import {
computeMapRect,
MAP_STATION_INTERVAL_M,
createNormalizer,
prepareLayer,
prepareMetricPolyline,
type MapRect,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolyline,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import { type RoutePoint } from "./B05_Profile_Api_Fetch";
import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
@@ -605,8 +608,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, attributes);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
+14 -3
View File
@@ -281,9 +281,20 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
onEditPlannedRoute: () =>
void openRouteEditModal(activeProjectId, () => {
navigateTo(ROUTES.B05_PROFILE);
}),
void openRouteEditModal(
activeProjectId,
() => {
navigateTo(ROUTES.B05_PROFILE);
},
{
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: confirmedSurface?.model_id ?? null,
contourIntervalM: latest?.surface_params.contour_interval_m,
smooth: latest?.surface_params.smooth,
},
),
onTempSave: () => void tempSaveAction(actionContext),
onGoCross: () => {
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
+10 -3
View File
@@ -25,6 +25,7 @@ import {
} from "./B05_Profile_Api_Fetch";
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { stateKey } from "../A00_Common/b_page_state";
import {
invalidateSectionDetail,
saveCachedCrossPatches,
@@ -170,10 +171,16 @@ export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
);
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
// ⚠ 이동 키까지 넘긴다(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야
// 한다). 종전에는 구간값 키만 넘겨, 옛 세션에 남은 관 이동 예약이 B05 [임시저장]에서는
// 조용히 빠졌다. 관 목록 자체는 이제 두 페이지가 같은 스냅샷(`pipes`)을 쓴다.
if (latest?.route?.id != null) {
await flushCulvertOptions(projectId, `b06:culvertopt:${projectId}:${latest.route.id}`).catch(
() => undefined,
);
const routeId = latest.route.id;
await flushCulvertOptions(
projectId,
stateKey("culvertopt", projectId, routeId),
stateKey("culvertmove", projectId, routeId),
).catch(() => undefined);
}
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
+322 -303
View File
@@ -16,36 +16,47 @@
import {
computeMapRect,
computeRouteView,
drawPreparedLayer,
createNormalizer,
hitPreparedLayer,
metricToScreen,
prepareLayer,
pickLevelStep,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
import {
buildEditedPolyline,
dragHandleTo as curveDragTo,
type EditedCurve,
type EditedNode,
} from "./B05_Profile_UI_RouteEdit_Curve";
import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
import {
bindRouteEditNavigation,
contourBandRect,
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
stationAtScreen,
} from "./B05_Profile_UI_RouteEdit_Input";
import {
centerDirectionOf,
createCurveLabel,
deflectionRad,
} from "./B05_Profile_UI_RouteEdit_Label";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
applyArcLocks,
applyCurveLimits,
curveShortfalls,
radiusFloorM,
curveSummary,
flattenServerPlan,
shortfallCrossed,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
import {
@@ -58,63 +69,38 @@ import "./B05_Profile_UI_Style_RouteEdit.css";
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
const NODE_HIT_PX = 9;
/** 노드 반지름(px). */
const NODE_R = 4;
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
const SEGMENT_HIT_PX = 12;
/** ** **(m) (2026-09-07).
*
* . ** **
* ( ).
* `drawPreparedLayer` . */
const CONTOUR_BAND_M = 300;
/** · (px) ** ** .
* 3.5px (2026-09-07 ). */
const CURVE_HANDLE_PX = 5;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
const CONTOUR_HIT_PX = 6;
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
const STATION_HIT_PX = 11;
/** B04 (350) ****
* (2026-09-12 ). 300m
* . */
const CONTOUR_LEVEL_BUDGET = 1200;
type Vertex = [number, number];
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
export interface RouteEditOptions {
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM?: number;
/** 확정 지표면 모델 id — 있으면 바탕 등고선을 **LAS 것**으로 쓴다(계획서 0-9 ⑥). */
surfaceModelId?: number | null;
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
contourIntervalM?: number;
smooth?: boolean;
}
export async function openRouteEditModal(
projectId: string,
onApplied: () => void | Promise<void>,
options: RouteEditOptions = {},
): Promise<void> {
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong> </strong>
<span class="b05-routeedit__hint">
= · = R · = ·
= · () = · =
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
<div class="b05-routeedit__foot">
<span class="b05-routeedit__status"> </span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> ()
<i class="is-planned"></i>
</span>
<button type="button" class="b05-routeedit__btn" data-act="undo" title="되돌리기 (Ctrl+Z)"
disabled> </button>
<button type="button" class="b05-routeedit__btn" data-act="redo" title="다시하기 (Ctrl+Y)"
disabled> </button>
<button type="button" class="b05-routeedit__btn" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled></button>
<button type="button" class="b05-routeedit__btn" data-act="reset"></button>
<button type="button" class="b05-routeedit__btn" data-act="cancel"></button>
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply"></button>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome();
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
const context = canvas.getContext("2d")!;
let expected: Vertex[] = [];
@@ -123,14 +109,13 @@ export async function openRouteEditModal(
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
let planned: Vertex[] = [];
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
let nodeInfo: Array<{
radius_m: number | null;
inner_angle_deg: number | null;
violations: string[];
}> = [];
let nodeInfo: EditedNode[] = [];
let minRadiusM = 0;
/** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */
let limitRadiusM = 0;
let limitArcM = 0;
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
let curveInfo: RoutePlanCurve[] = [];
let curveInfo: EditedCurve[] = [];
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
let curveOn: boolean[] = [];
let curveRadius: Array<number | null> = [];
@@ -138,12 +123,74 @@ export async function openRouteEditModal(
* R (`_Edits.ts`). */
let curveLock: CurveLock[] = [];
let curveArc: Array<number | null> = [];
/** (2026-09-12 )
* · . */
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
let history: RouteEditHistory | null = null;
let meta: VWorldMeta | null = null;
let sheets: PreparedLayer[] = [];
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
let contours: RouteEditContours | null = null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
let pickedContour = -1;
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
const crossPreview = createCrossPreview({
projectId,
side: overlay.querySelector<HTMLElement>(".b05-routeedit__side")!,
request: () => ({
vertices: planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
min_radius_m: minRadiusM || 12,
station_interval_m: stationIntervalM,
}),
});
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
const measure = createMeasureTool({
projectId,
stationIntervalM,
line: () => (plannedLine.length ? plannedLine : planned),
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
toScreen: (vertex) => toScreen(vertex),
isClosed: () => closed,
onChange: () => {
syncMeasureBox();
draw();
},
});
/** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */
function syncMeasureBox(): void {
const on = measure.active();
measureBox.hidden = !on;
measureText.textContent = measure.hint();
if (on && picked >= 0) {
picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다.
syncCurveBar();
}
}
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
let measureMode = false;
measureButton.addEventListener("click", () => {
measureMode = !measureMode;
measureButton.classList.toggle("is-active", measureMode);
if (!measureMode) measure.clear();
});
overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => {
measure.clear(); // 닫으면 잰 것이 지워진다(㉔).
measureMode = false;
measureButton.classList.remove("is-active");
syncMeasureBox();
draw();
});
let view: ViewState = {
width: 0,
height: 0,
@@ -153,6 +200,15 @@ export async function openRouteEditModal(
mapRect: computeMapRect(null, 0, 0),
};
let closed = false;
/** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */
const rotation = createMapRotation({
overlay,
size: () => view,
onChange: () => {
syncCurveBar(); // 떠 있는 패널도 돌아간 노드 옆으로 따라가야 한다.
draw();
},
});
const close = (): void => {
closed = true;
@@ -199,111 +255,42 @@ export async function openRouteEditModal(
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
}
function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void {
if (points.length < 2) return;
context.save();
context.setLineDash(dash);
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
points.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.restore();
/** 1m . `metricToScreen`
* 100m (1m ). */
function pxPerMeter(): number {
if (!meta) return 1;
const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min);
return Math.abs(x1 - x0) / 100;
}
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
const contourStepM = (): number =>
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
function draw(): void {
if (closed) return;
const style = getComputedStyle(document.documentElement);
context.clearRect(0, 0, view.width, view.height);
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
context.save();
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
const band = meta
? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M)
: null;
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot");
context.restore();
strokePolyline(
drawRouteEditScene(context, {
view,
toScreen,
pxPerMeter: pxPerMeter(),
hasMeta: meta !== null,
contours,
otherSheets,
pickedContour,
contourStepM: contourStepM(),
rotationRad: rotation.radians(),
uprightRad: rotation.uprightRad(),
measure: measure.marks(),
expected,
[6, 5],
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
plannedLine.length ? plannedLine : planned,
[],
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.lineWidth = 1;
planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0;
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) {
context.save();
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.beginPath();
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
context.fill();
context.restore();
}
plannedLine,
planned,
nodeInfo,
curveInfo,
curveOn,
picked,
stationIntervalM,
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
const on = curveOn[curve.node_first] !== false;
if (!on) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
syncCurveBar();
}
@@ -312,7 +299,10 @@ export async function openRouteEditModal(
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
handleAtScreen(
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null),
curveInfo.filter(
(entry) =>
(curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true,
),
toScreen,
px,
py,
@@ -346,12 +336,29 @@ export async function openRouteEditModal(
function markEdited(): void {
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
applyArcLocks(planned, curveLock, curveArc, curveRadius);
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
// 하한은 **R 과 L 을 함께 본 값**이다 — 내각 155° 이상은 R 만 본다(2026-09-12 확정).
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
plannedLine = built.vertices;
curveInfo = built.curves;
nodeInfo = built.nodes;
}
/** ( 0-9 ).
* . */
const routeHead = (): string =>
`예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` +
`계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` +
`노드 ${planned.length}`;
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
const contourHint = (): string => {
if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다.";
const level = contours?.layer.features[pickedContour]?.labelValue ?? null;
return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`;
};
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
const curveHint = (): string =>
curveSummary({
@@ -359,90 +366,45 @@ export async function openRouteEditModal(
curveOn,
curveRadius,
curveLock,
apexLock,
curveCount: curveInfo.length,
violationCount: nodeInfo.filter((node) => node.violations.length).length,
minRadiusM,
fresh: nodeInfo.length === 0,
});
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
const curveLabelBox = createCurveLabel({
onRadius: (value) => {
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
},
onLock: (lock) => {
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onCurveOn: (on) => {
if (picked < 0) return;
curveOn[picked] = on;
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
const curveBar = createCurveBar({
canvas,
state: () => ({
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
curveArc,
apexLock,
limitRadiusM,
limitArcM,
}),
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
applyEdit: (message) => applyEdit(message),
onUnselect: () => {
picked = -1;
syncCurveBar();
draw();
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function syncCurveBar(): void {
if (!(picked > 0 && picked < planned.length - 1)) {
curveLabelBox.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const rect = canvas.getBoundingClientRect();
const [screenX, screenY] = toScreen(planned[picked]);
curveLabelBox.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다.
at: [screenX + rect.left, screenY + rect.top],
centerDirection: pickedCurve
? centerDirectionOf(
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
toScreen(pickedCurve.start),
toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
});
}
const curveLabelBox = curveBar.label;
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
function applyEdit(message: string, record = true): void {
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}${message} ${curveHint()}`;
status.textContent = `${routeHead()}${message} ${curveHint()}`;
draw();
if (record) history?.commit(snapshotNow());
historyControls.sync();
@@ -450,7 +412,7 @@ export async function openRouteEditModal(
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
function snapshotNow(): RouteEditSnapshot {
return { planned, curveOn, curveRadius, curveLock, curveArc, picked };
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
}
const historyControls = bindHistoryControls({
@@ -462,6 +424,7 @@ export async function openRouteEditModal(
curveRadius = snapshot.curveRadius;
curveLock = snapshot.curveLock;
curveArc = snapshot.curveArc;
apexLock = snapshot.apexLock;
picked = snapshot.picked;
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
},
@@ -479,37 +442,87 @@ export async function openRouteEditModal(
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (event.shiftKey || measureMode) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void measure.pick(px, py);
return;
}
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
dragNode = nodeAt(px, py);
dragHandle = dragNode >= 0 ? null : handleAt(px, py);
const hitNode = nodeAt(px, py);
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1;
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragMoved = false;
if (dragNode >= 0) {
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
if (hitNode >= 0) {
picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
syncCurveBar();
draw();
} else if (dragHandle) {
picked = dragHandle.node;
measure.clear();
syncCurveBar();
draw();
} else if (
// 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나
// 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다.
((): boolean => {
if (picked >= 0) {
picked = -1;
syncCurveBar();
}
return false;
})()
) {
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
const chainage = stationAtScreen(
plannedLine.length ? plannedLine : planned,
toScreen,
stationIntervalM,
px,
py,
STATION_HIT_PX,
);
if (chainage === null) return false;
void crossPreview.open(chainage);
return true;
})()
) {
/* 횡단 창이 떴다 — 더 집지 않는다. */
} else if (contours) {
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
const hit = hitPreparedLayer(contours.layer, view, px, py, CONTOUR_HIT_PX, contourStepM());
if (hit !== pickedContour) {
pickedContour = hit;
status.textContent = `${routeHead()}${contourHint()} ${curveHint()}`;
draw();
}
}
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
const node = dragHandle.node;
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
planned[node] = moved.apex;
curveRadius[node] = Math.round(moved.radius * 100) / 100;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
curveRadius[node] = Math.max(
radiusFloorM(nodeInfo[node]?.inner_angle_deg, limitRadiusM, limitArcM),
Math.round(moved.radius * 100) / 100,
);
curveOn[node] = true;
picked = node;
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
@@ -517,22 +530,45 @@ export async function openRouteEditModal(
dragMoved = true;
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length} — 곡선을 잡는 중. ${curveHint()}`;
status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`;
draw();
}
return;
}
if (dragNode >= 0) {
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM);
const previous = planned[dragNode];
dragMoved = true;
planned[dragNode] = toMetric(px, py);
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) {
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
planned[dragNode] = previous;
markEdited();
status.textContent =
`${routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`;
draw();
return;
}
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `노드 ${planned.length} — 옮기는 중. ${curveHint()}`;
status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`;
draw();
return;
}
canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default";
const overNode = nodeAt(px, py);
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
overNode >= 0
? apexLock[overNode]
? "not-allowed"
: "grab"
: handleAt(px, py)
? "grab"
: "default";
});
const endDrag = (event: PointerEvent): void => {
@@ -542,6 +578,9 @@ export async function openRouteEditModal(
if (dragMoved) {
history?.commit(snapshotNow());
historyControls.sync();
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
void crossPreview.refresh();
}
dragNode = -1;
dragHandle = null;
@@ -552,8 +591,7 @@ export async function openRouteEditModal(
canvas.addEventListener("dblclick", (event) => {
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const segment = segmentAt(px, py);
if (segment < 0) return;
planned.splice(segment + 1, 0, toMetric(px, py));
@@ -562,6 +600,7 @@ export async function openRouteEditModal(
curveRadius.splice(segment + 1, 0, null);
curveLock.splice(segment + 1, 0, null);
curveArc.splice(segment + 1, 0, null);
apexLock.splice(segment + 1, 0, false);
picked = segment + 1;
applyEdit("새 노드를 넣었습니다(직선 추가).");
});
@@ -569,7 +608,7 @@ export async function openRouteEditModal(
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top));
if (index < 0) return;
if (planned.length <= 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
@@ -580,6 +619,7 @@ export async function openRouteEditModal(
curveRadius.splice(index, 1);
curveLock.splice(index, 1);
curveArc.splice(index, 1);
apexLock.splice(index, 1);
picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제).");
});
@@ -592,57 +632,18 @@ export async function openRouteEditModal(
view = next;
},
getMeta: () => meta,
// 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다.
unrotateDelta: rotation.unrotateDelta,
draw,
});
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
// 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를
// 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2).
const message = busy.querySelector("span")!;
const started = Date.now();
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
};
tick();
const timer = window.setInterval(tick, 1000);
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
close();
await onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
} finally {
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
bindRouteApply({
overlay,
busy,
projectId,
nodes: () => ({ planned, curveOn, curveRadius }),
close,
onApplied,
});
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
@@ -658,6 +659,8 @@ export async function openRouteEditModal(
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
const nodes = plan.nodes ?? [];
minRadiusM = plan.min_radius_m ?? 0;
limitRadiusM = plan.limit_radius_m ?? 0;
limitArcM = plan.limit_curve_length_m ?? 0;
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
planned = flat.planned;
@@ -667,6 +670,7 @@ export async function openRouteEditModal(
curveRadius = flat.curveRadius;
curveLock = flat.curveLock;
curveArc = flat.curveArc;
apexLock = flat.apexLock;
picked = -1;
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
history = createRouteEditHistory(snapshotNow());
@@ -675,9 +679,23 @@ export async function openRouteEditModal(
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
const normalizer = createNormalizer(drainage.meta);
sheets = drainage.layers
// 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
otherSheets = drainage.layers
.filter(([layer]) => layer !== "도엽_등고선")
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
.filter((layer): layer is PreparedLayer => layer !== null);
contours = await loadRouteEditContours(
projectId,
drainage.meta,
normalizer,
drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null,
{
surfaceModelId: options.surfaceModelId ?? null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
},
);
if (closed) return;
resize();
const xs = planned.map((vertex) => vertex[0]);
const ys = planned.map((vertex) => vertex[1]);
@@ -694,7 +712,8 @@ export async function openRouteEditModal(
);
view = { ...view, ...fitted };
status.textContent =
`노드 ${planned.length} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
curveHint();
draw();
} catch (error) {
@@ -0,0 +1,81 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Apply.ts
* **[]·[]** .
*
* ·· ( 90).
* ( 0-2, 2026-09-09) ** **
* .
* ========================================================================== */
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
type Vertex = [number, number];
export interface RouteApplyParams {
overlay: HTMLElement;
/** 화면 전체를 덮는 대기 막. 안에 `<span>` 한 개가 글을 받는다. */
busy: HTMLElement;
projectId: string;
/** 지금 편집값 — 누른 순간에 읽는다. */
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
onApplied: () => void | Promise<void>;
}
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
export function bindRouteApply(params: RouteApplyParams): void {
const { overlay, busy, projectId } = params;
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
// 87.3 · 90.0 · 93.9 · 95.4초).
const message = busy.querySelector("span")!;
const started = Date.now();
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
};
tick();
const timer = window.setInterval(tick, 1000);
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
params.close();
await params.onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
} finally {
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
const { planned, curveOn, curveRadius } = params.nodes();
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
});
}
@@ -0,0 +1,98 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts
* **** ·· .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* (`_Apply`·`_Rotate`·`_History`) .
*
* ****(2026-09-12 ~·) .
* , 2, · ,
* . .
* ========================================================================== */
/** ** **(2026-09-12 ).
* (``·``) . */
const HALF_TURN_ICON = {
ccw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
cw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8a5 5 0 0 1 10 0" /><path d="M13 8 14.8 5.6" /><path d="M13 8 10.6 6.6" /></svg>`,
};
export interface RouteEditChrome {
overlay: HTMLElement;
canvas: HTMLCanvasElement;
status: HTMLElement;
busy: HTMLElement;
measureBox: HTMLElement;
measureText: HTMLElement;
measureButton: HTMLButtonElement;
}
/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
export function createRouteEditChrome(): RouteEditChrome {
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong> </strong>
<span class="b05-routeedit__actions">
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)"> </button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
title="되돌리기 (Ctrl+Z)" disabled> </button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled> </button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled></button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset"></button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"></button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply"></button>
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
<div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas>
<div class="b05-routeedit__hint">
<span> = </span><span> = R </span>
<span> = </span><span> = </span>
<span> = </span><span>Shift+ = ·</span>
<span>() = </span><span> = </span>
</div>
<div class="b05-routeedit__spin">
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
</div>
<div class="b05-routeedit__measure" hidden>
<span class="b05-routeedit__measure-text"></span>
<button type="button" class="b05-routeedit__measure-close" aria-label="닫기"
title="닫기"></button>
</div>
<div class="b05-routeedit__info">
<span class="b05-routeedit__status"> </span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> ()
<i class="is-planned"></i>
</span>
</div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<div class="b05-routeedit__side"></div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const measureBox = overlay.querySelector<HTMLElement>(".b05-routeedit__measure")!;
const measureText = overlay.querySelector<HTMLElement>(".b05-routeedit__measure-text")!;
const measureButton = overlay.querySelector<HTMLButtonElement>('[data-act="measure"]')!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
return { overlay, canvas, status, busy, measureBox, measureText, measureButton };
}
@@ -0,0 +1,111 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Contour.ts
* ** ** .
*
* ** **(2026-09-12 ) LAS
* . ** LAS **
* , .
*
* GeoJSON( `등고수치` ), LAS
* (m) ( `level`). ** `PreparedLayer` **
* ·· .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import {
type GeoJsonCollection,
type Normalizer,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolylines,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
const SHEET_ELEVATION_KEYS = ["등고수치"];
/** 표고를 못 읽었을 때 라벨 솎기에 쓸 간격(m). */
const FALLBACK_INTERVAL_M = 5;
/** LAS ** m ** (2026-09-12 ).
*
* LAS 1m 1m·2m . 5m
* ** 1m 5 **
* . */
const LAS_CONTOUR_UNIT_M = 5;
export interface RouteEditContours {
layer: PreparedLayer;
/** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */
intervalM: number;
source: "las" | "sheet";
}
interface ContourResponse {
contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>;
}
/**
* . LAS, .
*
* LAS ** ** ,
* `source` .
*/
export async function loadRouteEditContours(
projectId: string,
meta: VWorldMeta,
normalizer: Normalizer,
sheet: GeoJsonCollection | null,
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
): Promise<RouteEditContours> {
if (options.surfaceModelId !== null) {
// 받아 오는 간격은 프로젝트 설정 그대로(보관함에 이미 있는 파일을 쓰려는 것) —
// **보이는 눈금**은 아래에서 5m 로 맞춘다.
const interval = options.intervalM > 0 ? options.intervalM : 1;
try {
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
const data = await fetchCachedJson<ContourResponse>(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
`/contour?interval=${interval}&smooth=${options.smooth}`,
);
const lines = (data.contours ?? [])
// 5m 단위만 남긴다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다.
.filter((contour) => Math.abs(contour.level % LAS_CONTOUR_UNIT_M) < 1e-6)
.map((contour) => ({
points: contour.coordinates.map(([x, y]) => [x, y] as const),
label: contour.level,
}))
.filter((line) => line.points.length >= 2);
if (lines.length > 0) {
return {
layer: prepareMetricPolylines(lines, meta),
intervalM: LAS_CONTOUR_UNIT_M,
source: "las",
};
}
} catch {
/* 내려앉는다 — 아래 도엽 갈래로 이어 간다. */
}
}
const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS);
return { layer, intervalM: inferIntervalM(layer), source: "sheet" };
}
/** 도엽 등고선의 간격(m) — 표고 값들의 **가장 좁은 칸**을 간격으로 본다. */
function inferIntervalM(layer: PreparedLayer): number {
const levels = [
...new Set(
layer.features
.map((feature) => feature.labelValue)
.filter((value): value is number => value !== null),
),
].sort((a, b) => a - b);
let smallest = Infinity;
for (let index = 1; index < levels.length; index += 1) {
const gap = levels[index] - levels[index - 1];
if (gap > 0 && gap < smallest) smallest = gap;
}
return Number.isFinite(smallest) ? smallest : FALLBACK_INTERVAL_M;
}
@@ -0,0 +1,154 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross.ts
* ** ** ** **( 0-9 ).
*
* · = ** **. .
* · = ** **. , ** **
* .
*
* (2026-09-12 ) ** · ·
* **. .
*
* ** ** [] .
* ( ) .
*
* **B05·B06 ** · `generate_sections`,
* `compute_cross_design`(), B06 `fillSlopeLengths`.
* ========================================================================== */
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import {
buildCrossSvg,
CROSS_PLOT_PAD,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { formatStation } from "./B05_Profile_Util_Station";
export interface CrossPreviewParams {
projectId: string;
/** 두 판이 들어앉을 오른쪽 세로 칸. */
side: HTMLElement;
/** 지금 편집값 — 셈을 부르는 순간에 읽는다. */
request: () => {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number;
station_interval_m: number;
};
}
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
open: (chainageM: number) => Promise<void>;
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
}
interface CrossPane {
root: HTMLElement;
/** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */
show: (preview: CrossPreviewResponse | null, intervalM: number) => void;
/** 기다리는 중임을 알린다. */
wait: (text: string) => void;
}
function createPane(title: string, empty: string): CrossPane {
const root = document.createElement("section");
root.className = "b05-routeedit__cross";
root.innerHTML = `
<div class="b05-routeedit__cross-head">
<strong class="b05-routeedit__cross-title">${title}</strong>
<span class="b05-routeedit__cross-station"></span>
</div>
<div class="b05-routeedit__cross-box"></div>
<div class="b05-routeedit__cross-foot">${empty}</div>`;
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
return {
root,
show(preview, intervalM) {
box.replaceChildren();
if (!preview) {
station.textContent = "";
foot.textContent = empty;
return;
}
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
station.textContent = formatStation(preview.chainage_m, intervalM);
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.max(Math.round(rect.width), 240),
Math.max(Math.round(rect.height), 150),
);
box.append(plot.svg);
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
foot.textContent = summarizeCross(preview);
},
wait(text) {
box.replaceChildren();
foot.textContent = text;
},
};
}
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다.");
const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다.");
params.side.append(current.root, previous.root);
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
let watching: number | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */
let shown: CrossPreviewResponse | null = null;
/** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */
let ticket = 0;
async function load(chainageM: number, keepPrevious: boolean): Promise<void> {
const mine = ++ticket;
const request = params.request();
if (keepPrevious && shown) previous.show(shown, request.station_interval_m);
current.wait("읽는 중…");
try {
const preview = await fetchCrossPreview(params.projectId, {
...request,
chainage_m: chainageM,
});
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
shown = preview;
current.show(preview, request.station_interval_m);
} catch (error) {
if (mine !== ticket) return;
shown = null;
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
}
}
return {
async open(chainageM) {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, params.request().station_interval_m);
watching = chainageM;
await load(chainageM, false);
},
async refresh() {
if (watching === null) return;
await load(watching, true);
},
};
}
@@ -0,0 +1,216 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross_Draw.ts
* **SVG ** · .
*
* `B05_Profile_UI_RouteEdit_Cross.ts` (2026-09-12, 700 ).
* B05·B06 .
*
* ** SVG **(2026-09-12 )
* . SVG . · **
* **(`A00_Common/b_svg_zoom_pan.ts`) B06 .
*
* ** · **(2026-09-12 ).
* `design_line` ** **,
* . B06 2026-08-20
* (`appendCrossDesignOverlay`)
* `toeOffsets` .
* ========================================================================== */
import { svgElement, svgText } from "@util/common_util_svg";
import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan";
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
import { fillSlopeLengths, toeOffsets } from "./../B06_Section/B06_Section_UI_Cross_Fit";
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 };
/** 지은 한 판 — 확대·이동은 `layer` 에만 걸린다(범례·안내 글자는 제자리). */
export interface CrossPlot {
svg: SVGSVGElement;
layer: SVGGElement;
width: number;
height: number;
/** 실제로 선이 놓인 범위 — 확대했을 때 팬이 갈 수 있는 데까지. */
content: ContentBounds;
}
/** 성토사면 길이·절성토 면적 한 줄. */
export function summarizeCross(preview: CrossPreviewResponse): string {
const design = preview.design;
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
const lengths = fillSlopeLengths(asSection(preview));
const sides = (["left", "right"] as const)
.filter((side) => lengths[side] !== null)
.map((side) => {
const value = lengths[side]!;
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
return `${side === "left" ? "좌" : "우"} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
});
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}`;
}
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
function asSection(preview: CrossPreviewResponse): CrossSection {
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
}
type Point = [number, number];
/**
* **· ** (2026-09-12 ).
*
* ** **
* .
* ( ) .
*/
function clipToToes(line: Point[], low: number, high: number): Point[] {
if (line.length < 2) return line;
const inside = (point: Point): boolean => point[0] >= low - 1e-9 && point[0] <= high + 1e-9;
const cutAt = (from: Point, to: Point, edge: number): Point => {
const span = to[0] - from[0];
const ratio = Math.abs(span) <= 1e-9 ? 0 : (edge - from[0]) / span;
return [edge, from[1] + (to[1] - from[1]) * ratio];
};
const out: Point[] = [];
line.forEach((point, index) => {
if (inside(point)) out.push(point);
const next = line[index + 1];
if (!next) return;
// 경계를 넘어가는 구간은 경계 자리에 점을 하나 세운다. 가까운 경계부터 넣어야 순서가 산다.
[low, high]
.filter((edge) => (point[0] - edge) * (next[0] - edge) < 0)
.sort((a, b) => Math.abs(a - point[0]) - Math.abs(b - point[0]))
.forEach((edge) => out.push(cutAt(point, next, edge)));
});
return out;
}
/**
* . (+offset)
* (`generate_sections` cad_exchange ).
*
* **· ** .
* (2026-09-12 실화면: 노면이 ).
*/
export function buildCrossSvg(
preview: CrossPreviewResponse,
width: number,
height: number,
): CrossPlot {
const svg = svgElement("svg", {
viewBox: `0 0 ${width} ${height}`,
width: "100%",
height: "100%",
preserveAspectRatio: "none",
class: "b05-routeedit__cross-svg",
});
const layer = svgElement("g", { class: "b05-routeedit__cross-plot" });
const ground = preview.samples
.filter((sample) => sample.valid && sample.elevation_m !== null)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
// 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
const design = clipToToes(
(preview.design?.design_line ?? []).map(
(point) => [point.offset_m, point.elevation_m] as Point,
),
toes.right ?? -Infinity, // offset = 우
toes.left ?? Infinity, // +offset = 좌
);
const all = [...ground, ...design];
if (all.length < 2) {
svg.append(layer);
return { svg, layer, width, height, content: { x: 0, y: 0, width, height } };
}
const offsets = all.map((point) => point[0]);
const heights = all.map((point) => point[1]);
const minOffset = Math.min(...offsets);
const maxOffset = Math.max(...offsets);
const minZ = Math.min(...heights);
const maxZ = Math.max(...heights);
const spanX = maxOffset - minOffset || 1;
const spanZ = maxZ - minZ || 1;
const plotWidth = Math.max(width - CROSS_PLOT_PAD.left - CROSS_PLOT_PAD.right, 1);
const plotHeight = Math.max(height - CROSS_PLOT_PAD.top - CROSS_PLOT_PAD.bottom, 1);
const scale = Math.min(plotWidth / spanX, plotHeight / spanZ);
const centerOffset = (minOffset + maxOffset) / 2;
const centerZ = (minZ + maxZ) / 2;
const toScreen = (point: Point): Point => [
width / 2 + (centerOffset - point[0]) * scale,
height / 2 + (centerZ - point[1]) * scale,
];
const path = (points: Point[], className: string): void => {
if (points.length < 2) return;
layer.append(
svgElement("polyline", {
class: className,
points: points.map((point) => toScreen(point).join(",")).join(" "),
}),
);
};
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
const [centerX] = toScreen([0, centerZ]);
layer.append(
svgElement("line", {
class: "b05-routeedit__cross-axis",
x1: centerX,
y1: CROSS_PLOT_PAD.top / 2,
x2: centerX,
y2: height - CROSS_PLOT_PAD.bottom / 2,
}),
);
path(ground, "b05-routeedit__cross-ground");
path(design, "b05-routeedit__cross-design");
// 범례·눈금 안내는 **확대해도 제자리**다 — 확대 묶음 밖에 둔다.
svg.append(layer);
svg.append(
svgText("원지반", {
class: "b05-routeedit__cross-key is-ground",
x: CROSS_PLOT_PAD.left,
y: 13,
"text-anchor": "start",
}),
svgText("기본 계획 횡단", {
class: "b05-routeedit__cross-key is-design",
x: width - CROSS_PLOT_PAD.right,
y: 13,
"text-anchor": "end",
}),
svgText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
{
class: "b05-routeedit__cross-key",
x: width / 2,
y: height - 6,
"text-anchor": "middle",
},
),
);
const screens = all.map(toScreen);
const xs = screens.map((point) => point[0]);
const ys = screens.map((point) => point[1]);
return {
svg,
layer,
width,
height,
content: {
x: Math.min(...xs),
y: Math.min(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
},
};
}
@@ -0,0 +1,176 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_CurveBar.ts
* **** ,
* . `_Label` .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* , `state()` .
*
* **R **(L = R·Δ) ** **
* . (`_Edits.ts` ).
* ========================================================================== */
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import {
curveOptionalAt,
deflectionRad,
radiusFloorM,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
import {
centerDirectionOf,
createCurveLabel,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
picked: number;
planned: Vertex[];
nodeInfo: EditedNode[];
curveInfo: EditedCurve[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
}
export interface CurveBarParams {
canvas: HTMLCanvasElement;
state: () => CurveBarState;
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
toScreen: (vertex: Vertex) => [number, number];
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
applyEdit: (message: string) => void;
/** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */
onUnselect: () => void;
}
export interface CurveBar {
label: CurveLabel;
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
sync: () => void;
}
export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onApexLock: (locked) => {
const { picked, apexLock } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다.");
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function sync(): void {
const {
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
apexLock,
limitRadiusM,
limitArcM,
} = params.state();
if (!(picked > 0 && picked < planned.length - 1)) {
label.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null;
const optional = curveOptionalAt(innerAngle);
const deflection = deflectionRad(innerAngle);
const rect = params.canvas.getBoundingClientRect();
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds: {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
},
centerDirection: pickedCurve
? centerDirectionOf(
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
params.toScreen(pickedCurve.start),
params.toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
apexLocked: apexLock[picked] === true,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
curveOptional: optional,
// 칸이 막는 하한은 **그 자리 값**이다 — R 칸은 L 하한까지 환산해 함께 보고,
// 곡선을 생략해도 되는 자리(내각 155° 이상)는 L 하한을 안 건다(2026-09-12 확정).
limitRadiusM: radiusFloorM(innerAngle, limitRadiusM, limitArcM),
limitArcM: optional ? 0 : limitArcM,
});
}
return { label, sync };
}
+114 -2
View File
@@ -12,6 +12,12 @@
*
* R (`buildEditedPolyline`).
* ** ** .
*
* ** R L **(2026-09-12 ). L = R·Δ
* ** ** `radiusFloorM` .
* ** 155° **(2 .2..(1) 155
* ). L 0 R
* , ** **(실측: 내각 174.8° R 12m 55m).
* ========================================================================== */
import {
@@ -20,11 +26,43 @@ import {
type EditedNode,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
import { deflectionRad } from "./B05_Profile_UI_RouteEdit_Label";
/** 무엇을 붙들고 있나. */
export type CurveLock = "radius" | "arc" | null;
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
return ((180 - innerAngleDeg) * Math.PI) / 180;
}
/** 곡선을 **안 둘 수 있는** 내각(도) — 별표2 .2.다.(1) · .3.라.(1). */
export const CURVE_OPTIONAL_INNER_ANGLE_DEG = 155;
/** 그 자리에서 곡선을 생략해도 되나 — 내각이 155° 이상이면 그렇다. */
export function curveOptionalAt(innerAngleDeg: number | null | undefined): boolean {
return innerAngleDeg !== null && innerAngleDeg !== undefined
? innerAngleDeg >= CURVE_OPTIONAL_INNER_ANGLE_DEG
: false;
}
/**
* ** **(m) R L .
*
* L = R·Δ L R /Δ . .
* 155° ( ) **R ** .
*/
export function radiusFloorM(
innerAngleDeg: number | null | undefined,
limitRadiusM: number,
limitArcM: number,
): number {
if (!(limitArcM > 0) || curveOptionalAt(innerAngleDeg)) return limitRadiusM;
const deflection = deflectionRad(innerAngleDeg);
if (!(deflection > 1e-9)) return limitRadiusM;
return Math.max(limitRadiusM, limitArcM / deflection);
}
/**
* ** ** (`curveRadius` ).
*
@@ -48,11 +86,81 @@ export function applyArcLocks(
}
}
/**
* **(R·L) **( 0-9 , 2026-09-12 ).
*
* ** () ** ,
* R .
*
* `radiusFloorM` R·L 155° R .
*/
export function applyCurveLimits(
planned: Vertex[],
curveOn: ReadonlyArray<boolean>,
curveRadius: Array<number | null>,
limitRadiusM: number,
limitArcM = 0,
): void {
if (limitRadiusM <= 0 && limitArcM <= 0) return;
for (let seat = 1; seat < planned.length - 1; seat += 1) {
if (curveOn[seat] === false) continue;
const current = curveRadius[seat];
if (current === null || current === undefined) continue;
const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]);
const floor = radiusFloorM(inner, limitRadiusM, limitArcM);
if (current < floor) curveRadius[seat] = floor;
}
}
/**
* **(R·L)** (m). 0.
*
* `radiusFloorM` L .
* ( 155° ) R .
* ** **.
*
* R .
* , ** **
* (2026-09-12).
*
* . **
* ** ( 1 2
* ). .
*/
export function curveShortfalls(
nodes: ReadonlyArray<EditedNode>,
limitRadiusM: number,
limitArcM = 0,
): number[] {
return nodes.map((node) => {
if (node.radius_m === null) return 0;
const floor = radiusFloorM(node.inner_angle_deg, limitRadiusM, limitArcM);
if (floor <= 0) return 0;
return Math.max(0, floor - node.radius_m);
});
}
/**
* ** **. (·)
* .
*
* ** **
* 1px (2026-09-12
* ). ( ), **
* ** .
*/
export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean {
if (before.length !== after.length) return false;
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
}
export interface CurveSummaryInput {
nodeCount: number;
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
/** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
curveCount: number;
/** 법정 기준을 못 맞춘 자리 수. */
@@ -68,7 +176,8 @@ export function curveSummary(input: CurveSummaryInput): string {
(on, index) => !on && index > 0 && index < input.nodeCount - 1,
).length;
const forced = input.curveRadius.filter((value) => value !== null).length;
const locked = input.curveLock.filter((lock) => lock !== null).length;
const locked =
input.curveLock.filter((lock) => lock !== null).length + input.apexLock.filter(Boolean).length;
const edits = [
off ? `곡선 지움 ${off}` : "",
forced ? `R 지정 ${forced}` : "",
@@ -96,6 +205,7 @@ export interface FlattenedPlan {
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
apexLock: boolean[];
}
interface ServerNode {
@@ -137,6 +247,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
curveRadius: [],
curveLock: [],
curveArc: [],
apexLock: [],
};
nodes.forEach((node, index) => {
if (dropped.has(index)) return;
@@ -154,6 +265,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
out.curveLock.push(null);
out.curveArc.push(null);
out.apexLock.push(false);
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
});
return out;
@@ -21,6 +21,8 @@ export interface RouteEditSnapshot {
curveLock: Array<"radius" | "arc" | null>;
/** 길이를 붙들었을 때의 그 길이(m). */
curveArc: Array<number | null>;
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
apexLock: boolean[];
picked: number;
}
@@ -50,6 +52,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
curveRadius: [...snapshot.curveRadius],
curveLock: [...snapshot.curveLock],
curveArc: [...snapshot.curveArc],
apexLock: [...snapshot.apexLock],
picked: snapshot.picked,
};
}
@@ -64,6 +67,7 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
if (a.curveRadius[index] !== b.curveRadius[index]) return false;
if (a.curveLock[index] !== b.curveLock[index]) return false;
if (a.curveArc[index] !== b.curveArc[index]) return false;
if (a.apexLock[index] !== b.apexLock[index]) return false;
}
return true;
}
+102 -7
View File
@@ -29,12 +29,16 @@ export interface RouteEditNavigationParams {
getView: () => ViewState;
setView: (next: ViewState) => void;
getMeta: () => VWorldMeta | null;
/** (dx, dy) ** ** .
* ( ). */
unrotateDelta?: (dx: number, dy: number) => [number, number];
draw: () => void;
}
/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
const { canvas, getView, setView, getMeta, draw } = params;
const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]);
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
canvas.addEventListener(
@@ -53,8 +57,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
const ratio = scale / view.scale;
const rect = canvas.getBoundingClientRect();
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
const [cursorX, cursorY] = unrotateDelta(
event.clientX - rect.left - rect.width / 2,
event.clientY - rect.top - rect.height / 2,
);
setView({
...view,
scale,
@@ -84,11 +90,8 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
canvas.addEventListener("pointermove", (event) => {
if (!dragStart) return;
setView({
...getView(),
offsetX: dragStart.offsetX + event.clientX - dragStart.x,
offsetY: dragStart.offsetY + event.clientY - dragStart.y,
});
const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y);
setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy });
draw();
});
@@ -181,6 +184,98 @@ export function segmentAtScreen(
return best;
}
/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */
export interface RoutePointHit {
/** 사업지 좌표(m). */
point: [number, number];
/** 시점에서 노선을 따라간 거리(m). */
chainageM: number;
}
/**
* ( ) ** ** . null.
*
* · ( 0-9 )
* . .
*/
export function routePointAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
px: number,
py: number,
maxPx: number,
): RoutePointHit | null {
let best: RoutePointHit | null = null;
let bestDistance = maxPx;
let travelled = 0;
for (let index = 0; index < line.length - 1; index += 1) {
const from = line[index];
const to = line[index + 1];
const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]);
const [ax, ay] = toScreen(from);
const [bx, by] = toScreen(to);
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
if (distance < bestDistance) {
bestDistance = distance;
best = {
point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t],
chainageM: travelled + segmentM * t,
};
}
travelled += segmentM;
}
return best;
}
/**
* ** ** (m). null( 0-9 ).
*
* `drawStationTicks` ** **
* . .
*/
export function stationAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
intervalM: number,
px: number,
py: number,
maxPx: number,
): number | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
for (let index = 1; index < line.length; index += 1) {
cumulative.push(
cumulative[index - 1] +
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
);
}
const total = cumulative[cumulative.length - 1];
let best: number | null = null;
let bestDistance = maxPx;
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += intervalM) {
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
const front = line[cursor];
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
const [x, y] = toScreen([
back[0] + (front[0] - back[0]) * ratio,
back[1] + (front[1] - back[1]) * ratio,
]);
const distance = Math.hypot(x - px, y - py);
if (distance < bestDistance) {
bestDistance = distance;
best = chainage;
}
}
return best;
}
/** `bandM` . null.
*
* ** ** ·· .
+101 -21
View File
@@ -11,19 +11,20 @@
*
* ****(2026-09-07 )
* · `document.body` `position: fixed` `overflow: hidden`
* ****. .
* ****.
* · ** **(2026-09-12 )
* .
* .
* · ** **, **16** (4 ).
* · ** **. ,
* .
* ========================================================================== */
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
import { CURVE_OPTIONAL_INNER_ANGLE_DEG, type CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
return ((180 - innerAngleDeg) * Math.PI) / 180;
}
// `deflectionRad` 는 화면을 안 만지는 셈이라 `_Edits.ts` 로 옮겼다(2026-09-12) — 순수
// 함수가 DOM 파일에 얹혀 있으면 시험이 화면째 들여와야 한다. 재수출로 부르던 자리를 지킨다.
export { deflectionRad } from "./B05_Profile_UI_RouteEdit_Edits";
/** ** **() .
*
@@ -60,20 +61,33 @@ export interface CurveLabelState {
at: [number, number];
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */
centerDirection: [number, number] | null;
/** 패널이 넘어가면 안 되는 테두리(화면 좌표) — 보통 모달의 지도 칸. 없으면 안 가둔다. */
bounds?: { left: number; top: number; right: number; bottom: number };
curveOn: boolean;
radiusShown: number | null;
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
arcLengthShown: number | null;
lock: CurveLock;
/** 교각점(이 꺾임점) 자리를 못 박았나 — 켜면 끌어서 못 옮긴다(2026-09-12 사용자 지시 ①). */
apexLocked: boolean;
/** 내각 155° 이상이라 **곡선을 안 둬도 되는** 자리인가(별표2 .2.다.(1)). */
curveOptional: boolean;
innerAngleDeg: number | null;
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM?: number;
limitArcM?: number;
}
export interface CurveLabelHandlers {
/** 닫기 단추 — 고른 꺾임점을 푼다(계획서 0-9 ㉖). */
onClose: () => void;
onRadius: (value: number | null) => void;
onArcLength: (value: number | null) => void;
onCurveOn: (on: boolean) => void;
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
onLock: (lock: CurveLock) => void;
/** 교각점 자리를 못 박거나 푼다. */
onApexLock: (locked: boolean) => void;
}
export interface CurveLabel {
@@ -106,6 +120,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<div class="b05-routeedit__label-head">
<span class="b05-routeedit__curve-label"></span>
<button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button>
<button type="button" class="b05-routeedit__label-close" data-act="curve-close"
aria-label="닫기" title="닫기"></button>
</div>
<label class="b05-routeedit__curve-field">
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
@@ -119,8 +135,11 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다"></button>
</label>
<span class="b05-routeedit__curve-info"></span>
<span class="b05-routeedit__curve-note"> </span>`;
<div class="b05-routeedit__curve-field">
<button type="button" class="b05-routeedit__lock" data-act="lock-apex"
title="교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다"></button>
</div>
<span class="b05-routeedit__curve-info"></span>`;
document.body.append(root);
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
@@ -130,6 +149,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
const arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!;
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
const lockApex = root.querySelector<HTMLButtonElement>('[data-act="lock-apex"]')!;
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
@@ -139,20 +159,38 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
let curveOn = true;
let lock: CurveLock = null;
let apexLocked = false;
let seat = -1;
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
let manual: [number, number] | null = null;
let anchor: [number, number] = [0, 0];
/** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */
let limitRadius = 0;
let limitArc = 0;
/** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */
let limit: CurveLabelState["bounds"];
const numberOf = (input: HTMLInputElement): number | null => {
/** . ** , **
* (2026-09-12 ) . */
const numberOf = (input: HTMLInputElement, floor: number): number | null => {
const value = Number(input.value);
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null;
if (floor > 0 && value < floor) {
input.value = String(floor);
return floor;
}
return value;
};
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
root
.querySelector('[data-act="curve-close"]')!
.addEventListener("click", () => handlers.onClose());
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
// 교각점 고정은 **곡선 유무와 무관**하다 — 곡선을 지운 자리도 꺾임점 자리는 못 박을 수 있다.
lockApex.addEventListener("click", () => handlers.onApexLock(!apexLocked));
// ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ──
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
@@ -169,8 +207,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
});
head.addEventListener("pointermove", (event) => {
if (!dragFrom) return;
const left = dragFrom.left + event.clientX - dragFrom.x;
const top = dragFrom.top + event.clientY - dragFrom.y;
// 끄는 동안에도 테두리를 지킨다 — 놓은 뒤에만 가두면 손이 간 자리에서 패널이 튄다.
const [left, top] = clamp(
dragFrom.left + event.clientX - dragFrom.x,
dragFrom.top + event.clientY - dragFrom.y,
root.offsetWidth,
root.offsetHeight,
limit,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
@@ -183,7 +227,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
head.addEventListener("pointerup", stopDrag);
head.addEventListener("pointercancel", stopDrag);
/** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */
/** , 16. .
* ** ** . */
function place(state: CurveLabelState): void {
const width = root.offsetWidth;
const height = root.offsetHeight;
@@ -193,12 +238,32 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
: ([1, 0] as [number, number]);
const distance = GAP_PX + boxReach(away[0], away[1], width, height);
anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2];
const left = anchor[0] + (manual ? manual[0] : 0);
const top = anchor[1] + (manual ? manual[1] : 0);
const [left, top] = clamp(
anchor[0] + (manual ? manual[0] : 0),
anchor[1] + (manual ? manual[1] : 0),
width,
height,
state.bounds,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
}
/** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */
function clamp(
left: number,
top: number,
width: number,
height: number,
bounds: CurveLabelState["bounds"],
): [number, number] {
if (!bounds) return [left, top];
return [
Math.max(bounds.left, Math.min(left, bounds.right - width)),
Math.max(bounds.top, Math.min(top, bounds.bottom - height)),
];
}
return {
show(state) {
if (state.seat !== seat) {
@@ -207,6 +272,13 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
}
curveOn = state.curveOn;
lock = state.lock;
apexLocked = state.apexLocked;
limit = state.bounds;
limitRadius = state.limitRadiusM ?? 0;
limitArc = state.limitArcM ?? 0;
// 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다.
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
arc.min = limitArc > 0 ? String(limitArc) : "1";
root.hidden = false;
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
@@ -216,15 +288,23 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
lockArc.disabled = !state.curveOn;
lockRadius.classList.toggle("is-on", lock === "radius");
lockArc.classList.toggle("is-on", lock === "arc");
lockApex.classList.toggle("is-on", apexLocked);
radius.value =
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
arc.value =
state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10);
const inner = state.innerAngleDeg;
const held =
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음";
// 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로,
// 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다.
// 내각 155° 이상은 법이 곡선을 안 둬도 된다고 한 자리다 — L 하한을 안 거는 까닭이
// 여기서 보여야 한다(2026-09-12 사용자 확정).
info.textContent = state.curveOn
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}`
? inner
? `내각 ${Math.round(inner)}°` +
(state.curveOptional
? ` · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능`
: "")
: ""
: "곡선 없음 — 직선이 그대로 꺾입니다";
place(state);
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
@@ -0,0 +1,120 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Measure.ts
* ** ** ( 0-9 ).
*
* Shift+ a·b . ·
* , .
*
* ** ** (`/route/elevations`).
* ( 0-2 7)
* .
* ========================================================================== */
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
type Vertex = [number, number];
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
const MEASURE_HIT_PX = 14;
interface MeasurePoint extends RoutePointHit {
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
z: number | null;
}
export interface MeasureToolParams {
projectId: string;
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
stationIntervalM: number;
/** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
line: () => Vertex[];
toScreen: (vertex: Vertex) => [number, number];
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
isClosed: () => boolean;
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
onChange: () => void;
}
export interface MeasureMark {
point: Vertex;
/** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
chainageM: number;
}
export interface MeasureTool {
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
marks: () => MeasureMark[];
/** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */
hint: () => string;
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
active: () => boolean;
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
clear: () => void;
}
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
const hint = (): string => {
if (picked.length === 0) return "";
const first = picked[0];
if (picked.length === 1) {
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
const second = picked[1];
const span = Math.abs(second.chainageM - first.chainageM);
const head =
`구간 ${formatStation(first.chainageM, params.stationIntervalM)}` +
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (first.z === null || second.z === null || span <= 1e-6) {
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
}
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
const rise = second.z - first.z;
return (
`${head} · 지반고 ${first.z.toFixed(1)}${second.z.toFixed(1)}m` +
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
);
};
return {
marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })),
hint,
active: () => picked.length > 0,
clear() {
if (picked.length === 0) return;
picked = [];
params.onChange();
},
async pick(px, py) {
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
try {
const heights = await fetchRouteElevations(
params.projectId,
asked.map((entry) => entry.point),
);
if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다.
asked.forEach((entry, index) => {
entry.z = heights[index] ?? null;
});
} catch {
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
}
params.onChange();
},
};
}
@@ -0,0 +1,465 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Render.ts
* **** ···· ,
* ·· .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* , `scene` .
*
* **B04 · **(`drawStationTicks`)
* ( 0-9 ).
* ========================================================================== */
import {
drawPreparedFeature,
drawPreparedLabels,
drawPreparedLayer,
layerScreenBounds,
normalizedToScreen,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
/** 노드 반지름(px). */
const NODE_R = 4;
/** ** **(m) (2026-09-07).
*
* . ** **
* ( ).
* `drawPreparedLayer` . */
const CONTOUR_BAND_M = 300;
/** · (px) ** ** .
* 3.5px (2026-09-07 ). */
const CURVE_HANDLE_PX = 5;
/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */
const OUTWARD_PX = 26;
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
const MEASURE_COLOR = "#22c55e";
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
);
}
return total;
}
export interface RouteEditScene {
view: ViewState;
/** 사업지 좌표(m) → 캔버스 px. */
toScreen: (vertex: Vertex) => [number, number];
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 이 값으로 정한다. */
pxPerMeter: number;
/** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */
hasMeta: boolean;
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */
contours: RouteEditContours | null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
otherSheets: ReadonlyArray<PreparedLayer>;
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
pickedContour: number;
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */
contourStepM: number;
expected: ReadonlyArray<Vertex>;
/** 그려 보이는 계획노선(원호 포함). */
plannedLine: ReadonlyArray<Vertex>;
/** 잡아 옮기는 노드(꺾임점). */
planned: ReadonlyArray<Vertex>;
nodeInfo: ReadonlyArray<EditedNode>;
curveInfo: ReadonlyArray<EditedCurve>;
curveOn: ReadonlyArray<boolean>;
/** 지금 고른 꺾임점. 없으면 -1. */
picked: number;
/** 규칙 측점 간격(m). */
stationIntervalM: number;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */
measure: ReadonlyArray<{ point: Vertex; chainageM: number }>;
/** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */
rotationRad: number;
/** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */
uprightRad: number;
}
/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */
function upright(
context: CanvasRenderingContext2D,
radians: number,
x: number,
y: number,
paint: () => void,
): void {
if (!radians) {
paint();
return;
}
context.save();
context.translate(x, y);
context.rotate(radians);
context.translate(-x, -y);
paint();
context.restore();
}
export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void {
const { view, toScreen } = scene;
const style = getComputedStyle(document.documentElement);
const line = scene.plannedLine.length ? scene.plannedLine : scene.planned;
context.clearRect(0, 0, view.width, view.height);
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
// 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯).
// 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다.
context.save();
if (scene.rotationRad) {
context.translate(view.width / 2, view.height / 2);
context.rotate(scene.rotationRad);
context.translate(-view.width / 2, -view.height / 2);
}
context.save();
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null;
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb";
context.lineWidth = 1.2;
// 바탕이 LAS 면 세류선도 **등고선이 있는 데까지만** 그린다(2026-09-12 사용자 지시 ⑮) —
// 도엽 하천중심선은 도엽 전체를 덮어 LAS 자료 밖까지 길게 뻗는다.
const lasBox =
scene.contours?.source === "las" ? layerScreenBounds(scene.contours.layer, view) : null;
context.save();
if (lasBox) {
context.beginPath();
context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
context.clip();
}
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
context.restore();
if (scene.contours) {
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
const everyM = scene.contourStepM;
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
if (scene.pickedContour >= 0) {
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
context.lineWidth = 2.6;
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
}
// 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고).
}
context.restore();
strokePolyline(
context,
toScreen,
scene.expected,
[6, 5],
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
context,
toScreen,
line,
[],
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.lineWidth = 1;
scene.planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0;
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
if (
scene.curveOn.length &&
!scene.curveOn[index] &&
index > 0 &&
index < scene.planned.length - 1
) {
context.save();
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.beginPath();
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
context.fill();
context.restore();
}
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
scene.curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === scene.picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
drawStationMarks(context, scene, line);
drawMeasureMarks(context, scene, line);
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
drawContourLabels(context, scene, band, style);
context.restore(); // 회전 끝
}
/** ** ** (2026-09-12 ).
*
* . ** **(`contourStepM`)
* . */
function drawContourLabels(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
band: { x: number; y: number; width: number; height: number } | null,
style: CSSStyleDeclaration,
): void {
if (!scene.contours) return;
context.save();
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
scene.contours.layer,
scene.view,
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
scene.contourStepM,
scene.uprightRad,
);
if (scene.pickedContour >= 0) drawPickedContourLabel(context, scene, scene.view);
context.restore();
}
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
function drawMeasureMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
if (scene.measure.length === 0) return;
context.save();
if (scene.measure.length >= 2) {
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
if (span.length >= 2) {
context.strokeStyle = MEASURE_COLOR;
context.lineWidth = 4;
context.beginPath();
span.forEach((vertex, index) => {
const [x, y] = scene.toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
}
}
context.lineWidth = 2.4;
context.strokeStyle = MEASURE_COLOR;
context.font = "bold 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
scene.measure.forEach((mark, index) => {
const [x, y] = scene.toScreen(mark.point);
context.fillStyle = "rgba(255,255,255,0.95)";
context.beginPath();
context.arc(x, y, 7, 0, Math.PI * 2);
context.fill();
context.stroke();
context.fillStyle = "#14532d";
upright(context, scene.uprightRad, x, y, () => context.fillText(index === 0 ? "a" : "b", x, y));
});
context.restore();
}
/**
* **** ( 0-9 ).
*
* ** ** . a b
* , **
* **(2026-09-12 ). .
*/
function spanBetween(
line: ReadonlyArray<Vertex>,
from: { point: Vertex; chainageM: number },
to: { point: Vertex; chainageM: number },
): Vertex[] {
const low = Math.min(from.chainageM, to.chainageM);
const high = Math.max(from.chainageM, to.chainageM);
const head = from.chainageM <= to.chainageM ? from.point : to.point;
const tail = from.chainageM <= to.chainageM ? to.point : from.point;
const inside: Vertex[] = [];
let travelled = 0;
for (let index = 1; index < line.length; index += 1) {
const step = Math.hypot(
line[index][0] - line[index - 1][0],
line[index][1] - line[index - 1][1],
);
// 정점의 누가거리가 두 점 사이면 그대로 잇는다 — 사이에 없는 정점은 건너뛴다.
if (travelled > low && travelled < high) inside.push(line[index - 1]);
travelled += step;
}
return [head, ...inside, tail];
}
/** 고른 등고선의 **높이값을 크게** 붙인다(계획서 0-9 ㉘) — 색만 바뀌면 몇 m 인지 안 보인다. */
function drawPickedContourLabel(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
view: ViewState,
): void {
const feature = scene.contours?.layer.features[scene.pickedContour];
if (!feature || feature.labelValue === null) return;
const [x, y] = normalizedToScreen(view, feature.labelAnchorX, feature.labelAnchorY);
const text = `${feature.labelValue}m`;
upright(context, scene.uprightRad, x, y, () => {
context.save();
context.font = "bold 13px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const width = context.measureText(text).width + 10;
context.fillStyle = "#7c3aed";
context.fillRect(x - width / 2, y - 9, width, 18);
context.fillStyle = "#ffffff";
context.fillText(text, x, y);
context.restore();
});
}
/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */
function drawStationMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
if (line.length < 2) return;
// 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게.
drawStationTicks(
context,
line.map(([x, y]) => ({ x, y })),
{
intervalM: scene.stationIntervalM,
pxPerMeter: scene.pxPerMeter,
toScreen: (x, y) => scene.toScreen([x, y]),
uprightRad: scene.uprightRad,
},
);
const total = polylineLengthM(line);
const last = line.length - 1;
endLabel(context, scene, line[0], line[1], `시점 ${formatStation(0, scene.stationIntervalM)}`);
endLabel(
context,
scene,
line[last],
line[last - 1],
`종점 ${formatStation(total, scene.stationIntervalM)}`,
);
}
/** · .
*
* ** **( ).
* (0+0.0 · 50+0.0) ****
* (2026-09-12 ). */
function endLabel(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
at: Vertex,
inward: Vertex,
text: string,
): void {
const [x0, y0] = scene.toScreen(at);
const [x1, y1] = scene.toScreen(inward);
const length = Math.hypot(x0 - x1, y0 - y1) || 1;
const x = x0 + ((x0 - x1) / length) * OUTWARD_PX;
const y = y0 + ((y0 - y1) / length) * OUTWARD_PX;
context.save();
if (scene.uprightRad) {
context.translate(x, y);
context.rotate(scene.uprightRad);
context.translate(-x, -y);
}
context.font = "bold 12px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const width = context.measureText(text).width + 10;
context.fillStyle = "rgba(255, 255, 255, 0.9)";
context.fillRect(x - width / 2, y - 26, width, 17);
context.strokeStyle = "#f97316";
context.lineWidth = 1;
context.strokeRect(x - width / 2, y - 26, width, 17);
context.fillStyle = "#111111";
context.fillText(text, x, y - 17.5);
context.restore();
}
function strokePolyline(
context: CanvasRenderingContext2D,
toScreen: (vertex: Vertex) => [number, number],
points: ReadonlyArray<Vertex>,
dash: number[],
color: string,
width: number,
): void {
if (points.length < 2) return;
context.save();
context.setLineDash(dash);
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
points.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.restore();
}
@@ -0,0 +1,83 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Rotate.ts
* · , ** **
* ( 0-9 , 2026-09-12 ).
*
* ** **. (
* CAD ), ** ** .
* .
* ========================================================================== */
/** () 15° . 90° ,
* 5° . */
const ROTATE_STEP_DEG = 15;
/** ** **( 0-9 , 2026-09-12 ).
*
* 180° .
* .
*
* ** `false` **
* (CAD ). . */
export const UPRIGHT_LABELS = true;
export interface MapRotationParams {
/** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */
overlay: HTMLElement;
/** 지금 캔버스 크기 — 회전축(한가운데)을 잡는 데 쓴다. */
size: () => { width: number; height: number };
/** 각이 바뀌었다 — 호출부가 다시 그린다. */
onChange: () => void;
}
export interface MapRotation {
/** 지금 돌린 각(라디안). 그리기가 캔버스 변환에 그대로 쓴다. */
radians: () => number;
/** 화면에 보이는 자리 → 그리기 좌표(돌리기 전). */
unrotate: (px: number, py: number) => [number, number];
/** 그리기 좌표 → 화면에 보이는 자리. 떠 있는 패널을 노드 옆에 붙일 때 쓴다. */
rerotate: (px: number, py: number) => [number, number];
/** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */
unrotateDelta: (dx: number, dy: number) => [number, number];
/** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */
uprightRad: () => number;
}
export function createMapRotation(params: MapRotationParams): MapRotation {
let radians = 0;
const spin = (px: number, py: number, angle: number): [number, number] => {
if (!angle) return [px, py];
const { width, height } = params.size();
const cx = width / 2;
const cy = height / 2;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const dx = px - cx;
const dy = py - cy;
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
};
for (const [act, sign] of [
["rotate-ccw", -1],
["rotate-cw", 1],
] as const) {
params.overlay.querySelector(`[data-act="${act}"]`)?.addEventListener("click", () => {
radians += (sign * ROTATE_STEP_DEG * Math.PI) / 180;
params.onChange();
});
}
return {
radians: () => radians,
unrotate: (px, py) => spin(px, py, -radians),
rerotate: (px, py) => spin(px, py, radians),
uprightRad: () => (UPRIGHT_LABELS ? -radians : 0),
unrotateDelta: (dx, dy) => {
if (!radians) return [dx, dy];
const cos = Math.cos(-radians);
const sin = Math.sin(-radians);
return [dx * cos - dy * sin, dx * sin + dy * cos];
},
};
}
@@ -9,12 +9,23 @@
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
export function field(
labelText: string,
input: HTMLElement,
basis?: string | null,
): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
if (basis) {
const note = document.createElement("small");
note.className = "b05-structure__basis";
note.textContent = basis;
wrapper.append(note);
}
return wrapper;
}
@@ -32,6 +43,88 @@ export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElem
return element;
}
/** 등록부 옵션 한 칸의 모양 — `StructureOptionField` 에서 이 칸이 쓰는 몫만. */
interface OptionShape {
input: "select" | "number" | "text";
choices: string[];
default: string | number | null;
required?: boolean;
/** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음). */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
not_in_table?: string | null;
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/**
* ** , **
* (· , ). 2026-09-14
* .
* .
*/
export function optionControl(
option: OptionShape,
stored: string | number | undefined,
): HTMLInputElement | HTMLSelectElement {
const value = stored === undefined || stored === null ? "" : String(stored);
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = suggested
? `— 안 정함 (제안 ${suggested}) —`
: option.required
? "— 선택 —"
: "— 안 정함 —";
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
element.value = value;
if (tip) element.title = tip;
return element;
}
const element =
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
if (option.input !== "number") element.type = "text";
element.value = value;
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
if (tip) element.title = tip;
const limit = option.warn_above;
if (typeof limit === "number") {
// 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰).
const warn = (): void => {
const over = element.value.trim() !== "" && Number(element.value) > limit;
element.classList.toggle("is-outside-standard", over);
element.title = over ? (option.warn_message ?? "") : tip;
};
element.addEventListener("input", warn);
warn();
}
return element;
}
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
export function suggestButton(
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-structure__suggest";
button.textContent = "제안값 넣기";
button.addEventListener("click", () => {
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
filled.forEach((entry) => {
entry.input.value = entry.suggested;
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
});
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
});
return button;
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
export interface StationFields {
wrap: HTMLElement;
+20 -29
View File
@@ -14,7 +14,6 @@
* ========================================================================== */
import {
defaultOptions,
isB05Option,
structureAnchorM,
type StructureInstance,
@@ -24,7 +23,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields";
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
import {
bindStructuresEvents,
@@ -316,36 +315,24 @@ export function createStructuresSection(
return;
}
optionRow.hidden = false;
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> =
[];
visible.forEach((option) => {
const preset = values[option.key] ?? option.default ?? "";
let input: HTMLInputElement | HTMLSelectElement;
if (option.input === "select") {
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
// 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다.
const mustPick = option.required === true && (option.default ?? "") === "";
if (mustPick) choices.unshift(["", "— 선택 —"]);
input = select(choices);
input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? "")));
} else if (option.input === "number") {
input = numberInput("0.1", "0");
input.value = String(preset ?? "");
if (option.required) (input as HTMLInputElement).placeholder = "필수 입력";
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = "text";
input.value = String(preset ?? "");
}
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다.
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
const input = optionControl(option, values[option.key]);
suggestions.push({ input, suggested: String(option.default ?? "") });
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
const locked = option.enabled === false;
if (locked && !input.value) input.value = String(option.default ?? "");
if (locked) {
input.disabled = true;
input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다.";
}
optionRow.append(field(label, input));
optionRow.append(field(label, input, option.default_basis));
input.addEventListener("change", () => liveCommit());
optionInputs.push({
key: option.key,
@@ -384,6 +371,9 @@ export function createStructuresSection(
input.addEventListener("change", () => refresh(true));
}
});
if (suggestions.some((entry) => entry.suggested)) {
optionRow.append(suggestButton(suggestions));
}
syncOptionLock();
syncRangeDisplay();
}
@@ -671,10 +661,11 @@ export function createStructuresSection(
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
memoField.value = "";
syncPlacementFields();
renderOptionFields(type.managed_by ? undefined : defaultOptions(type));
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
renderOptionFields();
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
syncFacilityForm({});
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
syncButtons();
@@ -231,6 +231,8 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus();
return;
}
// 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인
// 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`).
const before = ctx.readBeforeM();
if (anchor - before < -0.005) {
const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input;
+216 -25
View File
@@ -6,16 +6,21 @@
inset: 0;
z-index: var(--z-modal, 1000);
display: flex;
gap: var(--spacing-12);
align-items: center;
justify-content: center;
padding: var(--spacing-12);
background: rgb(0 0 0 / 55%);
}
/* 메인 창은 **왼쪽**, 횡단 판은 오른쪽 세로 (2026-09-12 사용자 지시 ).
좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 가진다. */
.b05-routeedit__box {
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: min(1200px, 94vw);
max-width: 1200px;
height: min(820px, 92vh);
overflow: hidden;
border: 1px solid var(--color-border);
@@ -33,10 +38,15 @@
border-bottom: 1px solid var(--color-border);
}
.b05-routeedit__hint {
/* 제목행 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ).
단추는 공용 규격(`.ui-btn`) 그대로 쓴다 모달이 따로 만든 크기를 걷어냈다. */
.b05-routeedit__actions {
display: flex;
flex: 1 1 auto;
color: var(--color-text-secondary);
font-size: var(--text-caption);
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-8);
}
.b05-routeedit__close {
@@ -62,27 +72,63 @@
touch-action: none;
}
.b05-routeedit__foot {
display: flex;
flex: none;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
border-top: 1px solid var(--color-border);
}
.b05-routeedit__status {
flex: 1 1 auto;
/* 지도 위에 얹는 판들 아래 정보행을 없애고 여기로 옮겼다(2026-09-12 사용자 지시 ··).
글판은 **클릭을 통과시킨다** 지도 조작을 가리면 된다. */
.b05-routeedit__hint,
.b05-routeedit__info {
position: absolute;
z-index: 1;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
border-radius: var(--radius-8, 6px);
background: color-mix(in srgb, var(--color-surface-raised) 78%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--color-text-secondary);
font-size: var(--text-caption);
pointer-events: none;
}
/* 조작 설명 — **2열**로 묶는다(사용자 지시 ⑩). 한 줄로 늘어놓으면 창이 좁을 때 접힌다. */
.b05-routeedit__hint {
top: var(--spacing-12);
left: var(--spacing-12);
display: grid;
grid-template-columns: auto auto;
gap: 2px var(--spacing-16);
max-width: 52%;
}
/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫). */
.b05-routeedit__info {
bottom: var(--spacing-12);
left: var(--spacing-12);
display: flex;
flex-direction: column;
gap: 4px;
max-width: 62%;
}
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */
.b05-routeedit__spin {
position: absolute;
top: var(--spacing-12);
right: var(--spacing-12);
z-index: 1;
display: flex;
gap: var(--spacing-8);
}
.b05-routeedit__spin .ui-btn {
padding: var(--spacing-8) var(--spacing-12);
font-size: var(--text-body);
line-height: 1;
}
.b05-routeedit__legend {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routeedit__legend i {
@@ -101,20 +147,148 @@
border-top: 2px solid var(--map-route, #f97316);
}
.b05-routeedit__btn {
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */
.b05-routeedit__side {
display: flex;
flex: none;
padding: var(--spacing-8) var(--spacing-16);
flex-direction: column;
gap: var(--spacing-12);
width: 452px;
height: min(820px, 92vh);
}
@media (width < 1500px) {
/* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */
.b05-routeedit__side {
display: none;
}
}
.b05-routeedit__cross {
display: flex;
flex: 1 1 0;
min-height: 0;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-16, 12px);
background: var(--color-surface-raised);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
}
.b05-routeedit__cross-head {
display: flex;
flex: none;
align-items: baseline;
gap: var(--spacing-8);
}
.b05-routeedit__cross-station {
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
/* 횡단 캔버스 그림이 아니라 **SVG **이다(2026-09-12 사용자 지시 ).
= 확대·축소, 가운데 버튼 끌기 = 이동, 더블클릭 = 원복(공용 조각이 맡음). */
.b05-routeedit__cross-box {
flex: 1 1 auto;
min-height: 150px;
overflow: hidden;
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface);
color: var(--color-text-body);
cursor: pointer;
}
.b05-routeedit__btn.is-primary {
border-color: transparent;
background: var(--color-primary, #7c3aed);
color: #fff;
.b05-routeedit__cross-svg {
display: block;
width: 100%;
height: 100%;
cursor: default;
}
.b05-routeedit__cross-svg.is-panning {
cursor: grabbing;
}
.b05-routeedit__cross-axis {
stroke: rgb(148 163 184 / 70%);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.b05-routeedit__cross-ground {
fill: none;
stroke: var(--color-text-secondary, #94a3b8);
stroke-width: 1.6;
vector-effect: non-scaling-stroke;
}
.b05-routeedit__cross-design {
fill: none;
stroke: var(--map-route, #f97316);
stroke-width: 2.2;
vector-effect: non-scaling-stroke;
}
.b05-routeedit__cross-key {
fill: var(--color-text-secondary, #94a3b8);
font-size: 11px;
}
.b05-routeedit__cross-key.is-design {
fill: var(--map-route, #f97316);
}
.b05-routeedit__cross-foot {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.5;
}
/* ㉓ 거리 재기와 되돌리기 사이 구분선. */
.b05-routeedit__divider {
width: 1px;
height: 20px;
margin: 0 var(--spacing-4, 4px);
background: var(--color-border);
}
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */
.b05-routeedit__measure {
position: absolute;
right: var(--spacing-12);
bottom: var(--spacing-12);
z-index: 1;
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
max-width: 52%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, #22c55e 60%, transparent);
border-radius: var(--radius-8, 6px);
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--color-text-body);
font-size: var(--text-caption);
}
/* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */
.b05-routeedit__measure-text {
min-width: 0;
line-height: 1.5;
}
.b05-routeedit__measure-close {
flex: none;
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
@@ -164,6 +338,15 @@
touch-action: none;
}
.b05-routeedit__label-close {
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1;
cursor: pointer;
}
/* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */
.b05-routeedit__lock {
padding: 1px 6px;
@@ -241,3 +424,11 @@
color: var(--color-text-secondary);
line-height: 1.35;
}
/* 거리 재기 켜진 동안 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ).
`is-active` 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */
.b05-routeedit__actions .ui-btn.is-active {
border-color: transparent;
background: var(--color-primary, #7c3aed);
color: #fff;
}
@@ -214,6 +214,18 @@
line-height: 1.5;
}
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
.b05-structure__basis {
color: var(--color-text-muted, #9aa1ad);
font-size: var(--text-caption, 12px);
line-height: 1.3;
}
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
.b05-structure__suggest {
align-self: start;
}
/* 시작·기준·종료 측점 = 3행. 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
+3 -15
View File
@@ -28,9 +28,6 @@ type PendingMap = Record<string, CulvertOptionPatch>;
export interface CulvertOptionWriter {
/** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
queue: (chainageM: number, patch: CulvertOptionPatch) => void;
/** (2026-08-29 : B06 ).
* . */
queueMove: (fromChainageM: number, toChainageM: number) => void;
/** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */
flush: () => Promise<void>;
}
@@ -62,7 +59,9 @@ function writePending(sessionKey: string | null, pending: PendingMap): void {
* . , ** ** []
* .
*/
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m). */
/** (m).
* ** **(2026-09-12 일원화: B05·B06 ).
* . */
type PendingMoves = Record<string, number>;
function readMoves(sessionKey: string | null): PendingMoves {
@@ -123,17 +122,6 @@ export function createCulvertOptionWriter(
pending[keyOf(chainageM)] = { ...(pending[keyOf(chainageM)] ?? {}), ...patch };
writePending(key, pending);
},
queueMove(fromChainageM, toChainageM) {
const key = moveKey();
if (!key) return;
const moves = readMoves(key);
// 이미 옮긴 관을 또 옮기면 **원래 자리** 기준으로 최종 위치만 남긴다.
const origin =
Object.keys(moves).find((entry) => Math.abs(moves[entry] - fromChainageM) < 0.005) ??
keyOf(fromChainageM);
moves[origin] = toChainageM;
window.sessionStorage.setItem(key, JSON.stringify(moves));
},
async flush() {
try {
await flushCulvertOptions(projectId(), sessionKey(), moveKey());
+9 -1
View File
@@ -207,8 +207,12 @@ export interface CulvertSideSpec {
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
basin_before_m?: number | null;
basin_after_m?: number | null;
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
/** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */
face_slope?: number;
/** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */
face_side?: string | null;
face_role?: string | null;
face_slope_ratio?: number | string | null;
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
apron_length_m?: number;
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
@@ -265,6 +269,8 @@ export interface FordSet {
min_cover_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
@@ -283,6 +289,8 @@ export interface BoxSet {
span_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
export interface CrossSection extends SectionStation {
+101 -12
View File
@@ -74,6 +74,26 @@ APRON_THICKNESS_M = 0.45
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
REVET_FACE_SLOPE = 0.3
# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`).
# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정
# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험
# `test_b06_pipe_wall_height_mirror`.
REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값)
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
def pipe_wall_min_height_m(diameter_m: float) -> float:
"""관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함.
최소 실제 설계값은 있어 값으로 수량은 미확정으로 (금액 ).
"""
raw = float(diameter_m) + REVET_FREEBOARD_M
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
@@ -139,7 +159,9 @@ def _number(value: Any, fallback: float | None) -> float | None:
return fallback
def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
def _side_spec(
options: dict[str, Any], defaults: dict[str, Any], side: str, diameter_m: float = 1.0
) -> dict[str, Any]:
"""유입("inlet")·유출("outlet") 한쪽의 부속 제원.
구조가 집수정이면 기슭막이·보호공을 만들지 않는다 집수정 단면은 후속 작업이라
@@ -164,9 +186,10 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
)
return spec
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
height = _number(
options.get(f"{side}_revet_height_m"),
_number(defaults.get(f"{side}_revet_height_m"), None),
_number(defaults.get(f"{side}_revet_height_m"), pipe_wall_min_height_m(diameter_m)),
)
length = _number(
options.get(f"{side}_revet_length_m"),
@@ -208,10 +231,11 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
)
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
return {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"diameter_m": diameter_m,
"min_cover_m": MIN_PIPE_COVER_M,
# 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로
# 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것.
@@ -219,8 +243,8 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip()
or None
),
"inlet": _side_spec(values, defaults, "inlet"),
"outlet": _side_spec(values, defaults, "outlet"),
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
}
@@ -249,6 +273,11 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
values.get(f"{role}_revet_after_m"), _number(values.get("after_m"), None)
),
"face_slope": REVET_FACE_SLOPE,
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(2026-09-14 B5 · TS `revetSide`).
# 설치 측은 `side` 기본값(양쪽)을 붙이기 **전** 값이라야 B08(빈 칸 = 자동)과 같게 갈림.
"face_side": values.get("side"),
"face_role": values.get("face_role"),
"face_slope_ratio": values.get("face_slope_ratio"),
}
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
@@ -308,6 +337,36 @@ def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> d
}
def _defaulted(values: dict[str, Any], fields: list[tuple[str, str, Any]]) -> list[str]:
"""정본에 안 적혀 **기본값으로 그린 칸** 「이름 값」 — 횡단도가 「기본값(미확정)」으로 적음.
2026-09-14 브레인 판정 저장만 하는 것으로는 모자람 · 그림이 설계값처럼 보이면 같은 .
TS `defaulted`(`common_util_culvert_sets.ts`).
"""
found = []
for key, label, used in fields:
if used is None or values.get(key) not in (None, ""):
continue
found.append(f"{label} {used:g}" if isinstance(used, float) else f"{label} {used}")
return found
def _wing_defaulted(values: dict[str, Any], wing: dict[str, Any], side: str) -> list[str]:
"""날개벽 한쪽의 기본값 칸 — 안 세운 날개벽은 치수를 안 봄."""
name = "유입" if side == "in" else "유출"
prefix = f"wing_{side}"
fields: list[tuple[str, str, Any]] = [
(prefix, f"{name} 날개벽", "있음" if wing["installed"] else "없음")
]
if wing["installed"]:
fields += [
(f"{prefix}_height_m", f"{name} 날개벽 높이", wing["height_m"]),
(f"{prefix}_length_m", f"{name} 날개벽 길이", wing["length_m"]),
(f"{prefix}_angle_deg", f"{name} 날개벽 각도", wing["angle_deg"]),
]
return _defaulted(values, fields)
def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장).
@@ -323,12 +382,26 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
depth = _number(values.get("ford_height_m"), None)
span = width if width and width > 0 else FORD_DEFAULT_WIDTH_M
pipe_count = max(int(count), 1) if count else 1
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
defaulted = _defaulted(
values,
[
("pipe_kind", "관종", str(kind) if kind else None),
("pipe_diameter_mm", "관경", diameter_mm),
("ford_width_m", "월류 폭", span),
("pipe_count", "배관 수량", pipe_count),
("ford_height_m", "월류 높이", 0.0),
],
)
return {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"pipe_count": max(int(count), 1) if count else 1,
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
"pipe_count": pipe_count,
"span_m": span,
# 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른
# 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다
# (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다.
@@ -336,8 +409,11 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
"wall_thickness_m": FORD_WALL_THICKNESS_M,
"min_cover_m": MIN_PIPE_COVER_M,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
"wing_in": wing_in,
"wing_out": wing_out,
"defaulted": defaulted
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
@@ -353,12 +429,14 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
depth = _number(values.get("ford_height_m"), None)
slope = _number(values.get("ford_slope_pct"), None)
span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M
return {
"type": "ford_pavement",
"span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M,
"span_m": span,
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
"depth_m": depth if depth and depth > 0 else None,
"slope_pct": slope,
"defaulted": _defaulted(values, [("ford_width_m", "월류 폭", span)]),
}
@@ -375,6 +453,8 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M
slab = FORD_SLAB_THICKNESS_M
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
return {
"type": "box",
"inner_width_m": inner_width or 2.0,
@@ -385,8 +465,17 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
"cover_m": BOX_COVER_M,
# 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다.
"span_m": (inner_width or 2.0) + 2 * wall,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
"wing_in": wing_in,
"wing_out": wing_out,
"defaulted": _defaulted(
values,
[
("body_width_m", "본체 폭", inner_width or 2.0),
("body_height_m", "본체 높이", inner_height or 2.0),
],
)
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
@@ -79,6 +79,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
"tiers": options.get("tiers"),
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
"face_role": options.get("face_role"),
"face_slope_ratio": options.get("face_slope_ratio"),
}
)
return found
+5 -4
View File
@@ -73,14 +73,13 @@ from B06_Section.B06_Section_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
NATURAL_SPOIL_MIN_GROUND_SLOPE,
SECTION_VERTICAL_EXAGGERATION,
@@ -144,10 +143,12 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
stored_standard_cross_section=stored_standard,
rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M,
earthwork_conversion=EARTHWORK_CONVERSION_FACTORS,
# ⚠ 상수를 직접 들지 않는다 — 프로젝트가 고른 계수가 있으면 화면도 그 값으로
# 그려야 서버가 뒤에 다시 셈한 값과 갈리지 않는다(CLAUDE.md 5장).
earthwork_conversion=await conversion_factors_for(project_id),
haul_equipment_limits=[
HaulEquipmentLimit(key=key, max_distance_m=limit)
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in await haul_limits_for(project_id)
],
natural_spoil_min_ground_slope=NATURAL_SPOIL_MIN_GROUND_SLOPE,
)
+7 -1
View File
@@ -25,7 +25,9 @@ from fastapi.responses import JSONResponse
from B06_Section.B06_Section_Server_Calc_Prebuild import (
BUNDLE,
_mass_haul_context,
conversion_factors_for,
haul_inputs_for,
haul_limits_for,
)
from common_util.common_util_node_bundle import run_bundle_json
@@ -63,6 +65,10 @@ async def compute_haul_plan(
# 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라
# 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리).
haul_inputs = await haul_inputs_for(project_id)
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
factors = await conversion_factors_for(project_id)
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
limits = await haul_limits_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
@@ -70,7 +76,7 @@ async def compute_haul_plan(
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs),
"context": _mass_haul_context(haul_inputs, factors, limits),
},
)
except Exception:
+12 -3
View File
@@ -14,8 +14,10 @@
* 실행: node <번들> <입력.json> <출력.json>
* { detail: 종횡단 (API와 ), context: { earthwork_conversion,
* natural_spoil_min_ground_slope, haul_equipment_limits } }
* { areas: [{ chainage_m, cut_area_m2, }], mass_haul: {} | null }
* { areas: [{ chainage_m, cut_area_m2, }], mass_haul: {} | null,
* extra_walls: [{ chainage_m, extra_walls: [ ] }] }
* areas ** **. .
* extra_walls ( ) · B08 ().
* 코드: 0 / 2
* ========================================================================== */
@@ -23,7 +25,11 @@ import { readFileSync, writeFileSync } from "node:fs";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import {
applyStructureAreaRows,
extraWallRows,
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
interface ServerCalcInput {
detail?: SectionDetailResponse;
@@ -112,4 +118,7 @@ const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
: null;
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul }));
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
const extraWalls = extraWallRows(sections);
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul, extra_walls: extraWalls }));
@@ -38,6 +38,11 @@ from B06_Section.B06_Section_Repository import (
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_project_settings import (
haul_equipment_limits,
mixed_conversion_factors,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
@@ -79,7 +84,38 @@ async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
return {}
def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, Any]:
async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]:
"""이 프로젝트가 쓸 토량환산계수. 못 읽으면 정본 기본값 — 화면은 그대로 선다.
곡선·운반·토적표가 **같은 계수** 서야 한다. 그래서 상수를 직접 들지 않고 함수를
거친다(고른 값은 프로젝트 설정 `conversion_factors_override` 산다).
"""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
# 암은 구성비 가중 C(㉱ (나)) — 토적표·운반표와 같은 함수.
return mixed_conversion_factors(quantity_settings(root))
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
"""이 프로젝트가 쓸 운반장비 거리 경계(도쟈 한계거리를 고쳤으면 그 값). 못 읽으면 기본값."""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 경계로 진행: project_id=%s", project_id)
return list(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
return haul_equipment_limits(quantity_settings(root))
def _mass_haul_context(
haul_inputs: dict[str, Any] | None = None,
factors: dict[str, dict[str, float]] | None = None,
limits: list[tuple[str, float | None]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
채집석 공제(`collected_stone_deduction_m3`) 상수가 아니라 **B08 내는 **이다.
@@ -93,11 +129,13 @@ def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, A
"""
inputs = haul_inputs or {}
return {
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
"earthwork_conversion": factors or EARTHWORK_CONVERSION_FACTORS,
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
# 프로젝트가 도쟈 한계거리를 고쳤으면 그 값, 없으면 정본 기본값.
"haul_equipment_limits": [
{"key": key, "max_distance_m": limit}
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in (limits or EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
],
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
@@ -196,7 +234,11 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
_NPM_SCRIPT,
{
"detail": detail,
"context": _mass_haul_context(haul_inputs),
"context": _mass_haul_context(
haul_inputs,
mixed_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(quantity_settings(project_root)),
),
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
@@ -204,7 +246,20 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
output = {}
rows = output.get("areas")
mass_haul = output.get("mass_haul")
if not fixed and not rows and not mass_haul:
# 선 다단 벽 목록(④) — 목록째 얹음(수가 아니라 `_AREA_KEYS` 로는 못 거름). 주인 측점엔 빈 목록도.
# 관 기준벽 벽 몸 겹침(㉡) — 역할별 ㎡ 한 벌. 빈 dict 도 실어 옛 값을 지움.
extra_walls = [
(
float(row["chainage_m"]),
{
"extra_walls": row["extra_walls"],
"wall_fill_overlap": row.get("wall_fill_overlap") or {},
},
)
for row in output.get("extra_walls") or []
if isinstance(row, dict) and isinstance(row.get("extra_walls"), list)
]
if not fixed and not rows and not mass_haul and not extra_walls:
return 0
updated = 0
@@ -240,6 +295,10 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
updated = await merge_cross_section_designs(
connection, route_id=route_id, entries=area_entries, replace=False
)
if extra_walls:
await merge_cross_section_designs(
connection, route_id=route_id, entries=extra_walls, replace=False
)
if isinstance(mass_haul, dict):
await merge_longitudinal_section_data(
connection, route_id=route_id, data_patch={"mass_haul": mass_haul}
+103 -12
View File
@@ -10,12 +10,17 @@
* DOM·SVG . Node .
* ========================================================================== */
import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import {
computeCardCulvert,
culvertLinkFor,
tierSpanOf,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
@@ -144,6 +149,22 @@ export const STRUCTURE_ROW_KEYS = [
"pipe_length_m",
] as const;
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
const trim = trimOfLayouts(layouts);
const designLine = layouts.design.design_line;
if (!trim || !Array.isArray(designLine) || designLine.length < 2) return null;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
return {
designLine: designLine as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
};
}
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
function areaRowOf(
section: CrossSection,
@@ -176,17 +197,11 @@ function areaRowOf(
typeof pipeLengthM === "number" && pipeLengthM > 0
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
: null;
const trim = trimOfLayouts(layouts);
const areaInput = areaInputOf(section, layouts);
if (!areaInput) return pipeRow;
const design = layouts.design;
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const areas = computeStructureAreas({
designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
...areaInput,
rockBoundaryOffsetM:
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
});
@@ -219,6 +234,82 @@ export function structureAreaRows(
.filter((row): row is Record<string, number> => !!row);
}
/** 선 다단 벽 한 매 — B08 이 줄을 세우는 값(파이썬 `B08_Quantity_Engine_Pipe.facility_structures`). */
export interface BuiltExtraWall {
key: string;
side: "outlet" | "basin";
form: string;
/** 순수 높이(m) — 바닥~상단(근입 0.5 포함). 지형에 맞춰 선 값. */
height_m: number;
/** 사용자가 높이·형태를 적었나 — 안 적었으면 B08 이 미확정으로 셈. */
height_set: boolean;
form_set: boolean;
before_m: number;
after_m: number;
/** () (2026-09-06 ) .
* B08 (2026-09-14 ). null. */
fill_overlap_m2: number | null;
}
export interface ExtraWallRow {
chainage_m: number;
extra_walls: BuiltExtraWall[];
wall_fill_overlap: Record<string, number>;
}
/**
* (· ) ** ** (, 2026-09-14).
*
* · (`extra_wall_counts`)
* ( `pipe_length_m` ). .
* `wall_fill_overlap` (). B08 .
*/
export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] {
const rows: ExtraWallRow[] = [];
for (const section of sections) {
if (!section.culvert) continue;
const owner = pipeOwnerChainage(section, sections);
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
const layouts = computeStoredLayouts(section, sections);
const culvert = layouts?.culvert;
if (!layouts || !culvert) continue;
const areaInput = areaInputOf(section, layouts);
const walls: BuiltExtraWall[] = [];
const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [
["outlet", culvert.extraWalls, culvert.revetShift.extras],
["basin", culvert.basinExtras, culvert.revetShift.basinExtras],
];
for (const [side, list, applied] of built) {
list.forEach((wall, index) => {
const key = `${side === "basin" ? "bextra" : "extra"}${index}`;
const span = tierSpanOf(section, key);
walls.push({
key,
side,
form: wall.form ?? "",
height_m: Number((wall.height + REVET_EMBED_DEPTH_M).toFixed(3)),
height_set: applied[index]?.h != null,
form_set: section.design?.revet_adjust?.[key]?.m != null,
before_m: span.beforeM,
after_m: span.afterM,
fill_overlap_m2: areaInput
? Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3))
: null,
});
});
}
// 관 기준벽(유입·유출)도 같은 겹침 — 다단에만 사유가 뜨면 「다단만 문제」로 읽힘(㉡ 2026-09-14).
const wall_fill_overlap: Record<string, number> = {};
for (const wall of culvert.walls) {
if (areaInput && (wall.role === "inlet" || wall.role === "outlet")) {
wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3));
}
}
rows.push({ chainage_m: section.chainage_m, extra_walls: walls, wall_fill_overlap });
}
return rows;
}
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
export function applyStructureAreaRows(
sections: readonly CrossSection[],
@@ -49,6 +49,40 @@ function fillSlopeInfo(section: CrossSection): HTMLElement | null {
return info;
}
/**
* ** ** `[보이는 글, 툴팁]` (2026-09-14 ).
* .
*/
function facilityNotes(section: CrossSection): Array<[string, string]> {
const notes: Array<[string, string]> = [];
const sets: Array<[string, string[] | undefined]> = [
["BOX암거", section.box?.defaulted],
["세월교", section.ford?.defaulted],
["물넘이포장", section.ford_pavement?.defaulted],
];
for (const [name, items] of sets) {
if (!items?.length) continue;
notes.push([
`${name} 기본값(미확정)`,
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
]);
}
const own = section.culvert?.hidden_pipe ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) {
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
]);
}
if (section.ford_pavement && !section.ford_pavement.depth_m) {
notes.push([
"⚠ 월류 높이 없음 — 파임 안 그림",
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
]);
}
return notes;
}
/**
* ( ).
*
@@ -86,6 +120,13 @@ export function appendCardHeader(
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
meta.append(openSlope);
}
for (const [text, tip] of facilityNotes(section)) {
const note = document.createElement("span");
note.className = "b06-cross-card__warning";
note.textContent = text;
note.title = tip;
meta.append(note);
}
const structureName = section.structure;
if (structureName) {
const structure = document.createElement("span");
+1 -2
View File
@@ -13,7 +13,6 @@
import {
REVET_EMBED_DEPTH_M,
FILL_SLOPE_RATIO_MIN,
REVET_LEAN_RATIO,
pipeWallThicknessM,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Geom";
@@ -183,7 +182,7 @@ export function appendCulvertOverlay(
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
wall.height + REVET_EMBED_DEPTH_M
).toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` +
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사)` +
`, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` +
(wall.floatGapM > 0.01
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)`
@@ -16,7 +16,8 @@ export const MIN_PIPE_COVER_M = 0.5;
/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */
export const REVET_THICKNESS_M = 0.45;
/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */
/** (1:n) **** (·) /
* . `B06_Section_UI_Cross_Lean.leanOf`( 13-4-4 [] ). */
export const REVET_LEAN_RATIO = 0.3;
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
@@ -6,7 +6,7 @@
* (2026-08-22 ):
* · ** 1:1.2** .
* · **( ) **.
* · = ** +0.5m와 (1:0.3)
* · = ** +0.5m와 (1:n · )
* **( ).
* · ( 0.5m ) ** ** .
* ========================================================================== */
@@ -48,6 +48,8 @@ export interface OutletExtrasInput {
ownerForm?: string | null;
/** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */
equalize?: boolean;
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
leanFor?: (form: string, pureHeightM: number) => number;
}
export interface OutletExtrasResult {
@@ -167,9 +169,9 @@ export function inletGroundConnector(
return null;
}
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
function jointRunOf(height: number): number {
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */
function jointRunOf(height: number, lean: number): number {
return 0.25 * REVET_THICKNESS_M + (lean / 2) * height;
}
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
@@ -226,16 +228,17 @@ function buildExtraWall(
material: RevetMaterial,
floatGapM: number,
form: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout {
const thickness = REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
// 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더
// 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 —
// 종전 전면 발끝 지반 추적 수렴 삭제).
@@ -256,6 +259,7 @@ function buildExtraWall(
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -281,7 +285,6 @@ function buildExtraWall(
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
const { outward, groundAt, limitOffset } = input;
const thickness = REVET_THICKNESS_M;
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
const walls: WallLayout[] = [];
@@ -315,6 +318,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
/** ( )~ · .
* **** . */
let exposed = height - REVET_EMBED_DEPTH_M;
// 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감.
const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO;
const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN;
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
const heightAt = (x: number): number => {
@@ -350,7 +356,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
}
if (autoOffset === null) autoOffset = bestOffset;
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed);
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean);
/** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */
const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN;
// 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다.
@@ -378,7 +384,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const placeable = (xShift: number, vShift: number): boolean => {
const topE = autoTop - vShift;
const aX = autoOffset + outward * xShift;
const jointX = aX - outward * jointRunOf(exposed);
const jointX = aX - outward * jointRunOf(exposed, lean);
if (
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
topE - 0.01
@@ -388,7 +394,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const bottom = bottomAt(aX, baseE);
const startE = bottom + REVET_EMBED_DEPTH_M;
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
const startX = topFrontX + outward * lean * (topE - startE);
if (groundAt(startX) < startE + 0.05) return true;
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
return (
@@ -438,6 +444,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
material,
Math.max(0, base - groundBase),
form,
lean,
);
// 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을
// 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동).
@@ -479,8 +486,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
src = {
offset:
wall.points[2].offset +
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
};
prevBottom = wall.bottomBack.elevation;
@@ -2,7 +2,7 @@
* B06_Section_UI_Cross_Culvert_Geom.ts
* (··) ** ** (`_Cross_Culvert.ts`) .
* `section.culvert` ( ).
* 1:0.3 , ,
* 1:n (n = · `leanOf`), ,
* (designTrim). = . `_Basin.ts`, · `_Extra.ts`.
* ========================================================================== */
@@ -24,6 +24,7 @@ import {
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
@@ -171,8 +172,19 @@ export function computeCulvertLayout(
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) =>
revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5).
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
const lean = leanOf({
form: wall.form,
height_m: wall.pureHeight,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? spec.face_side : null,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
return { ...wall, lean };
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
@@ -204,14 +216,21 @@ export function computeCulvertLayout(
edge: { offset_m: number },
outward: number,
wallHeight: number,
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight) / 2 - REVET_TRAP_TOP_M);
lean: number,
): number =>
edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M);
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
const placed = placeInletWall({
autoOffset: slopeZeroAnchor(inletInfo.edge, inletInfo.outward, inletWallSpec.height),
autoOffset: slopeZeroAnchor(
inletInfo.edge,
inletInfo.outward,
inletWallSpec.height,
inletWallSpec.lean,
),
outward: inletInfo.outward,
height: inletWallSpec.height,
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
@@ -238,6 +257,7 @@ export function computeCulvertLayout(
outletInfo.limit,
),
limitOffset: outletInfo.limit,
lean: outletWallSpec.lean,
},
});
appliedAdjust.inlet.x = placed.x;
@@ -308,6 +328,7 @@ export function computeCulvertLayout(
vertical: WallVertical | null = null,
material: RevetMaterial = "dry",
formLabel: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout | null => {
// spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다.
const reason: BasinLayout["reason"] | null = forceBasinReason;
@@ -340,13 +361,13 @@ export function computeCulvertLayout(
}
return null;
}
// 형상: 배면 수직 + 계류측 1:0.3 평행사변형 띠. 높이는 vertical(계산용)이 들고 온다.
// 형상: 배면 수직 + 계류측 1:n 평행사변형 띠(n = 표준경사). 높이는 vertical(계산용)이 들고 온다.
const height =
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const floatGapM = vertical?.floatGapM ?? 0;
// 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert.
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
@@ -357,7 +378,7 @@ export function computeCulvertLayout(
// 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다
// (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제).
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
@@ -376,6 +397,7 @@ export function computeCulvertLayout(
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -411,6 +433,7 @@ export function computeCulvertLayout(
wallVertical.inlet,
inletWallSpec.material,
inletWallSpec.form,
inletWallSpec.lean,
);
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
@@ -421,6 +444,7 @@ export function computeCulvertLayout(
outletInfo.edge,
outletInfo.outward,
outletWallSpec.height,
outletWallSpec.lean,
);
const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height;
const outletLineInvertAt = (offset: number): number =>
@@ -461,6 +485,7 @@ export function computeCulvertLayout(
wallVertical.outlet,
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다.
const pipeStart = basinPipeEnd ?? inlet;
@@ -471,8 +496,7 @@ export function computeCulvertLayout(
const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
return {
offset:
wall.points[2].offset +
wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - reference),
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference),
elevation: reference,
};
};
@@ -527,6 +551,7 @@ export function computeCulvertLayout(
},
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
@@ -599,9 +624,18 @@ export function computeCulvertLayout(
)
: null;
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈).
const extraLean = (form: string, pureHeightM: number): number =>
leanOf({
form,
height_m: pureHeightM,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? culvert.inlet.face_side : null,
});
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
const extras = buildExtrasAt(outletWall ? pipeCorners.outlet.bottom : null, {
const outletStart = outletWall ? pipeCorners.outlet.bottom : null;
const outletInput = {
startBottomElevation: outletWall?.bottomBack.elevation ?? 0,
outward: outletInfo.outward,
groundAt,
@@ -609,23 +643,47 @@ export function computeCulvertLayout(
adjusts: (revetShift?.extras ?? []).map(adjustOf),
ownerForm: outletWallSpec.form,
equalize: equalizeExtras === true,
});
leanFor: extraLean,
};
const extras = buildExtrasAt(outletStart, outletInput);
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
const basinExtras = buildExtrasAt(
culvert.hidden_pipe ? (inletWall ? pipeCorners.inlet.bottom : null) : basinFillStart,
{
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
const basinStart = culvert.hidden_pipe
? inletWall
? pipeCorners.inlet.bottom
: null
: basinFillStart;
const basinInput = {
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
equalize: false,
leanFor: extraLean,
};
const basinExtras = buildExtrasAt(basinStart, basinInput);
/** ** ( ) ** (2026-09-14
* · ). .
* B08 (`facility_structures`) . */
const confirmedOnly = (
built: OutletExtrasResult,
start: OffsetPoint | null,
input: typeof outletInput,
): OutletExtrasResult => {
const firstUnset = built.appliedAdjusts.findIndex((applied) => applied.h == null);
if (firstUnset < 0) return built;
return buildExtrasAt(start, {
...input,
adjusts: input.adjusts.slice(0, firstUnset),
equalize: false,
},
);
});
};
const extrasForArea = confirmedOnly(extras, outletStart, outletInput);
const basinExtrasForArea = confirmedOnly(basinExtras, basinStart, basinInput);
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
@@ -665,8 +723,8 @@ export function computeCulvertLayout(
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
}
};
extendTrimSlope(drawnFillPoints(extras), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtras), inletInfo.outward);
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
@@ -23,9 +23,10 @@ import type {
} from "./B06_Section_UI_Cross_Culvert_Types";
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
export function fillWallBaseWidth(height: number): number {
return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
/** (m) . ( ) .
* `lean` = ( · `revetWallSpec().lean`). */
export function fillWallBaseWidth(height: number, lean: number = REVET_LEAN_RATIO): number {
return REVET_THICKNESS_M * 1.5 + lean * height;
}
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
@@ -252,6 +253,7 @@ export function minShoulderWallOffset(
baseAt: (offset: number) => number,
toeOffset: number,
limitOffset: number,
lean: number = REVET_LEAN_RATIO,
): number | null {
const span = (limitOffset - edge.offset_m) * outward;
if (!(span > 0)) return null;
@@ -260,7 +262,7 @@ export function minShoulderWallOffset(
const offset = edge.offset_m + outward * ((span * i) / steps);
const rise = edge.elevation_m - (baseAt(offset) + height);
if (rise < FILL_MIN_RISE_M) continue;
const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue;
if ((offset - toeOffset) * outward > 1e-6) {
const toeRise = edge.elevation_m - (baseAt(toeOffset) + height);
@@ -296,13 +298,14 @@ export function solveWallVertical(
groundBase: number,
minHeight: number,
limitHeight: number,
lean: number = REVET_LEAN_RATIO,
): WallVertical {
const gridUp = (value: number): number =>
Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
let height = minHeight;
let desiredTop = groundBase + minHeight;
for (let i = 0; i < 4; i += 1) {
const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const joint = anchorOffset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
const run = Math.max(0, (joint - edge.offset_m) * outward);
desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN;
const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight);
@@ -438,6 +441,8 @@ export function outletReceivable(input: {
toeOffset: number;
limitOffset: number;
groundAt: (offset: number) => number;
/** 유출 벽 전면 기울기(표준경사). */
lean?: number;
}): boolean {
const invertAt = (offset: number): number =>
Math.min(input.groundAt(offset), input.inletElevation);
@@ -449,6 +454,7 @@ export function outletReceivable(input: {
invertAt,
input.toeOffset,
input.limitOffset,
input.lean,
) ?? input.toeOffset;
const invert = invertAt(auto);
if (invert >= input.groundAt(auto) - 0.01) return true;
@@ -486,6 +492,7 @@ export function placeInletWall(input: {
height: number;
toeOffset: number;
limitOffset: number;
lean?: number;
};
/** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */
requireCrossing?: boolean;
@@ -80,6 +80,8 @@ export interface WallLayout {
shiftFloorM?: number;
/** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */
material: RevetMaterial;
/** 전면 기울기 1:n 의 n — 품셈 13-4-4 [주]⑪ 표준경사(`wallLeanRatio`, B08 과 한 벌 · 2026-09-14). */
lean: number;
outward: number;
/** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */
points: OffsetPoint[];
+31 -25
View File
@@ -6,8 +6,9 @@
* . ** **, **
* ** . ( ).
*
* **(°)** (1:n n)
* (°) . 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
* **(1:n n)** (2026-09-12 ). (°)
* , · [ ]· 1:n
* . 참고: 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
*
* ** **.
* ** **
@@ -37,13 +38,18 @@ export function degreesToRatio(degrees: number): number {
return 1 / Math.tan((clamped * Math.PI) / 180);
}
/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
function sameDegrees(a: number, b: number): boolean {
return Math.abs(a - b) < 0.05;
/** 경사비를 화면 자릿수(0.01)로 맞춘다 — 사면이 서지 않는 각도(1~89° 밖)는 잘라 받는다. */
export function clampRatio(ratio: number): number {
return Math.round(degreesToRatio(ratioToDegrees(ratio)) * 100) / 100;
}
/** 0.01 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
function sameRatio(a: number, b: number): boolean {
return Math.abs(a - b) < 0.005;
}
/**
* 68.2° . · .
* 1:0.40 . · .
*
* () ** **
* ( ).
@@ -58,30 +64,34 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
const group = document.createElement("div");
group.className = "b06-design__seg-buttons";
const ratio = control.ratioFor(section);
const standard = control.standardRatioFor(section);
const degrees = ratioToDegrees(ratio);
const ratio = clampRatio(control.ratioFor(section));
const standard = clampRatio(control.standardRatioFor(section));
// 「1:」은 고정 머리말 — 사용자가 넣는 것은 뒤의 n 하나다.
const prefix = document.createElement("span");
prefix.className = "b06-design__cutslope-unit";
prefix.textContent = "1:";
const input = document.createElement("input");
input.type = "number";
input.className = "b06-design__cutslope-input";
input.step = "0.5";
input.min = "1";
input.max = "89";
input.value = degrees.toFixed(1);
input.step = "0.05";
input.min = "0.02";
input.max = "57.29";
input.value = ratio.toFixed(2);
input.title =
`이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` +
`지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
`이 측점의 암 절토 경사 — 경사비(1:n 의 n)로 넣는다. n 이 작을수록 급하다.\n` +
`지금 1:${ratio.toFixed(2)} (${ratioToDegrees(ratio).toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
`전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`;
const commit = (): void => {
const entered = Number(input.value);
if (!Number.isFinite(entered)) {
input.value = degrees.toFixed(1);
if (!Number.isFinite(entered) || entered <= 0) {
input.value = ratio.toFixed(2);
return;
}
// 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다.
const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered);
control.set(section.chainage_m, next);
const next = clampRatio(entered);
control.set(section.chainage_m, sameRatio(next, standard) ? null : next);
};
input.addEventListener("change", commit);
input.addEventListener("keydown", (event) => {
@@ -93,22 +103,18 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
// 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도.
input.addEventListener("pointerdown", (event) => event.stopPropagation());
const unit = document.createElement("span");
unit.className = "b06-design__cutslope-unit";
unit.textContent = "°";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b06-design__rockb-btn is-reset";
reset.textContent = "↺";
reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`;
reset.disabled = sameDegrees(degrees, ratioToDegrees(standard));
reset.disabled = sameRatio(ratio, standard);
reset.addEventListener("click", (event) => {
event.stopPropagation();
control.set(section.chainage_m, null);
});
group.append(input, unit, reset);
group.append(prefix, input, reset);
wrap.append(group);
return wrap;
}
+31 -8
View File
@@ -721,32 +721,55 @@ export function appendCrossDesignOverlay(
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
// 구분이 안 되는 경계를 표시한다.
const edges = design.carriageway_edges;
if (edges) {
for (const edge of [edges.left, edges.right]) {
const cx = x(edge.offset_m);
const cy = toDisplayY(edge.elevation_m);
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
const cx = x(offsetM);
const cy = toDisplayY(elevationM);
const tick = document.createElementNS(SVG_NS, "line");
tick.setAttribute("x1", String(cx));
tick.setAttribute("y1", String(cy - 3.6));
tick.setAttribute("y1", String(cy - half));
tick.setAttribute("x2", String(cx));
tick.setAttribute("y2", String(cy + 3.6));
tick.setAttribute("class", "b06-chart__carriageway-tick");
tick.setAttribute("y2", String(cy + half));
tick.setAttribute("class", className);
svg.append(tick);
};
for (const edge of [edges.left, edges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
}
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
const roadEdges = design.road_edges;
if (roadEdges) {
for (const edge of [roadEdges.left, roadEdges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
}
}
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
const standardWidth = design.carriageway_standard_width_m;
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2));
label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6));
label.setAttribute("class", "b06-chart__carriageway-label");
label.textContent =
const widthText =
widened > 0.001 && typeof standardWidth === "number"
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
label.textContent =
shoulderLeft != null && shoulderRight != null
? `${widthText} · 노견 ${
Math.abs(shoulderLeft - shoulderRight) < 0.005
? `${shoulderLeft.toFixed(2)}m`
: `${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
}`
: widthText;
svg.append(label);
}
}
+32
View File
@@ -58,6 +58,38 @@ export function toeFitHalfWidth(section: CrossSection): number | null {
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
}
/**
* · ** ** · offset(m). null.
*
* `toeFitHalfWidth` ** ** (2026-09-12 )
* .
* ** **(null)
* .
*/
export function toeOffsets(section: CrossSection): { left: number | null; right: number | null } {
const out: { left: number | null; right: number | null } = { left: null, right: null };
const design = section.design;
if (!design) return out;
const groundAt = groundInterpolator(section.samples);
const designAt = designInterpolator(design.design_line);
const edges = design.road_edges;
if (!groundAt || !designAt || !edges) return out;
const lineOffsets = design.design_line.map((point) => point.offset_m);
if (lineOffsets.length < 2) return out;
const { protectMax, protectMin } = protectedSpan(design, edges);
for (const side of ["left", "right"] as const) {
const outward = side === "left" ? 1 : -1;
const start = side === "left" ? protectMax : protectMin;
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
const meet = meetOffset(designAt, groundAt, start, limit, outward);
// 설계선 끝 밖으로 나간 값은 외삽이다 — 만난 것으로 치지 않는다.
if (Math.abs(meet) > Math.abs(limit) + 1e-9) continue;
if (Math.abs(designAt(meet) - groundAt(meet)) > MEET_TOLERANCE_M) continue;
out[side] = meet;
}
return out;
}
/**
* **** (m) null (2026-09-03 ).
*
@@ -27,6 +27,8 @@ export interface FordPavementSpec {
depth_m: number | null;
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
slope_pct: number | null;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
interface Edge {
+18
View File
@@ -0,0 +1,18 @@
/* =============================================================================
* B06_Section_UI_Cross_Lean.ts
* 13-4-4 [] (`resources/data_masonry/masonry_slope_*`)
* (2026-09-14 B5 · B08 ). `common_util_masonry_slope.ts`( ).
*
* (JSON) Node
* . ** **,
* ( ··C군 ).
* import `test_b06_wall_lean_table` .
* ========================================================================== */
import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope";
import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json";
/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */
export function leanOf(input: WallLeanInput): number {
return wallLeanRatio(masonrySlope, input);
}
+30 -3
View File
@@ -23,10 +23,10 @@ import {
PIPE_CONNECT_GRADE,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
@@ -61,8 +61,17 @@ export interface RevetmentSpec {
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
lift_m?: number | null;
shift_m?: number | null;
/** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */
face_role?: string | null;
face_slope_ratio?: number | string | null;
}
/** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */
const TABLE_FORM_BY_TYPE: Record<string, string> = {
masonry_wet: "돌쌓기(찰)",
masonry_dry: "돌쌓기(메)",
};
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
export interface RevetmentAdjust {
x?: number;
@@ -187,9 +196,19 @@ export function computeRevetmentLayout(
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
const height = pureHeight - REVET_EMBED_DEPTH_M;
// 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로
// 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀.
const lean = leanOf({
form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null),
height_m: requestedHeight,
section_mode: design.section_mode,
side: spec.side,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
const roadSlope = roadSlopePerOutward(design, side, outward);
const autoOffset =
edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2);
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
// 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
@@ -244,6 +263,7 @@ export function computeRevetmentLayout(
form: spec.form,
lengthM,
floatGapM,
lean,
});
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
@@ -267,6 +287,13 @@ export function computeRevetmentLayout(
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
ownerForm: spec.form,
equalize: false,
leanFor: (form, pureHeightM) =>
leanOf({
form,
height_m: pureHeightM,
section_mode: design.section_mode,
side: spec.side,
}),
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
@@ -375,7 +402,7 @@ export function appendRevetmentOverlay(
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
const tooltip =
`독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
`(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit(
`(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${revetHeightLimit(
wall.form,
).toFixed(1)}m 7-3)` +
(wall.floatGapM > 0.01
+26 -159
View File
@@ -1,24 +1,23 @@
/* =============================================================================
* B06_Section_UI_Cross_View_Zoom.ts
* **··** . (`_UI_Cross_View.ts`)
* 700 . ( ) .
* **··** .
*
* · ** **(`A00_Common/b_svg_zoom_pan.ts`)
* (2026-09-12 ) .
* **B06 ** 남는다: 카드 (`CROSS_PAD`) , ,
* . ( )
* (2026-08-02 ).
* ========================================================================== */
import {
attachSvgZoomPan,
type ContentBounds,
type ZoomPanHandle,
type ZoomPanState,
} from "../A00_Common/b_svg_zoom_pan";
import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common";
/**
* · . `zoom()` .
*
* ** **(2026-08-02 ).
* . · , , .
*/
export interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
/** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */
scale: () => number;
}
export type { ContentBounds, ZoomPanHandle, ZoomPanState };
/**
* (2026-08-23 ). //
@@ -34,28 +33,10 @@ export interface CrossWidthActions {
fit: () => void;
}
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState {
scale: number;
tx: number;
ty: number;
}
/**
* ( SVG ).
* ** **
* (2026-08-23 ).
*/
export interface ContentBounds {
x: number;
y: number;
width: number;
height: number;
}
/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */
export const cardZoomStates = new Map<string, ZoomPanState>();
/** 카드 SVG 에 줌·팬을 붙인다 — 여백만 씌워 공용 조각에 넘긴다. 부르는 꼴은 그대로다. */
export function attachZoomPan(
svg: SVGSVGElement,
plotLayer: SVGGElement,
@@ -68,132 +49,18 @@ export function attachZoomPan(
/** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds,
): ZoomPanHandle {
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
const plot = {
x: CROSS_PAD.left,
y: CROSS_PAD.top,
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1),
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
};
const maxScale = 8;
let scale = initial?.scale ?? 1;
let tx = initial?.tx ?? 0;
let ty = initial?.ty ?? 0;
const applyTransform = (): void => {
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty });
};
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 반폭 밖 캐시 보유분 포함)까지 늘어나,
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
const clampAxis = (
value: number,
start: number,
size: number,
from: number,
to: number,
): number =>
Math.min(
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
Math.max(start + size - scale * to, start - scale * from),
);
const clampPan = (): void => {
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
// 범위까지 열어 둔다 — 캐시 보유분이 표시 반폭보다 넓으면 1배에서도 끌어다 봐야 한다
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
// 캐시가 표시 폭과 같으면 범위가 플롯과 같아져 종전처럼 이동량 0으로 묶인다.
const bounds = content
? {
x: Math.min(content.x, plot.x),
y: Math.min(content.y, plot.y),
right: Math.max(content.x + content.width, plot.x + plot.width),
bottom: Math.max(content.y + content.height, plot.y + plot.height),
}
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
};
// 보이는 **플롯 영역의 중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
const zoom = (factor: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor));
const cx = plot.x + plot.width / 2;
const cy = plot.y + plot.height / 2;
tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
clampPan();
applyTransform();
};
let panning = false;
let moved = false;
let lastX = 0;
let lastY = 0;
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
svg.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
return attachSvgZoomPan({
svg,
layer: plotLayer,
widthPx,
heightPx,
pad: CROSS_PAD,
initial,
onChange,
content,
// 휠은 카드 목록을 훑는 몫이다 — 줌에 안 묶는다.
wheelZoom: false,
});
svg.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 측점 선택·면적 강조 몫이다.
if (event.button !== 1) return;
event.preventDefault();
panning = true;
moved = false;
lastX = event.clientX;
lastY = event.clientY;
svg.classList.add("is-panning");
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", (event) => {
if (!panning) return;
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
const rect = svg.getBoundingClientRect();
tx += ((event.clientX - lastX) / rect.width) * widthPx;
ty += ((event.clientY - lastY) / rect.height) * heightPx;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyTransform();
});
const endPan = (event: PointerEvent): void => {
if (!panning) return;
panning = false;
svg.classList.remove("is-panning");
try {
svg.releasePointerCapture(event.pointerId);
} catch {
/* 이미 해제됨 */
}
};
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
clampPan();
applyTransform();
}
svg.addEventListener("pointerup", endPan);
svg.addEventListener("pointercancel", endPan);
// 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다.
svg.addEventListener("click", (event) => {
if (moved) event.stopPropagation();
});
const currentScale = (): number => scale;
const reset = (): void => {
scale = 1;
tx = 0;
ty = 0;
applyTransform();
};
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
reset();
});
return { zoom, reset, scale: currentScale };
}
/**
+6 -2
View File
@@ -55,6 +55,8 @@ export interface RevetWallGeometryInput {
thickness?: number;
floatGapM?: number;
extraIndex?: number;
/** 전면 기울기 1:n 의 n — 표준경사(`leanOf`). 없으면 종전 0.3. */
lean?: number;
}
/**
@@ -65,14 +67,15 @@ export interface RevetWallGeometryInput {
export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout {
const { anchor, outward, height, material, role } = input;
const thickness = input.thickness ?? REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const lean = input.lean ?? REVET_LEAN_RATIO;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
@@ -90,6 +93,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou
height,
floatGapM: input.floatGapM ?? 0,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -172,15 +172,9 @@ export function appendWallHatch(
((offset - wall.bottomBack.offset) / bottomSpan)
: wall.bottomBack.elevation;
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(사용자 스케치의 가운데 선) —
// 상단 변 중간점에서 전면과 나란히 바닥으로 내려온다. 형태와 무관하게 늘 긋는다.
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
layer.append(
line(
[x(wall.topJoint.offset), toDisplayY(wall.topJoint.elevation)],
[x(jointBaseOffset), toDisplayY(bottomAtOffset(jointBaseOffset))],
),
);
// 벽을 가로지르던 대각 이음선은 **긋지 않는다**(2026-09-12 사용자: 필요 없음).
// 종전에는 상단 변 중간점에서 바닥까지 형태와 무관하게 늘 그어, 돌쌓기·콘크리트 해칭
// 위로 선이 하나 더 지나가 도면이 지저분했다.
clipSeq += 1;
const clipId = `b06-revet-clip-${keyId}-${clipSeq}`;
+45 -166
View File
@@ -1,4 +1,5 @@
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync";
import { createGradeEdit } from "./B06_Section_UI_Page_Grade_Edit";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
@@ -23,26 +24,20 @@ import {
type StandardCrossSection,
} from "./B06_Section_Api_Fetch";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import {
bermSpansFromStructures,
confirmCurrentSections,
createCutSlopeStore,
createRockBoundaryStore,
readBermSpans,
saveCurrentSections,
writeBermSpans,
type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import {
readStructurePick,
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { createSectionView } from "./B06_Section_UI_Section_View";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
@@ -171,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
if (adjust?.h == null) return null;
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
.pureHeight;
},
@@ -185,6 +183,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
},
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
@@ -201,8 +200,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
movePipe: (fromChainageM, toChainageM) =>
stationControls.queueCulvertMove(fromChainageM, toChainageM),
});
const dockDivider = document.createElement("hr");
@@ -218,140 +215,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
attachCollapsible(leftForm);
/**
* : (1) ( ),
* (2) · . .
* DB config .
*/
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
if (!projectId || currentRouteId === null || !sectionDetail) return;
const target = sectionDetail.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
if (!target) return;
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
if (target.design) {
target.design = {
...target.design,
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? target.design.ditch_side,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
};
sectionView.refreshCard(chainageM);
}
// (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
// [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로
// 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다.
writeCrossDesignChoice(projectId, currentRouteId, chainageM, {
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? null,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
});
// (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다.
await reconcileStaleDesigns({ force: true });
}
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
function changeFromDesign(chainageM: number): CrossDesignChange | null {
const target = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
const design = target?.design;
if (!design) return null;
return {
ground_type: design.ground_type,
section_mode: design.section_mode,
ditch_side: design.ditch_side ?? null,
ditch_type: design.ditch_type ?? "standard",
paved: design.paved,
two_stage_slope: design.two_stage_slope ?? true,
ditch_choice: design.ditch_choice ?? null,
};
}
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
function recomputeIfRock(chainageM: number): void {
const change = changeFromDesign(chainageM);
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
}
/**
* stale design을 · (E-1 + N-6).
* : (1) 2 (`two_stage_slope`) , (2) B05에서
* · (`design.design_elevation_m`)
* (`design_profiles`) . 0 API
* .
*
* ** 1** (2026-08-04
* for-await stale "이력 재생"
* ). B05 ( ),
* (profile_alignment.edits) . (··
* · ) , .
*/
async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) return;
const draft = readAlignmentDraft(currentRouteId);
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
//
// 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
const alignment = sectionDetail.longitudinal.profile_alignment as
| {
edits?: {
station_offsets?: Record<string, number>;
curve_radii?: Record<string, number>;
};
}
| undefined;
const edits = draft ?? {
station_offsets: alignment?.edits?.station_offsets ?? {},
curve_radii: alignment?.edits?.curve_radii ?? {},
};
// 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서
// 다른 값이 된다(2026-09-03 사용자 지시로 일원화).
const updated = await refreshCrossDesigns({
projectId,
routeId: currentRouteId,
detail: sectionDetail,
edits,
});
for (const chainageM of updated) sectionView.refreshCard(chainageM);
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
}
/**
* ****(C군 ) .
*
* (2026-09-07 ), .
* .
*/
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
if (!projectId || currentRouteId === null) return;
const next = bermSpansFromStructures(structures);
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
writeBermSpans(projectId, currentRouteId, next);
void reconcileStaleDesigns({ force: true });
}
// 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
const {
handleDesignChange,
recomputeIfRock,
reconcileStaleDesigns,
syncBermSpans,
applyPanelToAll,
} = createDesignSync({
projectId,
routeId: () => currentRouteId,
detail: () => sectionDetail,
view: () => sectionView,
});
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
async function refreshDetailForStructures(): Promise<void> {
@@ -408,28 +284,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
showToast(L("B06_View_Apply_Success"), "success");
}
/** [ ](N-2-1): design .
* handleDesignChange가 standardPanel.getValues()
* . await로 API . */
let applyingAll = false;
async function applyPanelToAll(): Promise<void> {
if (applyingAll || !sectionDetail || !projectId || currentRouteId === null) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
// 대신 도는 동안 다시 누르는 것만 막는다.
applyingAll = true;
try {
for (const section of targets) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
}
showToast(L("B06_Std_ApplyAll_Success"), "success");
} finally {
applyingAll = false;
}
}
// 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
const rockStore = createRockBoundaryStore({
sessionKey: () => stateKey("rockb", projectId, currentRouteId),
@@ -483,6 +337,31 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
);
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
// 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
const gradeEditFor = createGradeEdit({
routeId: () => currentRouteId,
detail: () => sectionDetail,
view: () => sectionView,
reconcile: () => reconcileStaleDesigns({ force: true }),
});
sectionView.setGradeEdit(gradeEditFor);
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
// 같은 함수를 타므로 목록·폼·알약이 함께 선다.
sectionView.setStructureEdit({
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
removeStructure: (structureId) => {
structuresPanel.removeStructureById(structureId);
},
movePipe: (fromChainageM, toChainageM) =>
structuresPanel.movePipeTo(fromChainageM, toChainageM),
moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM);
},
});
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
detail: () => sectionDetail,
@@ -0,0 +1,218 @@
/* =============================================================================
* B06_Section_UI_Page_Design_Sync.ts
* · . `_UI_Page` 700
* (2026-09-13 ).
* - `handleDesignChange` : + +
* - `recomputeIfRock` : ·
* - `reconcileStaleDesigns`: design (B05 )
* - `syncBermSpans` :
* - `applyPanelToAll` : [ ]
* ========================================================================== */
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import { showToast } from "@ui/ui_template_elements";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import {
bermSpansFromStructures,
readBermSpans,
writeBermSpans,
} from "./B06_Section_UI_Page_Persist";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { L } from "./B06_Section_UI_Page_Common";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
export interface DesignSyncContext {
projectId: string | null;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
/** 뷰는 이 모듈보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. */
view: () => SectionViewController;
}
export interface DesignSyncController {
handleDesignChange: (chainageM: number, change: CrossDesignChange) => Promise<void>;
recomputeIfRock: (chainageM: number) => void;
reconcileStaleDesigns: (options?: { force?: boolean }) => Promise<void>;
syncBermSpans: (structures: ReadonlyArray<StructureInstance>) => void;
applyPanelToAll: () => Promise<void>;
}
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
const { projectId } = ctx;
/**
* : (1) ( ),
* (2) · . .
* DB config .
*/
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
const sectionDetail = ctx.detail();
const currentRouteId = ctx.routeId();
if (!projectId || currentRouteId === null || !sectionDetail) return;
const target = sectionDetail.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
if (!target) return;
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
if (target.design) {
target.design = {
...target.design,
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? target.design.ditch_side,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
};
ctx.view().refreshCard(chainageM);
}
// (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
// [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로
// 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다.
writeCrossDesignChoice(projectId, currentRouteId, chainageM, {
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? null,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
});
// (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다.
await reconcileStaleDesigns({ force: true });
}
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
function changeFromDesign(chainageM: number): CrossDesignChange | null {
const target = ctx
.detail()
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01);
const design = target?.design;
if (!design) return null;
return {
ground_type: design.ground_type,
section_mode: design.section_mode,
ditch_side: design.ditch_side ?? null,
ditch_type: design.ditch_type ?? "standard",
paved: design.paved,
two_stage_slope: design.two_stage_slope ?? true,
ditch_choice: design.ditch_choice ?? null,
};
}
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
function recomputeIfRock(chainageM: number): void {
const change = changeFromDesign(chainageM);
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
}
/**
* stale design을 · (E-1 + N-6).
* : (1) 2 (`two_stage_slope`) , (2) B05에서
* · (`design.design_elevation_m`)
* (`design_profiles`) . 0 API
* .
*
* ** 1** (2026-08-04
* for-await stale "이력 재생"
* ). B05 ( ),
* (profile_alignment.edits) . (··
* · ) , .
*/
async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> {
const sectionDetail = ctx.detail();
const currentRouteId = ctx.routeId();
if (!sectionDetail || !projectId || currentRouteId === null) return;
const draft = readAlignmentDraft(currentRouteId);
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
//
// 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
const alignment = sectionDetail.longitudinal.profile_alignment as
| {
edits?: {
station_offsets?: Record<string, number>;
curve_radii?: Record<string, number>;
};
}
| undefined;
const edits = draft ?? {
station_offsets: alignment?.edits?.station_offsets ?? {},
curve_radii: alignment?.edits?.curve_radii ?? {},
};
// 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서
// 다른 값이 된다(2026-09-03 사용자 지시로 일원화).
const updated = await refreshCrossDesigns({
projectId,
routeId: currentRouteId,
detail: sectionDetail,
edits,
});
// 카드는 한꺼번에 갈아 끼운다 — 측점마다 `refreshCard` 를 부르면 그때마다 종단
// 그래프·유토곡선까지 다시 그려 측점 수만큼 화면이 멈췄다(2026-09-12).
ctx.view().refreshCards(updated);
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
}
/**
* ****(C군 ) .
*
* (2026-09-07 ), .
* .
*/
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
const currentRouteId = ctx.routeId();
if (!projectId || currentRouteId === null) return;
const next = bermSpansFromStructures(structures);
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
writeBermSpans(projectId, currentRouteId, next);
void reconcileStaleDesigns({ force: true });
}
/** [ ](N-2-1): design .
* handleDesignChange가 standardPanel.getValues()
* . await로 API . */
let applyingAll = false;
async function applyPanelToAll(): Promise<void> {
const sectionDetail = ctx.detail();
if (applyingAll || !sectionDetail || !projectId || ctx.routeId() === null) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
// 대신 도는 동안 다시 누르는 것만 막는다.
applyingAll = true;
try {
for (const section of targets) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
}
showToast(L("B06_Std_ApplyAll_Success"), "success");
} finally {
applyingAll = false;
}
}
return {
handleDesignChange,
recomputeIfRock,
reconcileStaleDesigns,
syncBermSpans,
applyPanelToAll,
};
}
@@ -36,6 +36,38 @@ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string,
return options;
}
/** 옵션 키 → 「기본값(미확정)」 목록의 이름(파이썬 `_defaulted` 이름표와 같은 글). */
const DEFAULTED_LABELS: Record<string, string> = {
pipe_kind: "관종",
pipe_diameter_mm: "관경",
ford_width_m: "월류 폭",
pipe_count: "배관 수량",
body_width_m: "본체 폭",
body_height_m: "본체 높이",
};
for (const [side, name] of [
["in", "유입"],
["out", "유출"],
]) {
DEFAULTED_LABELS[`wing_${side}`] = `${name} 날개벽`;
DEFAULTED_LABELS[`wing_${side}_height_m`] = `${name} 날개벽 높이`;
DEFAULTED_LABELS[`wing_${side}_length_m`] = `${name} 날개벽 길이`;
DEFAULTED_LABELS[`wing_${side}_angle_deg`] = `${name} 날개벽 각도`;
}
/** ()
* (2026-09-14 ). ( ). */
function dropDefaulted(spec: { defaulted?: string[] } | undefined, options: object): void {
if (!spec?.defaulted) return;
const labels = Object.keys(options).flatMap((key) => DEFAULTED_LABELS[key] ?? []);
spec.defaulted = spec.defaulted.filter(
(item) =>
!labels.some(
(label) => item.startsWith(`${label} `) && !item.slice(label.length + 1).includes(" "),
),
);
}
/** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */
function wingPatchFrom(patch: Record<string, number | string>, prefix: string): WingPatch {
const num = (key: string): number | undefined => {
@@ -157,6 +189,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
// 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리.
if (patch.ford_width_m) spec.span_m = patch.ford_width_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch);
deps.refreshCard(chainageM);
},
@@ -171,6 +204,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg);
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -349,6 +383,7 @@ export function createBoxControls(deps: FordControlDeps): {
}
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch as Record<string, number | string>);
deps.refreshCard(chainageM);
},
@@ -364,6 +399,7 @@ export function createBoxControls(deps: FordControlDeps): {
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
: 0;
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -0,0 +1,85 @@
/* =============================================================================
* B06_Section_UI_Page_Grade_Edit.ts
* (/) . `_UI_Page` 700
* (2026-09-13 ).
* ========================================================================== */
import {
createProfileEditStore,
type ProfileEditStore,
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
import {
adjustStation,
buildAlignment,
toAlignmentBase,
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { SectionViewController } from "./B06_Section_UI_Section_View";
type GradeEditProvider = NonNullable<Parameters<SectionViewController["setGradeEdit"]>[0]>;
export interface GradeEditContext {
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
view: () => SectionViewController;
/** 전 측점 횡단 재계산 — 손을 뗀 뒤 한 번만 돈다. */
reconcile: () => Promise<void>;
}
/**
* (/) B05 ** ** (2026-09-12 사용자: B06
* ). · (`reconcile`),
* []·[] (CLAUDE.md 5).
*/
export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider {
let gradeStore: ProfileEditStore | null = null;
let gradeRouteId: number | null = null;
/** 10(`HOLD_INTERVAL_MS`)
* , . */
const GRADE_RECONCILE_DEBOUNCE_MS = 120;
let gradeReconcileTimer = 0;
const scheduleGradeReconcile = (): void => {
window.clearTimeout(gradeReconcileTimer);
gradeReconcileTimer = window.setTimeout(() => {
void ctx.reconcile().then(() => ctx.view().setGradeEdit(gradeEditFor));
}, GRADE_RECONCILE_DEBOUNCE_MS);
};
const gradeEditFor: GradeEditProvider = () => {
const detail = ctx.detail();
const currentRouteId = ctx.routeId();
if (!detail || currentRouteId === null) return null;
const stored = readAlignment(detail.longitudinal);
if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다.
if (!gradeStore || gradeRouteId !== currentRouteId) {
gradeRouteId = currentRouteId;
gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined);
}
const store = gradeStore;
const base = toAlignmentBase(stored);
const alignment = buildAlignment(base, store.edits());
return {
alignment,
stepM: alignment.policy.edit_step_m,
onStation: (chainageM, delta) => {
store.replace(adjustStation(base, store.edits(), chainageM, delta));
// ① 선은 **그 자리에서** 움직인다 — 그래프가 읽는 계획선(`design_profiles`)만
// 갈아 끼우고 다시 그린다(재계산을 기다리면 누른 뒤 한참 뒤에 움직였다).
const current = ctx.detail();
if (current) {
current.longitudinal.design_profiles = [
toDesignProfile(
buildAlignment(base, store.edits()),
current.longitudinal.design_profiles?.[0],
),
];
}
ctx.view().setGradeEdit(gradeEditFor);
// ② 전 측점 횡단 재계산·카드 갱신은 무겁다 — 마지막 한 번만(B05 프리뷰와 같은 규칙).
// 편집분은 세션 초안에 있으므로 재계산이 그것을 그대로 읽는다.
scheduleGradeReconcile();
},
};
};
return gradeEditFor;
}
+28 -5
View File
@@ -18,6 +18,11 @@ import {
} from "./B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { saveProfileAlignment } from "../B05_Profile/B05_Profile_Api_Fetch";
import {
clearAlignmentDrafts,
readAlignmentDraft,
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
@@ -418,9 +423,26 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
};
}
/** 계획선 편집 초안이 있으면 종단 정본에 쓰고 초안을 지운다. 없으면 아무 일도 하지 않는다. */
async function flushAlignmentDraft(projectId: string, routeId: number | null): Promise<void> {
if (routeId === null) return;
const draft = readAlignmentDraft(routeId);
if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft);
clearAlignmentDrafts();
}
/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */
async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
// ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도
// 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가·
// 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로
// 하면 스냅샷이 B06 편집을 통째로 덮는다.
await flushPendingPipes(projectId).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
});
// 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
await ctx.flushCulvertOptions();
// B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만
@@ -431,11 +453,12 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string):
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
// B05 배수유역도에서 고친 관 목록(추가·이동·삭제)도 여기서 정본에 남긴다 — 예전에는
// B05 [임시저장]에만 실려, B06 에서 저장하면 그 편집이 사라졌다(2026-09-06 대응표).
await flushPendingPipes(projectId).catch((error) => {
// 계획선 편집(▲/▼)은 세션 초안에만 있다 — B06 에서 고쳤든 B05 에서 고쳤든 여기서
// 종단 정본으로 내보낸다(2026-09-12). 종전에는 B06 이 초안을 **읽기만** 해서, B06 에서
// 저장하면 계획선 편집이 다음 진입 때 사라졌다.
await flushAlignmentDraft(projectId, ctx.routeId()).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
showToast(`계획선 저장에 실패했습니다.${detail}`, "error");
});
await flushPendingStructures(projectId).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
@@ -94,8 +94,6 @@ export interface StationControls {
flushCulvertOptions: () => Promise<void>;
/** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */
queueCulvertOptions: (chainageM: number, patch: Record<string, number | string>) => void;
/** 기준 측점 이동을 예약한다 — B06 구조물 배치 폼의 측점 칸이 쓴다(2026-08-29). */
queueCulvertMove: (fromChainageM: number, toChainageM: number) => void;
load: () => void;
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
@@ -617,8 +615,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
flushCulvertOptions: () => culvertOptions.flush(),
queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch),
queueCulvertMove: (fromChainageM, toChainageM) =>
culvertOptions.queueMove(fromChainageM, toChainageM),
load: () => {
loadStationWidths();
loadRevetShifts();
@@ -24,12 +24,14 @@ import {
type StructureInstance,
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
readStructurePick,
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { showToast } from "@ui/ui_template_elements";
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
import {
adjustDockRoot,
refreshAdjustSlots,
@@ -39,8 +41,13 @@ import {
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
const PIPE_ADD_GUIDE = "계곡 통과 시설의 추가·삭제는 B05(종단) 화면에서 합니다.";
const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */
const PIPE_ADD_NOTICE =
"계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
const PIPE_REMOVE_NOTICE =
"계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
const PIPE_MOVE_NOTICE =
@@ -72,9 +79,6 @@ export interface B06StructuresPanelDeps {
/** (culvert )
* (2026-08-29 ). */
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
/** []·[]
* (2026-08-29 사용자: B06에서도 ). */
movePipe?: (fromChainageM: number, toChainageM: number) => void;
/** (C) ** **
* (2026-09-06 ). */
onStructuresChanged?: () => void;
@@ -97,6 +101,13 @@ export interface B06StructuresPanel {
showAtChainage: (chainageM: number | null) => void;
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
currentChainage: () => number | null;
/** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */
addStructureAt: (chainageM: number, typeId: string) => void;
removeStructureById: (structureId: string) => boolean;
addPipeAt: (chainageM: number) => void;
removePipeAt: (chainageM: number) => void;
movePipeTo: (fromChainageM: number, toChainageM: number) => void;
moveStructureTo: (structureId: string, toChainageM: number) => boolean;
/** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */
facility: {
setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void;
@@ -115,105 +126,153 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
}
type Merged = Record<string, string | number>;
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
const options = types.find((type) => type.type_id === typeId)?.options ?? [];
return Object.fromEntries(options.map((option) => [option.key, option.default]));
}
/**
* ** **(`section.ford`)
* ,
* (2026-08-30 사용자: 수량() 1).
* ** , .**
* ·[]
* (2026-09-14 ). .
*/
function keepSpec(
merged: Merged,
stored: Merged | undefined,
key: string,
spec: string | number | null | undefined,
fromDefault: unknown,
): void {
if (spec === null || spec === undefined) return;
if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec;
}
type WingSpec = {
installed: boolean;
height_m: number | null;
length_m: number | null;
angle_deg: number | null;
};
function keepWings(
merged: Merged,
stored: Merged | undefined,
defaults: Record<string, unknown>,
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
): void {
for (const [wing, prefix] of wings) {
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
keepSpec(
merged,
stored,
`${prefix}_height_m`,
wing.height_m,
defaults[`${prefix}_height_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_length_m`,
wing.length_m,
defaults[`${prefix}_length_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_angle_deg`,
wing.angle_deg,
defaults[`${prefix}_angle_deg`] ?? 45,
);
}
}
/**
* ** **(`section.ford`) · .
* (2026-08-30 사용자: 수량()
* 1) 1 ** ** ( ).
*/
function withFordSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
ford: NonNullable<SectionDetailResponse["cross_sections"][number]["ford"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind;
merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000);
merged.pipe_count = ford.pipe_count;
merged.ford_width_m = ford.span_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind);
const diameter = Math.round(ford.diameter_m * 1000);
keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000);
const count = Number(defaults.pipe_count ?? 0);
keepSpec(
merged,
options,
"pipe_count",
ford.pipe_count,
count > 0 ? Math.max(Math.trunc(count), 1) : 1,
);
keepSpec(
merged,
options,
"ford_width_m",
ford.span_m,
defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M,
);
keepWings(merged, options, defaults, [
[ford.wing_in, "wing_in"],
[ford.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* BOX암거 ** **(`section.box`)
* (2026-08-30 ). · , ·
* .
*/
/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */
function withBoxSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
merged.body_width_m = box.inner_width_m;
merged.body_height_m = box.inner_height_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2);
keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2);
keepWings(merged, options, defaults, [
[box.wing_in, "wing_in"],
[box.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* ** **
* (2026-08-29 ). ,
* ( ·) .
* ** ** (2026-08-29 ).
* (··· ·/· ) ** **
* (2026-09-14 ). .
*/
function withSpecDefaults(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
detail: SectionDetailResponse | null,
chainageM: number,
types: StructureType[],
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
): Record<string, string | number> | undefined {
): Merged | undefined {
const owner = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
);
// 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든
// 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는
// 구조물 배치 상세 UI, 로직·값은 조정창 것).
if (owner?.ford) return withFordSpec(options, owner.ford);
if (owner?.box) return withBoxSpec(options, owner.box);
// 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30).
if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge"));
if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert"));
const culvert = owner?.culvert;
if (!culvert) return options;
const merged: Record<string, string | number> = { ...(options ?? {}) };
const put = (key: string, value: string | number | null | undefined): void => {
if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value;
};
put("pipe_kind", culvert.pipe_kind);
put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000));
for (const [side, prefix] of [
[culvert.inlet, "inlet"],
[culvert.outlet, "outlet"],
] as const) {
put(`${prefix}_type`, side.structure);
// 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다
// 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다).
const merged: Merged = { ...(options ?? {}) };
for (const prefix of ["inlet", "outlet"] as const) {
// 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌.
const drawnForm = wallForm?.(chainageM, prefix);
if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm;
else put(`${prefix}_revet_form`, side.revet_form);
const drawnHeight = wallHeight?.(chainageM, prefix);
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
else put(`${prefix}_revet_height_m`, side.revet_height_m);
put(`${prefix}_revet_length_m`, side.revet_length_m);
put(`${prefix}_revet_before_m`, side.revet_before_m);
put(`${prefix}_revet_after_m`, side.revet_after_m);
}
put("inlet_basin_length_m", culvert.inlet.basin_length_m);
put("inlet_basin_before_m", culvert.inlet.basin_before_m);
put("inlet_basin_after_m", culvert.inlet.basin_after_m);
return merged;
}
@@ -248,29 +307,39 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
},
getInterval: deps.stationInterval,
onReveal: () => deps.reveal?.(),
onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"),
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
onPipeAdd: (chainageM, attributes) => {
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) {
showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error");
return;
}
pipeFacilities.push({
chainage_m: at,
facility: attributes.facility ?? "pipe",
source: "user",
options: attributes.options ?? {},
});
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
currentChainageM = at;
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_ADD_NOTICE, "success");
},
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
// (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다.
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M;
if (moved) {
if (deps.movePipe) {
deps.movePipe(fromChainageM, toChainageM);
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.chainage_m = toChainageM;
currentChainageM = toChainageM;
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
} else {
showToast(PIPE_MOVE_GUIDE, "error");
}
}
if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M)
movePipeTo(fromChainageM, toChainageM);
const patch = attributes.options;
if (!patch || !Object.keys(patch).length) return;
if (!deps.queuePipeOptions) {
@@ -287,9 +356,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
persistPipes();
section.setPipeFacilities(pipeFacilities);
},
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
onPipeRemove: (chainageM) => removePipeAt(chainageM),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
@@ -339,7 +409,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
structures = readPendingStructures(projectId) ?? stored.structures;
section.setStructures(structures);
const detail = deps.detail?.() ?? null;
pipeFacilities = pipeResponse.pipe_points.map((pipe) => ({
// 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며,
// 이래야 B05 에서 넣고 B06 으로 넘어와도 같은 목록이 보인다(2026-09-12 사용자).
const pendingPipes = readPendingPipes(projectId);
pipeFacilities = (pendingPipes ?? pipeResponse.pipe_points).map((pipe) => ({
chainage_m: pipe.chainage_m,
facility: pipe.facility ?? "pipe",
start_m: pipe.start_m,
@@ -350,6 +423,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
pipe.options,
detail,
pipe.chainage_m,
types,
deps.wallHeight,
deps.wallForm,
),
@@ -394,17 +468,84 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
hit.options,
deps.detail?.() ?? null,
hit.chainage_m,
markTypes,
deps.wallHeight,
deps.wallForm,
);
section.setPipeFacilities(pipeFacilities);
}
/**
* ** ** B05 (2026-09-12
* 사용자: 담는 ). B06
* .
*/
function persistPipes(): void {
if (!deps.projectId) return;
writePendingPipes(
deps.projectId,
pipeFacilities.map((pipe) => ({
chainage_m: pipe.chainage_m,
source: pipe.source ?? "user",
facility: pipe.facility,
...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}),
...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}),
...(pipe.options ? { options: pipe.options } : {}),
})),
);
}
/** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */
function movePipeTo(fromChainageM: number, toChainageM: number): void {
if (!deps.projectId) {
showToast(PIPE_MOVE_GUIDE, "error");
return;
}
const to = Number(toChainageM.toFixed(2));
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.chainage_m = to;
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
currentChainageM = to;
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
}
/** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */
function removePipeAt(chainageM: number): void {
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
pipeFacilities = pipeFacilities.filter(
(entry) => Math.abs(entry.chainage_m - at) >= PIPE_MATCH_M,
);
if (currentChainageM !== null && Math.abs(currentChainageM - at) < PIPE_MATCH_M)
currentChainageM = null;
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_REMOVE_NOTICE, "success");
}
return {
root: section.root,
listRoot: section.listRoot,
load,
currentChainage: () => currentChainageM,
// 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다.
addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId),
removeStructureById: (structureId) => section.removeById(structureId),
addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"),
removePipeAt: (chainageM) => removePipeAt(chainageM),
movePipeTo: (fromChainageM, toChainageM) => movePipeTo(fromChainageM, toChainageM),
moveStructureTo: (structureId, toChainageM) => section.moveById(structureId, toChainageM),
facility: {
setInletStructure: (value) => section.facility.setInletStructure(value),
onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler),
+63 -123
View File
@@ -17,8 +17,6 @@ import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
// 가로 스크롤 고정 Y축 — B05와 같은 오버레이를 쓴다(정의처: B05 MassHaul 모듈 + 그 CSS).
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import type {
CrossSection,
EarthworkConversion,
@@ -45,33 +43,24 @@ import {
effectiveCardHalfWidth,
} from "./B06_Section_UI_Cross_View_Metrics";
import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
import { LONG_PAD } from "./B06_Section_UI_Section_Common";
import { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import {
buildStructureLane,
STRUCTURE_LANE_HEIGHT_PX,
} from "../B05_Profile/B05_Profile_UI_Structures_Marks";
attachWindowScroll,
createElevationHold,
drawLongitudinalPanel,
type LongitudinalPanelInput,
} from "./B06_Section_UI_Section_View_Draw";
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal";
import {
buildLongitudinalChart,
visibleChainageRange,
visibleElevationRange,
} from "./B06_Section_UI_Section_View_Chart";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
import { badgeValuesFrom, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import {
applyElevationWindow,
needsFullRedraw,
Y_AXIS_WINDOW_CLASS,
type ElevationWindowResult,
} from "@util/common_util_chart_ywindow";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { type ElevationWindowResult } from "@util/common_util_chart_ywindow";
import {
BASE_PANEL_HEIGHT,
chartHeights,
@@ -118,6 +107,8 @@ export interface SectionViewController {
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/**
* (2026-08-29 B05/B06 일원화: 목록 ·). */
focusStation: (stationId: string) => void;
@@ -131,6 +122,10 @@ export interface SectionViewController {
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void;
}
@@ -163,6 +158,8 @@ export function createSectionView(
let currentNaturalSpoilSlope: number | undefined;
let markStructures: ReadonlyArray<StructureInstance> = [];
let markTypes: ReadonlyArray<StructureType> = [];
let structureEdit: SectionStructureEdit | null = null;
let gradeEdit: (() => LongitudinalPanelInput["grade"]) | null = null;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
@@ -504,94 +501,25 @@ export function createSectionView(
const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0;
const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT));
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
const chartWidth = Math.max(renderWidth, minWidth);
const chart = buildLongitudinalChart({
updateChartWindow = drawLongitudinalPanel({
detail,
chartWrap,
renderWidth: Math.max(renderWidth, minWidth),
chartHeight: heights.long,
keepScrollLeft,
selectedStationId,
exaggeration: currentExaggeration,
selectStation: (stationId) => selectStation(stationId, true),
stationInterval: cachedStationInterval,
chartWidth,
chartHeight: heights.long,
minWidth,
scrollLeft: keepScrollLeft,
viewportWidth: chartWrap.clientWidth || chartWidth,
structures: markStructures,
types: markTypes,
edit: structureEdit,
conversion: currentConversion,
naturalSpoilSlope: currentNaturalSpoilSlope,
selectStation: (stationId) => selectStation(stationId, true),
setMassBadge: (values) => massBadge.set(values),
grade: gradeEdit?.() ?? null,
holdRange,
});
const longAxis = chart.axis;
const nodes: Element[] = [chart.node];
// 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다.
// 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일).
if (markStructures.length && markTypes.length) {
nodes.push(
buildStructureLane({
structures: markStructures,
types: markTypes,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
widthPx: chartWidth,
axisWidthPx: LONG_PAD.left,
stationIntervalM: cachedStationInterval,
selectedId: null,
// B06 에서 알약을 누르면 그 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
onSelect: (structureId) => {
if (!structureId) return;
const hit = markStructures.find((entry) => entry.structure_id === structureId);
if (!hit) return;
const at = structureAnchorM(hit);
// 그 자리에 가장 가까운 측점 카드를 고른다 — 좌측 목록 클릭과 같은 규칙.
let best: { id: string; gap: number } | null = null;
for (const section of currentDetail?.cross_sections ?? []) {
const gap = Math.abs(section.chainage_m - at);
if (!best || gap < best.gap) best = { id: section.station_id, gap };
}
if (best) selectStation(best.id, true);
},
// 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다.
onMove: () => undefined,
}),
);
}
chartWrap.replaceChildren(...nodes);
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
if (currentConversion) {
const series = computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
currentConversion,
currentNaturalSpoilSlope,
);
const cross = series.find((entry) => entry.basis === "cross") ?? series[0];
massBadge.set(cross ? badgeValuesFrom(cross.result) : null);
} else {
massBadge.set(null);
}
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
if (longAxis) {
const overlay = buildStickyYAxis(longAxis, heights.long);
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
chartWrap.prepend(overlay);
}
// 세로 창 갱신기 — 스크롤마다 종단은 변환으로, 유토곡선은 곡선만 다시 그려 따라온다.
updateChartWindow = () => {
const { fromM, toM } = visibleChainageRange(
chart.toChainage,
chart.maxChainageM,
chartWrap.scrollLeft,
chartWrap.clientWidth || chartWidth,
);
return applyElevationWindow(
chartWrap,
visibleElevationRange(detail, fromM, toM) ?? undefined,
);
};
chartWrap.scrollLeft = keepScrollLeft;
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
syncScrollMargin();
@@ -608,22 +536,8 @@ export function createSectionView(
}
}
/** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
let settledScrollLeft = 0;
let scrollSettleTimer = 0;
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정, B05와 같은 규칙) —
// 스크롤마다 겹 하나의 변환만 갈아 끼우므로 실시간으로 따라온다.
chartWrap.addEventListener("scroll", () => {
const fitted = updateChartWindow?.() ?? null;
window.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
scrollSettleTimer = window.setTimeout(() => {
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = chartWrap.scrollLeft;
drawPanel();
}, SCROLL_SETTLE_MS);
});
attachWindowScroll(chartWrap, () => updateChartWindow, drawPanel);
const holdRange = createElevationHold(chartWrap, drawPanel);
const draw = (): void => {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
@@ -689,18 +603,35 @@ export function createSectionView(
chartWrap.scrollLeft = keepScrollLeft;
};
const refreshCard = (chainageM: number): void => {
if (!currentDetail) return;
/** 카드 한 장만 갈아 끼운다(상단 패널은 안 건드린다). 못 찾으면 false. */
const rebuildCard = (chainageM: number): boolean => {
if (!currentDetail) return false;
const section = currentDetail.cross_sections.find(
(candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01,
);
if (!section) return;
if (!section) return false;
const existing = document.getElementById(`cross-${section.station_id}`);
// 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다.
if (existing)
existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id)));
return true;
};
const refreshCard = (chainageM: number): void => {
// 단면적이 바뀌면 유토곡선도 함께 흔들리므로 상단 패널만 다시 그린다(카드 전체 재렌더 없음).
drawPanel();
if (rebuildCard(chainageM)) drawPanel();
};
/**
* ** ** .
*
* `refreshCard` · ,
* ms (2026-09-12 B05 ).
*/
const refreshCards = (chainages: ReadonlyArray<number>): void => {
let touched = false;
for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true;
if (touched) drawPanel();
};
const resizeObserver = new ResizeObserver(() => {
@@ -746,6 +677,7 @@ export function createSectionView(
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
},
refreshCard,
refreshCards,
focusStation(stationId) {
if (selectedStationId !== stationId) selectStation(stationId, true);
else revealCard(stationId, "smooth");
@@ -753,6 +685,14 @@ export function createSectionView(
setStationSelectListener(listener) {
stationSelectListener = listener;
},
setStructureEdit(edit) {
structureEdit = edit;
drawPanel();
},
setGradeEdit(provider) {
gradeEdit = provider;
drawPanel();
},
setStructureMarks(structures, types) {
markStructures = structures;
markTypes = types;
@@ -30,6 +30,15 @@ export interface LongitudinalChartInput {
/** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */
scrollLeft: number;
viewportWidth: number;
/** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */
onDragStation?: (stationId: string, toChainageM: number) => void;
/** X축·측점 라벨을 바닥에서 이만큼(px) 올린다 — 계획고 편집 ▼ 버튼과 겹치지 않게. */
bottomInsetPx?: number;
/** **** .
* ( ). */
holdRange?: (
next: { min: number; max: number } | null,
) => { min: number; max: number } | undefined;
}
export interface LongitudinalChartResult {
@@ -110,10 +119,13 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi
axis = next;
},
undefined,
undefined,
// 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화).
input.onDragStation,
input.bottomInsetPx ?? 0,
0,
0,
visibleElevationRange(detail, fromM, toM) ?? undefined,
input.holdRange
? input.holdRange(visibleElevationRange(detail, fromM, toM))
: (visibleElevationRange(detail, fromM, toM) ?? undefined),
),
);
return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM };
@@ -0,0 +1,282 @@
/* =============================================================================
* B06_Section_UI_Section_View_Draw.ts
* B06 ** ** · · ·
* · Y축, .
*
* (`_Section_View`) 700 (CLAUDE.md 4)
* . ** ** , · ·
* . .
* ========================================================================== */
import type { EarthworkConversion, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
import { badgeValuesFrom } from "@util/common_util_mass_haul_badge";
import {
applyElevationWindow,
needsFullRedraw,
Y_AXIS_WINDOW_CLASS,
type ElevationWindowResult,
} from "@util/common_util_chart_ywindow";
import { LONG_PAD } from "./B06_Section_UI_Section_Common";
import {
buildLongitudinalChart,
visibleChainageRange,
visibleElevationRange,
} from "./B06_Section_UI_Section_View_Chart";
import {
mountSectionStructureMenu,
moveMarkById,
type SectionStructureEdit,
} from "./B06_Section_UI_Section_View_Menu";
import { createEditOverlay } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
/** 그래프 한 벌을 세우는 데 필요한 값 — 본체가 재고 고른 것을 그대로 넘긴다. */
export interface LongitudinalPanelInput {
detail: SectionDetailResponse;
/** 그래프가 들어앉는 칸. 내용은 이 함수가 통째로 갈아 끼운다. */
chartWrap: HTMLElement;
/** 본체가 잰 그릴 폭·높이. */
renderWidth: number;
chartHeight: number;
/** 갈아 끼우기 전 가로 스크롤 자리 — 되돌려 놓는다. */
keepScrollLeft: number;
selectedStationId: string | null;
exaggeration: number;
stationInterval: number;
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
/** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭·끌기를 안 붙인다). */
edit: SectionStructureEdit | null;
conversion?: EarthworkConversion;
naturalSpoilSlope?: number;
selectStation: (stationId: string) => void;
/** 세로 창 가로채기 — 계획고를 만지는 동안 창을 고정한다(B05 와 같은 장치). */
holdRange?: (
next: { min: number; max: number } | null,
) => { min: number; max: number } | undefined;
/** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/ (B05 ).
* ( ). */
grade?: {
alignment: ProfileAlignment;
/** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */
stepM: number;
onStation: (chainageM: number, delta: number) => void;
} | null;
/** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */
setMassBadge: (values: ReturnType<typeof badgeValuesFrom> | null) => void;
}
/** 알약 레인이 설 높이(px) — 본체가 그릴 높이를 셈할 때 같은 값을 써야 한다. */
export { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
/**
* ** ** .
* .
*/
export function drawLongitudinalPanel(
input: LongitudinalPanelInput,
): () => ElevationWindowResult | null {
const { detail, chartWrap, structures, types, edit } = input;
const chartWidth = input.renderWidth;
/** 측점선·알약을 끌어 놓았을 때 — 관인지 구조물인지는 공용 판별이 가른다. */
const moveMark = (markId: string, toChainageM: number): void => {
if (!edit) return;
moveMarkById(markId, toChainageM, {
structures,
stations: detail.longitudinal.stations ?? [],
edit,
});
};
const chart = buildLongitudinalChart({
detail,
selectedStationId: input.selectedStationId,
exaggeration: input.exaggeration,
selectStation: input.selectStation,
stationInterval: input.stationInterval,
chartWidth,
chartHeight: input.chartHeight,
minWidth: chartWidth,
scrollLeft: input.keepScrollLeft,
viewportWidth: chartWrap.clientWidth || chartWidth,
// 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다
// (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다.
bottomInsetPx: input.grade ? 15 : 0,
holdRange: input.holdRange,
// 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동.
onDragStation: edit ? moveMark : undefined,
});
const nodes: Element[] = [chart.node];
// 구조물 알약 레인 — B05 종단과 **같은 부품**을 그대로 쓴다. 배수관·세월교도 같은
// 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일).
if (structures.length && types.length) {
nodes.push(
buildStructureLane({
structures,
types,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
widthPx: chartWidth,
axisWidthPx: LONG_PAD.left,
stationIntervalM: input.stationInterval,
selectedId: null,
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
onSelect: (structureId) => {
if (!structureId) return;
const hit = structures.find((entry) => entry.structure_id === structureId);
if (!hit) return;
const at = structureAnchorM(hit);
let best: { id: string; gap: number } | null = null;
for (const section of detail.cross_sections) {
const gap = Math.abs(section.chainage_m - at);
if (!best || gap < best.gap) best = { id: section.station_id, gap };
}
if (best) input.selectStation(best.id);
},
// 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서.
onMove: moveMark,
}),
);
}
chartWrap.replaceChildren(...nodes);
// 계획선 편집 버튼층 — B05 와 **같은 부품**(`createEditOverlay`)이다(2026-09-12 사용자:
// B05·B06 은 한 페이지인데 B06 에만 버튼이 없었다). 누른 값은 B05 와 같은 세션 초안에
// 쌓이고 [저장]·[확정]에서 종단 정본으로 나간다.
if (input.grade) {
const editLayer = createEditOverlay({
alignment: input.grade.alignment,
width: chartWidth,
x: chart.toX,
step: input.grade.stepM,
onStation: input.grade.onStation,
});
// 버튼층은 `inset: 0` 으로 부모를 꽉 채운다 — B05 는 부모가 그래프뿐이지만 B06 은
// **알약 레인도 같은 칸 안**에 있어, 그대로 두면 ▼ 가 레인 위로 밀려난다(실측:
// 그래프 바닥 231px 인데 버튼이 270px). 그래프 높이로 잘라 B05 와 같은 자리에 세운다.
editLayer.style.height = `${input.chartHeight}px`;
editLayer.style.bottom = "auto";
chartWrap.append(editLayer);
}
// 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군
// → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다).
if (edit) {
mountSectionStructureMenu(chartWrap, {
structures,
types,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
edit,
});
}
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
if (input.conversion) {
const series = computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
input.conversion,
input.naturalSpoilSlope,
);
const cross = series.find((entry) => entry.basis === "cross") ?? series[0];
input.setMassBadge(cross ? badgeValuesFrom(cross.result) : null);
} else {
input.setMassBadge(null);
}
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
if (chart.axis) {
const overlay = buildStickyYAxis(chart.axis, input.chartHeight);
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
chartWrap.prepend(overlay);
}
chartWrap.scrollLeft = input.keepScrollLeft;
// 세로 창 갱신기 — 스크롤마다 종단을 변환으로 따라오게 한다(다시 그리지 않는다).
return () => {
const { fromM, toM } = visibleChainageRange(
chart.toChainage,
chart.maxChainageM,
chartWrap.scrollLeft,
chartWrap.clientWidth || chartWidth,
);
const next = visibleElevationRange(detail, fromM, toM);
return applyElevationWindow(
chartWrap,
input.holdRange ? input.holdRange(next) : (next ?? undefined),
);
};
}
/** 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
/**
* ** ** ( ).
*
* ****(2026-09-04 , B05 ).
* .
*/
export function attachWindowScroll(
chartWrap: HTMLElement,
updater: () => (() => ElevationWindowResult | null) | null,
redraw: () => void,
): void {
let settledScrollLeft = 0;
let timer = 0;
chartWrap.addEventListener("scroll", () => {
const fitted = updater()?.() ?? null;
window.clearTimeout(timer);
if (!needsFullRedraw(fitted)) return;
timer = window.setTimeout(() => {
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = chartWrap.scrollLeft;
redraw();
}, SCROLL_SETTLE_MS);
});
}
/**
* ** **(B05 ).
*
* ,
* (2026-09-12 : ).
* .
*/
export function createElevationHold(
chartWrap: HTMLElement,
redraw: () => void,
): (next: { min: number; max: number } | null) => { min: number; max: number } | undefined {
let editing = false;
let held: { min: number; max: number } | undefined;
chartWrap.addEventListener("pointerdown", (event) => {
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
editing = true;
const release = (): void => {
editing = false;
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
redraw();
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
});
return (next) => {
if (editing) return held;
held = next ?? undefined;
return held;
};
}
@@ -0,0 +1,130 @@
/* =============================================================================
* B06_Section_UI_Section_View_Menu.ts
* B06 ** ** B05 (`mountStructureMenu`) .
*
* (2026-09-12 ) B05·B06 .
* .
*
* ·· ** ** []·[]
* (`pipe_points.json`) (CLAUDE.md 5).
* .
* ========================================================================== */
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */
export interface SectionStructureEdit {
/** 관(계곡 통과 시설)을 그 자리에 넣는다. */
addPipe: (chainageM: number) => void;
/** 관을 뺀다. */
removePipe: (chainageM: number) => void;
/** 레지스트리 종류를 골라 구조물을 넣는다(좌측 「구조물 배치」와 같은 2단 메뉴). */
addStructureType: (chainageM: number, typeId: string) => void;
/** 구조물(관 아님)을 뺀다. */
removeStructure: (structureId: string) => void;
/** 관을 다른 측점으로 옮긴다(측점선·알약 끌기). */
movePipe: (fromChainageM: number, toChainageM: number) => void;
/** 구조물(관 아님)을 다른 측점으로 옮긴다. */
moveStructure: (structureId: string, toChainageM: number) => void;
}
/**
* · id .
* id ** id** ( id) .
* () .
*/
export function moveMarkById(
markId: string,
toChainageM: number,
input: {
structures: ReadonlyArray<StructureInstance>;
stations: ReadonlyArray<{ station_id: string; chainage_m: number }>;
edit: SectionStructureEdit;
},
): void {
const direct = input.structures.find((entry) => String(entry.structure_id) === markId);
if (direct) {
moveStructureMark(input.structures, input.edit, markId, toChainageM);
return;
}
const station = input.stations.find((entry) => String(entry.station_id) === markId);
if (!station) return;
// 측점선 id 로 온 경우 — 그 측점 자리에 선 구조물을 찾는다(관 매칭과 같은 0.51m).
const near = input.structures.find(
(entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < 0.51,
);
if (near) moveStructureMark(input.structures, input.edit, String(near.structure_id), toChainageM);
}
/** 측점선·알약을 끌었을 때 — 관인지 구조물인지 갈라 같은 예약 경로로 보낸다. */
function moveStructureMark(
structures: ReadonlyArray<StructureInstance>,
edit: SectionStructureEdit,
structureId: string,
toChainageM: number,
): void {
const hit = structures.find((entry) => String(entry.structure_id) === structureId);
if (!hit) return;
const from = structureAnchorM(hit);
if (Math.abs(from - toChainageM) < 0.005) return;
if (isPipeMark(hit)) edit.movePipe(from, toChainageM);
else edit.moveStructure(structureId, toChainageM);
}
/** 알약·우클릭이 쓰는 「관인가」 판정 — 관은 정본이 달라 지우는 길도 다르다. */
export function isPipeMark(structure: StructureInstance): boolean {
return String(structure.structure_id ?? "").startsWith("pipe-");
}
/**
* .
* (B05 ).
*/
export function mountSectionStructureMenu(
host: HTMLElement,
input: {
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
x: (chainageM: number) => number;
chainageAt: (px: number) => number;
maxChainageM: number;
edit: SectionStructureEdit;
},
): void {
// 메뉴가 쓰는 최소 정보만 옮겨 담는다 — B06 에는 B05 의 비정규 측점 목록이 없다.
const stations: IrregularStation[] = input.structures.map((structure) => {
const chainage = structureAnchorM(structure);
const pipe = isPipeMark(structure);
return {
id: String(structure.structure_id),
station: 0,
remainder: 0,
chainage_m: chainage,
structure:
input.types.find((type) => type.type_id === structure.type_id)?.name ??
String(structure.type_id),
origin: pipe ? "pipe" : "user",
} as IrregularStation;
});
mountStructureMenu(host, {
stations,
x: input.x,
chainageAt: input.chainageAt,
maxChainageM: input.maxChainageM,
onRemove: (station) => {
if (station.origin === "pipe") input.edit.removePipe(station.chainage_m);
else input.edit.removeStructure(station.id);
},
onAddPipe: (chainageM) => input.edit.addPipe(chainageM),
structureTypes: input.types.map((type) => ({
type_id: type.type_id,
group: type.group,
name: type.name,
})),
onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId),
});
}
@@ -152,6 +152,14 @@
stroke-width: 1.2;
}
/* 노견 바깥 (2026-09-12) 차도 틱보다 짧고 옅다. 차도 틱과 사이가 노견이라,
노면이 넓어졌을 차도가 늘었는지 노견이 늘었는지 눈으로 바로 갈린다. */
.b06-chart__shoulder-tick {
stroke: var(--color-royal-amethyst);
stroke-width: 1;
opacity: 0.55;
}
/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */
.b06-chart__carriageway-label {
fill: var(--color-royal-amethyst);
@@ -4,11 +4,14 @@
* 넘칠 만큼 길어지면 안에서 스크롤한다. z-index는 카드 오버레이 최상단. */
.b06-structure-panel {
position: absolute;
top: var(--spacing-8);
/* 좌측 상단에는 ·성토 면적표가 이미 있다 조정창을 같은 자리에 두면 표를 덮어
수치가 보인다(2026-09-12 사용자). 높이(머리글+절토+성토 )만큼 내려 시작한다.
아래를 기준으로 붙이지 않는 까닭은 종전 그대로다 낮은 횡단도에서 창이 잘린다. */
top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));
left: var(--spacing-8);
z-index: 20;
display: flex;
max-height: calc(100% - 2 * var(--spacing-8));
max-height: calc(100% - 2 * var(--spacing-8) - var(--b06-areas-height, 3.6rem));
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
+2 -48
View File
@@ -15,7 +15,6 @@ export interface DesignDrawingItem {
| "landuse"
| "plan_lidar"
| "cross_standard"
| "standard"
| "blank";
label: string;
chainage_m: number | null;
@@ -103,7 +102,6 @@ export interface DesignDrawingResponse {
| "landuse"
| "plan_lidar"
| "cross_standard"
| "standard"
| "blank";
label: string;
drawing: CadDrawing;
@@ -246,49 +244,5 @@ export function resetFrameTemplate(projectId: string): Promise<void> {
});
}
/** 표준도 장 목록 — 제원 입력 칸이 쓰는 것만 추린 꼴. */
export interface StandardSheetsResponse {
status: string;
sheet_count: number;
structure_count: number;
sheets: {
key: string;
title: string;
type_id: string;
member_count: number;
options: Record<string, unknown>;
}[];
}
export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> {
return requestJson(`/projects/${projectId}/standard-sheets`);
}
/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */
export function putStandardSheetSpec(
projectId: string,
body: {
sheet_key: string;
base_revision: number;
stone_kind: string | null;
stone_supply: string | null;
back_len_cm: string | null;
face_slope_ratio: string | null;
foundation: string | null;
stone_coeff_basis: string | null;
fill_concrete_mpa: string | null;
thickness_top_m: string | null;
thickness_bottom_m: string | null;
},
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> {
return requestJson(`/projects/${projectId}/standard-sheets/spec`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** 구조물 정본 판번호 — 제원을 저장할 때 함께 보내야 다른 창 덮어쓰기를 막는다. */
export function fetchStructureRevision(projectId: string): Promise<{ revision: number }> {
return requestJson(`/projects/${projectId}/route/structures`);
}
// 표준도(구조물도) 장 목록·제원 저장 창구는 2026-09-13 B08 「구조물도」 탭으로 이관
// (`B08_Quantity_UI_StructureSheet.ts`).
@@ -1,308 +0,0 @@
"""표준도 **위쪽 그림** — 돌쌓기 단면(2단계, 2026-09-09).
실무 원본 탭이 치수조서 + 아래 수량산출서인데, 아래 표는 1단계에서 섰고 여기가 위다.
**기울기를 값으로 받는다 0.3 박지 않는다.**
판정은 **B08 `face_slope_ratio()` ** 그대로 부른다(품셈 13-4-4 [] 표준경사
직고·메찰·성절토). 사용자가 정했으면 값이 이긴다(확정 ). 그림은 **수량이 바로
**으로 기울고, 근거 문구도 같이 받아 그림에 적는다 판정을 벌로 짜면 그림과
수량이 갈린다.
**수량이 쓰는 상수로 그린다** `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY`
직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5).
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H 1.0)
터파기 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
터파기 치수는 **`common_util_excavation` ** 읽는다 횡단도가 쓰는 상수다.
여기서 다시 적으면 도면이 다른 터파기를 그린다.
**뒷길이가 두께에 들어간다**(확정 2 , 실무 구조물도 ). 뒷길이가 다르면 벽이
두꺼워지고 그림도 그만큼 넓어진다 예전 (0.45+0.10H / 0.45+0.40H) 뒷길이를
아예 봐서 35 45 같은 그림이 나왔다.
뒷길이를 정한 장은 두께를 낸다 그때는 **그리지 않는다**(0 으로 때우면
거짓 그림이 된다).
**치수가 없는 것은 그리되 치수를 적지 않는다** 막자갈(뒷채움) 우리 식이 입적에서
몸통·고임돌을 이라 ·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
없는 치수를 그림에 적으면 **표와 다른 번째 정본** 생긴다.
"""
from __future__ import annotations
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_line_entity,
_text_entity,
polyline_entity,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY, face_slope_ratio
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
)
#: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다.
SCALE_MM_PER_M = 40.0
#: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다.
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 여기 있었지만 그림이 두께를
#: **뒷길이**에서 내는데 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그렸다** —
#: 목록에만 있고 결과는 늘 빈 그림이었다. 품셈 **13-6(직경) ↔ 13-4(뒷길이)** 로 축이
#: 갈리는 자리이고 수량 쪽에서 이미 갈라 둔 그것이다(`EXPANDERS` 주석).
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
#: **전면 기울기 판정** 대상 — 그림 대상과 **다르다**. 큰돌쌓기는 그림은 못 그려도
#: 기울기는 판정된다(품셈 13-6 「1:0.3 이상」). 한 목록으로 묶어 두었더니 큰돌쌓기를
#: 그림에서 뺄 때 **장 제목의 「1:0.3」까지 사라졌다**(2026-09-09 화면 실측에서 잡음).
SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = {
"boulder_masonry": (
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
),
"retaining_wall": "그림 없음 — 옹벽은 단면이 달라 아직 그리지 않습니다(표준도 3단계)",
}
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
def figure_reason(sheet: dict[str, Any]) -> str | None:
"""그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`.
무엇을 받아야 서는지까지 적는다 뒷길이만 고르면 서는 장과, 자체가 없는 장은
사용자가 일이 다르다.
"""
type_id = str(sheet.get("type_id") or "")
if type_id not in FIGURE_TYPE_IDS:
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
if float(sheet.get("height_m") or 0.0) <= 0:
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
if back_length_cm(sheet) is None:
return "그림 없음 — **뒷길이를 고르면 그림이 섭니다**(두께가 뒷길이에서 나옵니다)"
return None
_LABEL_FONT = 4.0
_DIM_FONT = 3.4
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
/찰은 종류에서 온다. 큰돌쌓기는 `bond` (메쌓기/찰쌓기) 그것이고, 고르면
찰쌓기로 본다 품셈 13-6 1:0.3 **이상**이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
wet = False
elif type_id == "boulder_masonry":
wet = options.get("bond") != "메쌓기"
else:
wet = True
return face_slope_ratio(
options,
wet=wet,
height_m=float(sheet.get("height_m") or 0.0),
# ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아
# 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측).
face=sheet.get("face"),
face_reason=str(sheet.get("face_reason") or ""),
)
def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"]
bottom_t = top_t + STONE_MASONRY["thickness_slope_per_m"] * max(
height_m - STONE_MASONRY["thickness_height_base_m"], 0.0
)
return top_t, bottom_t
def back_length_cm(sheet: dict[str, Any]) -> float | None:
"""장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 두께를 못 내므로 그리지 않는다."""
options = sheet.get("options") or {}
raw = options.get("back_len_cm") or options.get("stone_back_length_cm")
try:
return float(raw) if raw not in (None, "") else None
except (TypeError, ValueError):
return None
def section_points(
height_m: float, slope_ratio: float, back_cm: float
) -> list[tuple[float, float]]:
"""벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다."""
top_t, bottom_t = wall_thickness(height_m, back_cm)
lean = slope_ratio * height_m
return [
(0.0, 0.0),
(lean, height_m),
(lean + top_t, height_m),
(bottom_t, 0.0),
(0.0, 0.0),
]
def build_figure(
drawing_id: str,
sheet: dict[str, Any],
layer_id: str,
origin: tuple[float, float],
line_color: str,
label_color: str,
guide_color: str,
) -> tuple[list[dict[str, Any]], float]:
"""장 하나의 그림. `(엔티티, 그림이 차지한 높이 ㎜)` — 못 그리면 `([], 0)`."""
if str(sheet.get("type_id") or "") not in FIGURE_TYPE_IDS:
return [], 0.0
height_m = float(sheet.get("height_m") or 0.0)
if height_m <= 0:
return [], 0.0
back_cm = back_length_cm(sheet)
if back_cm is None:
# 뒷길이가 없으면 두께를 못 낸다 — 0 으로 때우지 않고 그리지 않는다.
return [], 0.0
slope, slope_note = slope_of(sheet)
scale = SCALE_MM_PER_M
ox, oy = origin
def mm(point: tuple[float, float]) -> tuple[float, float]:
return (ox + point[0] * scale, oy + point[1] * scale)
points = section_points(height_m, slope, back_cm)
top_t, bottom_t = wall_thickness(height_m, back_cm)
average_t = (top_t + bottom_t) / 2.0
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목).
foundation = str((sheet.get("options") or {}).get("foundation") or "")
if foundation == "기초유":
base_w, base_d = WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M
elif foundation == "기초버림":
base_w, base_d = WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M
else:
base_w = base_d = 0.0 # 안 정한 장은 기초를 안 그린다 — 지어내지 않는다.
entities: list[dict[str, Any]] = []
wall = polyline_entity(
f"{drawing_id}:fig:wall", [mm(p) for p in points], layer_id, line_color, width=2
)
if wall is not None:
entities.append(wall)
# 터파기 — 우리 수량식(평균두께+0.2) × 높이 를 **그대로** 그린다. 점선.
dig = polyline_entity(
f"{drawing_id}:fig:dig",
[mm(p) for p in ((0.0, 0.0), (0.0, height_m), (dig_width, height_m), (dig_width, 0.0))],
layer_id,
guide_color,
dash=[4, 3],
)
if dig is not None:
entities.append(dig)
# 기초 — 벽 밑에 놓이는 칸. 「안 정함」이면 안 그린다.
if base_d > 0:
base = polyline_entity(
f"{drawing_id}:fig:base",
[mm(p) for p in ((0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0))],
layer_id,
guide_color,
)
if base is not None:
entities.append(base)
# 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀).
weep_y = height_m * 0.5
entities.append(
_line_entity(
f"{drawing_id}:fig:weep",
mm((slope * weep_y, weep_y)),
mm((slope * weep_y + top_t, weep_y)),
layer_id,
guide_color,
)
)
labels: list[tuple[str, tuple[float, float], str]] = [
(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
(f"상부 {top_t:.2f} m", (slope * height_m + top_t / 2.0, height_m + 0.14), "center"),
(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30), "center"),
(f"1 : {slope:g}", (slope * height_m / 2.0 - 0.30, height_m * 0.72), "right"),
(
f"터파기 폭 {dig_width:.2f} m (평균두께 {average_t:.2f} + 0.20)",
(dig_width + 0.16, height_m * 0.92),
"left",
),
(
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
if base_d > 0
else "기초 — 안 정함(기초유/기초버림)",
(dig_width + 0.16, -base_d / 2.0 if base_d else 0.0),
"left",
),
(
f"물구멍 — {STONE_MASONRY['weep_hole_area_m2']:g}㎡당 1개소",
(slope * weep_y + top_t + 0.16, weep_y),
"left",
),
("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"),
]
labels.append((f"뒷길이 ℓ₃ = {int(back_cm)}", (dig_width + 0.16, height_m * 0.72), "left"))
for index, (text, point, align) in enumerate(labels):
x, y = mm(point)
entities.append(
_text_entity(
f"{drawing_id}:fig:label:{index}",
text,
x,
y,
layer_id,
_DIM_FONT,
label_color,
align,
)
)
# 기울기 근거는 **그림 밑에** 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
note_x, note_y = mm((0.0, -0.62))
entities.append(
_text_entity(
f"{drawing_id}:fig:slopenote",
f"전면 기울기 — {slope_note}",
note_x,
note_y,
layer_id,
_DIM_FONT,
label_color,
"left",
)
)
title_x, title_y = mm((0.0, height_m + 0.52))
entities.append(
_text_entity(
f"{drawing_id}:fig:title",
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
title_x,
title_y,
layer_id,
_LABEL_FONT,
label_color,
"left",
)
)
used_mm = (height_m + 0.95 + base_d) * scale
return entities, used_mm
@@ -10,44 +10,37 @@
계획평면도가 쓰는 나눔 본을 따라야 하고 목록·라우터 곳이 함께 움직여야 한다().
지금은 **값이 눈에 보이는 ** 먼저라 장에 쌓고, 나눔은 다음이다.
값을 여기서 셈하지 않는다 `_Engine_Standard_Sheet.build_standard_sheets` 것을
값을 여기서 셈하지 않는다 `B08_Quantity_Engine_StructureSheet.build_standard_sheets` 것을
글자로 옮길 뿐이다. 셈이 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5).
**2026-09-13 B07 도면 목록에서 빠짐**(사용자 확정 구조물도는 B08 , PLAN 3 ).
목록·라우터 배선은 걷어냈고 ·그림 조립만 남김 B08 구조물도 상단 그림 일감이 다시 자리.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
_text_entity,
TABLE_LABEL_COLOR,
TABLE_LINE_COLOR,
TABLE_VALUE_COLOR,
_format,
_layer,
_text_entity,
polyline_entity,
table_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure, figure_reason
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
logger = logging.getLogger(__name__)
from B08_Quantity.B08_Quantity_Engine_StructureFigure import build_figure as figure_shapes
from B08_Quantity.B08_Quantity_Engine_StructureFigure import figure_reason
STANDARD_LAYER_ID = "standard"
#: 장 하나가 도면 하나 — 계획평면도와 같은 본을 따른다(`plan_lidar` / `plan_lidar_2`).
SHEET_ID_PREFIX = "standard_sheet"
def sheet_drawing_id(number: int, total: int) -> str:
"""장이 하나면 `standard_sheet`, 여럿이면 `standard_sheet_2` 처럼 번호를 붙인다."""
return SHEET_ID_PREFIX if total <= 1 and number <= 1 else f"{SHEET_ID_PREFIX}_{number}"
# 종이 밀리미터. 실무 시트가 「공종 | 산출근거 | 수량 | 단위」 넉 줄이라 그대로 간다.
# ⚠ **산출근거 칸을 넓게 잡는다** — 처음 176㎜ 로 냈더니 물구멍관·버림콘크리트처럼 단서가
@@ -75,6 +68,44 @@ def _figure_height(sheet: dict[str, Any]) -> float:
return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0
def build_figure(
drawing_id: str, sheet: dict[str, Any], origin: tuple[float, float]
) -> tuple[list[dict[str, Any]], float]:
"""B08 그림 모양 목록(m)을 CAD 엔티티(종이 ㎜)로. `(엔티티, 높이 ㎜)` — 못 그리면 `([], 0)`."""
shapes = figure_shapes(sheet) or []
ox, oy = origin
scale = FIGURE_SCALE_MM_PER_M
entities: list[dict[str, Any]] = []
for index, shape in enumerate(shapes):
x, y = (shape.get("at") or [0.0, 0.0])[:2]
if shape["kind"] == "text":
entities.append(
_text_entity(
f"{drawing_id}:fig:{index}",
shape["text"],
ox + x * scale,
oy + y * scale,
STANDARD_LAYER_ID,
shape["size"] * scale,
TABLE_LABEL_COLOR,
shape["align"],
)
)
continue
poly = polyline_entity(
f"{drawing_id}:fig:{index}",
[(ox + px * scale, oy + py * scale) for px, py in shape["points"]],
STANDARD_LAYER_ID,
TABLE_LINE_COLOR if shape["role"] == "wall" else TABLE_VALUE_COLOR,
dash=[4, 3] if shape["dash"] else None,
width=2 if shape["role"] == "wall" else 1,
)
if poly is not None:
entities.append(poly)
ys = [point[1] for shape in shapes for point in shape.get("points") or [shape["at"]]]
return entities, ((max(ys) - min(ys)) * scale if ys else 0.0)
def _cell(
text: str, color: str, *, align: str = "left", col_span: int = 1, bold: bool = False
) -> dict[str, Any]:
@@ -175,11 +206,7 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
figure, used = build_figure(
drawing_id,
sheet,
STANDARD_LAYER_ID,
(x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)),
TABLE_LINE_COLOR,
TABLE_LABEL_COLOR,
TABLE_VALUE_COLOR,
)
if figure:
entities.extend(figure)
@@ -223,69 +250,3 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
"entities": entities,
"layers": [_layer(STANDARD_LAYER_ID, "표준도")],
}
def standard_payload(
project_root: Path, section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""프로젝트 구조물을 제원 조합으로 묶은 장 목록. 실패해도 **빈 목록**을 낸다.
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
try:
structures, names, _skipped = _collect_structures(str(project_root))
# ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이
# 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측).
return build_standard_sheets(
build_unit_table(structures, names, section_modes), section_modes
)
except Exception:
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
return {"sheets": [], "structure_count": 0}
def sheet_items(
project_root: Path, section_modes: dict[float, str] | None = None
) -> list[tuple[str, str]]:
"""좌측 목록에 설 `(도면 id, 이름)`. 장이 없으면 **빈 장 하나**를 남긴다.
구조물이 없다고 단추가 통째로 사라지면 없어진 처럼 보인다 눌러서 사유를 읽게 한다.
"""
sheets = standard_payload(project_root, section_modes).get("sheets") or []
if not sheets:
return [(SHEET_ID_PREFIX, "표준도")]
total = len(sheets)
return [
(sheet_drawing_id(index, total), f"표준도 {index}장 ({sheet.get('title') or ''})".strip())
for index, sheet in enumerate(sheets, start=1)
]
def standard_drawing_for(
project_root: Path,
drawing_id: str,
label: str,
section_modes: dict[float, str] | None = None,
) -> dict[str, Any]:
"""프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문.
실패해도 도면은 연다 구조물 정본을 읽었다고 화면이 비면 사용자는 고장으로
읽는다. 그때는 대신 사유가 적힌 줄이 뜬다.
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
payload = standard_payload(project_root, section_modes)
sheets = payload.get("sheets") or []
# 이 도면 id 가 가리키는 장 하나만 남긴다 — 한 면에 한 장(2026-09-09, 그림이 붙으면서).
total = len(sheets)
picked = [
sheet
for index, sheet in enumerate(sheets, start=1)
if sheet_drawing_id(index, total) == drawing_id
]
return build_standard_drawing(drawing_id, label, {**payload, "sheets": picked})
+1 -6
View File
@@ -31,7 +31,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
use_company_templates,
use_title_fields,
)
from B07_DesignDetail.B07_DesignDetail_Router_Standard import section_modes_of
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
LANDUSE_ID,
@@ -271,10 +270,7 @@ async def get_design_drawing_list(
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
modes = await section_modes_of(route_id)
drawings = await asyncio.to_thread(
_drawing_list, project_root, longitudinal_path, designs, modes
)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
return DesignDrawingListResponse(
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
)
@@ -372,7 +368,6 @@ async def get_design_drawing(
longitudinal_path,
drawing_id,
source_design,
await section_modes_of(route_id),
)
return DesignDrawingResponse(
project_id=str(project_id),
@@ -1,195 +0,0 @@
"""B07 표준도(구조물도) 라우터 — 장 목록 조회와 **제원 입력**.
표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` ( 종류·조달·뒷길이·
전면 기울기) 그리는 화면이 없어(2026-09-09 실측) 자리를 여기가 맡는다.
** 하나 = 제원 조합 하나** 고치면 조합의 개소 전부에 걸린다.
값을 여기서 셈하지 않는다 정본(`structures.json`) 적기만 하고 ·그림은 다음 조회에서
정본으로 다시 선다. 표준도가 번째 정본이 되면 된다(CLAUDE.md 5).
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
async def section_modes_of_project(project_id: UUID) -> dict[float, str]:
"""프로젝트에서 노선을 찾아 단면유형 표를 낸다 — 노선을 모르면 빈 표."""
from B06_Section.B06_Section_Repository import get_workflow_route_context
from config.config_db import run_with_connection
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
except Exception:
logger.exception("B07 노선 조회 실패: project_id=%s", project_id)
return {}
return await section_modes_of(route_id) if route_id else {}
async def section_modes_of(route_id: int) -> dict[float, str]:
"""측점별 단면유형(`left_cut` 등) — 구조물이 **성토면인가 절토면인가**를 가르는 근거.
이것을 넘기면 판정이 통째로 가를 근거 없음으로 떨어져 ** 구조물이 종전값
1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 ** 넘긴 **이었다.
표를 만드는 셈은 `section_modes_from_designs` 벌을 쓴다 부르는 쪽마다 다시
짜면 B08 갈린다.
"""
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs
from config.config_db import run_with_connection
try:
designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception:
logger.exception("B07 단면유형 조회 실패: route_id=%s", route_id)
return {}
return section_modes_from_designs(designs)
class StandardSheetSpecRequest(BaseModel):
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
base_revision: int = Field(ge=0)
stone_kind: str | None = None
stone_supply: str | None = None
back_len_cm: int | str | None = None
face_slope_ratio: float | str | None = None
foundation: str | None = None
stone_coeff_basis: str | None = None
fill_concrete_mpa: str | None = None
thickness_top_m: float | str | None = None
thickness_bottom_m: float | str | None = None
@router.put("/{project_id}/standard-sheets/spec")
async def put_standard_sheet_spec(
project_id: UUID, payload: StandardSheetSpecRequest
) -> JSONResponse:
"""장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다.
값을 여기서 셈하지 않는다 정본(`structures.json`) 적기만 하고, ·그림은 다음
조회에서 정본으로 다시 선다. 표준도가 번째 정본이 되면 된다(CLAUDE.md 5).
"""
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_payload
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import (
apply_spec,
clean_spec,
drop_unregistered,
)
try:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path)).resolve()
except Exception:
logger.exception("B07 표준도 제원 저장 실패(경로): project_id=%s", project_id)
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
modes = await section_modes_of_project(project_id)
payload_sheets = await asyncio.to_thread(standard_payload, project_root, modes)
sheets = payload_sheets.get("sheets") or []
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
if picked is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "그 표준도 장을 찾지 못했습니다."},
)
member_ids = {
str(m.get("structure_id")) for m in picked.get("members") or [] if m.get("structure_id")
}
spec, notes = clean_spec(payload.model_dump(exclude={"sheet_key", "base_revision"}))
# ⚠ 등록부에 없는 칸은 저장소가 거절한다 — 한 칸 때문에 **전부** 못 저장되지 않게 거른다.
type_id = str(picked.get("type_id") or "")
definition = structure_type_map().get(type_id)
allowed = {field.key for field in definition.options} if definition else set()
spec, missing = drop_unregistered(type_id, spec, allowed)
notes.extend(missing)
try:
revision, stored = await asyncio.to_thread(load_structures, str(project_root))
updated, changed = apply_spec(stored, member_ids, spec)
new_revision = await asyncio.to_thread(
save_structures, str(project_root), updated, base_revision=payload.base_revision
)
except Exception as exc:
logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=409,
content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"},
)
return JSONResponse(
content={
"status": "success",
"project_id": str(project_id),
"revision": new_revision,
"previous_revision": revision,
"changed": changed,
# 범위 밖 값·표에 없는 규격은 **막지 않고 알린다**(실무에 1:0.7 이 실재).
"notes": notes,
}
)
@router.get("/{project_id}/standard-sheets")
async def get_standard_sheets(project_id: UUID) -> JSONResponse:
"""표준도(구조물도) **장 목록 + 하단표**.
수량을 여기서 새로 셈하지 않는다 B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고
단위당으로 접기만** 한다(계산 자리는 , CLAUDE.md 5).
"""
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
try:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = str(Path(resolve_stored_project_path(stored_path)).resolve())
except Exception:
logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id)
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
modes = await section_modes_of_project(project_id)
try:
structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root)
unit_table = await asyncio.to_thread(build_unit_table, structures, names, modes)
except Exception:
logger.exception("B07 표준도 전개 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
)
payload = build_standard_sheets(unit_table, modes)
payload["status"] = "success"
payload["project_id"] = str(project_id)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return JSONResponse(content=payload)
@@ -48,11 +48,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import (
STANDARD_LABEL,
build_standard_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import (
SHEET_ID_PREFIX,
sheet_items,
standard_drawing_for,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
QUANTITY_VALUE_KEYS,
)
@@ -113,7 +108,6 @@ def _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
section_modes: dict[float, str] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
@@ -205,10 +199,7 @@ def _drawing_list(
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
)
)
# 표준도 — **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09). 구조물이 없어도 한 장은
# 남긴다: 단추가 사라지면 「없어진 것」처럼 보이고 사유를 읽을 자리도 없어진다.
for drawing_id, label in sheet_items(project_root, section_modes):
drawings.append(DesignDrawingItem(id=drawing_id, kind="standard", label=label))
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④.
_store_drawing_numbers(project_root, drawings)
return drawings
@@ -410,7 +401,6 @@ def _read_drawing(
longitudinal_path: Path,
drawing_id: str,
stored_design: dict[str, Any] | None = None,
section_modes: dict[float, str] | None = None,
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
@@ -442,11 +432,6 @@ def _read_drawing(
stored_table = manifest_entry.get("quantity_table")
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
return kind, label, saved, True, table
if drawing_id == SHEET_ID_PREFIX or drawing_id.startswith(f"{SHEET_ID_PREFIX}_"):
# 표준도 — 제원 조합 한 벌이 한 장. 도각은 두르지 않는다(2026-09-08 사용자 지시).
label = dict(sheet_items(project_root, section_modes)).get(drawing_id, "표준도")
drawing = standard_drawing_for(project_root, drawing_id, label, section_modes)
return "standard", label, drawing, False, None
if drawing_id == COVER_ID:
# 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다.
+2 -4
View File
@@ -20,8 +20,7 @@ class DesignDrawingItem(BaseModel):
"landuse",
"plan_lidar",
"cross_standard",
# standard: 표준도(구조물도) — 제원 조합 하나가 한 장.
"standard",
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌.
"blank",
]
label: str
@@ -59,8 +58,7 @@ class DesignDrawingResponse(BaseModel):
"landuse",
"plan_lidar",
"cross_standard",
# standard: 표준도(구조물도) — 제원 조합 하나가 한 장.
"standard",
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌.
"blank",
]
label: str

Some files were not shown because too many files have changed in this diff Show More