Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1
This commit is contained in:
@@ -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<RouteSpanBand> = [];
|
||||
// 마지막으로 밖에 알린 관 선택(누가거리) — 같은 값 재알림을 막는다.
|
||||
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);
|
||||
|
||||
@@ -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<RouteSpanBand>) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RouteSpanBand>;
|
||||
}
|
||||
|
||||
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) =>
|
||||
|
||||
@@ -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<RoutePoint>,
|
||||
spans: ReadonlyArray<RouteSpanBand>,
|
||||
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<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
): 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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
|
||||
|
||||
@@ -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<number | null> = [];
|
||||
/** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m).
|
||||
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
||||
let curveLock: CurveLock[] = [];
|
||||
let curveArc: Array<number | null> = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -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<number, (typeof curves)[number]>();
|
||||
const dropped = new Set<number>();
|
||||
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());
|
||||
|
||||
@@ -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<number | null>,
|
||||
curveRadius: Array<number | null>,
|
||||
): 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<number | null>;
|
||||
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<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
curveArc: Array<number | null>;
|
||||
}
|
||||
|
||||
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<number, EditedCurve>();
|
||||
const dropped = new Set<number>();
|
||||
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;
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export interface RouteEditSnapshot {
|
||||
planned: Vertex[];
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
/** 무엇을 붙들고 있나 — 반지름 | 곡선 길이 | 없음 (`_Edits.ts`). */
|
||||
curveLock: Array<"radius" | "arc" | null>;
|
||||
/** 길이를 붙들었을 때의 그 길이(m). */
|
||||
curveArc: Array<number | null>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<label class="b05-routeedit__curve-field">반지름
|
||||
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-radius"
|
||||
title="반지름 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||||
</label>
|
||||
<label class="b05-routeedit__curve-field">곡선 길이
|
||||
<input type="number" class="b05-routeedit__curve-arc" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
|
||||
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||||
</label>
|
||||
<span class="b05-routeedit__curve-info"></span>`;
|
||||
host.append(root);
|
||||
<span class="b05-routeedit__curve-info"></span>
|
||||
<span class="b05-routeedit__curve-note">칸을 비우면 자동</span>`;
|
||||
document.body.append(root);
|
||||
|
||||
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
|
||||
const seatText = root.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
|
||||
const toggle = root.querySelector<HTMLButtonElement>('[data-act="curve-toggle"]')!;
|
||||
const radius = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-radius")!;
|
||||
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 info = root.querySelector<HTMLElement>(".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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user