Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -109,6 +109,10 @@ export const STATE_REGISTRY = {
|
||||
culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` },
|
||||
/** 암 경계선 오프셋(측점별). */
|
||||
rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` },
|
||||
/** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07).
|
||||
* 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야
|
||||
* 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */
|
||||
cutslope: { bucket: "draft", scope: "route" },
|
||||
/** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9.
|
||||
* 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을
|
||||
* 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */
|
||||
|
||||
@@ -505,10 +505,65 @@
|
||||
"placement": "interval",
|
||||
"style": {
|
||||
"color": "#2ec5dc",
|
||||
"abbr": "소단"
|
||||
"abbr": "소단구"
|
||||
},
|
||||
"drawing_views": ["cross_section", "quantity"],
|
||||
"options": []
|
||||
"options": [
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 10,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "before_m",
|
||||
"label": "기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "after_m",
|
||||
"label": "기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "width_m",
|
||||
"label": "폭",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 0.5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "interval_m",
|
||||
"label": "간격(사면길이)",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 3,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "slope_deg",
|
||||
"label": "사면 쪽 기울기",
|
||||
"input": "number",
|
||||
"unit": "°",
|
||||
"default": 0,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "chute",
|
||||
@@ -910,6 +965,73 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "berm",
|
||||
"group": "C",
|
||||
"name": "소단",
|
||||
"placement": "interval",
|
||||
"style": {
|
||||
"color": "#7f9a63",
|
||||
"abbr": "소단"
|
||||
},
|
||||
"drawing_views": ["profile", "cross_section", "quantity"],
|
||||
"options": [
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 10,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "before_m",
|
||||
"label": "기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "after_m",
|
||||
"label": "기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "width_m",
|
||||
"label": "폭",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 0.5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "interval_m",
|
||||
"label": "간격(사면길이)",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 3,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "slope_deg",
|
||||
"label": "안쪽 기울기",
|
||||
"input": "number",
|
||||
"unit": "°",
|
||||
"default": 0,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "erosion_check",
|
||||
"group": "D",
|
||||
|
||||
@@ -29,15 +29,39 @@ import { showToast } from "@ui/ui_template_elements";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
|
||||
import { dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
bindRouteEditNavigation,
|
||||
contourBandRect,
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
segmentAtScreen,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import {
|
||||
centerDirectionOf,
|
||||
createCurveLabel,
|
||||
deflectionRad,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import {
|
||||
bindHistoryControls,
|
||||
createRouteEditHistory,
|
||||
type RouteEditHistory,
|
||||
type RouteEditSnapshot,
|
||||
} from "./B05_Profile_UI_RouteEdit_History";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
|
||||
const NODE_HIT_PX = 9;
|
||||
/** 노드 반지름(px). */
|
||||
const NODE_R = 4;
|
||||
/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */
|
||||
const DRAG_THRESHOLD_PX = 3;
|
||||
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
|
||||
const SEGMENT_HIT_PX = 12;
|
||||
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
|
||||
*
|
||||
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
||||
* 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을
|
||||
* 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */
|
||||
const CONTOUR_BAND_M = 300;
|
||||
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
||||
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
||||
const CURVE_HANDLE_PX = 5;
|
||||
@@ -56,28 +80,24 @@ export async function openRouteEditModal(
|
||||
<div class="b05-routeedit__head">
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__hint">
|
||||
노드를 끌어 옮기고, 선을 두 번 누르면 노드가 생깁니다. 노드 오른쪽 클릭은 삭제.
|
||||
노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 ·
|
||||
노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
|
||||
<div class="b05-routeedit__curve" hidden>
|
||||
<span class="b05-routeedit__curve-label">고른 곡선</span>
|
||||
<label class="b05-routeedit__curve-field">R
|
||||
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
</label>
|
||||
<span class="b05-routeedit__curve-info"></span>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="curve-off">곡선 지우기</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="curve-on" hidden>곡선 넣기</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="curve-auto">반지름 자동</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__foot">
|
||||
<span class="b05-routeedit__status">노선을 읽는 중…</span>
|
||||
<span class="b05-routeedit__legend">
|
||||
<i class="is-expected"></i> 예상노선(원본)
|
||||
<i class="is-planned"></i> 계획노선
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="undo" title="되돌리기 (Ctrl+Z)"
|
||||
disabled>↶ 되돌리기</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="redo" title="다시하기 (Ctrl+Y)"
|
||||
disabled>↷ 다시하기</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="history-reset"
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="reset">예상노선으로</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="cancel">취소</button>
|
||||
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply">확인</button>
|
||||
@@ -110,6 +130,8 @@ export async function openRouteEditModal(
|
||||
let curveRadius: Array<number | null> = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||
let history: RouteEditHistory | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let sheets: PreparedLayer[] = [];
|
||||
let view: ViewState = {
|
||||
@@ -125,6 +147,7 @@ export async function openRouteEditModal(
|
||||
const close = (): void => {
|
||||
closed = true;
|
||||
window.removeEventListener("resize", resize);
|
||||
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
@@ -189,6 +212,16 @@ export async function openRouteEditModal(
|
||||
context.fillRect(0, 0, view.width, view.height);
|
||||
|
||||
context.save();
|
||||
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
||||
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
||||
const band = meta
|
||||
? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M)
|
||||
: null;
|
||||
if (band) {
|
||||
context.beginPath();
|
||||
context.rect(band.x, band.y, band.width, band.height);
|
||||
context.clip();
|
||||
}
|
||||
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
|
||||
context.lineWidth = 0.8;
|
||||
for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot");
|
||||
@@ -239,100 +272,64 @@ export async function openRouteEditModal(
|
||||
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
||||
context.lineWidth = 2;
|
||||
curveInfo.forEach((curve) => {
|
||||
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
|
||||
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
|
||||
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
|
||||
const on = curveOn[curve.node_first] !== false;
|
||||
if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다.
|
||||
if (!on) return; // 곡선을 지운 자리에는 접선점도 없다.
|
||||
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
|
||||
const isPicked = curve.node_first === picked;
|
||||
[curve.start, curve.end].forEach((point) => {
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
context.beginPath();
|
||||
context.rect(
|
||||
x - CURVE_HANDLE_PX,
|
||||
y - CURVE_HANDLE_PX,
|
||||
CURVE_HANDLE_PX * 2,
|
||||
CURVE_HANDLE_PX * 2,
|
||||
);
|
||||
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
|
||||
context.rect(x - size, y - size, size * 2, size * 2);
|
||||
context.fillStyle = isPicked
|
||||
? style.getPropertyValue("--map-route") || "#f97316"
|
||||
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
context.fill();
|
||||
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.stroke();
|
||||
});
|
||||
});
|
||||
context.restore();
|
||||
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
|
||||
syncCurveBar();
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */
|
||||
function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null {
|
||||
let best: { curve: number; end: "start" | "end" } | null = null;
|
||||
let bestDistance = NODE_HIT_PX + 2;
|
||||
curveInfo.forEach((curve, index) => {
|
||||
(["start", "end"] as const).forEach((which) => {
|
||||
const point = which === "start" ? curve.start : curve.end;
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance <= bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = { curve: index, end: which };
|
||||
}
|
||||
});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
|
||||
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
|
||||
handleAtScreen(curveInfo, 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 =>
|
||||
segmentAtScreen(planned, toScreen, px, py, SEGMENT_HIT_PX);
|
||||
|
||||
/** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */
|
||||
function dragHandleTo(
|
||||
curveIndex: number,
|
||||
node: number,
|
||||
which: "start" | "end",
|
||||
to: Vertex,
|
||||
): { apex: Vertex; radius: number } | null {
|
||||
const curve = curveInfo[curveIndex];
|
||||
const curve = curveInfo.find((entry) => entry.node_first === node);
|
||||
if (!curve) return null;
|
||||
const node = curve.node_first;
|
||||
const before = planned[node - 1];
|
||||
const after = planned[node + 1];
|
||||
if (!before || !after) return null;
|
||||
return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to);
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */
|
||||
function nodeAt(px: number, py: number): number {
|
||||
let best = -1;
|
||||
let bestDistance = NODE_HIT_PX;
|
||||
planned.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance <= bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. */
|
||||
function segmentAt(px: number, py: number): number {
|
||||
let best = -1;
|
||||
let bestDistance = 12;
|
||||
for (let index = 0; index < planned.length - 1; index += 1) {
|
||||
const [ax, ay] = toScreen(planned[index]);
|
||||
const [bx, by] = toScreen(planned[index + 1]);
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lengthSquared = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
|
||||
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다.
|
||||
* 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */
|
||||
/** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②).
|
||||
*
|
||||
* 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부
|
||||
* 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다.
|
||||
* 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때
|
||||
* 서버가 정본으로 다시 내는 것은 그대로다. */
|
||||
function markEdited(): void {
|
||||
plannedLine = [];
|
||||
nodeInfo = [];
|
||||
curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다.
|
||||
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
|
||||
plannedLine = built.vertices;
|
||||
curveInfo = built.curves;
|
||||
nodeInfo = built.nodes;
|
||||
}
|
||||
|
||||
/** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */
|
||||
@@ -356,100 +353,114 @@ export async function openRouteEditModal(
|
||||
);
|
||||
}
|
||||
|
||||
// ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ──
|
||||
const curveBar = overlay.querySelector<HTMLElement>(".b05-routeedit__curve")!;
|
||||
const curveLabel = curveBar.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
|
||||
const curveRadiusInput = curveBar.querySelector<HTMLInputElement>(
|
||||
".b05-routeedit__curve-radius",
|
||||
)!;
|
||||
const curveInfoText = curveBar.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
||||
const curveOffBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-off"]')!;
|
||||
const curveOnBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-on"]')!;
|
||||
const curveAutoBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-auto"]')!;
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
|
||||
const curveLabelBox = createCurveLabel(canvas.parentElement!, {
|
||||
onRadius: (value) => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
function syncCurveBar(): void {
|
||||
const editable = picked > 0 && picked < planned.length - 1;
|
||||
curveBar.hidden = !editable;
|
||||
if (!editable) return;
|
||||
const on = curveOn[picked] !== false;
|
||||
curveLabel.textContent = `${picked + 1}번째 꺾임점`;
|
||||
curveOffBtn.hidden = !on;
|
||||
curveOnBtn.hidden = on;
|
||||
curveRadiusInput.disabled = !on;
|
||||
curveAutoBtn.disabled = !on || curveRadius[picked] === null;
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
curveLabelBox.hide();
|
||||
return;
|
||||
}
|
||||
const forced = curveRadius[picked];
|
||||
const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null;
|
||||
curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10);
|
||||
const inner = nodeInfo[picked]?.inner_angle_deg;
|
||||
curveInfoText.textContent = on
|
||||
? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
|
||||
` · 법정 하한 ${minRadiusM}m`
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = forced ?? pickedCurve?.radius_m ?? null;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveLabelBox.show({
|
||||
seat: picked,
|
||||
at: toScreen(planned[picked]),
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
toScreen(pickedCurve.start),
|
||||
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,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
minRadiusM,
|
||||
});
|
||||
}
|
||||
|
||||
curveRadiusInput.addEventListener("change", () => {
|
||||
if (picked < 0) return;
|
||||
const value = Number(curveRadiusInput.value);
|
||||
curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
||||
function applyEdit(message: string, record = true): void {
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`;
|
||||
status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`;
|
||||
draw();
|
||||
if (record) history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
}
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
function snapshotNow(): RouteEditSnapshot {
|
||||
return { planned, curveOn, curveRadius, picked };
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
overlay,
|
||||
getHistory: () => history,
|
||||
restore: (snapshot, message) => {
|
||||
planned = snapshot.planned;
|
||||
curveOn = snapshot.curveOn;
|
||||
curveRadius = snapshot.curveRadius;
|
||||
picked = snapshot.picked;
|
||||
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
||||
},
|
||||
});
|
||||
|
||||
curveOffBtn.addEventListener("click", () => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = false;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`;
|
||||
draw();
|
||||
});
|
||||
|
||||
curveOnBtn.addEventListener("click", () => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = true;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`;
|
||||
draw();
|
||||
});
|
||||
|
||||
curveAutoBtn.addEventListener("click", () => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다.
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`;
|
||||
draw();
|
||||
});
|
||||
|
||||
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
// ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
// 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다.
|
||||
let dragNode = -1;
|
||||
/** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */
|
||||
let dragHandle: { curve: number; end: "start" | "end" } | null = null;
|
||||
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
/** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */
|
||||
let dragHandle: { node: number; end: "start" | "end" } | null = null;
|
||||
/** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다
|
||||
* (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */
|
||||
let dragMoved = false;
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
// 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작).
|
||||
dragHandle = handleAt(px, py);
|
||||
dragNode = dragHandle ? -1 : nodeAt(px, py);
|
||||
if (dragHandle) {
|
||||
picked = curveInfo[dragHandle.curve]?.node_first ?? -1;
|
||||
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
|
||||
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
|
||||
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
|
||||
dragNode = nodeAt(px, py);
|
||||
dragHandle = dragNode >= 0 ? null : handleAt(px, py);
|
||||
dragMoved = false;
|
||||
if (dragNode >= 0) {
|
||||
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else if (dragNode >= 0) {
|
||||
picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다.
|
||||
} else if (dragHandle) {
|
||||
picked = dragHandle.node;
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else {
|
||||
panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY };
|
||||
}
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
@@ -460,52 +471,47 @@ export async function openRouteEditModal(
|
||||
const py = event.clientY - rect.top;
|
||||
if (dragHandle) {
|
||||
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
|
||||
const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py));
|
||||
const node = dragHandle.node;
|
||||
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
|
||||
if (moved) {
|
||||
const node = curveInfo[dragHandle.curve].node_first;
|
||||
planned[node] = moved.apex;
|
||||
curveRadius[node] = Math.round(moved.radius * 100) / 100;
|
||||
curveOn[node] = true;
|
||||
picked = node;
|
||||
// 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다.
|
||||
const which = dragHandle.end === "start" ? "start" : "end";
|
||||
curveInfo[dragHandle.curve] = {
|
||||
...curveInfo[dragHandle.curve],
|
||||
apex: [moved.apex[0], moved.apex[1]],
|
||||
radius_m: moved.radius,
|
||||
[which]: toMetric(px, py),
|
||||
} as (typeof curveInfo)[number];
|
||||
plannedLine = [];
|
||||
nodeInfo = [];
|
||||
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
|
||||
// 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
|
||||
dragMoved = true;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`;
|
||||
draw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dragNode >= 0) {
|
||||
dragMoved = true;
|
||||
planned[dragNode] = toMetric(px, py);
|
||||
markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다.
|
||||
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
|
||||
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
|
||||
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
|
||||
status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
if (panFrom) {
|
||||
if (Math.hypot(px - panFrom.x, py - panFrom.y) < DRAG_THRESHOLD_PX) return;
|
||||
view = {
|
||||
...view,
|
||||
offsetX: panFrom.offsetX + (px - panFrom.x),
|
||||
offsetY: panFrom.offsetY + (py - panFrom.y),
|
||||
};
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default";
|
||||
canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default";
|
||||
});
|
||||
|
||||
const endDrag = (event: PointerEvent): void => {
|
||||
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
||||
// 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번
|
||||
// 눌러야 한다. 놓는 순간에만 쌓는다.
|
||||
if (dragMoved) {
|
||||
history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
}
|
||||
dragNode = -1;
|
||||
dragHandle = null;
|
||||
panFrom = null;
|
||||
dragMoved = false;
|
||||
};
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
@@ -521,10 +527,7 @@ export async function openRouteEditModal(
|
||||
curveOn.splice(segment + 1, 0, true);
|
||||
curveRadius.splice(segment + 1, 0, null);
|
||||
picked = segment + 1;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`;
|
||||
draw();
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
});
|
||||
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
@@ -540,33 +543,19 @@ export async function openRouteEditModal(
|
||||
curveOn.splice(index, 1);
|
||||
curveRadius.splice(index, 1);
|
||||
picked = -1;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`;
|
||||
draw();
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
});
|
||||
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2;
|
||||
const nextScale = Math.max(1, Math.min(2000, view.scale * factor));
|
||||
const ratio = nextScale / view.scale;
|
||||
// 커서 아래 지점이 제자리에 남도록 이동량을 함께 고친다.
|
||||
view = {
|
||||
...view,
|
||||
scale: nextScale,
|
||||
offsetX: px - (px - view.offsetX) * ratio,
|
||||
offsetY: py - (py - view.offsetY) * ratio,
|
||||
};
|
||||
draw();
|
||||
// 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용.
|
||||
bindRouteEditNavigation({
|
||||
canvas,
|
||||
getView: () => view,
|
||||
setView: (next) => {
|
||||
view = next;
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
getMeta: () => meta,
|
||||
draw,
|
||||
});
|
||||
|
||||
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
||||
busy.hidden = false;
|
||||
@@ -654,12 +643,18 @@ export async function openRouteEditModal(
|
||||
inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg,
|
||||
violations: curve ? (curve.violations ?? []) : (node.violations ?? []),
|
||||
});
|
||||
curveOn.push(true);
|
||||
// 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(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 });
|
||||
});
|
||||
picked = -1;
|
||||
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
historyControls.sync();
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
|
||||
@@ -1,16 +1,57 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Curve.ts
|
||||
* 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄
|
||||
* (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다.
|
||||
* 계획노선 편집의 **기하 셈** — 화면·DOM 을 안 만지는 순수 함수만 둔다.
|
||||
* (편집 모달 `B05_Profile_UI_RouteEdit.ts` 에서 떼어냄, 700줄 제한 2026-09-07)
|
||||
*
|
||||
* ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 —
|
||||
* 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다.
|
||||
* 두 벌이 아니라 **짝**이며, 왕복이 제자리인지는
|
||||
* `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다.
|
||||
* 두 가지가 들어 있다.
|
||||
*
|
||||
* ① **손잡이 → 교각점·반지름**(`dragHandleTo`) — 서버 셈의 **반대 방향**이다. 서버는
|
||||
* 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다.
|
||||
* 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다.
|
||||
*
|
||||
* ② **노드 → 폴리라인**(`buildEditedPolyline`) — ⚠⚠ **짝**:
|
||||
* `common_util/common_util_route_polyline.py` 의 `build_planned_polyline` 중
|
||||
* **편집 갈래**(`simplify=False` + `curve_flags`/`radii`)와 같은 값을 내야 한다.
|
||||
* 거울 시험: `tmp/tests/test_route_polyline_browser_mirror.py`.
|
||||
*
|
||||
* 왜 브라우저에도 두나(2026-09-07 사용자 지적 ①②) — 곡선을 서버만 그리면 노드를 잡는
|
||||
* 순간 그려 둔 선을 통째로 버려야 해서 **곡선이 전부 사라진 것처럼 보인다**. 값은 남아
|
||||
* 있는데 화면만 「지워졌다」고 말하니 더 나쁘다. 조작 중에는 브라우저가 같은 규칙으로
|
||||
* 즉시 그리고, [확인] 때 서버가 정본으로 다시 낸다(CLAUDE.md 「계산 자리」 ① 짝).
|
||||
*
|
||||
* 옮기지 않은 것 — 단순화·IP 추출·반지름 피팅(`_fit_radius_m`)은 **예상노선을 처음
|
||||
* 폴리라인으로 바꿀 때만** 쓰는 것이라 편집 갈래에서는 돌지 않는다(`node_indices` 가 None).
|
||||
* ========================================================================== */
|
||||
|
||||
export type Vertex = [number, number];
|
||||
|
||||
/** 곡선 성분 하나 — 서버 `RoutePlanCurve` 와 **같은 꼴**이라 그대로 바꿔 쓸 수 있다.
|
||||
*
|
||||
* ⚠ 여기서 다시 적는 이유 — 이 파일은 거울 시험이 `tsc` 로 **혼자 컴파일**하므로 바깥
|
||||
* 모듈을 들이지 않는다(경로 별칭 `@config/…` 가 딸려 와 컴파일이 깨진다). */
|
||||
export interface EditedCurve {
|
||||
apex: [number, number];
|
||||
radius_m: number;
|
||||
tangent_m: number;
|
||||
inner_angle_deg: number;
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
node_first: number;
|
||||
node_last: number;
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
/** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */
|
||||
const DUPLICATE_TOLERANCE_M = 0.5;
|
||||
/** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */
|
||||
const ARC_STEP_DEG = 5.0;
|
||||
/** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면
|
||||
* 90° 가 89.999…9 로 떨어져 원호 점 수가 하나 어긋난다(2026-09-07 거울 시험에서 실제로 남). */
|
||||
const DEG_PER_RAD = 180 / Math.PI;
|
||||
const RAD_PER_DEG = Math.PI / 180;
|
||||
/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */
|
||||
export const HAIRPIN_MIN_RADIUS_M = 10.0;
|
||||
|
||||
/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */
|
||||
export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null {
|
||||
const dx1 = a2[0] - a1[0];
|
||||
@@ -33,7 +74,7 @@ export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number
|
||||
const lb = Math.hypot(bx, by);
|
||||
if (la <= 0 || lb <= 0) return 180;
|
||||
const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb)));
|
||||
return (Math.acos(cosine) * 180) / Math.PI;
|
||||
return Math.acos(cosine) * DEG_PER_RAD;
|
||||
}
|
||||
|
||||
/** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」.
|
||||
@@ -59,10 +100,204 @@ export function dragHandleTo(
|
||||
: intersect(before, oldApex, to, after); // 나가는 직선을 돌린다
|
||||
if (!apex) return null;
|
||||
const inner = innerAngleDeg(before, apex, after);
|
||||
const halfTan = Math.tan(((180 - inner) * Math.PI) / 360);
|
||||
const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2);
|
||||
if (!(halfTan > 1e-9)) return null;
|
||||
const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]);
|
||||
const radius = tangent / halfTan;
|
||||
if (!(radius > 0) || !Number.isFinite(radius)) return null;
|
||||
return { apex, radius };
|
||||
}
|
||||
|
||||
const distance = (a: Vertex, b: Vertex): number => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
|
||||
/** 짝: `_unit`. from → to 방향의 단위벡터. 같은 자리면 (0,0). */
|
||||
function unit(from: Vertex, to: Vertex): Vertex {
|
||||
const dx = to[0] - from[0];
|
||||
const dy = to[1] - from[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= 0) return [0, 0];
|
||||
return [dx / length, dy / length];
|
||||
}
|
||||
|
||||
/** 짝: `_turn_sign`. 도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0. */
|
||||
function turnSign(before: Vertex, at: Vertex, after: Vertex): number {
|
||||
const cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]);
|
||||
if (Math.abs(cross) <= 1e-9) return 0;
|
||||
return cross > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
/** 짝: `_arc_geometry`. 반지름 하나에 대한 (접선시작, 접선끝, 중심). 못 끼우면 null. */
|
||||
function arcGeometry(
|
||||
before: Vertex,
|
||||
at: Vertex,
|
||||
after: Vertex,
|
||||
innerDeg: number,
|
||||
radius: number,
|
||||
halfTan: number,
|
||||
): { start: Vertex; end: Vertex; center: Vertex } | null {
|
||||
const tangent = radius * halfTan;
|
||||
const toBefore = unit(at, before);
|
||||
const toAfter = unit(at, after);
|
||||
const start: Vertex = [at[0] + toBefore[0] * tangent, at[1] + toBefore[1] * tangent];
|
||||
const end: Vertex = [at[0] + toAfter[0] * tangent, at[1] + toAfter[1] * tangent];
|
||||
const bisector: Vertex = [toBefore[0] + toAfter[0], toBefore[1] + toAfter[1]];
|
||||
const bisectorLength = Math.hypot(bisector[0], bisector[1]);
|
||||
if (bisectorLength <= 1e-9) return null;
|
||||
const centerDistance = radius / Math.sin((innerDeg * RAD_PER_DEG) / 2);
|
||||
const center: Vertex = [
|
||||
at[0] + (bisector[0] / bisectorLength) * centerDistance,
|
||||
at[1] + (bisector[1] / bisectorLength) * centerDistance,
|
||||
];
|
||||
return { start, end, center };
|
||||
}
|
||||
|
||||
/** 짝: `_arc_points`. 원호 위 점(양 끝은 빼고 — 부르는 쪽이 붙인다). */
|
||||
function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolean): Vertex[] {
|
||||
const radius = distance(center, start);
|
||||
if (radius <= 0) return [];
|
||||
const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]);
|
||||
const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]);
|
||||
let sweep = endAngle - startAngle;
|
||||
if (clockwise) {
|
||||
while (sweep > 0) sweep -= 2 * Math.PI;
|
||||
} else {
|
||||
while (sweep < 0) sweep += 2 * Math.PI;
|
||||
}
|
||||
const steps = Math.max(1, Math.trunc(Math.abs(sweep * DEG_PER_RAD) / ARC_STEP_DEG));
|
||||
const points: Vertex[] = [];
|
||||
for (let step = 1; step < steps; step += 1) {
|
||||
const angle = startAngle + (sweep * step) / steps;
|
||||
points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */
|
||||
export interface EditedNode {
|
||||
inner_angle_deg: number | null;
|
||||
radius_m: number | null;
|
||||
tangent_m: number | null;
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
export interface EditedPolyline {
|
||||
/** 그려 보이는 선(원호 포함). */
|
||||
vertices: Vertex[];
|
||||
/** 곡선 성분 — 손잡이·R 칸의 재료. `node_first`/`node_last` 는 **넘긴 목록의 자리**다. */
|
||||
curves: EditedCurve[];
|
||||
/** 넘긴 목록과 **자리가 같은** 노드 요약. */
|
||||
nodes: EditedNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 짝: `build_planned_polyline` 의 편집 갈래. 노드·곡선 켬끔·반지름으로 폴리라인을 만든다.
|
||||
*
|
||||
* `curveOn[i]` 가 거짓이면 그 자리에 곡선을 두지 않는다(직선이 그대로 꺾인다).
|
||||
* `curveRadius[i]` 가 있으면 그 반지름으로 못박고, 없으면 법정 하한을 쓴다.
|
||||
* 접선 자리가 모자라면 **줄이되 막지 않고** 위반으로 표시한다(서버와 같은 규칙).
|
||||
*
|
||||
* ⚠ 서버는 0.5m 안에 겹친 점을 버린다. 여기서도 같이 버리되, 돌려주는 자리 번호는
|
||||
* **넘긴 목록 기준**으로 되돌려 놓는다 — 화면이 잡고 있는 배열이 그것이기 때문이다.
|
||||
*/
|
||||
export function buildEditedPolyline(
|
||||
points: Vertex[],
|
||||
curveOn: boolean[],
|
||||
curveRadius: Array<number | null>,
|
||||
minRadiusM: number,
|
||||
hairpinMinRadiusM: number = HAIRPIN_MIN_RADIUS_M,
|
||||
): EditedPolyline {
|
||||
const nodes: EditedNode[] = points.map(() => ({
|
||||
inner_angle_deg: null,
|
||||
radius_m: null,
|
||||
tangent_m: null,
|
||||
violations: [],
|
||||
}));
|
||||
|
||||
// 겹친 점 버리기 — 편집값과 **함께** 걸러야 자리가 어긋나지 않는다.
|
||||
const cleaned: Vertex[] = [];
|
||||
const flags: boolean[] = [];
|
||||
const forcedRadii: Array<number | null> = [];
|
||||
const origin: number[] = []; // cleaned 자리 → 넘긴 목록 자리
|
||||
points.forEach((point, index) => {
|
||||
if (cleaned.length && distance(cleaned[cleaned.length - 1], point) <= DUPLICATE_TOLERANCE_M) {
|
||||
return;
|
||||
}
|
||||
cleaned.push(point);
|
||||
flags.push(curveOn[index] !== false);
|
||||
forcedRadii.push(curveRadius[index] ?? null);
|
||||
origin.push(index);
|
||||
});
|
||||
|
||||
if (cleaned.length < 3) return { vertices: [...cleaned], curves: [], nodes };
|
||||
|
||||
for (let index = 1; index < cleaned.length - 1; index += 1) {
|
||||
nodes[origin[index]].inner_angle_deg = innerAngleDeg(
|
||||
cleaned[index - 1],
|
||||
cleaned[index],
|
||||
cleaned[index + 1],
|
||||
);
|
||||
}
|
||||
|
||||
const vertices: Vertex[] = [cleaned[0]];
|
||||
const curves: EditedCurve[] = [];
|
||||
let cursor = 0; // 아직 선에 안 실은 첫 꺾임점
|
||||
|
||||
for (let at = 1; at < cleaned.length - 1; at += 1) {
|
||||
if (!flags[at]) continue; // 곡선을 지운 자리 — 직선이 그대로 꺾인다.
|
||||
const entryFrom = cleaned[at - 1];
|
||||
const apex = cleaned[at];
|
||||
const exitTo = cleaned[at + 1];
|
||||
const node = nodes[origin[at]];
|
||||
|
||||
const inner = innerAngleDeg(entryFrom, apex, exitTo);
|
||||
const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2);
|
||||
// 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만.
|
||||
const available = Math.min(distance(entryFrom, apex), distance(apex, exitTo)) / 2;
|
||||
if (!(halfTan > 1e-9) || available <= 0) continue;
|
||||
|
||||
const forced = forcedRadii[at];
|
||||
let radius = forced !== null && forced > 0 ? forced : minRadiusM;
|
||||
let tangent = radius * halfTan;
|
||||
if (tangent > available) {
|
||||
radius = available / halfTan;
|
||||
tangent = available;
|
||||
}
|
||||
|
||||
const geometry = arcGeometry(entryFrom, apex, exitTo, inner, radius, halfTan);
|
||||
if (radius <= 0 || geometry === null) continue;
|
||||
|
||||
if (radius < minRadiusM) {
|
||||
node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`);
|
||||
}
|
||||
if (radius < hairpinMinRadiusM) {
|
||||
node.violations.push(
|
||||
`배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`,
|
||||
);
|
||||
}
|
||||
node.radius_m = radius;
|
||||
node.tangent_m = tangent;
|
||||
curves.push({
|
||||
apex: [apex[0], apex[1]],
|
||||
radius_m: radius,
|
||||
tangent_m: tangent,
|
||||
inner_angle_deg: inner,
|
||||
start: [geometry.start[0], geometry.start[1]],
|
||||
end: [geometry.end[0], geometry.end[1]],
|
||||
node_first: origin[at],
|
||||
node_last: origin[at],
|
||||
violations: [...node.violations],
|
||||
});
|
||||
|
||||
// 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다.
|
||||
for (let index = cursor + 1; index < at; index += 1) vertices.push(cleaned[index]);
|
||||
cursor = at;
|
||||
|
||||
const clockwise = turnSign(entryFrom, apex, exitTo) < 0;
|
||||
vertices.push(geometry.start);
|
||||
vertices.push(...arcPoints(geometry.center, geometry.start, geometry.end, clockwise));
|
||||
vertices.push(geometry.end);
|
||||
}
|
||||
|
||||
for (let index = cursor + 1; index < cleaned.length; index += 1) vertices.push(cleaned[index]);
|
||||
return { vertices, curves, nodes };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_History.ts
|
||||
* 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시).
|
||||
*
|
||||
* 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니
|
||||
* **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로
|
||||
* 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다.
|
||||
*
|
||||
* 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로
|
||||
* 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다.
|
||||
* ========================================================================== */
|
||||
|
||||
export type Vertex = [number, number];
|
||||
|
||||
/** 되돌릴 수 있는 편집 상태 한 벌. */
|
||||
export interface RouteEditSnapshot {
|
||||
planned: Vertex[];
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
picked: number;
|
||||
}
|
||||
|
||||
/** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */
|
||||
const MAX_STEPS = 100;
|
||||
|
||||
export interface RouteEditHistory {
|
||||
/** 편집 한 번이 끝났다 — 지금 상태를 사진으로 쌓는다(다시하기 갈래는 버린다). */
|
||||
commit: (snapshot: RouteEditSnapshot) => void;
|
||||
/** 한 걸음 뒤로. 되돌릴 것이 없으면 null. */
|
||||
undo: () => RouteEditSnapshot | null;
|
||||
/** 한 걸음 앞으로. 없으면 null. */
|
||||
redo: () => RouteEditSnapshot | null;
|
||||
/** 창을 연 상태로. 이미 그 상태면 null. */
|
||||
reset: () => RouteEditSnapshot | null;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
/** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */
|
||||
isDirty: () => boolean;
|
||||
}
|
||||
|
||||
/** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */
|
||||
function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
|
||||
return {
|
||||
planned: snapshot.planned.map(([x, y]): Vertex => [x, y]),
|
||||
curveOn: [...snapshot.curveOn],
|
||||
curveRadius: [...snapshot.curveRadius],
|
||||
picked: snapshot.picked,
|
||||
};
|
||||
}
|
||||
|
||||
/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */
|
||||
function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
|
||||
if (a.planned.length !== b.planned.length) return false;
|
||||
for (let index = 0; index < a.planned.length; index += 1) {
|
||||
if (a.planned[index][0] !== b.planned[index][0]) return false;
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 첫 사진(창을 연 상태)으로 이력을 연다. */
|
||||
export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHistory {
|
||||
const steps: RouteEditSnapshot[] = [clone(initial)];
|
||||
let at = 0;
|
||||
|
||||
return {
|
||||
commit(snapshot) {
|
||||
// 되돌린 뒤 새로 고치면 앞쪽 갈래는 버린다 — 흔한 되돌리기 규칙 그대로.
|
||||
steps.length = at + 1;
|
||||
steps.push(clone(snapshot));
|
||||
if (steps.length > MAX_STEPS) steps.shift();
|
||||
at = steps.length - 1;
|
||||
},
|
||||
undo() {
|
||||
if (at <= 0) return null;
|
||||
at -= 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
redo() {
|
||||
if (at >= steps.length - 1) return null;
|
||||
at += 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
reset() {
|
||||
// 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다.
|
||||
if (sameRoute(steps[at], steps[0])) return null;
|
||||
// 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다.
|
||||
steps.length = at + 1;
|
||||
steps.push(clone(steps[0]));
|
||||
at = steps.length - 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
canUndo: () => at > 0,
|
||||
canRedo: () => at < steps.length - 1,
|
||||
isDirty: () => !sameRoute(steps[at], steps[0]),
|
||||
};
|
||||
}
|
||||
|
||||
export interface HistoryControlsParams {
|
||||
/** 단추가 들어 있는 칸 — `[data-act]` 로 찾는다. */
|
||||
overlay: HTMLElement;
|
||||
/** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */
|
||||
getHistory: () => RouteEditHistory | null;
|
||||
/** 사진 한 벌을 화면에 되살린다. */
|
||||
restore: (snapshot: RouteEditSnapshot, message: string) => void;
|
||||
}
|
||||
|
||||
/** [초기화]·[되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다.
|
||||
*
|
||||
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을
|
||||
* 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */
|
||||
export function bindHistoryControls(params: HistoryControlsParams): {
|
||||
sync: () => void;
|
||||
dispose: () => void;
|
||||
} {
|
||||
const { overlay, getHistory, restore } = params;
|
||||
const undoBtn = overlay.querySelector<HTMLButtonElement>('[data-act="undo"]')!;
|
||||
const redoBtn = overlay.querySelector<HTMLButtonElement>('[data-act="redo"]')!;
|
||||
const resetBtn = overlay.querySelector<HTMLButtonElement>('[data-act="history-reset"]')!;
|
||||
|
||||
const sync = (): void => {
|
||||
const history = getHistory();
|
||||
undoBtn.disabled = !history?.canUndo();
|
||||
redoBtn.disabled = !history?.canRedo();
|
||||
resetBtn.disabled = !history?.isDirty();
|
||||
};
|
||||
|
||||
const step = (which: "undo" | "redo" | "reset"): void => {
|
||||
const history = getHistory();
|
||||
if (!history) return;
|
||||
const snapshot = history[which]();
|
||||
if (!snapshot) return;
|
||||
restore(
|
||||
snapshot,
|
||||
which === "undo"
|
||||
? "되돌렸습니다."
|
||||
: which === "redo"
|
||||
? "다시 했습니다."
|
||||
: "창을 연 상태로 돌렸습니다.",
|
||||
);
|
||||
sync();
|
||||
};
|
||||
|
||||
undoBtn.addEventListener("click", () => step("undo"));
|
||||
redoBtn.addEventListener("click", () => step("redo"));
|
||||
resetBtn.addEventListener("click", () => step("reset"));
|
||||
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "z" && !event.shiftKey) step("undo");
|
||||
else if (key === "y" || (key === "z" && event.shiftKey)) step("redo");
|
||||
else return;
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return { sync, dispose: () => window.removeEventListener("keydown", onKey) };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Input.ts
|
||||
* 노선 편집 모달의 **지도 조작** — 휠 확대/축소와 가운데 버튼 팬.
|
||||
*
|
||||
* ⚠ 배수유역도(`B05_Profile_UI_Drainage_Interact.ts`)와 **같은 동작**이어야 한다
|
||||
* (2026-09-07 사용자 지시 ⑤). 한쪽만 고치면 두 지도가 서로 다르게 움직인다.
|
||||
* · 휠을 **당기면 확대**(`deltaY > 0` → 확대) · 계수 1.15 / 0.87
|
||||
* · 배율 상한은 「화면 폭 16m」로 계산(`computeMaxScale`), 하한 0.5
|
||||
* · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼이 팬까지 겸하면 노드를 집으려다 지도가
|
||||
* 딸려 움직인다. 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다.
|
||||
*
|
||||
* ⚠ 커서 고정은 **화면 중심 기준**으로 셈한다 — `affineOf` 가 `centerX*(1−scale)` 을 품고
|
||||
* 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그).
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
/** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */
|
||||
const ZOOM_IN_FACTOR = 1.15;
|
||||
const ZOOM_OUT_FACTOR = 0.87;
|
||||
/** 배율 하한 — 배수유역도와 같다(전체보다 조금 더 뒤로 뺄 수 있게). */
|
||||
const MIN_SCALE = 0.5;
|
||||
/** 배율 상한을 못 구할 때 쓸 값 — 배수유역도와 같은 자리에 같은 수. */
|
||||
const MAX_SCALE_FALLBACK = 16;
|
||||
|
||||
export interface RouteEditNavigationParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
getView: () => ViewState;
|
||||
setView: (next: ViewState) => void;
|
||||
getMeta: () => VWorldMeta | null;
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */
|
||||
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
|
||||
const { canvas, getView, setView, getMeta, draw } = params;
|
||||
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
const view = getView();
|
||||
const maxScale = computeMaxScale(
|
||||
getMeta(),
|
||||
view.mapRect.width,
|
||||
view.width,
|
||||
MAX_SCALE_FALLBACK,
|
||||
);
|
||||
const factor = event.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
|
||||
const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor));
|
||||
const ratio = scale / view.scale;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
|
||||
const cursorX = event.clientX - rect.left - rect.width / 2;
|
||||
const cursorY = event.clientY - rect.top - rect.height / 2;
|
||||
setView({
|
||||
...view,
|
||||
scale,
|
||||
offsetX: cursorX * (1 - ratio) + view.offsetX * ratio,
|
||||
offsetY: cursorY * (1 - ratio) + view.offsetY * ratio,
|
||||
});
|
||||
draw();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
// 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다.
|
||||
if (event.button === 1) event.preventDefault();
|
||||
// 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫.
|
||||
if (event.pointerType === "mouse" && event.button !== 1) return;
|
||||
const view = getView();
|
||||
dragStart = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
offsetX: view.offsetX,
|
||||
offsetY: view.offsetY,
|
||||
};
|
||||
canvas.style.cursor = "grabbing";
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!dragStart) return;
|
||||
setView({
|
||||
...getView(),
|
||||
offsetX: dragStart.offsetX + event.clientX - dragStart.x,
|
||||
offsetY: dragStart.offsetY + event.clientY - dragStart.y,
|
||||
});
|
||||
draw();
|
||||
});
|
||||
|
||||
const stop = (): void => {
|
||||
if (!dragStart) return;
|
||||
dragStart = null;
|
||||
canvas.style.removeProperty("cursor");
|
||||
};
|
||||
canvas.addEventListener("pointerup", stop);
|
||||
canvas.addEventListener("pointercancel", stop);
|
||||
}
|
||||
|
||||
/* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */
|
||||
|
||||
export type ScreenOf = (point: [number, number]) => [number, number];
|
||||
|
||||
/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */
|
||||
export function nodeAtScreen(
|
||||
points: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
hitPx: number,
|
||||
): number {
|
||||
let best = -1;
|
||||
let bestDistance = hitPx;
|
||||
points.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance <= bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(접선점). 없으면 null.
|
||||
*
|
||||
* 곡선 **전부**를 본다 — 접선점은 늘 그려지므로 늘 잡혀야 한다. 노드를 못 집던 문제는
|
||||
* 부르는 쪽에서 **노드를 먼저** 보는 것으로 풀었다(2026-09-07).
|
||||
* 잡은 것은 곡선 목록 자리가 아니라 **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로
|
||||
* 자리로 들면 끄는 도중 엉뚱한 곡선을 가리킨다. */
|
||||
export function handleAtScreen(
|
||||
curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
hitPx: number,
|
||||
): { node: number; end: "start" | "end" } | null {
|
||||
let best: { node: number; end: "start" | "end" } | null = null;
|
||||
let bestDistance = hitPx;
|
||||
curves.forEach((curve) => {
|
||||
(["start", "end"] as const).forEach((which) => {
|
||||
const point = which === "start" ? curve.start : curve.end;
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance <= bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = { node: curve.node_first, end: which };
|
||||
}
|
||||
});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */
|
||||
export function segmentAtScreen(
|
||||
points: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
maxPx: number,
|
||||
): number {
|
||||
let best = -1;
|
||||
let bestDistance = maxPx;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const [ax, ay] = toScreen(points[index]);
|
||||
const [bx, by] = toScreen(points[index + 1]);
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lengthSquared = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
|
||||
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null.
|
||||
*
|
||||
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는
|
||||
* 미터로 정해 두므로 **창 크기와 무관**하다(2026-09-07 사용자 지시 ⑥). */
|
||||
export function contourBandRect(
|
||||
line: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
bandM: number,
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
if (line.length < 2) return null;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const [x, y] of line) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
// 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다.
|
||||
const [left, bottom] = toScreen([minX - bandM, minY - bandM]);
|
||||
const [right, top] = toScreen([maxX + bandM, maxY + bandM]);
|
||||
return {
|
||||
x: Math.min(left, right),
|
||||
y: Math.min(top, bottom),
|
||||
width: Math.abs(right - left),
|
||||
height: Math.abs(bottom - top),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Label.ts
|
||||
* 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다.
|
||||
*
|
||||
* 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이
|
||||
* 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다.
|
||||
*
|
||||
* **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이
|
||||
* 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」
|
||||
* 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다.
|
||||
*
|
||||
* **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을
|
||||
* 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
|
||||
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
|
||||
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
|
||||
return ((180 - innerAngleDeg) * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 라벨을 그 반대쪽에 붙이는 데 쓴다.
|
||||
*
|
||||
* 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라
|
||||
* 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */
|
||||
export function centerDirectionOf(
|
||||
apex: [number, number],
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
): [number, number] | null {
|
||||
const arm = (point: [number, number]): [number, number] => {
|
||||
const dx = point[0] - apex[0];
|
||||
const dy = point[1] - apex[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
return length <= 1e-9 ? [0, 0] : [dx / length, dy / length];
|
||||
};
|
||||
const [ux, uy] = arm(start);
|
||||
const [vx, vy] = arm(end);
|
||||
const sx = ux + vx;
|
||||
const sy = uy + vy;
|
||||
const length = Math.hypot(sx, sy);
|
||||
return length <= 1e-9 ? null : [sx / length, sy / length];
|
||||
}
|
||||
|
||||
/** 라벨과 노드 사이 여백(px). */
|
||||
const GAP_PX = 14;
|
||||
/** 캔버스 가장자리에서 이만큼은 띄운다(px). */
|
||||
const EDGE_PX = 8;
|
||||
|
||||
export interface CurveLabelState {
|
||||
/** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */
|
||||
seat: number;
|
||||
/** 그 꺾임점의 화면 좌표(캔버스 기준 px). */
|
||||
at: [number, number];
|
||||
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다.
|
||||
* 곡선이 없으면 null — 그때는 오른쪽에 둔다. */
|
||||
centerDirection: [number, number] | null;
|
||||
/** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */
|
||||
canvas: { width: number; height: number };
|
||||
curveOn: boolean;
|
||||
/** 지금 보일 반지름(m). 곡선이 없으면 null. */
|
||||
radiusShown: number | null;
|
||||
/** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
||||
arcLengthShown: number | null;
|
||||
/** 사용자가 못박은 값인지(아니면 자동). */
|
||||
forced: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CurveLabel {
|
||||
show: (state: CurveLabelState) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */
|
||||
export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-routeedit__label";
|
||||
root.hidden = true;
|
||||
root.innerHTML = `
|
||||
<div class="b05-routeedit__label-head">
|
||||
<span class="b05-routeedit__curve-label"></span>
|
||||
<button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button>
|
||||
</div>
|
||||
<label class="b05-routeedit__curve-field">반지름
|
||||
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
</label>
|
||||
<label class="b05-routeedit__curve-field">곡선 길이
|
||||
<input type="number" class="b05-routeedit__curve-arc" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
</label>
|
||||
<span class="b05-routeedit__curve-info"></span>`;
|
||||
host.append(root);
|
||||
|
||||
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 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());
|
||||
|
||||
let curveOn = true;
|
||||
const numberOf = (input: HTMLInputElement): number | null => {
|
||||
const value = Number(input.value);
|
||||
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
};
|
||||
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
|
||||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
|
||||
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
|
||||
|
||||
/** 라벨을 **곡선 중심의 반대쪽**에 붙인다 — 상하좌우 넷 중 하나. */
|
||||
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));
|
||||
root.style.left = `${Math.round(left)}px`;
|
||||
root.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
show(state) {
|
||||
curveOn = state.curveOn;
|
||||
root.hidden = false;
|
||||
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
radius.disabled = !state.curveOn;
|
||||
arc.disabled = !state.curveOn;
|
||||
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;
|
||||
info.textContent = state.curveOn
|
||||
? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
|
||||
` · 법정 하한 ${state.minRadiusM}m · 칸을 비우면 자동`
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
place(state);
|
||||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다
|
||||
// (2026-09-07 실측: 처음 잰 높이 120px, 실제 131px 라 11px 어긋났음).
|
||||
requestAnimationFrame(() => {
|
||||
if (!root.hidden) place(state);
|
||||
});
|
||||
},
|
||||
hide() {
|
||||
root.hidden = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -131,21 +131,47 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시).
|
||||
바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */
|
||||
.b05-routeedit__curve {
|
||||
/* 바닥줄과 같이 **줄어들지 않게** 둔다 — 안 그러면 캔버스(flex:1)가 자리를 다 먹고
|
||||
이 줄이 눌려 단추가 캔버스 밑에 깔린다(2026-09-07 실화면에서 클릭이 가로채였음). */
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* R 라벨 — 고른 꺾임점 **옆에 떠 있는** 작은 상자(2026-09-07 사용자 지시 ③).
|
||||
예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다.
|
||||
자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */
|
||||
.b05-routeedit__label {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 12rem;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
/* 모달 본체와 **같은 토큰**을 쓴다 — `--color-surface-2` 는 이 테마에 없어 밝은
|
||||
기본값으로 떨어지면서 다크 화면에서 글자가 안 읽혔다(2026-09-07 실화면). */
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 45%);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__label-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface-2, #f9fafb);
|
||||
font-size: var(--font-size-13, 13px);
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b05-routeedit__label-toggle {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routeedit__label-toggle:disabled {
|
||||
color: var(--color-text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-label {
|
||||
@@ -154,30 +180,32 @@
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-field {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-radius {
|
||||
width: 5.5rem;
|
||||
.b05-routeedit__curve-radius,
|
||||
.b05-routeedit__curve-arc {
|
||||
width: 5rem;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-radius:disabled {
|
||||
background: var(--color-surface-3, #f3f4f6);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
.b05-routeedit__curve-radius:disabled,
|
||||
.b05-routeedit__curve-arc:disabled {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routeedit__curve-info {
|
||||
flex: 1 1 auto;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 15rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@ export async function previewCrossDesigns(
|
||||
rockBoundaryOffsets?: Record<string, number>;
|
||||
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
|
||||
berms?: Record<string, { width_m: number; interval_m: number; slope_deg: number }>;
|
||||
/** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */
|
||||
cutSlopeRatios?: Record<string, number>;
|
||||
},
|
||||
): Promise<CrossDesignPreviewResponse> {
|
||||
return requestJson<CrossDesignPreviewResponse>(
|
||||
@@ -267,6 +269,7 @@ export async function previewCrossDesigns(
|
||||
full_designs: options?.fullDesigns ?? false,
|
||||
rock_boundary_offsets: options?.rockBoundaryOffsets ?? null,
|
||||
berms: options?.berms ?? null,
|
||||
cut_slope_ratios: options?.cutSlopeRatios ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -464,6 +464,9 @@ export interface CrossDesign {
|
||||
/** 이 측점에 놓인 소단 제원(계획서 3-9). 없으면 계단 없음.
|
||||
* 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 계단이 서게 되싣는 값이다. */
|
||||
berm?: { width_m: number; interval_m: number; slope_deg: number };
|
||||
/** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07).
|
||||
* 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */
|
||||
cut_slope_ratio_user?: number;
|
||||
}
|
||||
|
||||
export interface CrossDesignResponse {
|
||||
@@ -485,6 +488,8 @@ export interface CrossDesignRequest {
|
||||
rock_boundary_offset_m?: number | null;
|
||||
/** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */
|
||||
two_stage_slope?: boolean;
|
||||
/** 이 측점만 쓰는 암 절토 경사비(1:n 의 n). 없으면 표준값(2026-09-07). */
|
||||
cut_slope_ratio?: number | null;
|
||||
/** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */
|
||||
ditch_enabled?: boolean | null;
|
||||
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
|
||||
@@ -497,6 +502,8 @@ export interface CrossSectionPatch {
|
||||
rock_boundary_offset_m?: number;
|
||||
/** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */
|
||||
display_half_width_m?: number;
|
||||
/** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */
|
||||
cut_slope_ratio_user?: number;
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
toAlignmentBase,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
|
||||
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
|
||||
export interface CrossRefreshEdits {
|
||||
@@ -83,6 +84,8 @@ export const USER_TOUCHED_KEYS = [
|
||||
"revet_follow_grade",
|
||||
// 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
|
||||
"berm",
|
||||
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
|
||||
"cut_slope_ratio_user",
|
||||
] as const;
|
||||
|
||||
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
||||
@@ -154,6 +157,23 @@ function bermPayload(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다.
|
||||
*
|
||||
* **0 은 「표준값을 씀」**이다(되돌리기). 그래서 0 을 거르지 않고 그대로 싣는다 — 거르면
|
||||
* 저장분에 남은 옛 사용자 값이 되살아난다.
|
||||
*/
|
||||
function readCutSlopeRatios(projectId: string, routeId: number): Map<number, number> {
|
||||
const out = new Map<number, number>();
|
||||
const raw = readState<Record<string, number>>("cutslope", projectId, routeId);
|
||||
if (!raw) return out;
|
||||
for (const [chainage, ratio] of Object.entries(raw)) {
|
||||
const at = Number(chainage);
|
||||
if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 안에서 전 측점을 다시 계산한다(정상 경로).
|
||||
*
|
||||
@@ -184,6 +204,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
|
||||
const rockOffsets = readRockOffsets(projectId, input.routeId);
|
||||
const rockDefault = readRockBoundaryDefault(projectId);
|
||||
// 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로
|
||||
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
|
||||
const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId);
|
||||
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
||||
// 순간 계단이 사라진다(계획서 3-9).
|
||||
const bermSpans = readBermSpans(projectId, input.routeId);
|
||||
@@ -195,6 +218,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
};
|
||||
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
|
||||
// (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]).
|
||||
/** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */
|
||||
const cutSlopeAt = (chainageM: number, design: Record<string, unknown>): number | null => {
|
||||
const session = cutSlopeRatios.get(rockKey(chainageM));
|
||||
if (session !== undefined) return session > 0 ? session : null;
|
||||
const stored = design.cut_slope_ratio_user;
|
||||
return typeof stored === "number" && stored > 0 ? stored : null;
|
||||
};
|
||||
const choices = crossDesignChoices(projectId, input.routeId);
|
||||
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
|
||||
|
||||
@@ -237,6 +267,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m),
|
||||
rockBoundaryOffsetM,
|
||||
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
|
||||
twoStageSlope:
|
||||
choice?.two_stage_slope ??
|
||||
(design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)),
|
||||
@@ -276,6 +307,10 @@ async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
|
||||
fullDesigns: true,
|
||||
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
|
||||
berms: bermPayload(projectId, routeId, detail),
|
||||
// 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만
|
||||
// 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07).
|
||||
cutSlopeRatios:
|
||||
readState<Record<string, number>>("cutslope", projectId, routeId) ?? undefined,
|
||||
},
|
||||
);
|
||||
if (shouldApply && !shouldApply()) return [];
|
||||
|
||||
@@ -568,6 +568,7 @@ def compute_cross_design(
|
||||
standard: dict[str, Any] | None = None,
|
||||
rock_boundary_offset_m: float | None = None,
|
||||
two_stage_slope: bool = True,
|
||||
cut_slope_ratio: float | None = None,
|
||||
ditch_enabled: bool | None = None,
|
||||
surface_drop_m: float = 0.0,
|
||||
plan_radius_m: float | None = None,
|
||||
@@ -584,6 +585,8 @@ def compute_cross_design(
|
||||
standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순.
|
||||
rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용.
|
||||
two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제).
|
||||
cut_slope_ratio: 이 측점만 쓰는 절토 경사비(1:n 의 n). 사용자가 카드에서 넣은 값이며
|
||||
None 이면 표준 횡단면 설정값을 그대로 쓴다(2026-09-07 사용자 지시).
|
||||
plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는
|
||||
입력이며, None·45m 이상이면 확폭이 없다.
|
||||
curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다
|
||||
@@ -607,6 +610,11 @@ def compute_cross_design(
|
||||
if ditch_type == "l_type" and preset_key != "rock":
|
||||
raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.")
|
||||
group = _resolve_group(preset_key, standard)
|
||||
# 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다.
|
||||
# ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 결과에 값만
|
||||
# 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다.
|
||||
if cut_slope_ratio is not None and float(cut_slope_ratio) > 0:
|
||||
group = {**group, "cut_slope_ratio": float(cut_slope_ratio)}
|
||||
paved_group = _resolve_group("paved", standard)
|
||||
# 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
|
||||
cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"]
|
||||
|
||||
@@ -39,6 +39,10 @@ _FORM_BY_TYPE = {
|
||||
}
|
||||
|
||||
|
||||
# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다).
|
||||
BERM_TYPE_ID = "berm"
|
||||
|
||||
|
||||
def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록."""
|
||||
try:
|
||||
@@ -48,6 +52,10 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
||||
definition = types.get(structure.type_id)
|
||||
if definition is None or definition.group != "C" or definition.placement != "interval":
|
||||
continue
|
||||
# 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
|
||||
# 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9).
|
||||
if structure.type_id == BERM_TYPE_ID:
|
||||
continue
|
||||
start, end = structure.start_m, structure.end_m
|
||||
if start is None or end is None:
|
||||
continue
|
||||
|
||||
@@ -44,6 +44,7 @@ from B06_Section.B06_Section_Router_Design import (
|
||||
ford_drop_at,
|
||||
ford_surface_drops,
|
||||
stored_berm,
|
||||
stored_cut_slope,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
attach_default_designs as _attach_default_designs,
|
||||
@@ -555,6 +556,7 @@ async def preview_cross_designs(
|
||||
request.rock_boundary_offsets,
|
||||
project_root,
|
||||
request.berms,
|
||||
request.cut_slope_ratios,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(rebuild)
|
||||
@@ -636,6 +638,8 @@ async def compute_cross_section_design(
|
||||
standard=request.standard_cross_section,
|
||||
rock_boundary_offset_m=request.rock_boundary_offset_m,
|
||||
two_stage_slope=request.two_stage_slope,
|
||||
# 측점별 암 절토 경사 — 요청값이 없으면 저장분에서 잇는다(2026-09-07).
|
||||
cut_slope_ratio=request.cut_slope_ratio or stored_cut_slope(stored_design or {}),
|
||||
ditch_enabled=request.ditch_enabled,
|
||||
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
|
||||
berm=stored_berm(stored_design or {}),
|
||||
|
||||
@@ -140,6 +140,9 @@ USER_TOUCHED_KEYS = (
|
||||
"revet_follow_grade",
|
||||
# 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
|
||||
"berm",
|
||||
# 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다
|
||||
# (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」).
|
||||
"cut_slope_ratio_user",
|
||||
)
|
||||
|
||||
|
||||
@@ -193,6 +196,7 @@ def enforce_pavement_ranges(
|
||||
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
),
|
||||
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
||||
cut_slope_ratio=stored_cut_slope(design),
|
||||
ditch_enabled=design.get("ditch_enabled"),
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
berm=stored_berm(design),
|
||||
@@ -245,6 +249,7 @@ def enforce_ford_surface_drops(
|
||||
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
),
|
||||
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
||||
cut_slope_ratio=stored_cut_slope(design),
|
||||
ditch_enabled=design.get("ditch_enabled"),
|
||||
surface_drop_m=wanted,
|
||||
berm=stored_berm(design),
|
||||
@@ -346,6 +351,18 @@ def attach_default_designs(
|
||||
continue
|
||||
|
||||
|
||||
def stored_cut_slope(stored: dict[str, Any]) -> float | None:
|
||||
"""저장분에 남은 측점별 암 절토 경사비 — 없거나 0(표준값으로 되돌림)이면 None.
|
||||
|
||||
⚠ 소단과 같은 성격이다 — **경사는 나르는 값이 아니라 기하 입력**이라 계산에 넣어야 한다.
|
||||
다시 계산한 뒤 키만 베껴 붙이면 설계선은 옛 경사로 나오고 값만 새것이 되어 어긋난다.
|
||||
"""
|
||||
value = stored.get("cut_slope_ratio_user")
|
||||
if isinstance(value, (int, float)) and float(value) > 0:
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
|
||||
"""저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC)."""
|
||||
spec = stored.get("berm")
|
||||
@@ -369,6 +386,7 @@ def recompute_designs_for_alignment(
|
||||
rock_boundary_offsets: dict[str, float] | None = None,
|
||||
project_root: Path | None = None,
|
||||
berms: dict[str, dict[str, float]] | None = None,
|
||||
cut_slope_ratios: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
modes = default_section_modes(longitudinal)
|
||||
pavement = pavement_suggestions(longitudinal)
|
||||
@@ -378,6 +396,13 @@ def recompute_designs_for_alignment(
|
||||
round(float(record["chainage_m"]), 3): (record.get("design") or {})
|
||||
for record in stored_designs
|
||||
}
|
||||
# 측점별 암 절토 경사(2026-09-07) — 0 은 「표준값을 씀」이라 저장분을 덮어 지운다.
|
||||
session_cut_slopes: dict[float, float] = {}
|
||||
for raw_key, ratio in (cut_slope_ratios or {}).items():
|
||||
try:
|
||||
session_cut_slopes[round(float(raw_key), 3)] = float(ratio)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
session_offsets: dict[float, float] = {}
|
||||
for raw_key, offset in (rock_boundary_offsets or {}).items():
|
||||
try:
|
||||
@@ -415,6 +440,11 @@ def recompute_designs_for_alignment(
|
||||
stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M),
|
||||
),
|
||||
two_stage_slope=bool(stored.get("two_stage_slope", True)),
|
||||
cut_slope_ratio=(
|
||||
(session_cut_slopes[key] or None)
|
||||
if key in session_cut_slopes
|
||||
else stored_cut_slope(stored)
|
||||
),
|
||||
ditch_enabled=stored.get("ditch_enabled"),
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
berm=session_berms.get(key) or stored_berm(stored),
|
||||
|
||||
@@ -52,6 +52,9 @@ class CrossDesignRequest(BaseModel):
|
||||
rock_boundary_offset_m: float | None = None
|
||||
# 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능.
|
||||
two_stage_slope: bool = True
|
||||
# 이 측점만 쓰는 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시).
|
||||
# None 이면 표준 횡단면 설정값을 쓴다.
|
||||
cut_slope_ratio: float | None = Field(default=None, gt=0)
|
||||
# 측구 생성 여부. None=자동 판정(측구측 절토면만 생성), True/False=수동 override.
|
||||
ditch_enabled: bool | None = None
|
||||
# B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순.
|
||||
@@ -136,6 +139,10 @@ class CrossSectionPatch(BaseModel):
|
||||
rock_boundary_offset_m: float | None = None
|
||||
# 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06).
|
||||
display_half_width_m: float | None = Field(default=None, gt=0)
|
||||
# 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시).
|
||||
# **0 은 「표준값을 씀」**(되돌리기)이라 gt=0 이 아니라 ge=0 이다 — 0 이 와야
|
||||
# 정본에 남은 옛 사용자 값이 지워진다.
|
||||
cut_slope_ratio_user: float | None = Field(default=None, ge=0)
|
||||
inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None
|
||||
basin_adjust: BasinAdjustPatch | None = None
|
||||
# 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
@@ -302,6 +309,9 @@ class CrossDesignPreviewRequest(BaseModel):
|
||||
# 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9).
|
||||
# 값이 없는 측점은 소단 없음 — 종전 설계 그대로다.
|
||||
berms: dict[str, dict[str, float]] | None = None
|
||||
# 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을
|
||||
# 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다.
|
||||
cut_slope_ratios: dict[str, float] | None = None
|
||||
|
||||
def edits(self) -> dict[str, Any]:
|
||||
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
|
||||
|
||||
@@ -56,7 +56,11 @@ async function withDraftWalls(
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
const names = new Map(
|
||||
types
|
||||
.filter((type) => type.group === "C" && type.placement === "interval")
|
||||
// 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
|
||||
// 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9).
|
||||
.filter(
|
||||
(type) => type.group === "C" && type.placement === "interval" && type.type_id !== "berm",
|
||||
)
|
||||
.map((type) => [type.type_id, type.name] as const),
|
||||
);
|
||||
if (!names.size) return detail;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Berm_Panel.ts
|
||||
* 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9).
|
||||
*
|
||||
* 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려
|
||||
* (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다
|
||||
* 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지
|
||||
* 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다.
|
||||
*
|
||||
* 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고,
|
||||
* 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서
|
||||
* 정본으로 나간다(CLAUDE.md 5장 데이터 3층).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
} from "@util/common_util_cross_berm";
|
||||
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
import { formatStation } from "../B05_Profile/B05_Profile_Util_Station";
|
||||
import { buildGroup } from "./B06_Section_UI_Page_Common";
|
||||
import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist";
|
||||
|
||||
/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */
|
||||
const DEFAULT_BEFORE_M = 5;
|
||||
const DEFAULT_AFTER_M = 5;
|
||||
|
||||
export interface BermPanelDeps {
|
||||
/** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */
|
||||
getInterval: () => number;
|
||||
/** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */
|
||||
target: () => { projectId: string; routeId: number } | null;
|
||||
/** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export interface BermPanel {
|
||||
root: HTMLElement;
|
||||
/** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
function numberField(
|
||||
label: string,
|
||||
value: number,
|
||||
step: string,
|
||||
): ReturnType<typeof createInputField> {
|
||||
const field = createInputField({ label, type: "number" });
|
||||
field.input.step = step;
|
||||
field.input.min = "0";
|
||||
field.input.value = String(value);
|
||||
return field;
|
||||
}
|
||||
|
||||
export function createBermPanel(deps: BermPanelDeps): BermPanel {
|
||||
const root = buildGroup("소단");
|
||||
|
||||
const anchor = stationFields("기준 측점", () => deps.getInterval());
|
||||
const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5");
|
||||
const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5");
|
||||
const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1");
|
||||
const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5");
|
||||
const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5");
|
||||
|
||||
const spanRow = document.createElement("div");
|
||||
spanRow.className = "b05-structure__grid";
|
||||
spanRow.append(beforeField.root, afterField.root);
|
||||
|
||||
const specRow = document.createElement("div");
|
||||
specRow.className = "b05-structure__grid";
|
||||
specRow.append(widthField.root, intervalField.root);
|
||||
|
||||
const slopeRow = document.createElement("div");
|
||||
slopeRow.className = "b05-structure__grid";
|
||||
slopeRow.append(slopeField.root);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b05-route__irregular-list";
|
||||
|
||||
let selected = -1;
|
||||
|
||||
const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() });
|
||||
const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() });
|
||||
removeButton.classList.add("is-danger");
|
||||
removeButton.disabled = true;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b06-profile__field-row";
|
||||
actions.append(addButton, removeButton);
|
||||
|
||||
root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list);
|
||||
|
||||
function spans(): BermSpan[] {
|
||||
const target = deps.target();
|
||||
return target ? readBermSpans(target.projectId, target.routeId) : [];
|
||||
}
|
||||
|
||||
function save(next: BermSpan[]): void {
|
||||
const target = deps.target();
|
||||
if (!target) return;
|
||||
writeBermSpans(target.projectId, target.routeId, next);
|
||||
render();
|
||||
deps.onChange();
|
||||
}
|
||||
|
||||
function readNumber(field: { input: HTMLInputElement }, fallback: number): number {
|
||||
const parsed = Number(field.input.value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function add(): void {
|
||||
const interval = deps.getInterval();
|
||||
const chainage = anchor.read(interval, true);
|
||||
if (chainage === null) {
|
||||
showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error");
|
||||
return;
|
||||
}
|
||||
const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M);
|
||||
const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M);
|
||||
if (width <= 0 || gap <= 0) {
|
||||
showToast("소단 폭과 간격은 0보다 커야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
const before = readNumber(beforeField, DEFAULT_BEFORE_M);
|
||||
const after = readNumber(afterField, DEFAULT_AFTER_M);
|
||||
save([
|
||||
...spans(),
|
||||
{
|
||||
start_m: Math.max(chainage - before, 0),
|
||||
end_m: chainage + after,
|
||||
width_m: width,
|
||||
interval_m: gap,
|
||||
slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG),
|
||||
},
|
||||
]);
|
||||
selected = -1;
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
if (selected < 0) return;
|
||||
const next = spans().filter((_span, index) => index !== selected);
|
||||
selected = -1;
|
||||
save(next);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const interval = deps.getInterval();
|
||||
const current = spans();
|
||||
list.replaceChildren();
|
||||
removeButton.disabled = selected < 0 || selected >= current.length;
|
||||
if (!current.length) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "b05-route__irregular-empty";
|
||||
empty.textContent = "놓은 소단이 없습니다.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
current.forEach((span, index) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "b05-route__irregular-item";
|
||||
item.classList.toggle("is-selected", index === selected);
|
||||
const where = document.createElement("strong");
|
||||
where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`;
|
||||
const what = document.createElement("span");
|
||||
what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`;
|
||||
item.append(where, what);
|
||||
item.addEventListener("click", () => {
|
||||
selected = index === selected ? -1 : index;
|
||||
render();
|
||||
});
|
||||
list.append(item);
|
||||
});
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
reload(): void {
|
||||
selected = -1;
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
||||
import { buildCutSlopeControl, type CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import {
|
||||
buildDesignControls,
|
||||
buildRockBoundaryControl,
|
||||
@@ -122,11 +123,12 @@ export function appendCardHeader(
|
||||
card.append(header);
|
||||
}
|
||||
|
||||
/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선 제어. */
|
||||
/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선·암 절토 경사 제어. */
|
||||
export function appendCardFooter(
|
||||
card: HTMLElement,
|
||||
section: CrossSection,
|
||||
rockBoundary?: RockBoundaryControl,
|
||||
cutSlope?: CutSlopeControl,
|
||||
): void {
|
||||
const footer = document.createElement("footer");
|
||||
const center = document.createElement("span");
|
||||
@@ -138,5 +140,12 @@ export function appendCardFooter(
|
||||
rockControl.classList.add("b06-cross-card__rockb");
|
||||
footer.append(rockControl);
|
||||
}
|
||||
// 암 절토 경사(각도)는 암 측점에만 — 토사 측점은 암반이 없어 쓰이지 않는다
|
||||
// (2026-09-07 사용자 확정). 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이다.
|
||||
if (cutSlope && section.design?.geometry_preset === "rock") {
|
||||
const slopeControl = buildCutSlopeControl(section, cutSlope);
|
||||
slopeControl.classList.add("b06-cross-card__cutslope");
|
||||
footer.append(slopeControl);
|
||||
}
|
||||
card.append(footer);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_CutSlope.ts
|
||||
* 측점 하나의 **암 절토 경사** 입력칸(2026-09-07 사용자 지시).
|
||||
*
|
||||
* 사용자 원문 — 「개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함 / 전체 변경을 위해서는
|
||||
* 좌측 패널의 표준 횡단면 설정을 이용」. 그래서 **전체 기본값은 좌측 패널**, **이 칸은 그 측점
|
||||
* 하나**만 바꾼다. 표준을 바꿔도 여기서 만진 측점은 그대로다(사용자 값이라 재계산이 살려 둔다).
|
||||
*
|
||||
* 화면에는 **각도(°)**로 보이고 속으로는 경사비(1:n 의 n)로 다닌다 — 소단 폼의
|
||||
* 「안쪽 기울기 (°)」와 같은 말법이다. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
|
||||
*
|
||||
* ⚠ 경사는 **나르는 값이 아니라 기하 입력**이다. 계산이 끝난 결과에 값만 베껴 붙이면 설계선은
|
||||
* 옛 경사로 그려지고 숫자만 새것이 된다 — 값은 반드시 **계산 전에** 넘어가야 한다
|
||||
* (`computeCrossDesign(cutSlopeRatio)` · `compute_cross_design(cut_slope_ratio=…)`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
|
||||
/** 측점별 암 절토 경사 제어기 — Page 가 세션 저장소와 연결해 구현한다. */
|
||||
export interface CutSlopeControl {
|
||||
/** 세션 → design 저장값 → 표준값 순으로 지금 경사비를 돌려준다. */
|
||||
ratioFor: (section: CrossSection) => number;
|
||||
/** 이 측점의 표준값(되돌릴 자리) — 사용자가 안 만진 상태의 값. */
|
||||
standardRatioFor: (section: CrossSection) => number;
|
||||
/** 경사비를 넣는다. null 이면 표준값으로 되돌린다. */
|
||||
set: (chainageM: number, ratio: number | null) => void;
|
||||
}
|
||||
|
||||
/** 경사비(1:n 의 n) → 수평에서 잰 각도(°). n 이 작을수록 급하다. */
|
||||
export function ratioToDegrees(ratio: number): number {
|
||||
return (Math.atan(1 / Math.max(ratio, 1e-6)) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/** 각도(°) → 경사비(1:n 의 n). 1~89° 밖은 사면이 서지 않아 잘라 받는다. */
|
||||
export function degreesToRatio(degrees: number): number {
|
||||
const clamped = Math.min(Math.max(degrees, 1), 89);
|
||||
return 1 / Math.tan((clamped * Math.PI) / 180);
|
||||
}
|
||||
|
||||
/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
|
||||
function sameDegrees(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < 0.05;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 하단에 서는 「암 절토 68.2° ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다.
|
||||
*
|
||||
* 되돌리기(↺)는 **표준 횡단면 설정값**으로 돌려놓는다 — 좌측 패널을 그 뒤에 바꾸면 이 측점도
|
||||
* 따라간다(사용자가 만진 흔적이 지워지므로).
|
||||
*/
|
||||
export function buildCutSlopeControl(section: CrossSection, control: CutSlopeControl): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b06-design__seg b06-design__cutslope";
|
||||
const legend = document.createElement("span");
|
||||
legend.className = "b06-design__seg-legend";
|
||||
legend.textContent = "암 절토";
|
||||
wrap.append(legend);
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.className = "b06-design__seg-buttons";
|
||||
const ratio = control.ratioFor(section);
|
||||
const standard = control.standardRatioFor(section);
|
||||
const degrees = ratioToDegrees(ratio);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.className = "b06-design__cutslope-input";
|
||||
input.step = "0.5";
|
||||
input.min = "1";
|
||||
input.max = "89";
|
||||
input.value = degrees.toFixed(1);
|
||||
input.title =
|
||||
`이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` +
|
||||
`지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
|
||||
`전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`;
|
||||
const commit = (): void => {
|
||||
const entered = Number(input.value);
|
||||
if (!Number.isFinite(entered)) {
|
||||
input.value = degrees.toFixed(1);
|
||||
return;
|
||||
}
|
||||
// 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다.
|
||||
const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered);
|
||||
control.set(section.chainage_m, next);
|
||||
};
|
||||
input.addEventListener("change", commit);
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}
|
||||
});
|
||||
// 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도.
|
||||
input.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
|
||||
const unit = document.createElement("span");
|
||||
unit.className = "b06-design__cutslope-unit";
|
||||
unit.textContent = "°";
|
||||
|
||||
const reset = document.createElement("button");
|
||||
reset.type = "button";
|
||||
reset.className = "b06-design__rockb-btn is-reset";
|
||||
reset.textContent = "↺";
|
||||
reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`;
|
||||
reset.disabled = sameDegrees(degrees, ratioToDegrees(standard));
|
||||
reset.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
control.set(section.chainage_m, null);
|
||||
});
|
||||
|
||||
group.append(input, unit, reset);
|
||||
wrap.append(group);
|
||||
return wrap;
|
||||
}
|
||||
@@ -69,11 +69,19 @@ function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
const GROUND_OPTIONS: Array<[GroundType, keyof typeof ui_locales]> = [
|
||||
["soil", "B06_Design_Ground_Soil"],
|
||||
["ripping_rock", "B06_Design_Ground_Ripping"],
|
||||
["blasting_rock", "B06_Design_Ground_Blasting"],
|
||||
];
|
||||
/**
|
||||
* 지반유형은 **「토사」 토글 하나**다(2026-09-07 사용자 확정) — 켜면 토사, **끄면 암**(기본).
|
||||
*
|
||||
* 사용자 원문 — 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만
|
||||
* 존재하고 이값은 활성화/비활성화로 반영(기본값은 비활성화)」. 까닭은 **암반 지정·범위가
|
||||
* 실무에서 애매해** 측점마다 암질을 못 박는 것이 오히려 틀리기 때문이고, 연암:경암 ·
|
||||
* 발파암:리핑암은 **설계내역에서 설계자가 비율로** 넣기로 했다(계획서 8-1).
|
||||
*
|
||||
* ⚠ 저장값은 종전 그대로 쓴다 — 끔 = `ripping_rock`, 켬 = `soil`. 옛 자료의 `blasting_rock`
|
||||
* 도 「암(끔)」으로 읽힌다. 유토곡선·수량은 암을 한 종류(리핑암)로 잡으며, 갈라 넣는 것은
|
||||
* 설계내역 몫이다.
|
||||
*/
|
||||
const GROUND_ROCK_DEFAULT: GroundType = "ripping_rock";
|
||||
const MODE_OPTIONS: Array<[SectionMode, keyof typeof ui_locales]> = [
|
||||
["left_cut", "B06_Design_Mode_LeftCut"],
|
||||
["right_cut", "B06_Design_Mode_RightCut"],
|
||||
@@ -321,7 +329,8 @@ export function buildDesignControls(
|
||||
twoStage: boolean;
|
||||
ditchEnabled: boolean | null;
|
||||
} = {
|
||||
ground: design?.ground_type ?? "soil",
|
||||
// 설계가 아직 없는 측점의 기본은 **암**이다(2026-09-07 사용자: 「기본값은 비활성화」).
|
||||
ground: design?.ground_type ?? GROUND_ROCK_DEFAULT,
|
||||
mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"),
|
||||
ditch: design?.ditch_side ?? null,
|
||||
ditchType: design?.ditch_type ?? "standard",
|
||||
@@ -353,11 +362,24 @@ export function buildDesignControls(
|
||||
});
|
||||
};
|
||||
|
||||
// 지반유형: 제목행 배치용으로 분리 반환(D-6). 라벨 삭제(3번) — 버튼만.
|
||||
const groundSegment = segment("", GROUND_OPTIONS, state.ground, (value) => {
|
||||
state.ground = value;
|
||||
emit();
|
||||
});
|
||||
// 지반유형: 제목행 배치용으로 분리 반환(D-6). 「토사」 토글 하나 — 끄면 암이다(2026-09-07).
|
||||
// 버튼명은 「토사」로 고정하고 **켜짐/꺼짐(색)**으로 상태를 보인다 — 측구 토글과 같은 꼴
|
||||
// (사용자 원문: 「토사버튼만 존재하고 이값은 활성화/비활성화로 반영」).
|
||||
const groundToggle = toggle(
|
||||
"",
|
||||
state.ground === "soil",
|
||||
L("B06_Design_Ground_Soil"),
|
||||
L("B06_Design_Ground_Soil"),
|
||||
() => {
|
||||
state.ground = state.ground === "soil" ? GROUND_ROCK_DEFAULT : "soil";
|
||||
emit();
|
||||
},
|
||||
);
|
||||
groundToggle.button.title =
|
||||
state.ground === "soil"
|
||||
? "토사 — 암반이 없어 절토는 토사 경사 하나로만 그린다. 누르면 암으로 바뀐다."
|
||||
: "암 — 암 경계선이 서고 그 아래는 암 절토각, 위는 토사 경사로 그린다. 누르면 토사로 바뀐다.";
|
||||
const groundSegment = groundToggle.wrap;
|
||||
groundSegment.classList.add("b06-design__seg--header");
|
||||
// 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성).
|
||||
// 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6).
|
||||
|
||||
@@ -134,7 +134,12 @@ function longestFillRun(
|
||||
if (!berm) return Math.abs(endOffset - startOffset) * slant;
|
||||
const low = Math.min(startOffset, endOffset);
|
||||
const high = Math.max(startOffset, endOffset);
|
||||
const bermRise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m;
|
||||
// 소단인지는 **기울기**로 가른다 — 폭으로 찾으면 못 찾는다. 설계선 꼭짓점에는 지반
|
||||
// 샘플(0.5m 격자)이 섞여 있어 폭 0.5m 짜리 소단이 두 도막으로 **쪼개지기** 때문이다
|
||||
// (2026-09-07 실측: 폭으로 찾으니 평탄부 0개). 소단 기울기는 2°(0.035)이고 성토 기울기는
|
||||
// 1:1.2~2.0(0.5~0.83)이라 절반만 잡아도 둘이 확실히 갈린다.
|
||||
const fillGradient = 1 / Math.max(design.fill_slope_ratio, 1e-6);
|
||||
const bermMaxGradient = fillGradient * 0.5;
|
||||
let longest = 0;
|
||||
let current = 0;
|
||||
const line = design.design_line;
|
||||
@@ -145,10 +150,10 @@ function longestFillRun(
|
||||
const to = Math.min(Math.max(a.offset_m, b.offset_m), high);
|
||||
if (to - from <= 1e-9) continue; // 이 도막은 사면 밖이다
|
||||
const run = Math.abs(b.offset_m - a.offset_m);
|
||||
const rise = b.elevation_m - a.elevation_m;
|
||||
const isBerm = Math.abs(run - berm.width_m) < 1e-6 && Math.abs(rise - bermRise) < 1e-6;
|
||||
if (isBerm) {
|
||||
longest = Math.max(longest, current);
|
||||
if (run <= 1e-9) continue;
|
||||
const gradient = Math.abs(b.elevation_m - a.elevation_m) / run;
|
||||
if (gradient < bermMaxGradient) {
|
||||
longest = Math.max(longest, current); // 소단에서 도막이 끊긴다
|
||||
current = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Do
|
||||
import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes";
|
||||
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
@@ -150,6 +151,8 @@ export function createCrossSectionCard(
|
||||
box?: BoxControl,
|
||||
/** 행 높이를 재며 이미 만들어 둔 기하 — 있으면 다시 계산하지 않는다(2026-09-06). */
|
||||
plotBase?: CrossPlotBase | null,
|
||||
/** 측점별 암 절토 경사 입력(2026-09-07) — 암 측점 하단 정보행에 선다. */
|
||||
cutSlope?: CutSlopeControl,
|
||||
): CrossCardElement {
|
||||
// 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고
|
||||
// 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다
|
||||
@@ -704,7 +707,7 @@ export function createCrossSectionCard(
|
||||
card.append(chartWrap);
|
||||
}
|
||||
|
||||
appendCardFooter(card, section, rockBoundary);
|
||||
appendCardFooter(card, section, rockBoundary, cutSlope);
|
||||
return card;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,14 @@ import {
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
||||
import { createBermPanel } from "./B06_Section_UI_Berm_Panel";
|
||||
import {
|
||||
bermSpansFromStructures,
|
||||
confirmCurrentSections,
|
||||
createCutSlopeStore,
|
||||
createRockBoundaryStore,
|
||||
readBermSpans,
|
||||
saveCurrentSections,
|
||||
writeBermSpans,
|
||||
type SectionPersistContext,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
@@ -46,6 +49,7 @@ import {
|
||||
} from "./B06_Section_UI_Page_Pipe_Options";
|
||||
import {
|
||||
createStandardPanel,
|
||||
effectiveStandardCross,
|
||||
rememberRockBoundaryDefault,
|
||||
type StandardPanelController,
|
||||
} from "./B06_Section_UI_Standard_Panel";
|
||||
@@ -149,7 +153,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
reveal: () => layout.setOptionsOpen(true),
|
||||
// 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4).
|
||||
// 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다.
|
||||
onMarks: (structures, types) => structureMarksSink?.(structures, types),
|
||||
onMarks: (structures, types) => {
|
||||
structureMarksSink?.(structures, types);
|
||||
syncBermSpans(structures);
|
||||
},
|
||||
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시
|
||||
// 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정).
|
||||
onStructuresChanged: () => void refreshDetailForStructures(),
|
||||
@@ -206,14 +213,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b06-profile__form";
|
||||
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
|
||||
// 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한).
|
||||
const bermPanel = createBermPanel({
|
||||
getInterval: () => stationInterval ?? 20,
|
||||
target: () =>
|
||||
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
|
||||
onChange: () => void reconcileStaleDesigns({ force: true }),
|
||||
});
|
||||
leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock);
|
||||
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
|
||||
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
||||
attachCollapsible(leftForm);
|
||||
|
||||
@@ -336,6 +336,20 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다.
|
||||
*
|
||||
* 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만
|
||||
* 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다.
|
||||
*/
|
||||
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
const next = bermSpansFromStructures(structures);
|
||||
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
|
||||
writeBermSpans(projectId, currentRouteId, next);
|
||||
void reconcileStaleDesigns({ force: true });
|
||||
}
|
||||
|
||||
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
|
||||
async function refreshDetailForStructures(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
@@ -423,6 +437,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
const rockOffsets = rockStore.offsets;
|
||||
const rockBoundaryControl = rockStore.control;
|
||||
|
||||
// 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다.
|
||||
// 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다.
|
||||
const cutSlopeStore = createCutSlopeStore({
|
||||
sessionKey: () => stateKey("cutslope", projectId, currentRouteId),
|
||||
standard: () => (projectId ? effectiveStandardCross(projectId) : null),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
recompute: (chainageM) => recomputeIfRock(chainageM),
|
||||
});
|
||||
const cutSlopeRatios = cutSlopeStore.ratios;
|
||||
|
||||
const stationControls = createStationControls({
|
||||
// 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다.
|
||||
// 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다
|
||||
@@ -452,6 +476,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationControls.revetLink,
|
||||
stationControls.ford,
|
||||
stationControls.box,
|
||||
cutSlopeStore.control,
|
||||
);
|
||||
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
||||
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
|
||||
@@ -595,6 +620,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
flushCulvertOptions: () => stationControls.flushCulvertOptions(),
|
||||
patchSources: () => ({
|
||||
rockOffsets,
|
||||
cutSlopeRatios,
|
||||
stationWidths,
|
||||
inletStructures,
|
||||
basinAdjustments,
|
||||
@@ -665,6 +691,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
|
||||
currentRouteId = context.route_id;
|
||||
rockStore.load();
|
||||
cutSlopeStore.load();
|
||||
stationControls.load();
|
||||
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다.
|
||||
void structuresPanel.load().then(restoreStructurePick);
|
||||
@@ -723,7 +750,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
|
||||
stationInterval = storedOptions.station_interval_m;
|
||||
renderSectionDetail();
|
||||
bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다.
|
||||
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
|
||||
updateActionState();
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert";
|
||||
export interface CrossPatchSources {
|
||||
/** 암 경계선 오프셋(누가거리 문자열 키). */
|
||||
rockOffsets: Map<string, number>;
|
||||
/** 측점별 암 절토 경사비(1:n 의 n). **0 은 「표준값을 씀」**(되돌리기)이다. */
|
||||
cutSlopeRatios: Map<string, number>;
|
||||
/** 측점 개별 표시 반폭(m). */
|
||||
stationWidths: Map<string, number>;
|
||||
inletStructures: Map<string, InletStructureChoice>;
|
||||
@@ -42,6 +44,10 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch
|
||||
sources.rockOffsets.forEach((offset, chainage) => {
|
||||
patchFor(Number(chainage)).rock_boundary_offset_m = offset;
|
||||
});
|
||||
// 측점별 암 절토 경사(2026-09-07) — 0 이면 「표준값을 씀」이라 정본의 옛 값을 덮어 지운다.
|
||||
sources.cutSlopeRatios.forEach((ratio, chainage) => {
|
||||
patchFor(Number(chainage)).cut_slope_ratio_user = ratio;
|
||||
});
|
||||
// 개별 표시 반폭(2026-08-06) — design에 병합돼 재접근 시 유지된다.
|
||||
sources.stationWidths.forEach((width, chainage) => {
|
||||
patchFor(Number(chainage)).display_half_width_m = width;
|
||||
|
||||
@@ -22,6 +22,11 @@ import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structure
|
||||
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||
import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
} from "@util/common_util_cross_berm";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
STRUCTURE_AREA_KEYS,
|
||||
@@ -30,6 +35,7 @@ import {
|
||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import { L } from "./B06_Section_UI_Page_Common";
|
||||
|
||||
@@ -86,6 +92,52 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp
|
||||
writeState("berm", spans, projectId, routeId);
|
||||
}
|
||||
|
||||
/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */
|
||||
export const BERM_TYPE_ID = "berm";
|
||||
|
||||
/**
|
||||
* 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다
|
||||
* (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리).
|
||||
*
|
||||
* 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을
|
||||
* 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은
|
||||
* 그 사본만 본다.
|
||||
*/
|
||||
export function bermSpansFromStructures(
|
||||
structures: ReadonlyArray<{
|
||||
type_id: string;
|
||||
start_m?: number | null;
|
||||
end_m?: number | null;
|
||||
chainage_m?: number | null;
|
||||
options?: Record<string, string | number>;
|
||||
}>,
|
||||
): BermSpan[] {
|
||||
const spans: BermSpan[] = [];
|
||||
for (const item of structures) {
|
||||
if (item.type_id !== BERM_TYPE_ID) continue;
|
||||
const anchor = item.chainage_m ?? item.start_m ?? null;
|
||||
const start = item.start_m ?? anchor;
|
||||
const end = item.end_m ?? anchor;
|
||||
if (start === null || end === null) continue;
|
||||
const options = item.options ?? {};
|
||||
const number = (key: string, fallback: number): number => {
|
||||
const parsed = Number(options[key]);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
const width = number("width_m", BERM_DEFAULT_WIDTH_M);
|
||||
const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M);
|
||||
if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다
|
||||
spans.push({
|
||||
start_m: Math.min(start, end),
|
||||
end_m: Math.max(start, end),
|
||||
width_m: width,
|
||||
interval_m: interval,
|
||||
slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
||||
});
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */
|
||||
export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null {
|
||||
const found = spans.find(
|
||||
@@ -198,6 +250,96 @@ export function createRockBoundaryStore(options: {
|
||||
};
|
||||
}
|
||||
|
||||
/** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */
|
||||
export interface CutSlopeStore {
|
||||
/** 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 가 그대로 읽는다.
|
||||
* **0 은 「표준값을 씀」**이다 — 되돌리기(↺)가 남기는 값이라, 저장분에 옛 사용자 값이
|
||||
* 남아 있어도 0 이 그것을 덮어 표준으로 돌려놓는다(지우기만 하면 옛 값이 되살아난다). */
|
||||
ratios: Map<string, number>;
|
||||
control: CutSlopeControl;
|
||||
load: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 하나만 다른 **암 절토 경사** 세션 저장소.
|
||||
*
|
||||
* 암 경계선 저장소와 같은 꼴이다 — 세션(sessionStorage)에 쌓고 [저장]·[확정]에서
|
||||
* `cross_patches`(`design.cut_slope_ratio_user`)로 정본에 나간다.
|
||||
*
|
||||
* ⚠ 값을 넣으면 **재계산을 부른다**. 경사는 나르는 값이 아니라 기하 입력이라, 값만 바꾸고
|
||||
* 다시 계산하지 않으면 설계선은 옛 경사로 남고 숫자만 새것이 된다.
|
||||
*/
|
||||
export function createCutSlopeStore(options: {
|
||||
sessionKey: () => string | null;
|
||||
/** 지금 표준 횡단면 설정(세션 편집값 우선) — 되돌릴 자리를 여기서 읽는다. */
|
||||
standard: () => StandardCrossSection | null;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 경사 변경 → 사면·소단·단면적 재계산. */
|
||||
recompute: (chainageM: number) => void;
|
||||
}): CutSlopeStore {
|
||||
const { sessionKey, standard, refreshCard, recompute } = options;
|
||||
const ratios = new Map<string, number>();
|
||||
const key = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
/** 이 측점이 쓰는 표준 경사비 — 지반 프리셋(암/토사)의 값. */
|
||||
const standardRatio = (section: { design?: { geometry_preset?: string } | null }): number => {
|
||||
const preset = section.design?.geometry_preset === "soil" ? "soil" : "rock";
|
||||
const group = standard()?.[preset] as { cut_slope_ratio?: number } | undefined;
|
||||
const value = group?.cut_slope_ratio;
|
||||
return typeof value === "number" && value > 0 ? value : 0.4;
|
||||
};
|
||||
|
||||
function persist(): void {
|
||||
const storageKey = sessionKey();
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ratios,
|
||||
load(): void {
|
||||
ratios.clear();
|
||||
const storageKey = sessionKey();
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
const raw = readByKey(storageKey);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number>;
|
||||
Object.entries(parsed).forEach(([chainage, ratio]) => {
|
||||
// 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다.
|
||||
if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 표준값으로 재시작. */
|
||||
}
|
||||
},
|
||||
control: {
|
||||
standardRatioFor: (section) => standardRatio(section),
|
||||
ratioFor: (section) => {
|
||||
const session = ratios.get(key(section.chainage_m));
|
||||
if (session === 0) return standardRatio(section); // 되돌림 — 저장분보다 세션이 먼저다.
|
||||
if (typeof session === "number" && session > 0) return session;
|
||||
const stored = (section.design as { cut_slope_ratio_user?: number } | undefined)
|
||||
?.cut_slope_ratio_user;
|
||||
if (typeof stored === "number" && stored > 0) return stored;
|
||||
return standardRatio(section);
|
||||
},
|
||||
set: (chainageM, ratio) => {
|
||||
// null = 되돌리기. 지우지 않고 0 을 남겨야 저장분에 있던 옛 값까지 표준으로 돌아간다.
|
||||
if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) ratios.set(key(chainageM), 0);
|
||||
else ratios.set(key(chainageM), Math.round(ratio * 10000) / 10000);
|
||||
persist();
|
||||
refreshCard(chainageM);
|
||||
recompute(chainageM);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */
|
||||
export interface SectionPersistContext {
|
||||
projectId: string | null;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
@@ -146,6 +147,8 @@ export function createSectionView(
|
||||
revetLink?: RevetLinkControl,
|
||||
ford?: FordControl,
|
||||
box?: BoxControl,
|
||||
/** 측점별 암 절토 경사 입력(2026-09-07). */
|
||||
cutSlope?: CutSlopeControl,
|
||||
): SectionViewController {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section";
|
||||
@@ -457,6 +460,7 @@ export function createSectionView(
|
||||
ford,
|
||||
box,
|
||||
plotBase,
|
||||
cutSlope,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements";
|
||||
import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram";
|
||||
import { ratioToDegrees } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import {
|
||||
getCompanyStandard,
|
||||
listCompanyStandards,
|
||||
@@ -303,6 +304,19 @@ export function createStandardPanel(
|
||||
});
|
||||
field.input.step = "0.1";
|
||||
field.input.min = "0";
|
||||
// 경사 칸은 **각도도 함께 보인다** — 카드의 개별 절토각 칸이 도(°)로 받으므로 두 자리의
|
||||
// 말이 갈리지 않게 한다(2026-09-07). 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
|
||||
if (spec.label === "B06_Std_Field_CutSlope" || spec.label === "B06_Std_Field_FillSlope") {
|
||||
const showAngle = (): void => {
|
||||
const ratio = Number(field.input.value);
|
||||
field.input.title =
|
||||
Number.isFinite(ratio) && ratio > 0
|
||||
? `1:${ratio} = ${ratioToDegrees(ratio).toFixed(1)}° (횡단 카드의 각도 칸과 같은 값)`
|
||||
: "";
|
||||
};
|
||||
showAngle();
|
||||
field.input.addEventListener("input", showAngle);
|
||||
}
|
||||
grid.append(field.root);
|
||||
};
|
||||
|
||||
|
||||
@@ -572,3 +572,38 @@
|
||||
border-radius: var(--radius-inputs);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* 측점별 암 절토 경사(2026-09-07) — 암 경계선이 행 가운데를 절대 배치로 쓰므로
|
||||
이 칸은 행의 **오른쪽 끝**에 둔다. 중심고(왼쪽)와 겹치지 않는다. */
|
||||
.b06-cross-card__cutslope {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--radius-inputs);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.b06-design__cutslope-input {
|
||||
width: 44px;
|
||||
padding: 0 2px;
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-mono);
|
||||
text-align: right;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
}
|
||||
|
||||
/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */
|
||||
.b06-design__cutslope-input::-webkit-outer-spin-button,
|
||||
.b06-design__cutslope-input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.b06-design__cutslope-unit {
|
||||
padding: 0 2px 0 1px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,15 @@
|
||||
**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은
|
||||
값이 나온다(거울 시험이 그것도 지킨다).
|
||||
|
||||
**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°.
|
||||
**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°.
|
||||
· 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**이다.
|
||||
기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을
|
||||
되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준):
|
||||
폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**.
|
||||
넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다.
|
||||
· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고
|
||||
**법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시).
|
||||
· 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다).
|
||||
기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다.
|
||||
그래서 지식DB 에도 적지 않는다(사용자 지시).
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -25,7 +26,7 @@ from typing import Callable, NamedTuple
|
||||
# 소단 기본값 — 근거는 위 모듈 설명.
|
||||
BERM_DEFAULT_WIDTH_M = 0.5
|
||||
BERM_DEFAULT_INTERVAL_M = 3.0
|
||||
BERM_DEFAULT_SLOPE_DEG = 2.0
|
||||
BERM_DEFAULT_SLOPE_DEG = 0.0
|
||||
|
||||
# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다.
|
||||
_STEP_M = 0.05
|
||||
|
||||
@@ -8,18 +8,19 @@
|
||||
* 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다.
|
||||
* 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다.
|
||||
*
|
||||
* 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°.
|
||||
* 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°.
|
||||
* · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**.
|
||||
* 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을
|
||||
* 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준):
|
||||
* 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**.
|
||||
* · 기울기 2°는 **2026-09-07 사용자 확정**이고 법령·교본 근거가 없다 — 지식DB 에 적지 않는다.
|
||||
* · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나
|
||||
* 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 소단 기본값 — 근거는 위 설명. */
|
||||
export const BERM_DEFAULT_WIDTH_M = 0.5;
|
||||
export const BERM_DEFAULT_INTERVAL_M = 3.0;
|
||||
export const BERM_DEFAULT_SLOPE_DEG = 2.0;
|
||||
export const BERM_DEFAULT_SLOPE_DEG = 0.0;
|
||||
|
||||
/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */
|
||||
const STEP_M = 0.05;
|
||||
|
||||
@@ -81,6 +81,9 @@ export interface CrossDesignOptions {
|
||||
standard: StandardCrossSectionSpec;
|
||||
rockBoundaryOffsetM?: number | null;
|
||||
twoStageSlope?: boolean;
|
||||
/** 이 측점만 쓰는 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값. 없으면 표준값.
|
||||
* 짝: 파이썬 `compute_cross_design(cut_slope_ratio=…)`. */
|
||||
cutSlopeRatio?: number | null;
|
||||
ditchEnabled?: boolean | null;
|
||||
/** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */
|
||||
surfaceDropM?: number;
|
||||
@@ -134,6 +137,8 @@ export interface CrossDesignResult {
|
||||
design_line: CrossDesignEdge[];
|
||||
/** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */
|
||||
cut_slope_segments: CutSlopeSegment[];
|
||||
/** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */
|
||||
berm?: { width_m: number; interval_m: number; slope_deg: number };
|
||||
surface_drop_m?: number;
|
||||
pavement_thickness_m?: number;
|
||||
rock_boundary_offset_m?: number;
|
||||
@@ -250,7 +255,14 @@ export function computeCrossDesign(
|
||||
if (ditchType === "l_type" && presetKey !== "rock") {
|
||||
throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.");
|
||||
}
|
||||
const group = resolveGroup(presetKey, options.standard);
|
||||
let group = resolveGroup(presetKey, options.standard);
|
||||
// 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다.
|
||||
// ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 값만 베껴
|
||||
// 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다.
|
||||
const userCutRatio = options.cutSlopeRatio;
|
||||
if (typeof userCutRatio === "number" && Number.isFinite(userCutRatio) && userCutRatio > 0) {
|
||||
group = { ...group, cut_slope_ratio: userCutRatio };
|
||||
}
|
||||
const pavedGroup = resolveGroup("paved", options.standard);
|
||||
// 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
|
||||
const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct;
|
||||
@@ -440,6 +452,15 @@ export function computeCrossDesign(
|
||||
cut_slope_segments: geometry.cutSlopeSegments(),
|
||||
};
|
||||
if (drop > 0) result.surface_drop_m = round4(drop);
|
||||
if (options.berm) {
|
||||
// 소단 제원을 설계에 되싣는다 — 짝 파이썬과 같은 까닭이고, **옹벽 의무 판정
|
||||
// (`fillSlopeLengths`)이 이 값을 보고** 사면을 도막으로 끊어 잰다(계획서 3-9).
|
||||
result.berm = {
|
||||
width_m: round4(options.berm.widthM),
|
||||
interval_m: round4(options.berm.intervalM),
|
||||
slope_deg: round4(options.berm.slopeDeg),
|
||||
};
|
||||
}
|
||||
if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m);
|
||||
if (presetKey === "rock" && rockBoundaryOffsetM !== null) {
|
||||
result.rock_boundary_offset_m = round4(rockBoundaryOffsetM);
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
* 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량
|
||||
* 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다.
|
||||
* 현 길이 20m 되는 높이 위쪽 → 종무대
|
||||
* 거기서 70m 되는 높이까지 → 도쟈
|
||||
* 거기서 60m 되는 높이까지 → 도쟈
|
||||
* 그 아래 ~ 평형선 → 덤프
|
||||
* 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다.
|
||||
*
|
||||
|
||||
@@ -16,9 +16,11 @@ from typing import Any
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
|
||||
# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는
|
||||
# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정).
|
||||
PENDING_TYPE_IDS = frozenset({"ditch_berm"})
|
||||
# 아직 셈하지 않는 타입 — 지금은 없다.
|
||||
#
|
||||
# 소단측구(`ditch_berm`)는 계획서 3-9 로 **소단이 실제로 서면서 빈 칸이 풀렸다**
|
||||
# (2026-09-07). 이제 다른 구조물과 같은 길로 연장이 나온다.
|
||||
PENDING_TYPE_IDS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _merge(spans: list[tuple[float, float]]) -> float:
|
||||
@@ -48,11 +50,11 @@ def _merge(spans: list[tuple[float, float]]) -> float:
|
||||
def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]:
|
||||
"""구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄).
|
||||
|
||||
빼는 것 셋 —
|
||||
빼는 것 둘 —
|
||||
· `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다.
|
||||
· `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로
|
||||
여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정).
|
||||
· `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다.
|
||||
(`PENDING_TYPE_IDS` 는 지금 비어 있다 — 소단측구가 3-9 로 풀렸다.)
|
||||
|
||||
시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다
|
||||
(`StructureInstance.validate_placement_fields`).
|
||||
|
||||
@@ -379,9 +379,13 @@ EARTHWORK_CONVERSION_FACTORS = {
|
||||
# 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m`
|
||||
# **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다.
|
||||
#
|
||||
# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭
|
||||
# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 60m < 덤프트럭
|
||||
#
|
||||
# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을
|
||||
# 2026-09-07 사용자 확정값(도쟈 70 → 60m). 근거 — 실무 오솔길 `EARTH.DAT` 헤더 6개
|
||||
# 공사지가 전부 `20.0 / 60.0`(거창·장수·진안·봉화·영월 본선·지선), 산림과임업기술
|
||||
# 5장 「다. 공사수량의 산출」이 「도저운반성토 60m 이하 / 덤프운반성토 60m 초과
|
||||
# (건설표준품셈 참조)」로 못 박음. 70m 는 Aislo 단독값이었음(2026-08-02 잠정 확정).
|
||||
# 유토곡선의 장비 경계현은 이 값을
|
||||
# **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫,
|
||||
# 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다.
|
||||
# 현장·발주처 기준에 따라 달라질 수 있으므로
|
||||
@@ -393,7 +397,7 @@ EARTHWORK_CONVERSION_FACTORS = {
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = (
|
||||
("free_haul", 20.0),
|
||||
("dozer", 70.0),
|
||||
("dozer", 60.0),
|
||||
("dump_truck", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 019_drop_legacy_audit_logs.sql
|
||||
-- 안 쓰는 빈 표 `audit_logs` 를 지운다 (계획서 0-8 곁가지, 2026-09-07 사용자 지시).
|
||||
--
|
||||
-- 왜 — 감사 기록의 **정본은 `system_audit_logs`** 다(`004_dashboard.sql`). `audit_logs` 는
|
||||
-- 최초 스키마(`001_create_schema.sql`)에만 있고 **코드가 한 곳도 쓰지 않는다**
|
||||
-- (쓰기 `common_util_audit.py:61`, 읽기 `B01_Dashboard_Repository.py:455·460`, 정리
|
||||
-- `purge_expired_audit_logs` 모두 `system_audit_logs`). 표가 둘이라 「감사 기록이 비었다」는
|
||||
-- 오진이 실제로 한 번 났다(2026-09-07).
|
||||
--
|
||||
-- 지우기 전 확인(2026-09-07, 공용 DB) — `audit_logs` **0행** · `system_audit_logs` 81행 ·
|
||||
-- 이 표를 참조하는 **외래키 0건**. 그래서 지워도 잃는 자료가 없다.
|
||||
--
|
||||
-- ⚠ 최초 스키마 파일은 **고치지 않았다** — 지나간 이력이라 다시 쓰지 않는 것이 규칙이다.
|
||||
-- 새로 설치하면 001 이 만들고 이 파일이 지운다.
|
||||
|
||||
DROP TABLE IF EXISTS audit_logs;
|
||||
Reference in New Issue
Block a user