diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index a4c7eb77..58827809 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -31,6 +31,7 @@ import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; +import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; import { mountDrainageToggles, @@ -121,6 +122,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let selectedBasin: number | null = null; // 측점 선택 마킹(계획선 위 누가거리). 유역이 없는 구조물 측점도 위치를 보여 준다. let markedChainage: number | null = null; + // 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 밖(종단 패널)이 + // 구조물 목록을 받을 때마다 넣어 준다. 여기서는 그리기만 하고 판정하지 않는다. + let intervalSpans: ReadonlyArray = []; // 마지막으로 밖에 알린 관 선택(누가거리) — 같은 값 재알림을 막는다. let lastNotifiedPipeChainage: number | null = null; // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. @@ -255,6 +259,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeColor, markedChainage, stationIntervalM: MAP_STATION_INTERVAL_M, + intervalSpans, }); updateImageTransform(); } @@ -590,6 +595,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage = chainageM; scheduleDraw(); }, + setIntervalSpans(spans) { + intervalSpans = spans; + scheduleDraw(); + }, addPipe(chainageM, attributes) { // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. facilityStore.set(chainageM, attributes ?? null); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index f50a2375..8474aa0f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -11,6 +11,7 @@ import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; export interface DrainagePanel { root: HTMLElement; @@ -44,6 +45,9 @@ export interface DrainagePanel { selectPipeAtChainage: (chainageM: number | null) => void; /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ markStation: (chainageM: number | null) => void; + /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). + * 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */ + setIntervalSpans: (spans: ReadonlyArray) => void; dispose: () => void; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index 847909e3..e5c92004 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -39,6 +39,7 @@ import { type DrainageLayer, } from "./B05_Profile_UI_Drainage_Parts"; import type { PipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; +import { drawRouteSpans, type RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; export interface DrainageScene { meta: VWorldMeta | null; @@ -67,6 +68,8 @@ export interface DrainageScene { markedChainage: number | null; /** 규칙 측점 간격(m) — 눈금·번호 표기 기준 (2026-09-04 사용자 지시). */ stationIntervalM: number; + /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */ + intervalSpans: ReadonlyArray; } export function drawDrainageScene( @@ -134,6 +137,10 @@ export function drawDrainageScene( return; } const projector = createMetricProjector(meta, view); + // 구간형 구조물 띠 — 계획선 바로 위, 강도 색칠 아래. 종단 레인의 띠와 같은 뜻이다. + if (scene.intervalSpans.length > 0) { + drawRouteSpans(context, scene.strengthSamples, scene.intervalSpans, projector.toScreen); + } // 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다. if (scene.showStrength && scene.strength.length > 0) { drawStrengthLine(context, scene.strengthSamples, scene.strength, scene.maxStrength, (point) => diff --git a/B05_Profile/B05_Profile_UI_Drainage_Spans.ts b/B05_Profile/B05_Profile_UI_Drainage_Spans.ts new file mode 100644 index 00000000..3b86acf9 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Spans.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Spans.ts + * 배수유역도(평면)에서 **구간형 구조물이 놓인 자리**를 계획선 위에 띠로 그린다. + * + * 왜 (계획서 3-6) — 산마루측구·도수로·옹벽처럼 **구간**으로 놓이는 시설은 종단에는 띠로 + * 보이는데 평면에는 아무 표시가 없었다. 「노선 위에 임의 구간을 얹는 부품이 없다」가 + * 그동안의 걸림돌이었는데, 계획선 표본(`strengthSamples`)이 **1m 간격이라 배열 인덱스가 + * 곧 누가거리**여서 구간 → 화면 선은 잘라 붙이기만 하면 된다. + * + * 그리는 자리 — **계획선 바로 위, 강도 색칠·마커 아래**. 굵고 반투명해서 계획선을 덮지 + * 않는다. 이름은 안 적는다(종단에 이미 있고, 지도에 글자를 얹으면 눈금·유역 번호와 겹친다). + * ========================================================================== */ + +import type { RoutePoint } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; + +/** 계획선 위에 얹을 구간 하나 — 누가거리 두 값과 색. */ +export interface RouteSpanBand { + startM: number; + endM: number; + /** 구조물 레지스트리의 표시색(`style.color`). */ + color: string; +} + +/** 띠 굵기(px) — 계획선(2px 안팎)보다 확실히 굵되 유역 채움을 가리지 않는 값. */ +const BAND_WIDTH_PX = 7; + +/** + * 구간 띠를 그린다. 표본이 없거나 구간이 비면 아무 것도 하지 않는다. + * + * 인덱스는 **누가거리(m)** 다 — 표본이 1m 간격이라 그렇다(`resampleRoute`). 범위를 벗어난 + * 값은 잘라 쓰고, 한 점짜리 구간(시작=끝)은 짧은 토막으로라도 보이게 한 칸을 준다. + */ +export function drawRouteSpans( + context: CanvasRenderingContext2D, + samples: ReadonlyArray, + spans: ReadonlyArray, + toScreen: (x: number, y: number) => [number, number], +): void { + if (samples.length < 2 || spans.length === 0) return; + const last = samples.length - 1; + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = BAND_WIDTH_PX; + for (const span of spans) { + const from = Math.max(0, Math.min(last, Math.floor(Math.min(span.startM, span.endM)))); + const to = Math.max(from + 1, Math.min(last, Math.ceil(Math.max(span.startM, span.endM)))); + context.beginPath(); + for (let index = from; index <= to; index += 1) { + const [x, y] = toScreen(samples[index].x, samples[index].y); + if (index === from) context.moveTo(x, y); + else context.lineTo(x, y); + } + context.strokeStyle = span.color; + context.globalAlpha = 0.45; + context.stroke(); + context.globalAlpha = 1; + } + context.restore(); +} + +/** + * 구조물 정본 + 타입 레지스트리 → 띠 목록. **종단 레인과 같은 자료**를 본다(계획서 3-6). + * + * · 구간형(`interval`)만 띠가 된다 — 점형 시설은 마커로 이미 보인다. + * · 색은 레지스트리 표시색을 그대로 쓴다(여기서 새로 정하지 않는다). + * · 시작·끝이 없으면 기준 측점으로 대신한다. 그것도 없으면 뺀다. + */ +export function routeSpansFromStructures( + structures: ReadonlyArray, + types: ReadonlyArray, +): RouteSpanBand[] { + const colorOf = new Map(types.map((type) => [type.type_id, type.style?.color])); + const spans: RouteSpanBand[] = []; + for (const item of structures) { + if (item.placement !== "interval") continue; + const start = item.start_m ?? item.chainage_m ?? null; + const end = item.end_m ?? item.chainage_m ?? null; + if (start === null || end === null) continue; + spans.push({ + startM: Math.min(start, end), + endM: Math.max(start, end), + color: colorOf.get(item.type_id) ?? "#8a8a8a", + }); + } + return spans; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index dff62835..08236aa3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -10,6 +10,7 @@ * `saveProfileAlignment()`로 편집 델타만 보낸다. * ========================================================================== */ +import { routeSpansFromStructures } from "./B05_Profile_UI_Drainage_Spans"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; @@ -655,6 +656,11 @@ export function createRouteProfilePanel( ); setCollapsed(readStateRaw("profile-collapsed") === "true"); + /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ + function syncDrainageSpans(): void { + drainagePanel.setIntervalSpans(routeSpansFromStructures(structures, structureTypes)); + } + return { root, render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) { @@ -747,6 +753,7 @@ export function createRouteProfilePanel( /** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */ setStructureTypes(types: StructureType[]) { structureTypes = types; + syncDrainageSpans(); draw(); }, /** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */ @@ -755,6 +762,7 @@ export function createRouteProfilePanel( if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) { selectedStructureId = null; } + syncDrainageSpans(); draw(); }, /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 09a1f611..8b644721 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -42,6 +42,12 @@ import { createCurveLabel, deflectionRad, } from "./B05_Profile_UI_RouteEdit_Label"; +import { + applyArcLocks, + curveSummary, + flattenServerPlan, + type CurveLock, +} from "./B05_Profile_UI_RouteEdit_Edits"; import { bindHistoryControls, createRouteEditHistory, @@ -128,6 +134,10 @@ export async function openRouteEditModal( /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ let curveOn: boolean[] = []; let curveRadius: Array = []; + /** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m). + * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ + let curveLock: CurveLock[] = []; + let curveArc: Array = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ @@ -148,6 +158,7 @@ export async function openRouteEditModal( closed = true; window.removeEventListener("resize", resize); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. + curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); @@ -299,7 +310,14 @@ export async function openRouteEditModal( /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => - handleAtScreen(curveInfo, toScreen, px, py, NODE_HIT_PX + 2); + handleAtScreen( + // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5). + curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null), + toScreen, + px, + py, + NODE_HIT_PX + 2, + ); const nodeAt = (px: number, py: number): number => nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); const segmentAt = (px: number, py: number): number => @@ -326,35 +344,29 @@ export async function openRouteEditModal( * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { + // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. + applyArcLocks(planned, curveLock, curveArc, curveRadius); const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); plannedLine = built.vertices; curveInfo = built.curves; nodeInfo = built.nodes; } - /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ - function curveHint(): string { - const off = curveOn.filter( - (on, index) => !on && index > 0 && index < planned.length - 1, - ).length; - const forced = curveRadius.filter((value) => value !== null).length; - const edits = [off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : ""] - .filter(Boolean) - .join(" · "); - if (!nodeInfo.length) { - const base = minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; - return edits ? `${base}${base ? " · " : ""}${edits}` : base; - } - const bad = nodeInfo.filter((node) => node.violations.length).length; - const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length; - return ( - `곡선 ${curves}곳(하한 R ${minRadiusM}m)` + - `${bad ? ` · 기준 미달 ${bad}곳` : ""}${edits ? ` · ${edits}` : ""}` - ); - } + /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ + const curveHint = (): string => + curveSummary({ + nodeCount: planned.length, + curveOn, + curveRadius, + curveLock, + curveCount: curveInfo.length, + violationCount: nodeInfo.filter((node) => node.violations.length).length, + minRadiusM, + fresh: nodeInfo.length === 0, + }); // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── - const curveLabelBox = createCurveLabel(canvas.parentElement!, { + const curveLabelBox = createCurveLabel({ onRadius: (value) => { if (picked < 0) return; curveRadius[picked] = value; @@ -366,9 +378,29 @@ export async function openRouteEditModal( // 곡선 길이 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; @@ -382,13 +414,15 @@ export async function openRouteEditModal( curveLabelBox.hide(); return; } - const forced = curveRadius[picked]; const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); - const shown = forced ?? pickedCurve?.radius_m ?? null; + 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, - at: toScreen(planned[picked]), + // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다. + at: [screenX + rect.left, screenY + rect.top], centerDirection: pickedCurve ? centerDirectionOf( toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), @@ -396,13 +430,11 @@ export async function openRouteEditModal( toScreen(pickedCurve.end), ) : null, - canvas: { width: view.width, height: view.height }, curveOn: curveOn[picked] !== false, radiusShown: shown, arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, - forced: forced !== null, + lock: curveLock[picked] ?? null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, - minRadiusM, }); } @@ -418,7 +450,7 @@ export async function openRouteEditModal( /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ function snapshotNow(): RouteEditSnapshot { - return { planned, curveOn, curveRadius, picked }; + return { planned, curveOn, curveRadius, curveLock, curveArc, picked }; } const historyControls = bindHistoryControls({ @@ -428,6 +460,8 @@ export async function openRouteEditModal( planned = snapshot.planned; curveOn = snapshot.curveOn; curveRadius = snapshot.curveRadius; + curveLock = snapshot.curveLock; + curveArc = snapshot.curveArc; picked = snapshot.picked; applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. }, @@ -526,6 +560,8 @@ export async function openRouteEditModal( // 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. curveOn.splice(segment + 1, 0, true); curveRadius.splice(segment + 1, 0, null); + curveLock.splice(segment + 1, 0, null); + curveArc.splice(segment + 1, 0, null); picked = segment + 1; applyEdit("새 노드를 넣었습니다(직선 추가)."); }); @@ -542,6 +578,8 @@ export async function openRouteEditModal( planned.splice(index, 1); curveOn.splice(index, 1); curveRadius.splice(index, 1); + curveLock.splice(index, 1); + curveArc.splice(index, 1); picked = -1; applyEdit("노드를 지웠습니다(직선 삭제)."); }); @@ -609,48 +647,15 @@ export async function openRouteEditModal( // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). const nodes = plan.nodes ?? []; minRadiusM = plan.min_radius_m ?? 0; - // 곡선 성분을 **편집할 수 있는 꼴로 펴 둔다**(2026-09-07). - // - // 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 - // 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 그런데 편집은 「꺾임점 하나 = - // 곡선 하나」로 표현되므로, 묶인 곡선을 그대로 두면 한 번만 손대도 그 묶음이 낱개로 - // 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R 12~199m → 전부 12m). - // - // 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼운다** — 안쪽 꺾임점은 그 곡선이 대신하므로 - // 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같고**, 이제 손대도 안 흩어진다. - const curves = plan.curves ?? []; - const replaced = new Map(); - const dropped = new Set(); - curves.forEach((curve) => { - replaced.set(curve.node_first, curve); - for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) { - dropped.add(index); - } - }); - planned = []; - nodeInfo = []; - curveOn = []; - curveRadius = []; - curveInfo = []; - nodes.forEach((node, index) => { - if (dropped.has(index)) return; - const curve = replaced.get(index); - const at: Vertex = curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]; - const seat = planned.length; - planned.push(at); - nodeInfo.push({ - radius_m: curve ? curve.radius_m : node.radius_m, - inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, - violations: curve ? (curve.violations ?? []) : (node.violations ?? []), - }); - // 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(2026-09-07). 전부 켬으로 열면 - // 아무것도 안 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다 - // (서버의 편집 갈래는 켜진 자리마다 원호를 끼우기 때문). 내각 179° 이상인 자리가 그렇다. - curveOn.push(curve !== undefined); - // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. - curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); - if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); - }); + // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). + const flat = flattenServerPlan(nodes, plan.curves ?? []); + planned = flat.planned; + nodeInfo = flat.nodes; + curveInfo = flat.curves; + curveOn = flat.curveOn; + curveRadius = flat.curveRadius; + curveLock = flat.curveLock; + curveArc = flat.curveArc; picked = -1; // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. history = createRouteEditHistory(snapshotNow()); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts new file mode 100644 index 00000000..a21e6987 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Edits.ts + * 꺾임점마다의 **편집값**을 다루는 순수 함수 — 잠금과 상태줄 요약. + * + * **잠금이 왜 필요한가**(2026-09-07 사용자 지시) — 노드를 옮기면 앞뒤 직선의 교각(Δ)이 + * 바뀐다. 반지름 R 과 곡선 길이 L 은 **L = R·Δ** 로 묶여 있으므로, 한쪽을 붙들면 다른 쪽은 + * 반드시 따라 움직인다. 둘 다 붙들 수는 없다(그러면 Δ 를 못 바꾼다). 그래서 잠금은 셋 중 하나다. + * + * · `null` — 자동. R 은 법정 하한을 쓰고 L 은 따라온다. + * · `"radius"` — **R 고정**. 노드를 옮겨도 R 이 그대로고 L 이 바뀐다. + * · `"arc"` — **곡선 길이 고정**. 노드를 옮기면 R 을 L/Δ 로 다시 잡는다. + * + * ⚠ 접선 자리가 모자라면 그리기 단계에서 R 을 줄이는 것은 그대로다(`buildEditedPolyline`). + * 그것은 **그려지는 값**만 줄이고 잠가 둔 값은 안 건드린다 — 자리를 넓히면 되돌아온다. + * ========================================================================== */ + +import { + innerAngleDeg, + type EditedCurve, + type EditedNode, + type Vertex, +} from "./B05_Profile_UI_RouteEdit_Curve"; +import { deflectionRad } from "./B05_Profile_UI_RouteEdit_Label"; + +/** 무엇을 붙들고 있나. */ +export type CurveLock = "radius" | "arc" | null; + +/** + * **곡선 길이를 잠근 자리**의 반지름을 지금 교각에 맞춰 다시 잡는다(`curveRadius` 를 고침). + * + * 노드를 옮길 때마다 부른다. 잠그지 않았거나 R 을 잠근 자리는 손대지 않는다. + * 교각이 0 에 가까우면(거의 직선) 길이를 지킬 방법이 없으므로 그대로 둔다. + */ +export function applyArcLocks( + planned: Vertex[], + curveLock: CurveLock[], + curveArc: Array, + curveRadius: Array, +): void { + for (let seat = 1; seat < planned.length - 1; seat += 1) { + if (curveLock[seat] !== "arc") continue; + const length = curveArc[seat]; + if (length === null || length === undefined) continue; + const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]); + const deflection = deflectionRad(inner); + if (deflection <= 1e-9) continue; + curveRadius[seat] = length / deflection; + } +} + +export interface CurveSummaryInput { + nodeCount: number; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + /** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */ + curveCount: number; + /** 법정 기준을 못 맞춘 자리 수. */ + violationCount: number; + minRadiusM: number; + /** 아직 한 번도 안 그렸나(막 열었을 때). */ + fresh: boolean; +} + +/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */ +export function curveSummary(input: CurveSummaryInput): string { + const off = input.curveOn.filter( + (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 edits = [ + off ? `곡선 지움 ${off}곳` : "", + forced ? `R 지정 ${forced}곳` : "", + locked ? `고정 ${locked}곳` : "", + ] + .filter(Boolean) + .join(" · "); + if (input.fresh) { + const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [확인] 때 반영` : ""; + return edits ? `${base}${base ? " · " : ""}${edits}` : base; + } + return ( + `곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` + + `${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` + + `${edits ? ` · ${edits}` : ""}` + ); +} + +/** 서버가 준 노드·곡선을 **편집할 수 있는 꼴**로 편 결과. */ +export interface FlattenedPlan { + planned: Vertex[]; + nodes: EditedNode[]; + curves: EditedCurve[]; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + curveArc: Array; +} + +interface ServerNode { + x: number; + y: number; + radius_m: number | null; + inner_angle_deg: number | null; + violations?: string[]; +} + +/** + * 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07). + * + * 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 + * 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 묶인 채로 두면 한 번만 손대도 + * 묶음이 낱개로 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R + * 12~199m → 전부 12m). 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼우고** 안쪽 꺾임점은 + * 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같다**. + * + * ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안 + * 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집 + * 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다. + */ +export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan { + const replaced = new Map(); + const dropped = new Set(); + curves.forEach((curve) => { + replaced.set(curve.node_first, curve); + for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) { + dropped.add(index); + } + }); + + const out: FlattenedPlan = { + planned: [], + nodes: [], + curves: [], + curveOn: [], + curveRadius: [], + curveLock: [], + curveArc: [], + }; + nodes.forEach((node, index) => { + if (dropped.has(index)) return; + const curve = replaced.get(index); + const seat = out.planned.length; + out.planned.push(curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]); + out.nodes.push({ + radius_m: curve ? curve.radius_m : node.radius_m, + inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, + tangent_m: null, + violations: curve ? (curve.violations ?? []) : (node.violations ?? []), + }); + out.curveOn.push(curve !== undefined); + // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. + out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); + out.curveLock.push(null); + out.curveArc.push(null); + if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat }); + }); + return out; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index a460c458..9db4946c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -17,6 +17,10 @@ export interface RouteEditSnapshot { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array; + /** 무엇을 붙들고 있나 — 반지름 | 곡선 길이 | 없음 (`_Edits.ts`). */ + curveLock: Array<"radius" | "arc" | null>; + /** 길이를 붙들었을 때의 그 길이(m). */ + curveArc: Array; picked: number; } @@ -44,6 +48,8 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { planned: snapshot.planned.map(([x, y]): Vertex => [x, y]), curveOn: [...snapshot.curveOn], curveRadius: [...snapshot.curveRadius], + curveLock: [...snapshot.curveLock], + curveArc: [...snapshot.curveArc], picked: snapshot.picked, }; } @@ -56,6 +62,8 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { if (a.planned[index][1] !== b.planned[index][1]) return false; if (a.curveOn[index] !== b.curveOn[index]) return false; 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; } return true; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index 9096e81b..b1a37a79 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -1,25 +1,31 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Label.ts - * 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다. + * 고른 꺾임점 옆에 뜨는 **곡선 조작 패널** — 반지름·곡선 길이를 보고 고치고 붙든다. * - * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 - * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. + * 왜 옆인가(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이라 지금 고른 것이 + * 지도 어디인지 눈으로 안 이어졌다. * - * **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이 - * 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」 - * 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다. + * **R 과 곡선 길이는 한 쌍**(L = R·Δ, Δ 는 앞뒤 직선이 정하는 교각) — 한쪽을 고치면 다른 + * 쪽이 따라온다. 「자동」 단추는 없다: 칸을 비우는 것이 곧 자동이다. 어느 쪽을 붙들지는 + * **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고). * - * **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을 - * 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다. + * **자리**(2026-09-07 사용자 지시) + * · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라 + * 안에 두면 가장자리에서 **잘린다**. 화면 밖으로도 넘어갈 수 있어야 한다. + * · 자동 자리는 **곡선 중심의 반대쪽**, **16방위**로 잡는다(4방위는 대각 자리에서 곡선을 물었다). + * · 머리를 잡아 **손으로 옮길 수 있다**. 옮긴 자리는 그 꺾임점을 보는 동안 유지되고, + * 다른 꺾임점을 고르면 자동 자리로 돌아간다. * ========================================================================== */ +import 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; } -/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 라벨을 그 반대쪽에 붙이는 데 쓴다. +/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 패널을 그 반대쪽에 붙이는 데 쓴다. * * 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라 * 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */ @@ -42,48 +48,57 @@ export function centerDirectionOf( return length <= 1e-9 ? null : [sx / length, sy / length]; } -/** 라벨과 노드 사이 여백(px). */ -const GAP_PX = 14; -/** 캔버스 가장자리에서 이만큼은 띄운다(px). */ -const EDGE_PX = 8; +/** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */ +const GAP_PX = 40; +/** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */ +const COMPASS_STEPS = 16; export interface CurveLabelState { /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ seat: number; - /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ + /** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */ at: [number, number]; - /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다. - * 곡선이 없으면 null — 그때는 오른쪽에 둔다. */ + /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */ centerDirection: [number, number] | null; - /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ - canvas: { width: number; height: number }; curveOn: boolean; - /** 지금 보일 반지름(m). 곡선이 없으면 null. */ radiusShown: number | null; - /** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ + /** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ arcLengthShown: number | null; - /** 사용자가 못박은 값인지(아니면 자동). */ - forced: boolean; + lock: CurveLock; innerAngleDeg: number | null; - minRadiusM: number; } export interface CurveLabelHandlers { - /** R 칸을 고쳤다 — null 이면 「자동」(칸을 비운 것). */ onRadius: (value: number | null) => void; - /** 곡선 길이 칸을 고쳤다 — null 이면 「자동」. */ onArcLength: (value: number | null) => void; - /** 곡선을 지우거나 넣었다. */ onCurveOn: (on: boolean) => void; + /** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */ + onLock: (lock: CurveLock) => void; } export interface CurveLabel { show: (state: CurveLabelState) => void; hide: () => void; + /** 창을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */ + destroy: () => void; } -/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */ -export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel { +/** 방향을 16방위 중 가장 가까운 것으로 맞춘다. */ +function quantize(dx: number, dy: number): [number, number] { + const step = (2 * Math.PI) / COMPASS_STEPS; + const angle = Math.round(Math.atan2(dy, dx) / step) * step; + return [Math.cos(angle), Math.sin(angle)]; +} + +/** 가운데에서 그 방향으로 상자 가장자리까지의 거리 — 방위마다 다르다. */ +function boxReach(dx: number, dy: number, width: number, height: number): number { + const byX = Math.abs(dx) < 1e-9 ? Infinity : width / 2 / Math.abs(dx); + const byY = Math.abs(dy) < 1e-9 ? Infinity : height / 2 / Math.abs(dy); + return Math.min(byX, byY); +} + +/** 패널을 만든다. 몸통은 `document.body` 에 붙어 모달 밖으로도 넘어간다. */ +export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { const root = document.createElement("div"); root.className = "b05-routeedit__label"; root.hidden = true; @@ -95,26 +110,40 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers - `; - host.append(root); + + 칸을 비우면 자동`; + document.body.append(root); + const head = root.querySelector(".b05-routeedit__label-head")!; const seatText = root.querySelector(".b05-routeedit__curve-label")!; const toggle = root.querySelector('[data-act="curve-toggle"]')!; const radius = root.querySelector(".b05-routeedit__curve-radius")!; const arc = root.querySelector(".b05-routeedit__curve-arc")!; + const lockRadius = root.querySelector('[data-act="lock-radius"]')!; + const lockArc = root.querySelector('[data-act="lock-arc"]')!; const info = root.querySelector(".b05-routeedit__curve-info")!; - // 라벨 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. - root.addEventListener("pointerdown", (event) => event.stopPropagation()); - root.addEventListener("dblclick", (event) => event.stopPropagation()); - root.addEventListener("contextmenu", (event) => event.stopPropagation()); + // 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. + for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) { + root.addEventListener(type, (event) => event.stopPropagation()); + } let curveOn = true; + let lock: CurveLock = null; + let seat = -1; + /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ + let manual: [number, number] | null = null; + let anchor: [number, number] = [0, 0]; + const numberOf = (input: HTMLInputElement): number | null => { const value = Number(input.value); return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null; @@ -122,53 +151,83 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers radius.addEventListener("change", () => handlers.onRadius(numberOf(radius))); arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc))); toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); + lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); + lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); - /** 라벨을 **곡선 중심의 반대쪽**에 붙인다 — 상하좌우 넷 중 하나. */ + // ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ── + let dragFrom: { x: number; y: number; left: number; top: number } | null = null; + head.addEventListener("pointerdown", (event) => { + if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로. + dragFrom = { + x: event.clientX, + y: event.clientY, + left: root.offsetLeft, + top: root.offsetTop, + }; + head.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + head.addEventListener("pointermove", (event) => { + if (!dragFrom) return; + const left = dragFrom.left + event.clientX - dragFrom.x; + const top = dragFrom.top + event.clientY - dragFrom.y; + root.style.left = `${Math.round(left)}px`; + root.style.top = `${Math.round(top)}px`; + // 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다. + manual = [left - anchor[0], top - anchor[1]]; + }); + const stopDrag = (event: PointerEvent): void => { + if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId); + dragFrom = null; + }; + head.addEventListener("pointerup", stopDrag); + head.addEventListener("pointercancel", stopDrag); + + /** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */ function place(state: CurveLabelState): void { const width = root.offsetWidth; const height = root.offsetHeight; const [nx, ny] = state.at; - const direction = state.centerDirection; - let left = nx + GAP_PX; // 곡선이 없으면 오른쪽이 기본이다. - let top = ny - height / 2; - if (direction) { - const [dx, dy] = direction; - if (Math.abs(dx) >= Math.abs(dy)) { - // 중심이 오른쪽이면 라벨은 왼쪽으로. - left = dx > 0 ? nx - GAP_PX - width : nx + GAP_PX; - top = ny - height / 2; - } else { - left = nx - width / 2; - top = dy > 0 ? ny - GAP_PX - height : ny + GAP_PX; - } - } - // 캔버스 밖으로 나가면 안으로 접는다 — 노선 끝을 골라도 칸이 잘리지 않게. - left = Math.max(EDGE_PX, Math.min(left, state.canvas.width - width - EDGE_PX)); - top = Math.max(EDGE_PX, Math.min(top, state.canvas.height - height - EDGE_PX)); + const away = state.centerDirection + ? quantize(-state.centerDirection[0], -state.centerDirection[1]) + : ([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); root.style.left = `${Math.round(left)}px`; root.style.top = `${Math.round(top)}px`; } return { show(state) { + if (state.seat !== seat) { + seat = state.seat; + manual = null; // 다른 꺾임점이면 자동 자리부터 다시. + } curveOn = state.curveOn; + lock = state.lock; root.hidden = false; seatText.textContent = `${state.seat + 1}번째 꺾임점`; toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; radius.disabled = !state.curveOn; arc.disabled = !state.curveOn; + lockRadius.disabled = !state.curveOn; + lockArc.disabled = !state.curveOn; + lockRadius.classList.toggle("is-on", lock === "radius"); + lockArc.classList.toggle("is-on", lock === "arc"); 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" ? "곡선 길이 고정" : "고정 없음"; info.textContent = state.curveOn - ? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + - ` · 법정 하한 ${state.minRadiusM}m · 칸을 비우면 자동` + ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` : "곡선 없음 — 직선이 그대로 꺾입니다"; place(state); - // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다 - // (2026-09-07 실측: 처음 잰 높이 120px, 실제 131px 라 11px 어긋났음). + // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. requestAnimationFrame(() => { if (!root.hidden) place(state); }); @@ -176,5 +235,8 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers hide() { root.hidden = true; }, + destroy() { + root.remove(); + }, }; } diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 6b4791db..789350c7 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -135,8 +135,10 @@ 예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다. 자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */ .b05-routeedit__label { - position: absolute; - z-index: 2; + /* `document.body` 에 붙여 **화면 기준**으로 띄운다 — 모달이 `overflow: hidden` 이라 + 안에 두면 가장자리에서 잘린다(2026-09-07 사용자 지시 1). */ + position: fixed; + z-index: calc(var(--z-modal, 1000) + 1); display: flex; flex-direction: column; gap: 4px; @@ -152,11 +154,41 @@ font-size: var(--text-caption); } +/* 머리는 **잡아 옮기는 자리**다 — 패널이 곡선을 가리면 손으로 치울 수 있어야 한다. */ .b05-routeedit__label-head { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-8, 8px); + cursor: move; + touch-action: none; +} + +/* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */ +.b05-routeedit__lock { + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); + color: var(--color-text-secondary); + font: inherit; + cursor: pointer; +} + +.b05-routeedit__lock.is-on { + border-color: transparent; + background: var(--color-primary, #7c3aed); + color: #fff; +} + +.b05-routeedit__lock:disabled { + color: var(--color-text-secondary); + cursor: default; +} + +/* 「칸을 비우면 자동」 — 상태 설명과 줄을 나눈다(2026-09-07 사용자 지시 4). */ +.b05-routeedit__curve-note { + color: var(--color-text-secondary); } .b05-routeedit__label-toggle { diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py new file mode 100644 index 00000000..e5ca8445 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -0,0 +1,679 @@ +"""B09 원가계산 — ⑤ 공사원가계산서 엔진. + +순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어 +**공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5). + +지켜야 할 것 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인된 것만) + 1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다. + 2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / … + 하나로 뭉치면 틀린다. + 3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽**(고용노동부 고시 제2025-11호). + ⚠ **A 가 항상 작지 않다** — 관급을 넣어 대상액이 구간 경계를 넘으면 뒤집힌다 + (실증: 울진 A 채택 / 거창 B 채택). + 4. **이윤 수동 조정액** — 실무는 도급공사비 끝수를 맞추려 이윤을 깎는다. 법에 없는 + 관행이므로 **설계자가 명시로 넣을 때만** 적용하고 프로그램이 스스로 깎지 않는다. + 5. **비목 목록을 코드에 박지 않는다** — 공사마다 있는 줄이 다르다(퇴직공제·폐기물처리 등). + 6. 요율은 전부 `B09_Estimation_Rates` 를 거쳐 데이터에서 읽는다. 코드에 숫자가 없다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal + +from B09_Estimation.B09_Estimation_Rates import ( + RateDataset, + RateLookupError, + base_amount, + flat_rate, + load_rate_dataset, + pension_rate_percent, + rate_percent, + select_bracket, +) + +_ZERO = Decimal(0) +_HUNDRED = Decimal(100) +_VAT_DIVISOR = Decimal("1.1") + +#: 기본으로 켜는 법정경비 비목. 공사마다 다르므로 `CostInput.enabled_items` 로 갈아끼운다. +DEFAULT_STATUTORY_ITEMS: tuple[str, ...] = ( + "industrial_accident_insurance", + "employment_insurance", + "health_insurance", + "long_term_care_insurance", + "national_pension", + "safety_management_cost", + "other_expense", + "environment_preservation", + "retirement_mutual_aid", +) + +#: 켤 수 있으나 기본은 끄는 비목 (공사·발주처에 따라 등장). +OPTIONAL_STATUTORY_ITEMS: tuple[str, ...] = ( + "wage_claim_contribution", + "asbestos_contribution", + "equipment_payment_guarantee", + "subcontract_payment_guarantee", + "performance_guarantee_fee", +) + + +def floor_won(value: Decimal) -> Decimal: + """원 단위 버림 — 원가계산서 모든 줄의 기본 처리.""" + return value.quantize(Decimal(1), rounding=ROUND_FLOOR) + + +def ceil_thousand(value: Decimal) -> Decimal: + """천원 올림 — 관급자재대 표기.""" + return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000 + + +@dataclass +class CostInput: + """원가계산 입력. + + 금액은 전부 원 단위 `Decimal`. 요율·구간 판정에 쓰는 조건이 함께 들어온다. + """ + + direct_material_krw: Decimal + direct_labor_krw: Decimal + direct_expense_krw: Decimal + indirect_material_krw: Decimal = _ZERO + + #: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 값을 그대로 쓴다. + work_type_indirect_labor: str = "civil" + work_type_safety: str = "civil" + duration_days: int = 183 + pension_year: int = 2026 + + #: 관급자재 — 순자재대와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1). + owner_supplied_material_krw: Decimal = _ZERO + procurement_fee_krw: Decimal = _ZERO + include_fee_in_owner_material_total: bool = True + + #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액을 쓴다. + owner_supplied_for_safety_krw: Decimal | None = None + #: 위 금액이 부가세 포함인가 — 포함이면 1.1 로 나눠 부가세를 뺀다(규정: 부가세 제외 기준). + owner_supplied_includes_vat: bool = True + + #: 규모 구간 판정에 쓸 금액. None 이면 순공사원가를 쓴다(추정가격 순환 회피). + estimated_price_krw: Decimal | None = None + + #: 이윤 수동 조정액 — 설계자가 명시로 넣을 때만. 프로그램이 스스로 채우지 않는다. + profit_adjustment_krw: Decimal = _ZERO + + #: 환경보전비 공종(요율 데이터 `rate_environment.all_work_types` 의 값). + environment_work_type: str = "civil_road" + #: 건설기계대여대금 지급보증 공종. + equipment_guarantee_work_type: str = "civil_general" + + enabled_items: tuple[str, ...] = DEFAULT_STATUTORY_ITEMS + + #: 요율 데이터 파일명. 연도를 갈아끼우는 자리. + rate_file_name: str = "rates_2026.json" + + +@dataclass +class CostLine: + """원가계산서 한 줄 — 화면이 「밑수 · 율 · 금액」 셋을 다 보이므로 셋을 다 든다.""" + + key: str + name: str + base_label: str + base_amount_krw: Decimal + rate_percent: Decimal | None + flat_amount_krw: Decimal + amount_krw: Decimal + note: str = "" + + +@dataclass +class CostResult: + lines: list[CostLine] = field(default_factory=list) + totals: dict[str, Decimal] = field(default_factory=dict) + rate_version: dict[str, str] = field(default_factory=dict) + notes: list[str] = field(default_factory=list) + + def line(self, key: str) -> CostLine: + for item in self.lines: + if item.key == key: + return item + raise KeyError(f"원가계산서에 없는 줄입니다: {key}") + + def amount(self, key: str) -> Decimal: + return self.line(key).amount_krw + + +def _line( + result: CostResult, + *, + key: str, + name: str, + base_label: str, + base: Decimal, + percent: Decimal | None = None, + flat: Decimal = _ZERO, + amount: Decimal | None = None, + note: str = "", +) -> Decimal: + """줄 하나를 계산해 결과에 담고 금액을 돌려준다. 금액은 항상 원 단위 버림.""" + if amount is None: + computed = base * (percent or _ZERO) / _HUNDRED + flat + amount = floor_won(computed) + result.lines.append( + CostLine( + key=key, + name=name, + base_label=base_label, + base_amount_krw=base, + rate_percent=percent, + flat_amount_krw=flat, + amount_krw=amount, + note=note, + ) + ) + return amount + + +def _safety_management_cost( + result: CostResult, + dataset: RateDataset, + data: CostInput, + *, + material_cost: Decimal, +) -> Decimal: + """산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다. + + A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액 + B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2 + 두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. + """ + variable = dataset.variable("rate_safety_pct") + brackets = variable["brackets"] + + owner_supplied = data.owner_supplied_for_safety_krw + if owner_supplied is None: + owner_supplied = data.owner_supplied_material_krw + if data.owner_supplied_includes_vat: + owner_supplied = owner_supplied / _VAT_DIVISOR + + base_with = material_cost + data.direct_labor_krw + owner_supplied + base_without = material_cost + data.direct_labor_krw + + def evaluate( + base: Decimal, *, multiplier: Decimal, label: str + ) -> tuple[Decimal, Decimal, Decimal]: + row = select_bracket( + brackets, + amount_field="target_amount_bracket", + amount=base, + equals={"work_type": data.work_type_safety}, + label=label, + ) + percent = rate_percent(row, label=label) + flat = base_amount(row) + amount = floor_won((base * percent / _HUNDRED + flat) * multiplier) + return amount, percent, flat + + amount_a, percent_a, flat_a = evaluate( + base_with, multiplier=Decimal(1), label="안전관리비 A(관급 포함)" + ) + amount_b, percent_b, flat_b = evaluate( + base_without, multiplier=Decimal("1.2"), label="안전관리비 B(관급 제외 × 1.2)" + ) + + adopted = "A" if amount_a <= amount_b else "B" + + _line( + result, + key="safety_management_cost_a", + name="산업안전보건관리비 A(관급 포함)", + base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)", + base=base_with, + percent=percent_a, + flat=flat_a, + amount=amount_a, + note="채택" if adopted == "A" else "미채택", + ) + _line( + result, + key="safety_management_cost_b", + name="산업안전보건관리비 B(관급 제외 × 1.2)", + base_label="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배", + base=base_without, + percent=percent_b, + flat=flat_b, + amount=amount_b, + note="채택" if adopted == "B" else "미채택", + ) + + adopted_amount = min(amount_a, amount_b) + return _line( + result, + key="safety_management_cost", + name="산업안전보건관리비", + base_label=f"A·B 중 작은 금액 (채택 = {adopted})", + base=base_with if adopted == "A" else base_without, + percent=percent_a if adopted == "A" else percent_b, + amount=adopted_amount, + note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액", + ) + + +def _statutory_expenses( + result: CostResult, + dataset: RateDataset, + data: CostInput, + *, + material_cost: Decimal, + total_labor_cost: Decimal, + direct_construction_cost: Decimal, +) -> Decimal: + """법정경비 묶음. `enabled_items` 에 든 줄만 계산한다.""" + enabled = set(data.enabled_items) + total = _ZERO + + if "industrial_accident_insurance" in enabled: + total += _line( + result, + key="industrial_accident_insurance", + name="산재보험료", + base_label="노무비(직접+간접)", + base=total_labor_cost, + percent=flat_rate(dataset, "rate_sanjae"), + ) + + if "employment_insurance" in enabled: + variable = dataset.variable("rate_goyong") + # 고용보험료는 등급(1~7)이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 + # 숫자 구간에 안 걸리므로 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 1.01 %). + row = select_bracket( + variable["brackets"], + amount_field="estimated_amount_bracket", + amount=_scale_reference(data, direct_construction_cost), + residual_label="below_official_threshold", + label="고용보험료", + ) + total += _line( + result, + key="employment_insurance", + name="고용보험료", + base_label="노무비(직접+간접)", + base=total_labor_cost, + percent=rate_percent(row, label="고용보험료"), + ) + + health_amount = _ZERO + if "health_insurance" in enabled: + health_amount = _line( + result, + key="health_insurance", + name="국민건강보험료", + base_label="직접노무비", + base=data.direct_labor_krw, + percent=flat_rate(dataset, "rate_health"), + ) + total += health_amount + + if "long_term_care_insurance" in enabled: + if "health_insurance" not in enabled: + raise RateLookupError( + "노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다" + ) + total += _line( + result, + key="long_term_care_insurance", + name="노인장기요양보험료", + base_label="국민건강보험료", + base=health_amount, + percent=flat_rate(dataset, "rate_care"), + ) + + if "national_pension" in enabled: + total += _line( + result, + key="national_pension", + name="국민연금보험료", + base_label="직접노무비", + base=data.direct_labor_krw, + percent=pension_rate_percent(dataset, data.pension_year), + ) + + if "safety_management_cost" in enabled: + total += _safety_management_cost(result, dataset, data, material_cost=material_cost) + + if "other_expense" in enabled: + variable = dataset.variable("rate_other_expense") + row = select_bracket( + variable["brackets"], + amount_field="direct_cost_bracket", + amount=direct_construction_cost, + duration_days=data.duration_days, + equals={"work_type": data.work_type_indirect_labor}, + label="기타경비", + ) + total += _line( + result, + key="other_expense", + name="기타경비", + base_label="재료비+노무비(직접+간접)", + base=material_cost + total_labor_cost, + percent=rate_percent(row, label="기타경비"), + ) + + if "environment_preservation" in enabled: + variable = dataset.variable("rate_environment") + threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0))) + if _scale_reference(data, direct_construction_cost) >= threshold: + row = next( + ( + r + for r in variable["all_work_types"] + if r.get("work_type") == data.environment_work_type + ), + None, + ) + if row is None: + raise RateLookupError( + f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}" + ) + total += _line( + result, + key="environment_preservation", + name="환경보전비", + base_label="직접공사비", + base=direct_construction_cost, + percent=rate_percent(row, label="환경보전비"), + note=( + "⚠ 임도 공종 채택값 미확정 — 지식DB " + "`rate_environment.forest_road_selection_status: pending`" + ), + ) + + if "retirement_mutual_aid" in enabled: + variable = dataset.variable("rate_retirement_mutual_aid") + threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0))) + if _scale_reference(data, direct_construction_cost) >= threshold: + total += _line( + result, + key="retirement_mutual_aid", + name="퇴직공제부금비", + base_label="직접노무비", + base=data.direct_labor_krw, + percent=Decimal(str(variable["rate_percent"])), + ) + + if "wage_claim_contribution" in enabled: + total += _line( + result, + key="wage_claim_contribution", + name="임금채권보장기금 부담금", + base_label="노무비(직접+간접)", + base=total_labor_cost, + percent=flat_rate(dataset, "rate_wage_claim_contribution"), + ) + + if "asbestos_contribution" in enabled: + total += _line( + result, + key="asbestos_contribution", + name="석면피해구제 분담금", + base_label="노무비(직접+간접)", + base=total_labor_cost, + percent=flat_rate(dataset, "rate_asbestos_contribution"), + ) + + if "equipment_payment_guarantee" in enabled: + variable = dataset.variable("rate_equipment_payment_guarantee") + row = next( + ( + r + for r in variable["general_construction"] + variable["specialty_construction"] + if r.get("work_type") == data.equipment_guarantee_work_type + ), + None, + ) + if row is None: + raise RateLookupError( + "건설기계대여대금 지급보증: 공종을 못 찾았습니다 — " + f"{data.equipment_guarantee_work_type}" + ) + total += _line( + result, + key="equipment_payment_guarantee", + name="건설기계대여대금 지급보증수수료", + base_label="직접공사비", + base=direct_construction_cost, + percent=rate_percent(row, label="건설기계대여대금 지급보증수수료"), + ) + + if "subcontract_payment_guarantee" in enabled: + variable = dataset.variable("rate_subcontract_payment_guarantee") + row = select_bracket( + variable["brackets"], + amount_field="estimated_price_bracket", + amount=_scale_reference(data, direct_construction_cost), + label="하도급대금 지급보증수수료", + ) + total += _line( + result, + key="subcontract_payment_guarantee", + name="하도급대금 지급보증수수료", + base_label="직접공사비", + base=direct_construction_cost, + percent=rate_percent(row, label="하도급대금 지급보증수수료"), + ) + + return total + + +def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal: + """규모 구간 판정 기준액. + + 조달청 제비율표는 「추정가격」으로 구간을 나누지만, 추정가격은 원가 계산 결과에 + 딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 추정가격을 명시하면 그 값을, + 없으면 **직접공사비**를 기준으로 쓴다. + """ + if data.estimated_price_krw is not None: + return data.estimated_price_krw + return direct_construction_cost + + +def calculate_cost(data: CostInput) -> CostResult: + """공사원가계산서 한 장을 계산한다.""" + dataset = load_rate_dataset(data.rate_file_name) + result = CostResult(rate_version=dataset.version_stamp) + + material_cost = data.direct_material_krw + data.indirect_material_krw + _line( + result, + key="material_cost", + name="재료비", + base_label="직접재료비+간접재료비", + base=material_cost, + amount=material_cost, + ) + + direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw + + indirect_labor_row = select_bracket( + dataset.variable("rate_indirect_labor")["brackets"], + amount_field="direct_cost_bracket", + amount=direct_construction_cost, + duration_days=data.duration_days, + equals={"work_type": data.work_type_indirect_labor}, + label="간접노무비", + ) + indirect_labor = _line( + result, + key="indirect_labor_cost", + name="간접노무비", + base_label="직접노무비", + base=data.direct_labor_krw, + percent=rate_percent(indirect_labor_row, label="간접노무비"), + ) + total_labor_cost = data.direct_labor_krw + indirect_labor + _line( + result, + key="labor_cost", + name="노무비", + base_label="직접노무비+간접노무비", + base=total_labor_cost, + amount=total_labor_cost, + ) + + statutory = _statutory_expenses( + result, + dataset, + data, + material_cost=material_cost, + total_labor_cost=total_labor_cost, + direct_construction_cost=direct_construction_cost, + ) + expense_total = data.direct_expense_krw + statutory + _line( + result, + key="expense", + name="경비", + base_label="직접경비(산출경비)+법정경비", + base=expense_total, + amount=expense_total, + ) + + net_construction_cost = material_cost + total_labor_cost + expense_total + _line( + result, + key="net_construction_cost", + name="순공사원가", + base_label="재료비+노무비+경비", + base=net_construction_cost, + amount=net_construction_cost, + ) + + scale = _scale_reference(data, direct_construction_cost) + overhead_row = select_bracket( + dataset.variable("rate_overhead")["civil_landscape_industrial"], + amount_field="estimated_price_bracket", + amount=scale, + label="일반관리비", + ) + overhead = _line( + result, + key="general_overhead", + name="일반관리비", + base_label="순공사원가", + base=net_construction_cost, + percent=rate_percent(overhead_row, label="일반관리비"), + ) + + profit_row = select_bracket( + dataset.variable("rate_profit")["brackets"], + amount_field="estimated_price_bracket", + amount=scale, + label="이윤", + ) + profit_base = total_labor_cost + expense_total + overhead + profit_before = floor_won(profit_base * rate_percent(profit_row, label="이윤") / _HUNDRED) + _line( + result, + key="profit_before_adjustment", + name="이윤(조정 전)", + base_label="노무비+경비+일반관리비 (재료비 제외)", + base=profit_base, + percent=rate_percent(profit_row, label="이윤"), + amount=profit_before, + ) + if data.profit_adjustment_krw: + _line( + result, + key="profit_adjustment", + name="이윤 조정액", + base_label="설계자 명시 입력", + base=_ZERO, + amount=-data.profit_adjustment_krw, + note="도급공사비 끝수 맞춤 — 법정 항목 아님", + ) + profit = profit_before - data.profit_adjustment_krw + _line( + result, + key="profit", + name="이윤", + base_label="조정 전 이윤 − 조정액", + base=profit_base, + amount=profit, + ) + + total_cost = net_construction_cost + overhead + profit + _line( + result, + key="total_cost", + name="총원가", + base_label="순공사원가+일반관리비+이윤", + base=total_cost, + amount=total_cost, + ) + + vat = _line( + result, + key="vat", + name="부가가치세", + base_label="총원가", + base=total_cost, + percent=flat_rate(dataset, "rate_vat"), + ) + contract_amount = total_cost + vat + _line( + result, + key="contract_amount", + name="도급공사비", + base_label="총원가+부가가치세", + base=contract_amount, + amount=contract_amount, + ) + + owner_total = _ZERO + if data.owner_supplied_material_krw: + raw = data.owner_supplied_material_krw + if data.include_fee_in_owner_material_total: + raw = raw + data.procurement_fee_krw + owner_total = ceil_thousand(raw) + _line( + result, + key="owner_supplied_material_total", + name="관급자재대", + base_label=( + "순자재대+조달수수료 (천원 올림)" + if data.include_fee_in_owner_material_total + else "순자재대 (천원 올림)" + ), + base=raw, + amount=owner_total, + note="총원가 밖 별도 표기", + ) + + grand_total = contract_amount + owner_total + _line( + result, + key="grand_total", + name="총공사비", + base_label="도급공사비+관급자재대", + base=grand_total, + amount=grand_total, + ) + + result.totals = { + "material_cost": material_cost, + "labor_cost": total_labor_cost, + "expense": expense_total, + "direct_construction_cost": direct_construction_cost, + "net_construction_cost": net_construction_cost, + "general_overhead": overhead, + "profit": profit, + "total_cost": total_cost, + "vat": vat, + "contract_amount": contract_amount, + "owner_supplied_material_total": owner_total, + "grand_total": grand_total, + } + return result diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py new file mode 100644 index 00000000..b1c0155b --- /dev/null +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -0,0 +1,245 @@ +"""B09 원가계산 — 요율 데이터 로더·구간 조회. + +요율은 **코드에 박지 않는다**. `resources/data_cost_input_value/rates_*.json` 이 정본이고 +이 모듈은 그 파일을 읽어 구간을 골라 주는 일만 한다 (PLAN 9-2·8-10 ★법대로). + +핵심 규칙 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인): + - 요율표는 **한 벌**이다. 안전관리비 A/B 는 요율이 두 벌인 것이 아니라 + **같은 표를 대상액 두 개로 각각 조회**하는 것이다. + - 구간 라벨의 `billion` 은 **십억 원(10^9)**, `million` 은 **백만 원(10^6)** 이다. + `lt_5_billion` = 50억 미만. (2026-09-07 값 파일 대조로 확정) + - 판정 실패는 **조용히 넘기지 않는다** — 기본값으로 때우면 금액이 조용히 틀린다. +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from decimal import Decimal +from functools import lru_cache +from typing import Any + +# 구간 라벨의 단위 접미사 → 원(KRW) 배수. +_UNIT_MULTIPLIER: dict[str, int] = { + "million": 1_000_000, + "billion": 1_000_000_000, +} + +_RESOURCE_SUBPATH = ("resources", "data_cost_input_value") + +# 라벨 문법 — 숫자 구간만 해석한다. 그 밖(`turnkey_or_alternative` 등)은 명시 선택자로 고른다. +_RE_LT = re.compile(r"^lt_(\d+(?:\.\d+)?)_(million|billion)$") +_RE_GTE = re.compile(r"^gte_(\d+(?:\.\d+)?)_(million|billion)(?:_(.+))?$") +_RE_RANGE_ONE_UNIT = re.compile(r"^(\d+(?:\.\d+)?)_to_(\d+(?:\.\d+)?)_(million|billion)$") +_RE_RANGE_TWO_UNIT = re.compile( + r"^(\d+(?:\.\d+)?)_(million|billion)_to_(\d+(?:\.\d+)?)_(million|billion)$" +) +_RE_DAYS_LTE = re.compile(r"^lte_(\d+)_days$") +_RE_DAYS_GTE = re.compile(r"^gte_(\d+)_days$") +_RE_DAYS_RANGE = re.compile(r"^(\d+)_to_(\d+)_days$") + + +class RateLookupError(LookupError): + """요율 구간을 못 고른 경우. 기본값으로 때우지 않고 여기서 멈춘다.""" + + +@dataclass(frozen=True) +class RateDataset: + """요율 데이터셋 한 벌 — 재현성 표기용 신원(9-2)을 함께 든다.""" + + dataset_id: str + effective_date: str + sha256: str + variables: dict[str, Any] + + def variable(self, name: str) -> Any: + try: + return self.variables[name] + except KeyError as exc: # pragma: no cover - 데이터 파손 시에만 + raise RateLookupError(f"요율 항목이 데이터셋에 없습니다: {name}") from exc + + @property + def version_stamp(self) -> dict[str, str]: + """내역서·화면에 남길 「어느 판으로 계산했나」 표기.""" + return { + "dataset_id": self.dataset_id, + "effective_date": self.effective_date, + "sha256": self.sha256, + } + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _dataset_dir() -> str: + return os.path.join(_project_root(), *_RESOURCE_SUBPATH) + + +def _manifest_entry(file_name: str) -> dict[str, Any]: + manifest_path = os.path.join(_dataset_dir(), "_manifest.json") + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + for entry in manifest.get("files", []): + if entry.get("file") == file_name: + return entry + raise RateLookupError(f"매니페스트에 없는 요율 파일입니다: {file_name}") + + +@lru_cache(maxsize=8) +def load_rate_dataset(file_name: str = "rates_2026.json") -> RateDataset: + """요율 파일 한 벌을 읽는다. 매니페스트의 지문·적용일을 함께 실어 재현성을 남긴다.""" + entry = _manifest_entry(file_name) + with open(os.path.join(_dataset_dir(), file_name), encoding="utf-8") as handle: + payload = json.load(handle) + return RateDataset( + dataset_id=payload.get("dataset_id", entry.get("dataset_id", "")), + effective_date=payload.get("effective_date", entry.get("effective_date", "")), + sha256=entry.get("sha256", ""), + variables=payload.get("variables", {}), + ) + + +def _bracket_bounds(label: str) -> tuple[Decimal, Decimal] | None: + """금액 구간 라벨 → [하한, 상한). 숫자 구간이 아니면 None.""" + match = _RE_LT.match(label) + if match: + return Decimal(0), Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)] + + match = _RE_RANGE_TWO_UNIT.match(label) + if match: + low = Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)] + high = Decimal(match.group(3)) * _UNIT_MULTIPLIER[match.group(4)] + return low, high + + match = _RE_RANGE_ONE_UNIT.match(label) + if match: + unit = _UNIT_MULTIPLIER[match.group(3)] + return Decimal(match.group(1)) * unit, Decimal(match.group(2)) * unit + + match = _RE_GTE.match(label) + if match: + return Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)], Decimal("Infinity") + + return None + + +def _duration_bounds(label: str) -> tuple[int, int] | None: + """공사기간 구간 라벨 → [하한일, 상한일]. 숫자 구간이 아니면 None.""" + match = _RE_DAYS_LTE.match(label) + if match: + return 0, int(match.group(1)) + + match = _RE_DAYS_RANGE.match(label) + if match: + return int(match.group(1)), int(match.group(2)) + + match = _RE_DAYS_GTE.match(label) + if match: + return int(match.group(1)), 10**9 + + return None + + +def _amount_matches(label: str, amount: Decimal) -> bool: + bounds = _bracket_bounds(label) + if bounds is None: + return False + low, high = bounds + return low <= amount < high + + +def _duration_matches(label: str, days: int) -> bool: + bounds = _duration_bounds(label) + if bounds is None: + return False + low, high = bounds + return low <= days <= high + + +def select_bracket( + brackets: list[dict[str, Any]], + *, + amount_field: str | None = None, + amount: Decimal | None = None, + duration_days: int | None = None, + duration_field: str = "duration_bracket", + equals: dict[str, Any] | None = None, + residual_label: str | None = None, + label: str, +) -> dict[str, Any]: + """구간 목록에서 한 행을 고른다. 못 고르면 `RateLookupError` — 기본값으로 안 때운다. + + `equals` 는 `work_type` 처럼 값이 그대로 맞아야 하는 열이다. + `residual_label` 은 숫자 구간이 아닌 **잔여 구간** 라벨이다(예: 고용보험료의 + `below_official_threshold`). 숫자 구간이 하나도 안 맞을 때만 쓰며, **부르는 쪽이 + 이름을 대야** 한다 — 조용한 기본값이 아니다. + """ + candidates = list(brackets) + + if equals: + for key, expected in equals.items(): + candidates = [row for row in candidates if row.get(key) == expected] + + if amount_field is not None and amount is not None: + candidates = [ + row for row in candidates if _amount_matches(str(row.get(amount_field, "")), amount) + ] + + if duration_days is not None: + candidates = [ + row + for row in candidates + if _duration_matches(str(row.get(duration_field, "")), duration_days) + ] + + if not candidates and residual_label is not None and amount_field is not None: + candidates = [row for row in brackets if row.get(amount_field) == residual_label] + if equals: + for key, expected in equals.items(): + candidates = [row for row in candidates if row.get(key) == expected] + + if not candidates: + raise RateLookupError( + f"{label}: 조건에 맞는 요율 구간이 없습니다 " + f"(금액={amount}, 기간={duration_days}일, 조건={equals})" + ) + if len(candidates) > 1: + raise RateLookupError( + f"{label}: 요율 구간이 {len(candidates)}개 겹칩니다 — 데이터 점검 필요 " + f"({[row.get(amount_field) for row in candidates]})" + ) + return candidates[0] + + +def rate_percent(row: dict[str, Any], *, label: str) -> Decimal: + if "rate_percent" not in row: + raise RateLookupError(f"{label}: 고른 구간에 요율이 없습니다 ({row})") + return Decimal(str(row["rate_percent"])) + + +def base_amount(row: dict[str, Any]) -> Decimal: + """구간에 딸린 기초액(안전관리비 등). 없으면 0.""" + return Decimal(str(row.get("base_amount_krw", 0))) + + +def flat_rate(dataset: RateDataset, name: str) -> Decimal: + """구간이 없는 단일 요율(산재·건강·요양·부가세 등).""" + variable = dataset.variable(name) + if "rate_percent" not in variable: + raise RateLookupError(f"{name}: 단일 요율이 아닙니다 — 구간 조회가 필요합니다") + return Decimal(str(variable["rate_percent"])) + + +def pension_rate_percent(dataset: RateDataset, year: int) -> Decimal: + """국민연금 — 연도별 특례 스케줄(2026 = 4.75 %, 2033~ 본칙 6.5 %).""" + variable = dataset.variable("rate_pension") + for row in variable.get("annual_rates", []): + if int(row.get("year", 0)) == year: + return Decimal(str(row["rate_percent"])) + fallback = variable.get("rate_from_2033_percent") + if fallback is None: + raise RateLookupError(f"rate_pension: {year}년 요율이 없습니다") + return Decimal(str(fallback)) diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 9649bbda..4435b560 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -20,9 +20,12 @@ 그래서 지식DB 에도 적지 않는다(사용자 지시). """ +import logging import math from typing import Callable, NamedTuple +logger = logging.getLogger(__name__) + # 소단 기본값 — 근거는 위 모듈 설명. BERM_DEFAULT_WIDTH_M = 0.5 BERM_DEFAULT_INTERVAL_M = 3.0 @@ -31,6 +34,10 @@ BERM_DEFAULT_SLOPE_DEG = 0.0 # 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. _STEP_M = 0.05 _MAX_REACH_M = 200.0 +# 걸음 수 상한 — 정상 경로의 최대는 200/0.05 = 4,000 이다. 소단은 걸음 없이 거리를 더하므로 +# 여유를 크게 두고 **10배**로 잡는다. 넘으면 조용히 자르지 않고 경고를 남긴다 — 조용히 +# 자르면 절토선이 짧아진 채 값이 나가 또 조용히 틀린다(2026-09-07 25 지적). +_MAX_STEPS = int(_MAX_REACH_M / _STEP_M) * 10 class BermSpec(NamedTuple): @@ -87,7 +94,24 @@ def cut_profile_points( in_soil = rock_boundary_z is None or elevation >= rock_boundary_z(dist) ratio = soil_cut_ratio if (rock_boundary_z is not None and in_soil) else cut_ratio + # ⚠ 제자리 무릎을 막는 자리 — 경계선 기울기가 **암 경사와 토사 경사 사이**면 「토사로 + # 바꾸면 경계 아래, 암으로 바꾸면 경계 위」가 되어 같은 자리에서 영원히 뒤집힌다 + # (보간 비율 `share` 가 0 이라 한 걸음도 안 나간다). 그러면 화면이 통째로 멈춘다 + # (2026-09-07 실사고 — 소단 한 건을 놓자 브라우저가 25분간 안 끝남). 직전 무릎 자리를 + # 들고 있다가 **같은 자리면 뒤집지 않고 한 걸음 나아간다**. + last_knee_dist = float("-inf") + steps = 0 while dist < limit: + steps += 1 + if steps > _MAX_STEPS: + logger.warning( + "절토 사면 걸음이 상한(%d)을 넘어 멈춥니다 — 거리 %.3fm, 경사비 %.3f. " + "제자리 무릎이 남아 있을 수 있습니다.", + _MAX_STEPS, + dist, + ratio, + ) + break rise = _STEP_M / ratio slant = math.hypot(_STEP_M, rise) @@ -119,12 +143,15 @@ def cut_profile_points( share = min(max(share, 0.0), 1.0) knee_dist = dist + _STEP_M * share knee_z = elevation + rise * share - slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation) - dist, elevation = knee_dist, knee_z - points.append((dist, elevation)) # 무릎 - in_soil = not in_soil - ratio = soil_cut_ratio if in_soil else cut_ratio - continue + # 앞으로 나아가는 무릎만 인정한다(위 ⚠ 참조). 같은 자리면 그냥 한 걸음 간다. + if knee_dist > last_knee_dist + 1e-9: + slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation) + dist, elevation = knee_dist, knee_z + points.append((dist, elevation)) # 무릎 + in_soil = not in_soil + ratio = soil_cut_ratio if in_soil else cut_ratio + last_knee_dist = knee_dist + continue dist, elevation = next_dist, next_z slant_since_berm += slant diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index bc12a09b..a4842290 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -25,6 +25,10 @@ export const BERM_DEFAULT_SLOPE_DEG = 0.0; /** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ const STEP_M = 0.05; const MAX_REACH_M = 200.0; +/** 걸음 수 상한 — 정상 경로의 최대는 200/0.05 = 4,000. 소단은 걸음 없이 거리를 더하므로 + * 여유를 크게 두고 10배로 잡는다. 넘으면 조용히 자르지 않고 콘솔에 알린다. + * 짝: 파이썬 `_MAX_STEPS`. */ +const MAX_STEPS = (MAX_REACH_M / STEP_M) * 10; /** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */ export interface BermSpec { @@ -71,7 +75,21 @@ export function cutProfilePoints( let inSoil = rockBoundaryZ === null || elevation >= rockBoundaryZ(dist); let ratio = rockBoundaryZ !== null && inSoil ? soilCutRatio : cutRatio; + // ⚠ 제자리 무릎을 막는 자리 — 경계선 기울기가 **암 경사와 토사 경사 사이**면 「토사로 + // 바꾸면 경계 아래, 암으로 바꾸면 경계 위」가 되어 같은 자리에서 영원히 뒤집힌다(보간 비율 + // `share` 가 0 이라 한 걸음도 안 나간다). 그러면 화면이 통째로 멈춘다(2026-09-07 실사고 — + // 소단 한 건을 놓자 브라우저가 25분간 안 끝남). 직전 무릎 자리를 들고 있다가 **같은 자리면 + // 뒤집지 않고 한 걸음 나아간다**. 짝: 파이썬 `cut_profile_points`. + let lastKneeDist = Number.NEGATIVE_INFINITY; + let steps = 0; while (dist < limit) { + steps += 1; + if (steps > MAX_STEPS) { + console.warn( + `절토 사면 걸음이 상한(${MAX_STEPS})을 넘어 멈춥니다 — 거리 ${dist.toFixed(3)}m, 경사비 ${ratio}.`, + ); + break; + } const rise = STEP_M / ratio; const slant = Math.hypot(STEP_M, rise); @@ -104,13 +122,17 @@ export function cutProfilePoints( share = Math.min(Math.max(share, 0), 1); const kneeDist = dist + STEP_M * share; const kneeZ = elevation + rise * share; - slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation); - dist = kneeDist; - elevation = kneeZ; - points.push([dist, elevation]); // 무릎 - inSoil = !inSoil; - ratio = inSoil ? soilCutRatio : cutRatio; - continue; + // 앞으로 나아가는 무릎만 인정한다(위 ⚠ 참조). 같은 자리면 그냥 한 걸음 간다. + if (kneeDist > lastKneeDist + 1e-9) { + slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation); + dist = kneeDist; + elevation = kneeZ; + points.push([dist, elevation]); // 무릎 + inSoil = !inSoil; + ratio = inSoil ? soilCutRatio : cutRatio; + lastKneeDist = kneeDist; + continue; + } } }